DEV Community

Cover image for Do agents survive a crash, and why does an LLM retry execute the same side effect twice? 34 runs measured
sunnydachs
sunnydachs

Posted on

Do agents survive a crash, and why does an LLM retry execute the same side effect twice? 34 runs measured

When you hand an agent work with external effects, two worries always come up.

First: when the process dies, what happens to the work in progress? An agent paused at an approval gate crashes — can it resume, or does everything restart from scratch?

Second: when an LLM retries, does it execute the same side effect twice? Retrying a send or a data mutation carries a real double-execution risk.

My previous article measured HITL approval, audit trails, and structured output across three frameworks. It recorded a failure where Strands double-fired a publish call with the identical draft. This article measures what comes next: 34 more runs, same task, same model, same recorder proxy.

Repo (all code, traces, and analysis scripts open):

https://github.com/sunnydachs/agent-framework-showdown

(Read the previous article here. here.)

The experiment design

Same task, same model, same recorder proxy: 3 frameworks x 3 cells x multiple runs.

[agent] → [approval gate] → [publish (destructive)]
      ↑               ↑
  crash it here    pauses waiting
Enter fullscreen mode Exit fullscreen mode

The three cells:

  • Cell A — crash recovery: SIGKILL a process paused at the approval gate; can a NEW process resume from where it stopped?
  • Cell B — idempotency: call publish under 3 key strategies (position key / content hash / no key) and count duplicate executions on retry
  • Cell C — audit under retry: score whether an auditor reading the traces alone can detect the duplicate

The three idempotency key strategies:

position key:   {workflow}:{step}:{tool}
content hash:   sha256(the raw arguments)
no key:         nothing
Enter fullscreen mode Exit fullscreen mode

Cell A: crash recovery — a durable checkpointer is the difference between resume and redo

SIGKILL the agent while it waits for approval, then resume in a new process:

Framework Persistence Resume time State survived LLM calls to resume
LangGraph (durable checkpointer) checkpointer on disk 0.01-0.02s 3/3 zero
LangGraph (no checkpointer) none - 0/3 -
Strands none built-in 4.9s avg full re-run 5.3 avg
CrewAI none for agents 4.2s avg full re-run 2.0 avg

LangGraph's durable checkpointer persists the graph state to disk even while the process is dead. The new process restores to that position in 0.01s — with zero LLM calls. The difference between resume and redo is only whether the state lives outside the process.

Without a checkpointer, an identical-looking "resume" is a full re-run: Strands runs its average 5.3 LLM calls again and pays the full token cost a second time.

What if the crash happens before the first checkpoint?

