From d98293d91723f8551cf3f8f7875aa9cdf5085f03 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 2 Jun 2026 19:42:57 +0800 Subject: [PATCH] fix(daemon): auto-recover transcript on ring_evicted resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a WebUI client reconnects with a stale SSE cursor past the EventBus ring buffer, the daemon emits state_resync_required with reason ring_evicted. Previously the provider did nothing for this reason, leaving the transcript frozen because the reducer's awaitingResync latch silently dropped all subsequent replay events. Hoist store.reset() before the reason-specific branches so it runs for all resync reasons including ring_evicted. This clears the awaitingResync latch and empties the stale transcript, allowing the ring replay events (already queued by EventBus.subscribe) to flow through the reducer and rebuild the transcript from the surviving ring contents. Also remove the misleading "Reload the session to recover." suffix from the resync error message — reloading re-triggers ring eviction for long sessions, and the WebUI now auto-recovers. --- .../src/daemon/ui/transcript.ts | 2 +- .../session/DaemonSessionProvider.test.tsx | 31 ++++++++++++++++--- .../daemon/session/DaemonSessionProvider.tsx | 3 +- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 363e96e0192..8917175a05f 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -316,7 +316,7 @@ function handleStateResyncRequired( appendStatusBlock( state, 'error', - `State resync required: ${formatMissedRange(event.lastDeliveredId, event.earliestAvailableId)}. Reload the session to recover.`, + `State resync required: ${formatMissedRange(event.lastDeliveredId, event.earliestAvailableId)}.`, event, ); } diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 811e983eba0..b30b5e3a92e 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -1934,7 +1934,7 @@ describe('DaemonSessionProvider', () => { ]); }); - it('clears awaitingResync on replay_complete after ring-evicted resync', async () => { + it('resets store on ring-evicted resync so replay events rebuild transcript', async () => { const liveDelivered = createDeferred(); const session = createMockSession({ lastEventId: 10, @@ -1950,13 +1950,24 @@ describe('DaemonSessionProvider', () => { earliestAvailableId: 12, }, }; + yield { + id: 12, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'replayed history' }, + }, + }, + }; yield { v: 1, type: 'replay_complete', - data: { replayedCount: 0 }, + data: { replayedCount: 1 }, }; yield { - id: 12, + id: 13, v: 1, type: 'session_update', data: { @@ -1997,12 +2008,24 @@ describe('DaemonSessionProvider', () => { expect(awaitingResync).toBe(false); expect(blocks).toEqual( expect.arrayContaining([ + expect.objectContaining({ + kind: 'assistant', + text: 'replayed history', + }), expect.objectContaining({ kind: 'assistant', text: 'live after replay', }), ]), ); + expect(blocks).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'error', + text: expect.stringContaining('State resync required'), + }), + ]), + ); }); it('clears ring-evicted awaitingResync on same-session reattach', async () => { @@ -2078,7 +2101,7 @@ describe('DaemonSessionProvider', () => { await firstStreamDone.promise; await flushPromises(); }); - expect(blocks).toEqual( + expect(blocks).not.toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 'error', diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 1b8aeffd6ee..09a3aac52bb 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -508,12 +508,11 @@ export function DaemonSessionProvider({ : undefined; setPromptStatus('idle'); clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); + store.reset(); if (reason === 'epoch_reset') { - store.reset(); activeSession.setLastEventId(0); } else if (reason !== 'ring_evicted') { resyncRequested = true; - store.reset(); session = undefined; sessionRef.current = undefined; setConnection((current) => ({