Agents

Every action your AI agent takes should be safe to run twice

Agents retry after ambiguous failures, so every action needs to be safe twice. Ledgers, content checks and dry runs — and the create that is genuinely not idempotent.

MobileByteSensei 9 min read
A row of identical slabs, every notch already filled, arrows arriving and changing nothing
agentsguardrailsapitool-use

An agent hit an error, could not tell whether the work had happened, and did the sensible thing: it tried again.

That is the correct behaviour. Whether it is safe behaviour is a property of what you built.

TL;DR

  • Agents retry, resume and get interrupted — so any action that is not safe to repeat will eventually be repeated at the worst moment.
  • Idempotency is a design property of the ACTION, not something a retry wrapper can add afterwards.
  • The actions worth making idempotent first, and the ones that genuinely cannot be.

The case this post is about

One check, written to make an append operation safe to retry:

if (body.includes(text)) return { already: true }     // looks bulletproof

It reported the text missing on text that was plainly on the page, so the retry appended it a second time.

The editor had applied smart typography between the write and the read: web — the was stored as web—the. A byte-for-byte comparison against what was typed cannot match what was stored, so the most defensive-looking line in the function was the one that broke idempotency.

This post follows that one line, because the fix is a normalisation nobody thinks to write — and because the operation underneath it still has a window that cannot be closed at all.

The symptom

Agents retry more than humans do, for a structural reason: they are frequently uncertain whether an action completed. A tool call that times out, a response that does not parse, a step whose output was swallowed — each leaves the same question, and the cheapest answer is to run it again.

I have now watched all three of these in one project:

  • an append step that would have stacked a second copy of the same paragraph
  • a create step that would have produced a permanent duplicate on a third-party service
  • a verification step that reported failure on work that had succeeded, inviting exactly the retry that would break it

The third is the one worth pausing on. A false negative in verification is not a cosmetic bug — it is a retry generator, and it points the retry at the operation least able to survive one.

Why the obvious fix failed

The obvious fix is "do not retry", enforced by telling the agent not to. It fails for the same reason most prompt-level rules fail, and it is also wrong: retrying after an ambiguous failure is correct. The problem is not the retry. The problem is an action that cannot absorb one.

The second attempt is a --dry-run default, which genuinely helps — nothing irreversible happens without an explicit flag. But it does not make the applied path safe; it just makes it deliberate. The second --apply is as destructive as the first.

The third attempt, and the one that took me longest to abandon, is checking whether the work is "already done" by looking at the thing you produced. For the append step that meant searching the document for the text:

if (body.includes(text)) return { already: true }     // looks bulletproof

It reported false on text that was plainly present. The editor had applied smart typography — web — the stored as web—the — so a byte comparison failed on a match a human would call identical. An idempotency check that is too strict is worse than none, because it converts a safe no-op into a duplicate.

The actual mechanism

Three mechanisms, in descending order of how much I trust them.

A ledger written before anything else is believed. For an operation with no remote read-back, a local record is the only idempotency key available:

if (await exists(`.publish-out/${slug}.ledger.yaml`)) {
  console.error(`${slug} already has a ledger — it exists remotely. Refusing.`)
  Deno.exit(1)
}

The ledger duplicate-guard, and when the write happens

The ordering is the whole trick: after the operation returns, write the ledger before logging, before the next item, before any cosmetic follow-up. A create whose ledger did not land is indistinguishable from one that never happened.

A content check that normalises before comparing. When the target can be read back, compare what a reader would call the same rather than what a byte comparison would:

const norm = (s: string) =>
  s.replace(/[‐-―—–]/g, "-").replace(/\s+/g, " ").trim().toLowerCase()
if (norm(body).includes(norm(text).slice(0, 48))) return { already: true }

Every transformation the target applies — smart quotes, dash substitution, whitespace collapsing — has to be normalised away, or the check fails on its own success.

A dry run that proves the resolution, not just the intent. Useful when it resolves the same target the real run would and reports it, so you see which item would be affected before anything happens.

The fix

The honest part of this design is admitting where it stops.

One operation here is genuinely not idempotent: the create. The API offers no idempotency key, no read-back, and no delete. The ledger is a local approximation, and there is a real window — small, unavoidable — between the remote create succeeding and the local write landing. A crash in that window leaves a created item with no record, and the next run creates a second.

I cannot close that window. What I did instead was shrink it and make its consequences visible:

  • the ledger write is the very next statement after the create returns, with nothing between
  • a failed ledger write is reported as a hard error naming the item, not a warning
  • batches are serial, so at most one item can be in that window at a time

