#442 stores a resumable round the user cannot reach
7dbb26c (#442) made a chat turn that ran tools and then failed resumable, so the retry does not re-pay. The storage half works. The retrieval half is behind a door with no handle.
Found while diagnosing a real production stall: f8ddc272-0390-4826-8812-94989e3d2ebd (Heartfull Repo Coder), 2026-08-12 03:11:48 UTC, trace 9a6a5579-cdea-4073-a22f-f0852666b32d. The stall itself is not a defect — a provider outage, correctly detected, half-written answer correctly discarded, honestly reported, and the only occurrence of its class in 1,050 chat calls over 7 days. This issue is only about what happened next.
The turn qualified
Five tools ran before the provider went silent (repo_find ❌ stale-runner, github_list_issues ✅, repo_git ✅, repo_grep ❌, repo_read_file ✅ on event_form_dialog.dart). agent-think.ts:1097 pushes every executed call including failures, Anthropic returned contentBlocks so roundMessages.push(...) fired at :1126, and both provider call sites are wrapped (:955, :1151). agent-do.ts:630-631 stores it. Both preconditions of buildResumableRound were met.
Inferred, not verified: that the write landed — no MCP tool reads DO storage keys, so the resumableRound slot cannot be observed. Under RESUMABLE_MAX_BYTES (96 KB) it should have; repo_read_file output is pre-capped by capToolResult.
Three independent reasons it was unreachable
1. The gate is byte equality with the failed message. lib/resumable-round.ts:87:
return round.prompt === prompt;
2. This owner drives the instance by voice. The failed message carries an audioKey and a dictation field. A re-spoken sentence never transcribes identically — his own two utterances at 22:06:01 and 22:06:48 express the same intent with different dictation. So for a voice user the gate can essentially never open, and voice is a first-class input on this platform.
3. The console offers nothing to press, and has already discarded the text. store/console/src/pages/InstanceDetail.tsx:796 runs setInput("") before the POST, and the catch at :647-652 appends Error: … without restoring it. Grepped: the only "Retry" in that file is voice.retryDictation (:1191-1192), which is dictation recovery, not a chat resend. There is no resend affordance anywhere in the console.
So the error message says "Send the message again" — and the user cannot, verbatim, from the surface that told him to.
4. It then expires. RESUMABLE_TTL_MS is 10 minutes (resumable-round.ts:70). The round expired unused at ~03:21:49. His next attempt on that page was 03:24:56.
Cost
He did not pay twice, because he never resent — the transcript ends at the 03:11:49 error. But the tool work of that turn (five calls, one of them a file read) was thrown away, and had he resent he would have paid for all of it again despite #442 existing precisely to prevent that.
Two candidate shapes
(a) A Retry control that re-POSTs the exact failed text. Cheap, keeps the human in the loop, and satisfies the byte-equality gate by construction because it replays the stored string rather than a re-typed one.
(b) One automatic server-side retry when the thrown error carries a resumable round. Safe by construction — the round carries executed and mutations, so the existing dedup survives — and it removes "Send the message again" from the user's plate entirely.
(b) is the stronger answer for a voice user, since it never depends on reproducing a string. They are not exclusive.
Acceptance criteria
- A chat turn that failed with a stored resumable round can be retried without the user reproducing the original text byte-for-byte.
- The retry reuses the stored round — the already-executed tools are not re-run and not re-billed. Assert this, don't assume it.
- A voice-originated turn is covered. If the chosen shape is a UI control, it must be reachable on the surface where the failure is shown.
- If the composer is to remain the recovery path,
InstanceDetail.tsx restores the text on failure instead of discarding it at :796.
RESUMABLE_TTL_MS is reconsidered against however the retry is actually reached — 10 minutes is ample for an automatic retry and tight for a human who stepped away.
- A test proves the round is consumed, not merely stored — the current tests cover construction.
One adjacent nit, deliberately not filed separately
lib/user-ai.ts:326-328 maps any AbortError to the same "20s of silence" sentence, whose number is the constant rather than a measurement. By elimination the observed row was a genuine measured stall (the fetch-site catch at :195-198 can only raise DeadlineExceeded("first-token"), which prints differently; a dropped socket rejects with a non-AbortError that asProviderError returns null for and rethrows verbatim). Worth correcting whenever that file is next touched.
#442 stores a resumable round the user cannot reach
7dbb26c(#442) made a chat turn that ran tools and then failed resumable, so the retry does not re-pay. The storage half works. The retrieval half is behind a door with no handle.Found while diagnosing a real production stall:
f8ddc272-0390-4826-8812-94989e3d2ebd(Heartfull Repo Coder), 2026-08-12 03:11:48 UTC, trace9a6a5579-cdea-4073-a22f-f0852666b32d. The stall itself is not a defect — a provider outage, correctly detected, half-written answer correctly discarded, honestly reported, and the only occurrence of its class in 1,050 chat calls over 7 days. This issue is only about what happened next.The turn qualified
Five tools ran before the provider went silent (
repo_find❌ stale-runner,github_list_issues✅,repo_git✅,repo_grep❌,repo_read_file✅ onevent_form_dialog.dart).agent-think.ts:1097pushes every executed call including failures, Anthropic returnedcontentBlockssoroundMessages.push(...)fired at:1126, and both provider call sites are wrapped (:955,:1151).agent-do.ts:630-631stores it. Both preconditions ofbuildResumableRoundwere met.Inferred, not verified: that the write landed — no MCP tool reads DO storage keys, so the
resumableRoundslot cannot be observed. UnderRESUMABLE_MAX_BYTES(96 KB) it should have;repo_read_fileoutput is pre-capped bycapToolResult.Three independent reasons it was unreachable
1. The gate is byte equality with the failed message.
lib/resumable-round.ts:87:2. This owner drives the instance by voice. The failed message carries an
audioKeyand adictationfield. A re-spoken sentence never transcribes identically — his own two utterances at 22:06:01 and 22:06:48 express the same intent with differentdictation. So for a voice user the gate can essentially never open, and voice is a first-class input on this platform.3. The console offers nothing to press, and has already discarded the text.
store/console/src/pages/InstanceDetail.tsx:796runssetInput("")before the POST, and the catch at:647-652appendsError: …without restoring it. Grepped: the only "Retry" in that file isvoice.retryDictation(:1191-1192), which is dictation recovery, not a chat resend. There is no resend affordance anywhere in the console.So the error message says "Send the message again" — and the user cannot, verbatim, from the surface that told him to.
4. It then expires.
RESUMABLE_TTL_MSis 10 minutes (resumable-round.ts:70). The round expired unused at ~03:21:49. His next attempt on that page was 03:24:56.Cost
He did not pay twice, because he never resent — the transcript ends at the 03:11:49 error. But the tool work of that turn (five calls, one of them a file read) was thrown away, and had he resent he would have paid for all of it again despite #442 existing precisely to prevent that.
Two candidate shapes
(a) A Retry control that re-POSTs the exact failed text. Cheap, keeps the human in the loop, and satisfies the byte-equality gate by construction because it replays the stored string rather than a re-typed one.
(b) One automatic server-side retry when the thrown error carries a resumable round. Safe by construction — the round carries
executedandmutations, so the existing dedup survives — and it removes "Send the message again" from the user's plate entirely.(b) is the stronger answer for a voice user, since it never depends on reproducing a string. They are not exclusive.
Acceptance criteria
InstanceDetail.tsxrestores the text on failure instead of discarding it at:796.RESUMABLE_TTL_MSis reconsidered against however the retry is actually reached — 10 minutes is ample for an automatic retry and tight for a human who stepped away.One adjacent nit, deliberately not filed separately
lib/user-ai.ts:326-328maps anyAbortErrorto the same "20s of silence" sentence, whose number is the constant rather than a measurement. By elimination the observed row was a genuine measured stall (the fetch-site catch at:195-198can only raiseDeadlineExceeded("first-token"), which prints differently; a dropped socket rejects with a non-AbortErrorthatasProviderErrorreturns null for and rethrows verbatim). Worth correcting whenever that file is next touched.