Workflow resumeFromRunId re-executes successful agent() calls (not just failed ones) when script uses pipeline()/parallel()
What happened
Ran a workflow: pipeline(8 categories, genStage, verifyStage, assembleStage), each stage fanning out via parallel() internally (9 subtopic agent() calls per category in genStage, 1 agent() call per category in verifyStage). 72/80 agent() calls succeeded, 8 failed (session rate limit). The workflow completed successfully overall because the script had its own fallback logic for failed verify calls.
Resumed with Workflow({scriptPath, resumeFromRunId}), script unchanged, expecting only the 8 failed calls to re-run — per the docs: "the longest unchanged prefix of agent() calls returns cached results instantly; the first edited/new call and everything after it runs live."
Instead, ~70 of the 72 already-successful calls re-executed live (new agentIds, materially different output text since generation isn't deterministic), burning ~2.4M additional tokens on top of the original ~2.7M.
Root cause (as far as I can tell from journal.jsonl)
Confirmed via the run's journal.jsonl: across both runs there were 151 total started events, but only ~80 unique keys matched between run 1 and run 2 — the rest were logged as brand-new keys/agentIds even though the script and every prompt were byte-identical between runs.
This points to the resume cache being position-in-sequence based (a literal "prefix" of the call log) rather than pure content/hash-addressed. parallel()/pipeline() — which the tool's own documentation recommends as the default pattern ("DEFAULT TO pipeline()") — have non-deterministic completion/call order across runs, since it depends on model/network latency for each concurrent agent. So the actual sequence in which agent() calls get logged differs run-to-run, the "prefix" diverges almost immediately, and everything after that point in the log re-executes live instead of being served from cache — even calls with an identical (prompt, opts) key that succeeded last time.
Impact
This silently doubles cost for what should be the cheapest, most common recovery action — "some calls failed (rate limit/transient error), resume to retry just those." Any workflow using the tool's own recommended parallel()/pipeline() pattern is exposed to this, and there's no warning that resume is unsafe/expensive in that shape.
Suggested fix
- Make the resume cache purely content/key-addressed (hash of prompt + opts), independent of the order calls were logged/started in, so a matching key hits cache regardless of position.
- Failing that, at minimum: warn (or refuse) resume when the script contains
parallel()/pipeline()calls, since call order isn't guaranteed deterministic and the "prefix" guarantee can't hold.
Happy to share the script and journal.jsonl if useful for repro.
4 Comments
The root cause you've traced -- parallel()/pipeline() producing non-deterministic call-log order so the "longest prefix" cache diverges almost immediately -- is a real structural mismatch between the resume contract the docs describe and how the journal actually works.
A few observations from building an external workflow orchestrator that dispatches Claude Code sessions:
Content-addressed keys need to be sequence-independent to be useful. The current cache model works for strictly sequential agent() calls where order is fixed. The moment you use parallel() -- the pattern the tool's own docs describe as the default -- you get non-deterministic log order, the prefix diverges, and the cache is effectively a no-op for anything past the first divergence. The fix needs to be either (a) hash the (prompt, opts) pair and cache by that key independent of sequence position, or (b) give pipeline()/parallel() explicit stage IDs that survive between runs so the journal can anchor cache hits to a stable identifier rather than log position.
The token waste is invisible until after the resume fires. Even if you watch journal.jsonl during the re-run, you don't know you're paying full price until the new agentIds start appearing. A pre-resume warning -- "X of Y cached calls are sequence-mismatched and will re-execute; estimated cost: ..." -- would let the user abort before the spend rather than audit the journal after.
The cross-session compaction case from #65796 compounds this. If a resumed run also crosses a session boundary (compaction between run 1 and run 2), you get two independent sources of cache miss: the parallel ordering problem you've documented here, and the session-scoped journal lookup that #65796 describes. Both need to be fixed for resume to be reliable in any non-trivial workflow.
For now, the practical mitigation is to avoid pipeline()/parallel() in workflows you need to resume cheaply and use sequential stage calls instead -- painful given the docs, but it's the only way to get the prefix-match the cache assumes.
Confirming the same defect from a different trigger and adding a piece that wasn't captured in the original report: the tool ships two contradictory doc strings for
resumeFromRunId, and the runtime implements only one of them (without warning).The contradiction (verbatim)
Parameter description (documents per-call caching):
Tool description (documents prefix-based invalidation — this is the one quoted in the OP above):
These describe mutually exclusive semantics. The parameter doc promises selective per-call re-run of only what changed. The tool doc promises that a single edited/new call invalidates every call after it in launch order, regardless of whether those later calls are themselves unchanged. The runtime implements the tool-doc (prefix) contract, but a user who reads only the parameter doc has no reason to expect that.
My repro (sequential, no parallel()/pipeline() involved)
resumeFromRunId, relying on the parameter doc's per-call semantics.parallel()/pipeline()non-determinism to explain a "prefix divergence" — it's just straight positional invalidation.(prompt, opts)were unchanged.So this isn't only a
parallel()/pipeline()ordering problem — plain sequential edits trigger it too, which lines up with "prefix invalidation" being the actual implemented behavior rather than an artifact of non-deterministic concurrent completion order.Suggested fix (in addition to the OP's)
Whichever semantics you land on, the docs need to stop contradicting each other:
resumeFromRunIdemit a pre-launch warning stating exactly how many already-completed calls are about to be invalidated and re-run live, so users can abort before burning usage on duplicate work.Environment: Claude Code CLI, macOS, model claude-fable-5, observed 2026-07-06.
Independent repro + an additional trigger: session BACKGROUNDING, not only explicit
resumeFromRunIdReproduced this independently on Claude Code v2.1.201 (macOS, Max), and want to flag a second trigger path that maintainers may not have on the radar.
Setup: one
parallel()of 16 independent agents (8 buckets + 6 groups + 2 hunters) plus a final assembler. Two early buckets failed mid-run due to an API interruption.What I saw — the same prefix re-run fired on BOTH of these events:
Workflow({ scriptPath, resumeFromRunId }), andIn both cases only the two agents at the very front of the array cache-hit. Every agent ordered after the first failed one re-ran for real — including ~12 that had already completed successfully. Estimated ~300–400k wasted tokens per event.
Evidence (journal.jsonl): the two front agents have exactly one
startedacross the entire history (cache-hit on both resumes); every agent from the first failed one onward has threestartedentries with distinct agentIds — genuine re-execution, not replay. Confirms the resume cache is position/prefix-based rather than per-agent content-keyed, as the OP describes.The extra data point: backgrounding is a non-obvious way to hit this. A user who just wants to view or park a running workflow ("← for agent") silently re-runs completed agents, because adopting an in-flight workflow routes it through the same prefix cache. A per-agent (content-key) cache hit for independent
parallel()members would fix both the explicit-resume path and this backgrounding path at once.I hit what looks like the same root cause as this issue, but with a more severe consequence than extra cost -- resume can silently swap content between two chunks that were processed concurrently.
Setup: A Workflow script processes a document in ~6 chunks (~45 items each) via pipeline()/parallel(), each chunk going through a decode -> compose stage. The script computes its own content hash (a field it calls
bundle_sha256) over each stage's returned payload for downstream cross-checking. After a partial failure, I resumed withWorkflow({scriptPath, resumeFromRunId}), unchanged script and args.What I found in journal.jsonl: two independent
resultentries (different key, different agentId, both marked completed) carry the exact samebundle_sha256value in their returned payload -- a hash the script itself computed, not journal metadata -- while the actual returned records are materially different (different item ranges/content, confirmed by diffing the payloads directly, not just comparing the hash strings).Why this is worse than the cost issue described in this ticket: the calling script sees two clean results, each internally self-consistent, so nothing throws. Wrong-but-plausible content can be silently accepted downstream with no error surfacing at all. In my case the pipeline writes processed text to disk; a corrupted chunk assignment would have been written as if correct, and any check that only asks "did the stage throw" would not catch this.
This looks consistent with the root cause described here (cache addressing by call-sequence position, which collides under parallel()/pipeline()'s non-deterministic completion order) -- worth flagging that when this fails, it doesn't just fail slow (full re-run), it can fail silently wrong.
Happy to share a redacted journal.jsonl (hashes/keys/line-numbers only, no payload content) if useful for repro.