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.
This commit is contained in:
Neil
2026-11-11 02:20:34 -08:00
parent ac6dc0463f
commit e3813c5bae
2 changed files with 90 additions and 1 deletions
@@ -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(
@@ -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<string>; 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<CloudOfflineTerminal[]> {
// 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<string, string | null>()
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<string>()
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,