That last property is the argument against parallelism here that I find most convincing. Ten parallel creates means ten simultaneous windows, and a crash loses an unknown subset. Serial means the blast radius is exactly one, and you know which one.

Written out, the window is two statements with nothing permitted between them, and the recovery path is what the ledger is actually for:

type Ledger = { slug: string; remoteId: string; createdAt: string };

async function createOnce(slug: string, api: Api, now: () => string): Promise<Ledger> {
  const path = `.publish-out/${slug}.ledger.yaml`;

  // Second run: the ledger IS the proof. No read-back endpoint exists to ask the server.
  const existing = await readLedger(path);
  if (existing) {
    console.log(`${slug}: already created as ${existing.remoteId} — no-op`);
    return existing;
  }

  const res = await api.create(slug);                    // <- the irreversible moment
  const ledger: Ledger = { slug, remoteId: res.id, createdAt: now() };

  try {
    await writeLedger(path, ledger);                     // <- the VERY next statement
  } catch (e) {
    // A failed ledger write after a successful create is the worst state this can reach.
    // It is a hard error naming the id, because a warning here becomes a duplicate later.
    throw new Error(
      `${slug}: CREATED REMOTELY as ${res.id} but the ledger write FAILED (${e}). ` +
      `Write .publish-out/${slug}.ledger.yaml with remoteId: ${res.id} before re-running.`,
    );
  }
  return ledger;
}

The same batch run twice, with a simulated crash in the window on the first pass:

$ publish three-phases warm-context-codegen reading-the-405
three-phases: created as 9f2c1b05
warm-context-codegen: CREATED REMOTELY as 4e81aa3c but the ledger write FAILED
  (NotFound: .publish-out). Write .publish-out/warm-context-codegen.ledger.yaml
  with remoteId: 4e81aa3c before re-running.

$ publish three-phases warm-context-codegen reading-the-405
three-phases: already created as 9f2c1b05 — no-op
warm-context-codegen: created as 7c04b912        <- a duplicate, permanently
reading-the-405: created as 1d55e0f7

That second line is the window, and the whole design is an admission that it cannot be closed. What the error message buys is that the duplicate was avoidable by a human — the remote id is printed, the exact file to write is named, and a person who reads the output recovers in one command. The version that logged a warning instead printed the same information into a scrollback nobody read, and the duplicate appeared on the next run with no record of why.

How we proved it

The append step, run twice against the same target:

run 1:   "appended": true   "already": false
run 2:   "appended": false  "already": true

Two runs, and the count that proves only one copy landed

The second identical strike changing nothing

Then the thing that actually matters — reading the target and counting:

stripCount: 1

One copy after two applies. The count is the real test; the already: true is just the mechanism reporting on itself, which is precisely the kind of evidence I would not accept anywhere else.

The create guard, against a slug that already has a ledger:

already has a ledger at .publish-out/<slug>.ledger.yaml — it exists remotely. Refusing.

And the case that proves the normalisation matters: before it, the idempotency check returned already: false on a document that already contained the text, and a caller trusting it would have appended a second copy. The bug was in the guard, which is the component whose failure is hardest to notice, because a guard that wrongly says "not done yet" looks exactly like a guard working.

The retry you cannot see

One more failure mode, and it is the one that does not look like a retry at all.

A script wrote its output to a relative path. It ran under a harness whose working directory was not the caller's, so the file landed somewhere else entirely — and the script reported {"ok": true, "moving": true}, because from its own perspective everything had worked. The encode succeeded. The bytes were real. They were just not where anyone would look.

The operator's response to an output file that is not there is, naturally, to run it again. And again. Each run succeeds, each writes a perfectly good file into the void, and the loop only ends when someone goes looking with find.

That is a retry storm generated entirely by a path bug, and no amount of idempotency design prevents it, because every run genuinely is idempotent — it produces the same correct output in the same wrong place. The fix is not in the retry logic:

if (!output.startsWith("/")) {
  die(`output must be an ABSOLUTE path, got "${output}"`)
}

Refuse rather than misplace. An operation that cannot be certain where its effect landed should not report success, and "I wrote the file" is a weaker claim than it sounds when the path was resolved against a directory you did not choose.

What this generalizes to

Assume every action runs twice, because with an agent in the loop it eventually will. Give each one a durable record written before the result is believed, compare on normalised content rather than bytes, and keep irreversible operations serial so the uncertain window holds one item. Then find the operation that still cannot be made safe, and write down exactly where its window is — an honest gap you can see beats a guarantee you cannot keep.


Keep reading

One post a week on shipping software with AI agents — what broke, what the fix was, and the evidence. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS