fix(ws): stop replaying a turn after output was emitted - #92
Conversation
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
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.
cea3215 to
192c003
Compare
|
Updated to Cubic caught that the gate was too COARSE, and chasing it found two more sites than it named.
A separate Chasing that turned up the same defect in the two rate-limit paths: admission classification and the mid-stream split. Proven by probe — 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: Gate: 1074 pass / 0 fail (1064 on |
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 APICallErrorwithisRetryable: true, and OpenCode's retry loop converts retryableAPICallErrors 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.invalidateTransportimplements the split, andfail(error, connectionError)separates the two channels — the consumer gets the gated error, the pool still gets aResponseStreamErrorfor its own bookkeeping.Sites gated
onRetryableTerminalcallbackError,APICallErroras causeThe 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:
closeCompleted()policy.socket.sendfailure — production callbacks never return a replacement socket, so this send always precedes output.Why not
closeCompleted()The obvious fix is wrong.
closeCompleted()enqueuesdata: [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 on9bf8f4c),tscclean.ws.tsmoves 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
TypeErroror a hang. Pre-output rerouting was separately verified as intact — a pre-output wrapped 503 still yieldsAPICallError/isRetryable: true, and the inverse mutation fails.Need help on this PR? Tag
@codesmith-botwith 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.
invalidateTransport; lifecycle frames (response.created/response.in_progress) do not trip the gate.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 plainErrorto the consumer while the pool records aResponseStreamError.APICallErroronly 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.