fix(ai-sessions): hold the catch-up gate across every await and cancel a stale retry

Two edges of the round-18 serialization, both in the same seam:

- The superseded() probe ran after reloadChat but not after loadPastChat,
  which also awaits (image hydration). A catch-up overtaken during that
  await still settled the caught-up gate, unlocking the composer over a
  transcript missing the newer turn for as long as the queued catch-up
  was still reading. Re-probed before caughtUp is set, so a superseded
  catch-up leaves the gate to the one that owns it. Every await in
  applyTurnEnd is now followed by a probe before any write or release.

- The unavailable-store retry re-queued carrying its own turn-end's chat
  id, and newest-queued-wins let that stale retry supersede a newer
  turn-end's catch-up — re-adopting and re-persisting the chat a /clear
  had rotated away from. A newly queued catch-up now cancels any pending
  retry; the new task schedules its own if it needs one, so a retry can
  no longer outlive its turn-end.

Also trims a test comment that repeated the executor contract documented
on createLatestWins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N8x51GqABUEFvg52V6aYo
This commit is contained in:
hugocasa
2026-08-31 23:35:11 +02:00
co-authored by Claude Fable 5
parent 8789f5b9af
commit 3e25ae7b00
2 changed files with 9 additions and 2 deletions
@@ -26,8 +26,6 @@ describe('createLatestWins', () => {
order.push('second')
})
await tick()
// The second task waits for the first however slow it is, so its writes
// can never land between — or after — the first's.
expect(order).toEqual(['first:start'])
first.resolve()
await tick()
@@ -1111,6 +1111,10 @@ function applyRunStatus(msg: RunStatusMsg): void {
const catchUps = createLatestWins()
function queueCatchUp(sessionId: string, chatId: string, attempt = 0): void {
// A pending unavailable-store retry carries its own turn-end's chat id; left
// armed, it could fire after this newer catch-up queued and supersede it with
// the stale id. This catch-up schedules its own retry if it needs one.
cancelCatchUpRetry(sessionId)
catchUps.run(sessionId, (superseded) => applyTurnEnd(sessionId, chatId, attempt, superseded))
}
@@ -1177,6 +1181,11 @@ async function applyTurnEnd(
m.adoptEmptyChat(id)
setSessionChatId(sessionId, id)
}
// Re-probed because loadPastChat awaits too (image hydration): a turn-end
// that arrived during it owns the gate now, and settling it here would
// unlock the composer over a transcript missing that newer turn for as
// long as the queued catch-up is still reading.
if (superseded()) return
caughtUp = true
} catch (e) {
// A read that threw leaves the same mismatched pair an 'unavailable' one