diff --git a/src/main/persistence/window-save-divergence.ts b/src/main/persistence/window-save-divergence.ts index a088b595b5d..f6712503d44 100644 --- a/src/main/persistence/window-save-divergence.ts +++ b/src/main/persistence/window-save-divergence.ts @@ -195,7 +195,8 @@ export function windowSaveDivergences( ...rawPaths.keys() ]) const result: WindowSaveDivergence[] = [] - // Ignored, as main's own writes: pane bindings, resume records, default-tab marks, closed-tab records, row owners. + // Ignored, as main's own writes: pane bindings, resume records, default-tab marks, closed-tab records, row owners, + // clientHostedBrowserPagesByWorkspace and the topology revision. for (const workspace of workspaces) { const paths = [ ...diffWorkspaceLayouts(was?.workspaces[workspace], now?.workspaces[workspace]), diff --git a/src/renderer/src/app-shell/quit-sleep-record-capture.test.ts b/src/renderer/src/app-shell/quit-sleep-record-capture.test.ts new file mode 100644 index 00000000000..eed3d07131e --- /dev/null +++ b/src/renderer/src/app-shell/quit-sleep-record-capture.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { AppState } from '../store/types' +import { createQuitSleepRecordCapture } from './quit-sleep-record-capture' +import { createShutdownCheckpointPersist } from './shutdown-checkpoint-persist' + +const RECORD: SleepingAgentSessionRecord = { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: '', + state: 'working', + capturedAt: 1, + updatedAt: 1 +} + +/** A window holding tab-1, whose quit capture records RECORD (and then finds it unchanged). */ +function windowStore() { + let records: AppState['sleepingAgentSessionsByPaneKey'] = {} + const state = (): AppState => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the capture reads only these fields. + ({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + sleepingAgentSessionsByPaneKey: records, + captureAllSleepingAgentSessions: () => { + records = { [RECORD.paneKey]: RECORD } + } + }) as unknown as AppState + return { getState: state } +} + +function persist(stage: (args: { sleepRecords?: SleepingAgentSessionRecord[] }) => void) { + const capture = createQuitSleepRecordCapture(windowStore()) + return createShutdownCheckpointPersist({ + shouldCaptureSession: () => true, + captureTerminalBuffers: vi.fn(), + captureSleepingAgentSessions: capture.capture, + onSleepRecordsStaged: capture.staged, + buildSessionSnapshots: () => [], + buildUiPatch: () => ({}), + hasDirtyOpenFiles: () => false, + isDegradableShutdownInProgress: () => false, + stageBeforeUnloadSync: stage + }) +} + +describe('the quit capture’s resume records in a layout-owner build', () => { + it('sends the first capture’s records again on a stage retried after a failure', () => { + const staged: (SleepingAgentSessionRecord[] | undefined)[] = [] + let fail = true + const checkpoint = persist(({ sleepRecords }) => { + staged.push(sleepRecords) + if (fail) { + throw new Error('stage failed') + } + }) + + expect(() => checkpoint.run()).toThrow('stage failed') + fail = false + checkpoint.run() + + expect(staged).toEqual([[RECORD], [RECORD]]) + }) + + it('sends nothing again once a stage carried them', () => { + const staged: (SleepingAgentSessionRecord[] | undefined)[] = [] + const checkpoint = persist(({ sleepRecords }) => staged.push(sleepRecords)) + + checkpoint.run() + checkpoint.run() + + expect(staged).toEqual([[RECORD], undefined]) + }) +}) diff --git a/src/renderer/src/app-shell/quit-sleep-record-capture.ts b/src/renderer/src/app-shell/quit-sleep-record-capture.ts new file mode 100644 index 00000000000..c352c4319b5 --- /dev/null +++ b/src/renderer/src/app-shell/quit-sleep-record-capture.ts @@ -0,0 +1,27 @@ +import type { AppState } from '../store/types' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { changedSleepingRecords } from '../store/terminals/terminal-sleep-layout-owner' + +/** + * A layout-owner build's quit capture: the resume records it changed of tabs the window holds, + * since the last stage that carried them, so a stage retried after a failed one still sends the + * first capture's records. + */ +export function createQuitSleepRecordCapture(store: { getState: () => AppState }): { + capture: () => SleepingAgentSessionRecord[] + staged: () => void +} { + let unstagedSince: AppState['sleepingAgentSessionsByPaneKey'] | null = null + return { + capture: () => { + const previous = unstagedSince ?? store.getState().sleepingAgentSessionsByPaneKey + unstagedSince = previous + store.getState().captureAllSleepingAgentSessions('quit') + const state = store.getState() + return changedSleepingRecords(state, previous, state.sleepingAgentSessionsByPaneKey).recorded + }, + staged: () => { + unstagedSince = null + } + } +} diff --git a/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts b/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts index 23194e31bc8..3eb4f025c41 100644 --- a/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts +++ b/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts @@ -17,6 +17,8 @@ export type ShutdownCheckpointPersistDeps = { captureTerminalBuffers: () => void /** Returns the records it changed when main records them (a layout-owner build). */ captureSleepingAgentSessions: () => SleepingAgentSessionRecord[] | void + /** The stage carried the capture's records: a retry's capture may diff from here. */ + onSleepRecordsStaged?: () => void buildSessionSnapshots: () => WorkspaceSessionHostSnapshot[] buildUiPatch: () => Partial hasDirtyOpenFiles: () => boolean @@ -82,6 +84,9 @@ export function createShutdownCheckpointPersist( ui: deps.buildUiPatch(), ...(sleepRecords && sleepRecords.length > 0 && !degraded ? { sleepRecords } : {}) }) + if (!degraded) { + deps.onSleepRecordsStaged?.() + } } catch (error) { // A durable-only stage has nothing safer left to fall back to. if (degraded) { diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 932083ff52d..6fcf4e888e8 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -20,8 +20,7 @@ import { createShutdownCheckpointGuard } from '../lib/shutdown-checkpoint-guard' import { createShutdownCheckpointPersist } from './shutdown-checkpoint-persist' -import { changedSleepingRecords } from '../store/terminals/terminal-sleep-layout-owner' -import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { createQuitSleepRecordCapture } from './quit-sleep-record-capture' import { shutdownBufferCaptures } from '../components/terminal-pane/shutdown-buffer-captures' import { dispatchWindowCloseRequest, @@ -36,14 +35,6 @@ import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' -/** The quit capture, returning the resume records it changed of tabs the window holds. */ -function captureQuitSleepRecordsForMain(): SleepingAgentSessionRecord[] { - const previous = useAppStore.getState().sleepingAgentSessionsByPaneKey - useAppStore.getState().captureAllSleepingAgentSessions('quit') - const state = useAppStore.getState() - return changedSleepingRecords(state, previous, state.sleepingAgentSessionsByPaneKey).recorded -} - // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 @@ -77,6 +68,9 @@ export function useAppSessionPersistence(): void { // two firings, PTY exit events can arrive and unmount TerminalPanes, // emptying shutdownBufferCaptures. The guard prevents the second call // from overwriting the good session data with an empty snapshot. + const quitSleepRecords = ORCA_LAYOUT_OWNER_IS_MAIN + ? createQuitSleepRecordCapture(useAppStore) + : null const shutdownCheckpointPersist = createShutdownCheckpointPersist({ shouldCaptureSession: () => shouldPersistWorkspaceSession(useAppStore.getState()), captureTerminalBuffers: () => { @@ -93,9 +87,10 @@ export function useAppSessionPersistence(): void { // map so a daemon/session death while the app is closed can still // cold-restore via the agent's resume command (#5232). // Switch point: the stage writes only the capture's changes into main's records. - captureSleepingAgentSessions: ORCA_LAYOUT_OWNER_IS_MAIN - ? captureQuitSleepRecordsForMain + captureSleepingAgentSessions: quitSleepRecords + ? quitSleepRecords.capture : () => useAppStore.getState().captureAllSleepingAgentSessions('quit'), + onSleepRecordsStaged: quitSleepRecords?.staged, // Why: re-read state after capture() calls populated scrollback buffers // into the store via Zustand setters. The shouldCaptureSession read is // only for the gating flags and would miss those updates. diff --git a/src/renderer/src/runtime/runtime-layout-client.test.ts b/src/renderer/src/runtime/runtime-layout-client.test.ts index 95c535e5755..5ddc9b01a59 100644 --- a/src/renderer/src/runtime/runtime-layout-client.test.ts +++ b/src/renderer/src/runtime/runtime-layout-client.test.ts @@ -203,29 +203,35 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta expect(positions).toEqual([3, 4, 5, 6]) }) - it('after a lost stream, keeps the last state and never reopens it', async () => { - subscribe() - const first = await opened(1) - first.send( - snapshot([ - { key: 'a', layout: layout('a', ['t1', 't2']) }, - { key: 'b', layout: layout('b') } - ]) - ) - first.drop() - expect(first.unsubscribed).toBe(true) - expect(status).toEqual({ state: 'lost' }) - await vi.advanceTimersByTimeAsync(60_000) - expect(host.streams).toHaveLength(1) - // The last state stays; a late frame from the dropped stream changes nothing. - first.send({ type: 'removed', key: 'a' }) - expect(cache).toEqual({ a: layout('a', ['t1', 't2']), b: layout('b') }) - }) + it.skipIf(target.kind === 'local')( + 'after a lost stream, keeps the last state and never reopens it', + async () => { + subscribe() + const first = await opened(1) + first.send( + snapshot([ + { key: 'a', layout: layout('a', ['t1', 't2']) }, + { key: 'b', layout: layout('b') } + ]) + ) + first.drop() + expect(first.unsubscribed).toBe(true) + expect(status).toEqual({ state: 'lost' }) + await vi.advanceTimersByTimeAsync(60_000) + expect(host.streams).toHaveLength(1) + // The last state stays; a late frame from the dropped stream changes nothing. + first.send({ type: 'removed', key: 'a' }) + expect(cache).toEqual({ a: layout('a', ['t1', 't2']), b: layout('b') }) + } + ) it.each([ ['a failed subscribe', () => host.rejectNextSubscribe(), async () => {}], ['an error frame', () => {}, async () => (await opened(1)).fail('stream failed')] ])('stops after %s without retrying', async (_case, before, fail) => { + if (target.kind === 'local') { + return + } before() const subscription = subscribe() await fail() @@ -289,3 +295,94 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta await vi.waitFor(() => expect(stream.unsubscribed).toBe(true)) }) }) + +describe('the local layout stream after a loss', () => { + let host: FakeHost + let cache: WorkspaceLayoutCache + let statuses: RuntimeLayoutStreamStatus[] + + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + host = installFakeHost({ kind: 'local' }) + cache = EMPTY_WORKSPACE_LAYOUT_CACHE + statuses = [] + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + const subscribe = () => + subscribeRuntimeLayout( + { kind: 'local' }, + {}, + (frame) => { + cache = applyLayoutFrame(cache, frame) + }, + (next) => { + statuses.push(next) + } + ) + + async function opened(count: number): Promise { + await vi.waitFor(() => expect(host.streams).toHaveLength(count)) + await vi.advanceTimersByTimeAsync(0) + return host.streams[count - 1]! + } + + const snapshot = (workspaces: { key: string; layout: PublishedWorkspaceLayout }[]) => ({ + type: 'snapshot', + subscriptionId: 'layout-1', + workspaces + }) + + it('reopens it, and the new snapshot replaces the cache', async () => { + subscribe() + const first = await opened(1) + first.send( + snapshot([ + { key: 'a', layout: layout('a') }, + { key: 'b', layout: layout('b') } + ]) + ) + first.drop() + expect(statuses.at(-1)).toEqual({ state: 'lost' }) + + await vi.advanceTimersByTimeAsync(1_000) + const second = await opened(2) + expect(statuses.at(-1)).toEqual({ state: 'connecting' }) + second.send(snapshot([{ key: 'a', layout: layout('a', ['t1']) }])) + expect(statuses.at(-1)).toEqual({ state: 'live' }) + expect(cache).toEqual({ a: layout('a', ['t1']) }) + // A late frame from the dropped stream changes nothing. + first.send({ type: 'removed', key: 'a' }) + expect(cache).toEqual({ a: layout('a', ['t1']) }) + }) + + it('waits longer after each failed reopen, and close stops a pending one', async () => { + const subscription = subscribe() + ;(await opened(1)).drop() + host.rejectNextSubscribe() + await vi.advanceTimersByTimeAsync(1_000) + expect(statuses.at(-1)).toEqual({ state: 'lost' }) + await vi.advanceTimersByTimeAsync(1_999) + expect(host.streams).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + await opened(2) + host.streams[1]!.drop() + subscription.close() + expect(statuses.at(-1)).toEqual({ state: 'closed' }) + await vi.advanceTimersByTimeAsync(60_000) + expect(host.streams).toHaveLength(2) + }) + + it('never reopens after a refusal', async () => { + subscribe() + ;(await opened(1)).fail('no layout stream', 'method_not_found') + await vi.advanceTimersByTimeAsync(60_000) + expect(host.streams).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/runtime/runtime-layout-client.ts b/src/renderer/src/runtime/runtime-layout-client.ts index 1b63efd54cd..b3c252f2fe3 100644 --- a/src/renderer/src/runtime/runtime-layout-client.ts +++ b/src/renderer/src/runtime/runtime-layout-client.ts @@ -21,9 +21,13 @@ export type RuntimeLayoutStreamStatus = // An older host without the method, or a caller scope denied it: a retry gets the same answer. const REFUSAL_CODES = ['method_not_found', 'forbidden'] as const +/** First wait before a lost local stream reopens; it doubles to the cap until a snapshot comes. */ +const LOCAL_REOPEN_DELAY_MS = 1_000 +const LOCAL_REOPEN_DELAY_CAP_MS = 30_000 + /** - * Opens the stream once; `onFrame` gets a snapshot first, then that stream's changes. A local - * stream ends only on close, document retirement or a refusal, so nothing reopens it. A remote + * Opens the stream; `onFrame` gets a snapshot first, then that stream's changes. A lost local + * stream reports `lost`, then reopens, and its snapshot replaces what the caller holds. A remote * stream that drops reports `lost`; reopening it belongs to the connection's reconnect. */ export function subscribeRuntimeLayout( @@ -38,6 +42,8 @@ export function subscribeRuntimeLayout( let generation = 0 let handle: { unsubscribe: () => void } | null = null let state: RuntimeLayoutStreamStatus['state'] = 'connecting' + let reopenTimer: ReturnType | null = null + let reopenDelay = LOCAL_REOPEN_DELAY_MS const setStatus = (status: RuntimeLayoutStreamStatus): void => { state = status.state @@ -64,6 +70,15 @@ export function subscribeRuntimeLayout( } const refusal = REFUSAL_CODES.find((code) => hasRuntimeRpcErrorCode(error, code)) stop(refusal ? { state: 'refused', code: refusal } : { state: 'lost' }) + // Why: layout.apply still reaches main without the stream, so only main's own changes would + // go unseen until a relaunch. + if (!refusal && target.kind === 'local') { + reopenTimer = setTimeout(() => { + reopenTimer = null + void open() + }, reopenDelay) + reopenDelay = Math.min(reopenDelay * 2, LOCAL_REOPEN_DELAY_CAP_MS) + } } const receive = (streamGeneration: number, value: unknown): void => { @@ -90,6 +105,7 @@ export function subscribeRuntimeLayout( return } if (frame.type === 'snapshot') { + reopenDelay = LOCAL_REOPEN_DELAY_MS setStatus({ state: 'live' }) } else if (state !== 'live') { // The host sends the snapshot first; a change before it has nothing to replace. @@ -120,7 +136,11 @@ export function subscribeRuntimeLayout( void open() return { close: () => { - if (state === 'connecting' || state === 'live') { + if (reopenTimer) { + clearTimeout(reopenTimer) + reopenTimer = null + stop({ state: 'closed' }) + } else if (state === 'connecting' || state === 'live') { stop({ state: 'closed' }) } } diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts index 84ed356504b..d8b1f7f991e 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts @@ -21,7 +21,11 @@ import { reportWindowSessionWriteActivity } from '../lib/window-session-write-ac import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' import { createTestStore } from '../store/slices/store-test-helpers' import type { LayoutFrameSlices } from '../store/workspace-layout-frame-slices' -import { afterLayoutFrame, endLayoutFramePositions } from './layout-frame-position' +import { + afterLayoutFrame, + endLayoutFramePositions, + layoutFramesFlowing +} from './layout-frame-position' import { holdTabPaneTree, requestTabPaneTreeRedraw } from './layout-owner-pane-intents' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' @@ -344,6 +348,36 @@ describe('the window drawn from layout frames', () => { sync.close() }) + it('after a lost stream reopens, its snapshot redraws every workspace from main', async () => { + const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) + sync.start() + await flushMicrotasks() + stream.onStatus!({ state: 'lost' }) + expect(layoutFramesFlowing()).toBe(false) + // Drawn while no frame came; main's snapshot does not have it. + const drawn = { type: 'leaf' as const, leafId: 'drawn-by-the-window' } + store.setState({ + terminalLayoutsByTabId: { + ...store.getState().terminalLayoutsByTabId, + 'tab-shell': { ...store.getState().terminalLayoutsByTabId['tab-shell']!, root: drawn } + } + }) + + stream.onStatus!({ state: 'connecting' }) + expect(layoutFramesFlowing()).toBe(true) + const renamed = structuredClone(layouts) + renamed[GIT_KEY]!.tabs.find((tab) => tab.id === 'tab-unbound')!.customTitle = 'from main' + stream.onFrame!(snapshot(renamed)) + stream.onStatus!({ state: 'live' }) + await flushMicrotasks() + + const rows = store.getState().tabsByWorktree[GIT_KEY]! + expect(rows.find((tab) => tab.id === 'tab-unbound')!.customTitle).toBe('from main') + expect(store.getState().terminalLayoutsByTabId['tab-shell']!.root).not.toEqual(drawn) + expect(sync.terminalTab(GIT_KEY, 'tab-shell')).toBeDefined() + sync.close() + }) + it('shows a live title change of the focused pane at once, even while a write is owed', async () => { const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) sync.start() diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index 7c0f8e85cfc..3625edd8896 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -259,6 +259,10 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa (frame) => { cache = applyLayoutFrame(cache, frame) if (frame.type === 'snapshot') { + // A reopened stream's snapshot redraws every workspace: the window drew alone meanwhile. + if (snapshotReceived) { + shown = {} + } snapshotReceived = true live = started } @@ -268,7 +272,11 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa if (status.state === 'lost' || status.state === 'refused') { console.warn('[layout-owner] layout stream stopped:', status) } - if (status.state !== 'connecting' && status.state !== 'live') { + if (status.state === 'connecting' && !streamOpen) { + // The local stream reopens after a loss; replies wait for its frames again. + streamOpen = true + beginLayoutFramePositions() + } else if (status.state !== 'connecting' && status.state !== 'live') { streamOpen = false endLayoutFramePositions() }