mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user