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
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 |
|
Verified the bug against Your two-flag split is the right shape. On the open cubic P2It argues the gate should key on user-visible text rather than any non-control frame, since a drop after I disagree with the direction, and the reason matters more than the verdict: user-visible text is not the hazard the invariant exists to prevent. Duplicate text is the cosmetic half. The expensive half is a tool call that already dispatched — re-running a side-effecting tool, and paying for it twice. The residual cost you are accepting is real and worth stating plainly in the code: a transport failure in the window after the first output item but before anything the user would notice now ends the turn instead of rerouting. That is the conservative direction — it loses a reroute, it never double-charges. Given the choice between "occasionally fail to reroute" and "occasionally re-run a side-effecting tool", the first is obviously right, but a future reader will hit that comment and wonder, so let the comment answer them. If you want the reroute window back without weakening the guarantee, the discriminator is dispatch rather than visibility: a frame that OpenCode cannot have acted on yet is safe to replay. That needs checking against how OpenCode's parser dispatches tool calls, and is a bigger change than this PR should carry — worth an issue rather than a revision here. Gate is green on my machine: 1074 pass / 0 fail, typecheck clean. Blocking only on the comment. Add the note about what the conservative gate gives up and I will merge this. |
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.
192c003 to
4fb45d6
Compare
|
Comment added — Agreed on the cubic P2, and your reason is better than the one I would have given. I had been justifying the conservative classification by risk asymmetry — over-gating costs a reroute, under-gating costs a double charge — which is true but leaves the impression we were being cautious about a fuzzy boundary. Your framing is sharper: the boundary is not fuzzy at all. The last line points at your dispatch-vs-visibility discriminator so a future reader finds the real answer rather than re-deriving the text-gating idea and reopening the case. Happy to file that as an issue — it needs checking against how OpenCode's parser dispatches tool calls, which is outside this PR. On calibration: your read matches ours. These came out of an automated sweep, and we filed only the three we had verified by hand out of 44 raw findings — the aggregate severity ordering was not trustworthy (one HIGH turned out to require an operator to configure fallback on a success status, i.e. misconfiguration rather than a defect). Treating them as claims to test rather than a queue to burn down is exactly right, and it is why the other ~38 are sitting unfiled until someone verifies them. |
|
Verified at The comment says the right thing, and the closing line pointing at a dispatch-based discriminator is the part that earns its place: it stops the next reader re-deriving text-gating and reopening the case. Please do file that issue. Approved. Merging. One thing I hit while gating this that is worth reporting separately, because it is not yours and it is not a regression: That test fails when run alone under a Not a production defect, but it is the shape that hides one: a test that only passes with its siblings cannot be trusted to gate the behaviour it names, and anyone bisecting with On calibration: filing 3 verified out of 44 raw findings is the right ratio to have applied, and the HIGH-that-needed-a-misconfiguration is a good example of why the severity number cannot be read as a queue position. Worth keeping the other ~38 unfiled until someone tests them — an unverified finding costs a reviewer more than it saves. |
|
Filed as #105, with the text-gating rejection written up as a constraint rather than a footnote — so whoever picks it up cannot re-derive the idea without first meeting the On the order-dependent test — I cannot reproduce it, which changes its shapeRan your exact command on Linux at So it is not deterministically order-dependent — it is environment-dependent, which is a worse shape than the one you described and worth capturing in your issue. Two things I checked that narrow it: It is not sidebar-state leakage. The test lives at Worth noting the hazard that preload defends against is real on my machine: That leaves module-level process state rather than file state as the remaining candidate — a memoized settings object, or an in-process quota cache that a sibling test's body resets and a hook does not. Since the test's precondition is literally "an empty process quota cache", anything that leaves that cache non-empty across a filtered run would flip it. I agree with your framing entirely: a test that only passes alongside its siblings cannot gate the behaviour it names, and it hands a false positive to anyone bisecting with Happy to take it if useful — I have the reproduction environment for the negative case, which is half of pinning it. Your call whether it lands on your side since you have the failing box. |
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/wrapped provider errors could surface as retryable and trigger a replay; now they fail visibly and non-retryably.
response.created/response.in_progress) do not count as output.onRetryableTerminal, and wrapped provider errors (408/409/429/500/503). Before output (including after lifecycle-only frames), failures remain retryable; after output, the consumer sees a plainErrorwhile the pool records aResponseStreamError.onRateLimitReached; after output, they end the turn without retry.APICallErroris produced only if no output was emitted (after output it is provided as thecauseof a plainError).Written for commit 4fb45d6. Summary will update on new commits.