Skip to content

fix(ws): stop replaying a turn after output was emitted - #92

Open
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:fix/ws-no-replay-after-emit
Open

fix(ws): stop replaying a turn after output was emitted#92
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:fix/ws-no-replay-after-emit

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #88.

The rate-limit path already refused to retry once output had reached the consumer — its comment names the harm exactly: "side-effecting tools, and double-bill — so end the turn WITHOUT a retry." Five other failure paths never consulted emitted, so a failure arriving after partial output could still surface a retryable marker and replay the turn: duplicated text, tool calls run twice, the turn billed twice.

Branched from 9bf8f4c, independent of #87.

The rule

ResponseStreamError extends APICallError with isRetryable: true, and OpenCode's retry loop converts retryable APICallErrors into SessionRetry attempts. So the gate is: before output a failure may surface a retryable marker and reroute; after output it must fail visibly and non-retryably.

invalidateTransport implements the split, and fail(error, connectionError) separates the two channels — the consumer gets the gated error, the pool still gets a ResponseStreamError for its own bookkeeping.

Sites gated

site before after
idle timeout retryable gated
socket error retryable gated
early close retryable gated
unexpected binary frame retryable gated
throwing onRetryableTerminal callback retryable gated
wrapped provider error (408/409/429/500/503) retryable plain Error, APICallError as cause

The callback path mattered most in practice: the connection-limit callback is a throwing path, so it was reachable in normal operation.

Post-output failures deliberately still count toward streamRetries — the socket genuinely failed, and a successful terminal response resets the counter.

Deliberately NOT gated

Eleven sites were enumerated; six stay ungated on purpose:

  • Pre-output admission and rate-limit failures — these should reroute.
  • Post-output rate limits — already handled by the existing closeCompleted() policy.
  • Synchronous socket.send failure — production callbacks never return a replacement socket, so this send always precedes output.
  • Abort — already plain and non-retryable.
  • Ordinary terminal frames and cancellation — expose no retry marker.

Why not closeCompleted()

The obvious fix is wrong. closeCompleted() enqueues data: [DONE] and closes normally, so a turn that died halfway would look complete — trading duplication for silent truncation, which is harder to detect.

Tests

1071 pass / 0 fail (1064 on 9bf8f4c), tsc clean. ws.ts moves 48 lines.

Reviewed independently by a different model family, which found two of the five sites we had missed — the binary-frame and throwing-callback paths, plus the wrapped-error bypass — by enumerating every consumer-visible error construction rather than reviewing only the sites we named. Those were proven with live probes before being fixed.

Every new test was mutation-checked behaviourally (keep the API, flip only the gate); each fails with a concrete assertion that a retryable marker was received, and no red was a TypeError or a hang. Pre-output rerouting was separately verified as intact — a pre-output wrapped 503 still yields APICallError / isRetryable: true, and the inverse mutation fails.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Stop replaying a WebSocket turn after genuine output (text/tool/function) reaches the client to avoid duplicated text, tool re-runs, and double billing. Previously, post-output transport failures surfaced as retryable and triggered a replay; now they fail visibly and non-retryably.

  • Gate retryability to pre-output only via a new emittedOutput flag and invalidateTransport; lifecycle frames (response.created/response.in_progress) do not trip the gate.
  • Apply the gate to idle timeout, socket error, early close, unexpected binary frame, thrown onRetryableTerminal, and wrapped provider errors (408/409/429/500/503). Before output (including after lifecycle-only frames), failures remain retryable; after output, wrapped errors surface as plain Error to the consumer while the pool records a ResponseStreamError.
  • Wrapped errors produce an APICallError only if no output was emitted. Tests cover each gated path and a pre-output retry case.

Written for commit 192c003. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/src/ws.ts Outdated
The rate-limit path already refuses to retry once output reached the consumer, to avoid duplicated text, re-run tool calls, and double billing. The transport-failure handlers did not apply the same gate, so a socket error, early close, or idle timeout after partial output could still surface as a retryable stream error.

Apply the emitted-output gate to all five failure paths and rate-limit decisions after genuine output. Control lifecycle frames remain retryable until text or a tool/function frame can be replayed. A pre-output failure stays retryable; a post-output failure now fails visibly and non-retryably rather than being retried or closed as if it had succeeded.
@iceteaSA
iceteaSA force-pushed the fix/ws-no-replay-after-emit branch from cea3215 to 192c003 Compare August 19, 2026 06:44
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Updated to 192c003 (force-push; previous head cea3215 preserved on our side).

Cubic caught that the gate was too COARSE, and chasing it found two more sites than it named.

emitted flips on the first enqueued frame — normally response.created — so the entire window between a response starting and its first real token was treated as "output already delivered". Every failure in that window became non-retryable, killing reroutes that were completely safe. That is where connection failures cluster, so it was a real capability loss, and it was our own over-correction.

A separate emittedOutput flag now drives replay decisions. emitted keeps its original meaning for the three consumers that want it.

Chasing that turned up the same defect in the two rate-limit paths: admission classification and the mid-stream split. Proven by probe — response.created → response.failed(rate_limit) closed normally instead of rejecting retryably, and response.created → 429 stayed retryable but never called onRateLimitReached, so a retry could select the same exhausted account. A reroute that silently fails to mark the account is worse than no reroute.

That is three rounds of the same defect class on this branch: a retry decision keyed on the wrong signal. So we stopped patching sites and audited all 10 that decide retry, reroute, replay, or mark. Two changed, eight verified correct, no further instances.

Classification was validated against the pool rather than by frame name: response.created and response.in_progress commit no continuation state (updateContinuation runs only from onComplete, finalized-call recording only on response.output_item.done, onTerminal uninvoked). Everything ambiguous — output-item, content-part, unknown, terminal — is classified conservatively as output, because a wrong call that way costs a reroute while the reverse duplicates output and re-runs tools.

Gate: 1074 pass / 0 fail (1064 on 9bf8f4c), tsc clean. Re-reviewed after each round; final APPROVE 0 must / 0 should, with every site mutated back independently and each reddening only its own test behaviourally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stream retry after partial output can duplicate a turn and re-run tool calls

1 participant