From e3813c5baeeebf1649a562a3a2d0ef91b6076b41 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 02:20:34 -0800 Subject: [PATCH] fix(cloud): the asleep copy keeps one terminal per tab, not one per boot Seen on m4air: after two wake/sleep cycles the asleep read-only copy listed 3 terminals for a workspace with one tab. Each boot respawns the tab's terminal under a new handle and the old handles' cached reads stayed. The copy now keeps only the terminals the newest whole-workspace terminal.list names (plus any read after it); a filtered listing never decides, and with no listing all are kept. --- .../cloud-offline-cache.test.ts | 60 +++++++++++++++++++ .../hosted-sandbox/cloud-offline-snapshot.ts | 31 +++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/main/hosted-sandbox/cloud-offline-cache.test.ts b/src/main/hosted-sandbox/cloud-offline-cache.test.ts index e252e2b201d..440cd55a53e 100644 --- a/src/main/hosted-sandbox/cloud-offline-cache.test.ts +++ b/src/main/hosted-sandbox/cloud-offline-cache.test.ts @@ -164,6 +164,66 @@ describe('legacy plaintext cache files', () => { }) describe('offline snapshot', () => { + it('keeps one terminal per tab across wake/sleep cycles, not one per boot', async () => { + const { cache, tick } = setup() + // Each boot respawns the tab's terminal under a new handle; every capture lists and reads it. + for (const handle of ['boot1', 'boot2', 'boot3']) { + cache.record( + 'env', + 'terminal.list', + { worktree: 'id:w' }, + ok({ terminals: [{ handle, title: 'zsh' }] }) + ) + cache.record( + 'env', + 'terminal.read', + { terminal: handle, limit: 400 }, + ok({ terminal: { handle, tail: [`$ on ${handle}`] } }) + ) + tick() + } + // A filtered listing (one tab) never decides which terminals are live. + cache.record('env', 'terminal.list', { worktree: 'id:w', tabId: 't' }, ok({ terminals: [] })) + tick() + const snapshot = await buildOfflineSnapshot(cache, 'env', 'w', true) + expect(snapshot.terminals.map((t) => [t.handle, t.lines])).toEqual([['boot3', ['$ on boot3']]]) + }) + + it('keeps a terminal read after the newest listing (opened since), and all with no listing', async () => { + const { cache, tick } = setup() + cache.record( + 'env', + 'terminal.read', + { terminal: 'old' }, + ok({ terminal: { handle: 'old', tail: ['a'] } }) + ) + tick() + expect((await buildOfflineSnapshot(cache, 'env', 'w', true)).terminals).toHaveLength(1) + cache.record( + 'env', + 'terminal.list', + { worktree: 'id:w' }, + ok({ terminals: [{ handle: 'h1' }] }) + ) + cache.record( + 'env', + 'terminal.read', + { terminal: 'h1' }, + ok({ terminal: { handle: 'h1', tail: ['b'] } }) + ) + tick() + cache.record( + 'env', + 'terminal.read', + { terminal: 'new' }, + ok({ terminal: { handle: 'new', tail: ['c'] } }) + ) + const handles = (await buildOfflineSnapshot(cache, 'env', 'w', true)).terminals.map( + (t) => t.handle + ) + expect(handles.sort()).toEqual(['h1', 'new']) + }) + it('builds chats, terminals and git state from cached answers', async () => { const { cache, tick } = setup() cache.record( diff --git a/src/main/hosted-sandbox/cloud-offline-snapshot.ts b/src/main/hosted-sandbox/cloud-offline-snapshot.ts index 2965d500959..b0faca2769b 100644 --- a/src/main/hosted-sandbox/cloud-offline-snapshot.ts +++ b/src/main/hosted-sandbox/cloud-offline-snapshot.ts @@ -144,13 +144,35 @@ function chatsOf(entries: OfflineCacheEntry[]): CloudOfflineChat[] { return [...bySession.values()].sort((a, b) => b.at - a.at) } +/** + * The newest `terminal.list` of the whole workspace (the capture's own `{worktree}` call, not a + * filtered one) that answered with a terminal list: its handles are the live terminals. + */ +function newestWorkspaceListing( + lists: readonly OfflineCacheEntry[] +): { handles: Set; at: number } | null { + for (const list of lists) { + const params = ParamsSchema.safeParse(list.params) + const keys = params.success ? Object.keys(params.data) : [] + if (keys.length !== 1 || keys[0] !== 'worktree') { + continue + } + const parsed = TerminalListSchema.safeParse(list.result) + if (parsed.success && parsed.data.terminals) { + return { handles: new Set(parsed.data.terminals.map((t) => t.handle)), at: list.at } + } + } + return null +} + async function terminalsOf( cache: CloudOfflineCache, environmentId: string ): Promise { // Every listing contributes titles (a filtered listing can be empty); newest first wins. + const lists = await cache.entries(environmentId, 'terminal.list') const titles = new Map() - for (const list of await cache.entries(environmentId, 'terminal.list')) { + for (const list of lists) { const parsed = TerminalListSchema.safeParse(list.result) for (const terminal of parsed.success ? (parsed.data.terminals ?? []) : []) { if (!titles.has(terminal.handle)) { @@ -158,6 +180,10 @@ async function terminalsOf( } } } + // Each boot respawns a tab's terminal under a new handle, and reads of the old ones stay cached: + // only the terminals the newest whole-workspace listing names are this workspace's terminals + // (plus any read after that listing, which it could not know yet). No such listing: keep all. + const current = newestWorkspaceListing(lists) const seen = new Set() const terminals: CloudOfflineTerminal[] = [] for (const entry of await cache.entries(environmentId, 'terminal.read')) { @@ -167,6 +193,9 @@ async function terminalsOf( if (!handle || seen.has(handle) || !result?.tail) { continue } + if (current && !current.handles.has(handle) && entry.at <= current.at) { + continue + } seen.add(handle) terminals.push({ handle,