Skip to content

[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

@serge-ivo

#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

  1. A chat turn that failed with a stored resumable round can be retried without the user reproducing the original text byte-for-byte.
  2. The retry reuses the stored round — the already-executed tools are not re-run and not re-billed. Assert this, don't assume it.
  3. 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.
  4. If the composer is to remain the recovery path, InstanceDetail.tsx restores the text on failure instead of discarding it at :796.
  5. 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.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions