Repository navigation
[bug] #442 stores a resumable round the user cannot reach — the gate is byte-equal text, the owner speaks, and the console clears the composer before sending #518
Description
#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.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.- 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.
Activity
fix(chat): the platform performs the retry it asks for, so a failed t…
serge-ivo commentedon Aug 12, 2026
Shipped in
2bc9475(CI green; API deployed withb590eee's run after mine was cancelled by the concurrency group, Host deployed from mine).Shape chosen: (b), plus the cheap half of (a)
(b) — one automatic server-side retry, because it is the only shape that never depends on
reproducing a string, and the owner of the instance in the report speaks his messages.
The question this hinged on was whether an automatic retry can be idempotent for the mutating
tools that already ran. It can, and not by a new mechanism — this is exactly what the round was
built to carry:
resume.executedseeds the cross-round dedup map andresume.mutationsseeds the counter(
agent-think.ts:882-883), so an identical re-issued call is refused at:1015-1017withis_error, precisely as it would be inside one turn;tool_resultblocks, which is why the model does notre-issue them.
Not asserted from reading:
agent-think-resume.test.tsdrives the realrunAgentThinktwicewith only the provider and the tool executor faked. The write executes once across both
attempts. Remove the resume from that test and the same assertion counts two — checked.
Two honest limits: the guarantee is per identical (name+args) call, so a different mutating call
the model chooses given the results does run (correct, and identical to in-turn behaviour); and only
the structured Anthropic path stores a round at all, so a Workers-AI-fallback turn gets no auto-retry
(unchanged from #442).
(a) — the composer keeps the words, as the manual fallback for when the retry also fails:
InstanceDetail.tsxrestores the exact string on a failed send, into an empty box only. That is thelanding place a turn recovered mid-reply already takes (#175), so a voice mode shows the composer
for it instead of swallowing it (
lib/composer.ts), and it satisfies the byte-equality gate byconstruction where retyping cannot.
Against the acceptance criteria
:796still clears before the POST (that is right — the message is in flight); the catchnow restores it.
RESUMABLE_TTL_MS10m → 30m. Sized to the human path, using this ticket's ownmeasurement: failure 03:11:49, next attempt 03:24:56 = 13m 07s, i.e. the real gap already
exceeded the old TTL. The automatic path consumes the round within seconds either way.
agent-think-resume.test.tsproves consumption, and a source guardproves the DO reaches the thinker only through
thinkWithAutoResume— a later edit callingthis.thinkdirectly would otherwise disable the retry in production with every unit test green,which is the shape of this bug.
What decides a retry
UserAiProviderErrornow carriesretryable, set where the failure is still distinguishable:first-token ✅, stall ✅,
total❌ (its own message says a retry "will fail the same way"),upstream 5xx ✅, 4xx ❌, stream errors ✅, credentials ❌.
ai-deadlines.test.tsholdsisRetryableDeadlineequal to the sentence the user reads, so the advice and the automaticbehaviour cannot drift apart. Every recovery writes
chat.auto_resumedto the trace, so "how oftendoes this fire, and does it work?" is answerable — which it was not for #442's storage half.
The adjacent nit is fixed while in the file: a runtime
AbortErrorno longer borrowsdeadlineMessage("stall", 20_000), whose "20s of silence" was the constant rather than ameasurement (
connectionLostMessage).Deliberately unchanged, per the report: the 20s deadline, the discard, and the error text.
Verified
scripts/check-*,docs:drift, and Playwright 365/365 (chromium + webkit) — the console change is in scope.Both new behavioural assertions were mutation-checked (each fails when the fix is removed).
proagentstore.online/consolecarries the restore —catch(e){ …Error… , E(e=>e.trim()?e:t) }in the served bundle. The public trial chat(
POST /v1/public/agents/repo-chat/try) reaches the rewiredrunTurnon the deployed API andreturns the credentials error once, immediately — i.e. a non-retryable failure is not retried.
fabricating an outage or spending a real key against a broken provider; the class occurred once
in 1,050 calls over 7 days.
chat.auto_resumedis the row to look for when it next happens.Closing — the stored round is now reachable both automatically and by hand.
fix(coding): a Pilot run that dies files a classified, correlated rec…
fix(coding): a run our own deploy interrupted is replayed, not buried…
fix(runs): the retryable verdict has a reader on every driver, and th…
test(user-ai): the ceiling arm measures the deadline arithmetic inste…
feat(#907): Add resilient handling for transient AI provider stream s…