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

Closed

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.

Activity

  1. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in 2bc9475 (CI green; API deployed with b590eee'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.executed seeds the cross-round dedup map and resume.mutations seeds the counter
      (agent-think.ts:882-883), so an identical re-issued call is refused at :1015-1017 with
      is_error, precisely as it would be inside one turn;
    • the results themselves are replayed as tool_result blocks, which is why the model does not
      re-issue them.

    Not asserted from reading: agent-think-resume.test.ts drives the real runAgentThink twice
    with 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.tsx restores the exact string on a failed send, into an empty box only. That is the
    landing 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 by
    construction where retyping cannot.

    Against the acceptance criteria

    1. ✅ Retry without reproducing the text — automatic, and byte-exact by restoration when manual.
    2. ✅ Asserted, not assumed — see the execution count above.
    3. ✅ Voice covered by (b) unconditionally; the (a) fallback surfaces on the chat surface itself.
    4. ✅ :796 still clears before the POST (that is right — the message is in flight); the catch
      now restores it.
    5. ✅ RESUMABLE_TTL_MS 10m → 30m. Sized to the human path, using this ticket's own
      measurement: 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.
    6. ✅ The deliverable: agent-think-resume.test.ts proves consumption, and a source guard
      proves the DO reaches the thinker only through thinkWithAutoResume — a later edit calling
      this.think directly would otherwise disable the retry in production with every unit test green,
      which is the shape of this bug.

    What decides a retry

    UserAiProviderError now carries retryable, 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.ts holds
    isRetryableDeadline equal to the sentence the user reads, so the advice and the automatic
    behaviour cannot drift apart. Every recovery writes chat.auto_resumed to the trace, so "how often
    does 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 AbortError no longer borrows
    deadlineMessage("stall", 20_000), whose "20s of silence" was the constant rather than a
    measurement (connectionLostMessage).

    Deliberately unchanged, per the report: the 20s deadline, the discard, and the error text.

    Verified

    • Full bar green: typecheck (11 projects), 7,887 unit tests, biome, all six 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).
    • Live: the deployed console at proagentstore.online/console carries 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 rewired runTurn on the deployed API and
      returns the credentials error once, immediately — i.e. a non-retryable failure is not retried.
    • Not verified live: an actual provider stall triggering the auto-retry. Inducing one means
      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_resumed is the row to look for when it next happens.

    Closing — the stored round is now reachable both automatically and by hand.

  2. added a commit that references this issue on Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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