From 3e25ae7b00eeeaded90e06d39a6bb3ad2b8ddc53 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 31 Aug 2026 23:35:11 +0200 Subject: [PATCH] fix(ai-sessions): hold the catch-up gate across every await and cancel a stale retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019N8x51GqABUEFvg52V6aYo --- frontend/src/lib/components/sessions/latestWins.test.ts | 2 -- .../src/lib/components/sessions/sessionRuntime.svelte.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/sessions/latestWins.test.ts b/frontend/src/lib/components/sessions/latestWins.test.ts index 3ef35886ba..cf7686caf9 100644 --- a/frontend/src/lib/components/sessions/latestWins.test.ts +++ b/frontend/src/lib/components/sessions/latestWins.test.ts @@ -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() diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 46da5ad16a..0e3afded1f 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -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