Proposed, not shipped Measured findings and a build plan, compiled with Dossier and Margin.

Dossier · Field report · July 2026

The wave finished. A third of it never came back.

Fan out a few dozen Claude Code agents across a repo. One hits a rate limit, or the proxy restarts, or the connection drops for a second. That agent returns nothing, the script filters it out, and the run reports completed.

We pulled the runtime apart to find out why, then counted what it has already cost on one machine.

Status Everything measured here is measured. The fix is a plan, not a release.

73 harness failures in a single week of real sessions
6.6h p90 time a session sat dead before a human noticed
516 workflow journals sitting on one Mac
0 retries an agent gets when the API errors
01 · The mechanism

An API error gets no retry at all

The workflow runtime has three retry mechanisms, and they’re decent. A stalled agent gets five restarts. A throttled-looking response gets one retry after a 45 second sleep. A schema validation failure gets five nudges.

Not the API error. This is the entire path:

// after the stall-retry loop, in the local agent runner
if (It.apiError) {
  let yr = `[${te}] failed: ${It.apiError}`;
  return A.push(yr),
         r({type:"progress", …}),
         null
}
claude 2.1.220, bundled runtime at ≈ byte 234M

A rate limit comes back fast, so it doesn’t even qualify for the throttle retry; that one needs the request to have run for over 90 seconds first. It goes straight to null. Then parallel() and pipeline() turn failures into null as well, and the .filter(Boolean) at the end of a generated script drops them on the floor.

What each failure is worth
FailureHandlingRetries
No progress for 180 secondsWatchdog aborts, agent restarts from its original prompt5
Throttled-looking responseSleep 45 s, try once more1
Schema validation failureNudge in conversation5
Rate limit, usage limit, dropped connection, 5xxReturn null0

The tool result does carry a failures block and an error count. The status sitting next to it says completed.

02 · Why resume doesn’t save you

The journal is filed under the session, not the run

This is the part worth knowing even if you never change a thing. The resume journal is written to a path built from the session id, and the session id isn’t stable.

function Ste(e) {                                     // e = the runId
  let t = dW() ?? tS(gn());                           // project dir
  return join(t, kt(), "subagents", "workflows", e);  // kt() = CURRENT session id
}
the resolver, read from claude 2.1.220
~/.claude/projects/<project>/<SESSION-UUID>/subagents/workflows/<wf_runId>/journal.jsonl ↑ the session id is a parent segment, resolved fresh every time you resume

Auto-compaction mints a new session id. So the resume looks under a directory that has never contained that run, finds nothing, and cold-starts from phase one, while a complete journal sits intact one folder over. Long runs are the ones most likely to compact, which means the failure correlates neatly with the runs you most want back.

It’s filed and open, labelled bug and has repro. The reporter had a 61 KB journal with twelve entries sitting recoverable on disk while the resume started again from nothing.

03 · The count

516 journals, and the gaps are visible

A clean run shows exact parity: every agent that started returned a result. An interrupted one doesn’t. Each dot below is one agent attempt on a single machine.

The five largest workflow journals on one machine. Three of them show a large gap between agents that started and agents that returned a result: 96 started and 61 returned, 128 started and 78 returned, 107 started and 55 returned. The remaining two show exact parity at 164 and 269.

returned a result started, never returned

One honest caveat: a started entry is appended per attempt rather than per agent, so a gap isn’t a one-to-one count of lost work. The contrast between the parity rows and the 107/55 row is real all the same.

04 · Why you can’t salvage half

The keys chain, and the first miss is fatal

Cached results are keyed by a sha256 chain over the previous key, the prompt bytes and the options. Not a positional index, which had been ambiguous until the binary settled it.

function zSd(e, t, r) {              // (prompt, opts, prevKey)
  let n = createHash("sha256")
    .update(r).update("\x00")        // previous key
    .update(e).update("\x00")        // prompt bytes
    .update(Jq_(t)).digest("hex");   // normalised opts
  return `v2:${n}`;
}

And the miss is harder than a prefix boundary. A sticky flag is set the first time a lookup misses, and after that no lookup is consulted again for the rest of the run, including ones whose key would still have matched. An orphaned journal isn’t a degraded resume. It’s a fully cold one.

One useful thing falls out of the same function: only schema, model, effort, isolation and agentType are normalised into the key. Labels and phases aren’t, so you can relabel or regroup agents freely without invalidating anything.

05 · The fix

Three boundaries, because they cover different failures

Picking one is a category error. A proxy can’t know whether an agent already committed something, and a journal can’t keep a wave alive through a five hour usage window.

01 · Transport

The classifier

A 429 with somewhere to rotate becomes a silent account swap. A 529, a 504 or a dropped socket comes back as x-should-retry with an honest delay, so the client’s own loop absorbs it. Real 400s pass straight through.

02 · Orchestrator

The run index

A project-scoped map of run id to journal path, so a resume finds its history whichever session wrote it. Before spending anything it reports the predicted cache-hit rate, and 0% fails loudly instead of quietly starting over.

03 · Supervisor

The ledger

A task isn’t done because a process exited zero. Record the worktree, the base commit, the output commit and the validation receipt, then reconcile what’s uncertain by reading git.

Three of the five research backends we put this to recommended adopting a durable execution engine instead. We’re not, and the reason is narrow: what we measured is a lookup problem, not a durability problem. The journal is intact on disk every single time. Temporal wants every model call and clock read inside an Activity and terminates a history at 51,200 events; DBOS wants an authoritative Postgres. Both are a second distributed system to fix a path that resolves wrong.

Worth noting which backends said what. The two that could read the actual repository recommended the light path; the three that could only search the web recommended the substrates, in a market where those four vendors publish heavily on exactly this question.

06 · The plan

Cheapest thing first, and it’s an afternoon

Phase 0 is a file copy. If it works, the 516 journals already on disk become recoverable and the rest of the plan is de-risked before anyone writes code. If it doesn’t, that’s worth knowing cheaply.

0
Relocate one orphaned journal by handCopy a wf_<runId> directory into the current session’s tree and resume. Validates the whole premise.
an afternoon
1
Run index, rehydrate, predictResolve by run id across the project. Report the expected hit rate before spending a token.
days
2
Transport classifier and exhaustion contractRotate, or hand back a retryable status and the real reset time. Never a canned success.
days · parallel with 1
3
Durable record, supervisor, relaunchTask states including uncertain, leases, attempts, worktree and base commit, reconciliation by git.
weeks
4
Worker execution modelReal resumable workers for the expensive lanes, with a typed error stream per attempt. One lane at a time.
weeks · lane by lane
5
Evaluate a durable substrateDeferred. Two triggers only: waves that span machines, or two people resuming one run.
not scheduled
07 · Limits

What none of this fixes