Skip to content

fix(sessions): stop marking completed turns as interrupted - #988

Merged
philmerrell merged 2 commits into
developfrom
fix/false-interrupted-turn-marker
Sep 6, 2026
Merged

fix(sessions): stop marking completed turns as interrupted#988
philmerrell merged 2 commits into
developfrom
fix/false-interrupted-turn-marker

Conversation

@philmerrell

@philmerrell philmerrell commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

The bug

A session's AbortController is 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 posted navigated_away for it. One departure marked every session ever streamed in that tab (two prod rows share a timestamp to the millisecond).

What it costs

  1. A "Response interrupted" chip + Continue button on a complete answer. Clicking Continue sends a continue_truncated turn with an empty prompt, so the model resumes a response that already finished — a billed turn producing a duplicate.
  2. Invisibly, a false <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)

  • 1,680 sessions carry the marker; 1,505 (90%) got it more than 30s after their last message landed — the turn was already over. 1,475 of those are navigated_away.
  • The reported session 36085dfb…: 2 messages, complete, lastMessageAt 20:01:25, marker written 20:03:31.
  • Not model-specific — reported against Terra/Luna, but the oldest marker is 2026-08-21, three weeks before GPT-5.6 existed in prod.

The fix

ClientreleaseAbortController(sessionId, controller) called from finalizeStream. Identity-checked, so a superseded stream's late close can't clear the controller of the stream that replaced it. This makes streamingSessionIds() mean what its docstring already claimed ("created per request and nulled on abort").

ServerPOST /sessions/{id}/interrupt verifies "mid-turn" against the session's single-flight lease before recording navigated_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_stopped is 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.py clears 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, 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.

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

  • SPA: controller is released on normal completion (verified to fail without the fix), release is identity-checked, released session drops out of streamingSessionIds().
  • Backend: navigated_away recorded when the lease is held, ignored (still 204) when it isn't, user_stopped ungated and still arms cancel.
  • Backfill: 11 moto-backed cases, weighted toward what the script refuses to touch (every non-navigated_away reason, sub-threshold gaps, a marker predating the last message, re-marked rows, reason upgrades, idempotency).
  • Full suites green: 1,935 backend tests, 334 SPA session-service tests, tsc --noEmit clean.

🤖 Generated with Claude Code

philmerrell and others added 2 commits September 6, 2026 14:52
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>
@philmerrell
philmerrell merged commit c576679 into develop Sep 6, 2026
4 checks passed
@philmerrell
philmerrell deleted the fix/false-interrupted-turn-marker branch September 6, 2026 21:13
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.

1 participant