fix(ai-sessions): stop a disposed runtime's catch-up from writing back

The catch-up probes asked only whether a newer turn-end had superseded
this one, never whether the runtime it captured still exists. A session
deleted in another tab mid-read disposed the runtime under the task,
and the resuming catch-up re-armed the orphaned manager's job poller
(a self-rescheduling chain nothing could reach to stop) and re-took the
store handle disposal had just closed. The same identity check
initRuntime already makes now sits beside each supersession probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N8x51GqABUEFvg52V6aYo
This commit is contained in:
hugocasa
2026-09-01 09:14:57 +02:00
co-authored by Claude Fable 5
parent 054b65cdba
commit 07b033f458
@@ -1158,6 +1158,10 @@ async function applyTurnEnd(
// only for that newer catch-up to redo them — and it settles the caught-up
// gate too, so leave it held here.
if (superseded()) return
// Disposal is the other thing the awaits can hide: a session deleted in
// another tab mid-read must not have its orphaned manager re-arm the job
// poller or re-take the store handle disposal just closed.
if (runtimes.get(sessionId) !== runtime) return
if (found === 'unavailable') {
// The store is unreadable *right now*, which says nothing about the
// conversation — so this tab still holds the one from before the run.
@@ -1186,6 +1190,7 @@ async function applyTurnEnd(
// unlock the composer over a transcript missing that newer turn for as
// long as the queued catch-up is still reading.
if (superseded()) return
if (runtimes.get(sessionId) !== runtime) return
caughtUp = true
} catch (e) {
// A read that threw leaves the same mismatched pair an 'unavailable' one