fix(sessions): stop marking completed turns as interrupted - #988
Merged
Conversation
A session's AbortController was created per request but released only by
the Stop button — never on normal stream teardown. So after any turn that
finished on its own, `streamingSessionIds()` kept reporting that session
as in-flight for the life of the tab, and the page-hide attribution
handler signalled `navigated_away` for it on the next refresh, tab close,
or cross-document navigation. One departure marked every session ever
streamed in that tab.
The result is a "Response interrupted" chip plus a Continue button on a
complete answer, and — invisibly — a false `<interruption_note>` prepended
to that session's next prompt and persisted in its history, telling the
model its previous response was cut off and to pick up where it left off.
Measured in prod: 1,678 sessions carry the marker and 1,505 of them (90%)
received it more than 30s after their last message landed; 1,475 of those
are `navigated_away`. Not model-specific and not new — the oldest is
2026-08-21.
Two changes:
* `releaseAbortController` on stream teardown (identity-checked, so a
superseded stream's late close can't clear its replacement's handle).
This makes `streamingSessionIds()` mean what its docstring claims.
* `POST /sessions/{id}/interrupt` now verifies "mid-turn" against the
session's single-flight lease before recording `navigated_away`, rather
than taking the client's word for it. Old tabs run the old SPA long
after this ships. `user_stopped` is deliberately not gated: the button
only exists while streaming, and it also arms distributed cancellation,
which must still reach a turn whose lease read fails open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One-shot cleanup for the markers the stale-controller bug left behind. Each one shows a "Response interrupted" chip plus a Continue button on a complete answer, and puts a false <interruption_note> into that session's next prompt — persisted in history, invisible in the UI. Markers self-clear only at the start of that session's next turn, so a conversation nobody returns to stays armed indefinitely. Clears only what is provably false: reason `navigated_away`, marked more than 900s after the session's last message. Gap alone is not enough — an interrupted continuation persists no assistant message, so `lastMessageAt` stays at the previous turn and a real interruption can show a modest positive gap. But the SSE stream times out at 600s, so nothing is still running 15 minutes later. `user_stopped` and `connection_lost` rows are never touched: neither comes from this bug, and clearing one would destroy a real record. Dry-run by default. Each write is conditional on the exact timestamp and reason the scan read, so a session re-marked in between is skipped rather than clobbered; re-running is a no-op. Prod dry-run (2026-09-06): 1,680 marked rows → 787 provably false, 847 left alone as ambiguous, 46 left alone as another reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 6, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A session's
AbortControlleris created per request but released only by the Stop button — never on normal stream teardown.finalizeStream()cleared the loading flag and streaming state and left the controller behind.streamingSessionIds()tests exactly that controller, and the page-hide handler uses it to decide which turns a departure interrupted. So after any turn that finished on its own, the session looked in-flight for the life of the tab, and the next refresh / tab close / cross-document navigation postednavigated_awayfor it. One departure marked every session ever streamed in that tab (two prod rows share a timestamp to the millisecond).What it costs
continue_truncatedturn with an empty prompt, so the model resumes a response that already finished — a billed turn producing a duplicate.<interruption_note>prepended to that session's next prompt and persisted in its history: "Your previous response was cut off by a connection interruption … If the user asks you to continue, pick up where it left off instead of starting over." It outlives the marker and ages out only via compaction.Evidence (prod,
boisestateai-v2-sessions-metadata)navigated_away.36085dfb…: 2 messages, complete,lastMessageAt20:01:25, marker written 20:03:31.The fix
Client —
releaseAbortController(sessionId, controller)called fromfinalizeStream. Identity-checked, so a superseded stream's late close can't clear the controller of the stream that replaced it. This makesstreamingSessionIds()mean what its docstring already claimed ("created per request and nulled on abort").Server —
POST /sessions/{id}/interruptverifies "mid-turn" against the session's single-flight lease before recordingnavigated_away, instead of taking the client's word for it. Old tabs keep running the old SPA long after this ships. The lease is held for the whole live turn (90s window, 10s heartbeat), so this can't drop a real departure.user_stoppedis deliberately not gated: the Stop button only exists while streaming, and the same request arms distributed cancellation, which must still reach a turn whose lease read fails or fails open.Backfill
backend/scripts/backfill_false_interrupted_markers.pyclears the markers already out there — they self-clear only at the start of that session's next turn, so a conversation nobody returns to stays armed indefinitely.It acts only on what is provably false: reason
navigated_away, marked more than 900s after the session's last message. Gap alone would not be safe — an interrupted continuation persists no assistant message, solastMessageAtstays at the previous turn and a real interruption can show a modest positive gap. But the SSE stream times out at 600s, so nothing is still running 15 minutes later.user_stoppedandconnection_lostrows are never touched.Dry-run by default; every write is conditional on the exact timestamp and reason the scan read, so a row re-marked in between is skipped rather than clobbered, and re-running is a no-op.
Prod dry-run: 1,680 marked rows → 787 to clear, 847 left alone as ambiguous, 46 left alone as another reason.
Tests
streamingSessionIds().navigated_awayrecorded when the lease is held, ignored (still 204) when it isn't,user_stoppedungated and still arms cancel.navigated_awayreason, sub-threshold gaps, a marker predating the last message, re-marked rows, reason upgrades, idempotency).tsc --noEmitclean.🤖 Generated with Claude Code