LangGraph has a known issue here (#8764): if the process dies before the first checkpoint is persisted, recovery may find no checkpoint and no record that the run was ever accepted. On the version I tested, resuming the empty thread succeeded without raising — so the behavior is version-dependent. Don't rely on the error either way; keep an external acceptance ledger.

Cell B: idempotency — the content-hash key silently fails exactly when the model rewords

Average duplicate executions when an LLM retry re-calls publish:

Key strategy Same-args retry Reworded retry
Position key 1 dup, all deduped 3 dups, 33% deduped + rest rejected as caller bug
Content hash 1 dup, all deduped 1 dup, 0% deduped — it slipped through
No key 1.33 dups, 0% deduped 1 dup, 0% deduped

This is the core result. The content-hash key silently fails the moment the model rewords the arguments on retry.

The reason is simple. An LLM retry does not replay the saved HTTP request. It reasons again from a context that now includes the timeout error, and emits a new tool call. The arguments get reworded, the order changes, fields appear. With sha256(args) as the key, the retry produces a different hash, sails past the dedup check, and executes the side effect a second time.

The position key ({workflow}:{step}:{tool}) identifies the intent — where the call sits in the workflow — not the bytes. The same position with the same operation yields the same key no matter how the arguments change.

One more measurement: every duplicated publish carried a different tool_call ID on the wire. Nothing at the protocol layer detects "this is the second time for this operation." Detection lives at the recording level only.

Note: the position-key strategy rejects "same position, different arguments" calls as a caller bug (2.67 of the runs here). That is by design — it flags intent drift instead of letting the key be reused.

Cell C: audit under retry — the trace-only auditor

Can an auditor reading the traces alone recover these four facts:

  • Decision rationale is readable: 100% in all cells
  • The duplicate is detectable: 100% in all cells
  • Dedup is provable (which attempt was deduped, and why): 100% in all cells
  • The retry directive is visible: 100% in all cells

But this is only because the recording is at the wire level. The proxy keeps every attempt — first call, dedup, caller-bug rejection — as its own record, so the auditor can reconstruct everything.

Framework-level trace surfaces show none of the double-firing. What matters in audit design is where the evidence lives.

The most important finding: the failure shape changes with the key strategy

Across the 34 runs, each key strategy fails differently:

  • Content-hash key: fails silently. The hash just changes — no error, dedup passed, and only the side effect runs twice
  • Position key: fails loudly. "Same position, different arguments" is rejected as a caller bug, so intent drift surfaces as an error
  • No key: prevents nothing. Every retry executes twice

For enterprise use, the silent failure is the scariest class. The intuition "a hash key makes retries safe" breaks the moment the caller becomes non-deterministic — that is the conclusion from these measurements.

Honest limitations

  • 3 runs per cell is directional, not a statistical claim
  • The approver is scripted; no real UI or notification flow
  • The destructive action is simulated (though whether the gate held is read directly from the recorded traffic)
  • Single model. A different model may reword arguments differently

Reproduce it

Everything is open. The README has the commands for all experiments (34 runs here + 72 from the earlier articles):

https://github.com/sunnydachs/agent-framework-showdown

This is a personal OSS project — no warranty. Use at your own risk, and issues are welcome.

Top comments (9)

Collapse
 
tokenlat profile image
TokenLat •

The double-fire you caught is the same seam I keep hitting: the (step, tool, model) tuple quietly assumes steps are read-only. The moment a step mutates state, cost attribution and retry semantics split apart — a retry of a write is a duplicated action, not a duplicated cost. Content-hash keys stop the same call, but they don't tell the router a step is side-effecting in the first place. I'd want a step-level effect tag the retry layer reads before firing, so "cheaper path" can't mean "double side effect." Do you tag read-vs-write at the step, or leave it to the caller's retry loop?

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks — agreed on the seam. In this harness the tag lives at the tool boundary rather than the step: only the destructive publish routes through the idempotency ledger, the read tool never touches it, and the key is caller-assigned (one position key per run). There is no retry layer between router and tool here — the retry being measured is the model's own re-reasoning after a transient directive, so a router reading a step-level tag would never fire; the guard sits where the effect executes. The case you describe — "cheaper path" becoming a double side effect — is what the position key measured: same position + reworded args was refused as a caller bug (8 refusals across 3 runs, one publish per run) instead of silently reusing the key.

Collapse
 
axiru profile image
Axiru •

The content-hash failing silently when the model rewords is the scary class. A position key that names the intent, not the bytes, is the right direction. For a refund or payout tool the same shape has to sit above the provider: claim the logical action before execute so a second tool_call id cannot move money again.

If you ever point that recorder at a money tool, and you have roughly the last 90 days of refunds or outflows as a Stripe export or CSV, we can shadow-run a claim-before-execute rule and send back would-have allow, hold, and deny counts with reason codes. Nothing blocks production. Want us to try it on a sample?

When you promote Cell B past a simulated publish into a provider call, do you treat a timeout as unknown and reconcile on the same position key, or does the next process start a fresh claim?

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks — agreed on the shape: claim the logical action before execute, above the provider, so a second tool_call id can't move money twice. The recorder proxy would sit naturally at that layer — it already sees every attempt before the tool does. The recorded traces from the crash/idempotency runs are public in the repo, so the rule can be shadow-run over them as-is.

Collapse
 
axiru profile image
Axiru •

Public traces at the claim-before-execute layer are exactly what we want to shadow-run. If you drop the repo path (or a permalink to the crash/idempotency recorder exports), we can run a claim-before-provider rule over those attempts and send back would-have allow, hold, and deny counts with reason codes. Nothing blocks your production path.

When you promote that recorder past a simulated publish into a real refund or payout tool, do you still key the claim on the logical action so a second tool_call id cannot settle twice?

Thread Thread
 
sunnydachs profile image
sunnydachs • • Edited

The traces are already public — the repo is linked at the top of the article:
github.com/sunnydachs/agent-framew...

The retry runs are the 18 idem traces under traces/ (e.g. traces/llm_calls_strands__strands__idem_position_reworded_run1.jsonl), the ledger exports in traces/idem/, and the per-run exec logs under outputs/ (e.g. outputs/idem_retry_strands__idem_position_reworded_run1.json).

On your question: the claim keys on the logical action, not the wire ID. The caller assigns one position key per run; the ledger inserts a PENDING row carrying a parameter hash before publish() runs. Measured in the reworded-retry runs: every duplicated call came with a different tool_call ID and the claim still held — 8 refusals as caller bugs across the 3 runs, one publish per run.

Thread Thread
 
axiru profile image
Axiru •

Thanks — those paths are enough. Same shadow run will cover the idem traces and ledger exports; we'll post the counts on the other thread and mirror them here if useful.

Collapse
 
hannune profile image
Tae Kim •

Cell B hit me first because I'd have reached for sha256(args) too. It's intuitive but fragile: an LLM under timeout pressure re-reasons from a context that now includes the error, so the tool arguments come out reworded and the hash changes. Position key wins because it doesn't care what the model said, only where in the workflow it is. Worth testing next: does field-ordering variance from the JSON serializer alone produce false mismatches, even without any semantic rewording?

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks! Great question — the traces already hold part of the answer: field-order variance is real (a pair of identical calls hashed to two different sha256 values), but no false mismatch within a single run yet — the position key never hashes args at all.