Persist agent provider sessions at quit and resume them on cold restore (#5240)

Agent provider session ids lived only in the in-memory
agentStatusByPaneKey map, so a daemon/session death while the app was
closed (reboot, crash, update kill) left nothing to resume from -
terminals cold-restored as plain shells with no Claude/Codex/Gemini
session (#5232, Bug 2).

The quit flush now captures resumable live agents into the persisted
sleeping-session map with origin 'quit'. Quit-origin records are
consumed only by the pane-level cold-restore resume (which injects the
agent's resume command into the replacement shell); worktree activation
skips them so a warm-reattached agent never gets a duplicate resume
tab. Sleep-origin behavior is unchanged, and a warm reattach clears the
record on the agent's next status event.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-11 20:17:23 -07:00
committed by GitHub
co-authored by Orca
parent 4df412f18e
commit b09d7124c4
9 changed files with 537 additions and 14 deletions
+5
View File
@@ -1087,6 +1087,11 @@ function App(): React.JSX.Element {
// Don't let one pane's failure block the rest.
}
}
// Why: agent provider session ids live only in agentStatusByPaneKey,
// which is in-memory. Capture them into the persisted sleeping-session
// map so a daemon/session death while the app is closed can still
// cold-restore via the agent's resume command (#5232).
useAppStore.getState().captureAllSleepingAgentSessions()
// Why: re-read state after capture() calls populated scrollback buffers
// into the store via Zustand setters. The earlier read is only for the
// gating flags and would miss those updates.
@@ -80,6 +80,8 @@ type StoreState = {
consumePendingSnapshot: ReturnType<typeof vi.fn>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
agentStatusByPaneKey: Record<string, unknown>
sleepingAgentSessionsByPaneKey: Record<string, unknown>
clearSleepingAgentSession: ReturnType<typeof vi.fn>
markWorktreeUnread: ReturnType<typeof vi.fn>
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
setAgentStatus: ReturnType<typeof vi.fn>
@@ -471,6 +473,10 @@ describe('connectPanePty', () => {
consumePendingSnapshot: vi.fn(() => null),
runtimePaneTitlesByTabId: {},
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
clearSleepingAgentSession: vi.fn((paneKey: string) => {
delete mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]
}),
markWorktreeUnread: vi.fn(),
observeTerminalGitHubPullRequestLink: vi.fn(),
setAgentStatus: vi.fn((paneKey: string, payload: Record<string, unknown>) => {
@@ -3082,6 +3088,110 @@ describe('connectPanePty', () => {
)
})
it('resumes from the quit-captured sleeping record when cold-restoring after an app restart', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return {
id: 'fresh-pty',
coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }
}
}
return 'fresh-pty'
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
// Why: after an app restart agentStatusByPaneKey is empty — the persisted
// sleeping record is the only source of the provider session id (#5232).
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }]
},
settings: {
...mockStoreState.settings,
agentCmdOverrides: {}
},
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
}
} as StoreState
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
expect(transport.sendInput).toHaveBeenCalledWith(
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r"
)
// Why: consuming the record prevents a later worktree activation from
// launching a duplicate resume tab for the same session.
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
})
it('does not consume the sleeping record when daemon reattach returns a live snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('tab-pty')
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return { id: sessionId, snapshot: 'live-snapshot' }
}
return null
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState = {
...mockStoreState,
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
}
} as StoreState
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(transport.sendInput).not.toHaveBeenCalled()
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
})
it('does not resume the provider session when daemon reattach returns a live snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('tab-pty')
@@ -1796,31 +1796,39 @@ export function connectPanePty(
if (pendingStartupCommand) {
return false
}
const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey]
if (!entry || entry.state === 'done' || !isResumableTuiAgent(entry.agentType)) {
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
// Why: agentStatusByPaneKey is in-memory only. After an app restart, the
// quit-captured sleeping record is the only surviving source of this
// pane's provider session id (#5232). Live entries win when present —
// they are fresher and setAgentStatus already cleared the record.
const sleepingRecord = entry ? null : state.sleepingAgentSessionsByPaneKey[cacheKey]
const agentType = entry?.agentType ?? sleepingRecord?.agent
const agentState = entry?.state ?? sleepingRecord?.state
const rawProviderSession = entry?.providerSession ?? sleepingRecord?.providerSession
if (!agentType || agentState === 'done' || !isResumableTuiAgent(agentType)) {
return false
}
const providerSession = normalizeAgentProviderSession(entry.providerSession)
const providerSession = normalizeAgentProviderSession(rawProviderSession)
if (!providerSession) {
return false
}
const startupPlan = buildAgentResumeStartupPlan({
agent: entry.agentType,
agent: agentType,
providerSession,
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(
entry.agentType,
useAppStore.getState().settings?.agentDefaultArgs
),
agentEnv: resolveTuiAgentLaunchEnv(
entry.agentType,
useAppStore.getState().settings?.agentDefaultEnv
),
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(agentType, state.settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agentType, state.settings?.agentDefaultEnv),
platform: getColdRestoreAgentResumePlatform()
})
if (!startupPlan) {
return false
}
if (sleepingRecord) {
// Why: the record is one-shot — consuming it here prevents a later
// worktree activation from launching a duplicate resume tab.
useAppStore.getState().clearSleepingAgentSession(cacheKey)
}
// Why: cold restore means the PTY process is gone but the agent provider
// session is still resumable, so the replacement shell must launch it.
pendingStartupCommand = startupPlan.launchCommand
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
const initialAppStoreState = useAppStore.getState()
afterEach(() => {
vi.unstubAllGlobals()
useAppStore.setState(initialAppStoreState, true)
})
function makeRecord(
overrides: Partial<SleepingAgentSessionRecord> = {}
): SleepingAgentSessionRecord {
return {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude',
providerSession: { key: 'session_id', id: 'sess-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1,
...overrides
}
}
function makeTerminalTab(id: string, worktreeId: string): Record<string, unknown> {
return {
id,
ptyId: null,
worktreeId,
title: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
describe('resumeSleepingAgentSessionsForWorktree', () => {
it('skips quit-captured records — their restored pane owns recovery', () => {
const record = makeRecord({ origin: 'quit' })
useAppStore.setState({
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(0)
// Why: the restored pane either warm-reattaches the still-running agent or
// cold-restores with the resume command; a separate tab here would
// duplicate the session.
expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
})
it('resumes legacy sleep records without an origin even when their tab still exists', () => {
const record = makeRecord()
useAppStore.setState({
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(1)
const state = useAppStore.getState()
const resumedTab = (state.tabsByWorktree['wt-1'] ?? []).find((tab) => tab.id !== 'tab-1')
expect(resumedTab?.launchAgent).toBe('claude')
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('resumes worktree-sleep records into a fresh tab', () => {
const record = makeRecord({ origin: 'worktree-sleep' })
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(1)
const state = useAppStore.getState()
const tabs = state.tabsByWorktree['wt-1'] ?? []
expect(tabs).toHaveLength(1)
expect(tabs[0]?.launchAgent).toBe('claude')
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
})
@@ -80,6 +80,11 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean
export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number {
const records = Object.values(useAppStore.getState().sleepingAgentSessionsByPaneKey)
.filter((record) => record.worktreeId === worktreeId)
// Why: quit-time captures (#5232) cover panes that still exist in the
// restored session. Those panes own their own recovery — warm reattach
// when the daemon kept the agent alive, or the pane-level cold-restore
// resume — so launching a separate tab here would duplicate the session.
.filter((record) => record.origin !== 'quit')
.sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)
let launched = 0
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { AppState } from '../types'
import { createTestStore, makeTab } from './store-test-helpers'
function makeAgentEntry(overrides: {
paneKey: string
worktreeId: string
sessionId?: string
}): AgentStatusEntry {
return {
state: 'working',
prompt: 'finish the task',
updatedAt: 1,
stateStartedAt: 1,
stateHistory: [],
agentType: 'claude',
paneKey: overrides.paneKey,
worktreeId: overrides.worktreeId,
...(overrides.sessionId
? { providerSession: { key: 'session_id' as const, id: overrides.sessionId } }
: {})
}
}
describe('captureAllSleepingAgentSessions', () => {
it('captures resumable agents across every worktree, not just one', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })],
'wt-2': [makeTab({ id: 'tab-2', worktreeId: 'wt-2' })]
},
agentStatusByPaneKey: {
'tab-1:leaf-1': makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
sessionId: 'sess-1'
}),
'tab-2:leaf-2': makeAgentEntry({
paneKey: 'tab-2:leaf-2',
worktreeId: 'wt-2',
sessionId: 'sess-2'
})
}
} as Partial<AppState>)
store.getState().captureAllSleepingAgentSessions()
const records = store.getState().sleepingAgentSessionsByPaneKey
expect(records['tab-1:leaf-1']).toMatchObject({
agent: 'claude',
worktreeId: 'wt-1',
tabId: 'tab-1',
providerSession: { key: 'session_id', id: 'sess-1' },
origin: 'quit'
})
expect(records['tab-2:leaf-2']).toMatchObject({
agent: 'claude',
worktreeId: 'wt-2',
tabId: 'tab-2',
providerSession: { key: 'session_id', id: 'sess-2' },
origin: 'quit'
})
})
it('skips done agents — there is no turn left to resume', () => {
const store = createTestStore()
const entry = makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
sessionId: 'sess-1'
})
entry.state = 'done'
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
},
agentStatusByPaneKey: { 'tab-1:leaf-1': entry }
} as Partial<AppState>)
store.getState().captureAllSleepingAgentSessions()
expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({})
})
it('skips agents without a resumable provider session', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
},
agentStatusByPaneKey: {
'tab-1:leaf-1': makeAgentEntry({ paneKey: 'tab-1:leaf-1', worktreeId: 'wt-1' })
}
} as Partial<AppState>)
store.getState().captureAllSleepingAgentSessions()
expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({})
})
it('captures entries attributed only via tab prefix when the entry has no worktreeId', () => {
const store = createTestStore()
const entry = makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
sessionId: 'sess-1'
})
delete entry.worktreeId
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
},
agentStatusByPaneKey: { 'tab-1:leaf-1': entry }
} as Partial<AppState>)
store.getState().captureAllSleepingAgentSessions()
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
worktreeId: 'wt-1',
providerSession: { key: 'session_id', id: 'sess-1' }
})
})
})
+42 -1
View File
@@ -113,6 +113,9 @@ export type AgentStatusSlice = {
dropAgentStatusByWorktree: (worktreeId: string) => void
captureSleepingAgentSessionsByWorktree: (worktreeId: string) => void
/** Capture resumable agent sessions across every worktree. Called from the
* quit flush so provider session ids survive an app restart. */
captureAllSleepingAgentSessions: () => void
clearSleepingAgentSession: (paneKey: string) => void
clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void
pruneSleepingAgentSessions: (validWorktreeIds: Set<string>) => void
@@ -189,6 +192,7 @@ function sleepingRecordFromEntry(args: {
worktreeId: string
tab?: TerminalTab
capturedAt: number
origin?: SleepingAgentSessionRecord['origin']
}): SleepingAgentSessionRecord | null {
const agent = args.entry.agentType
if (!isResumableTuiAgent(agent) || !args.entry.providerSession) {
@@ -213,7 +217,8 @@ function sleepingRecordFromEntry(args: {
: {}),
...(args.entry.lastAssistantMessage
? { lastAssistantMessage: args.entry.lastAssistantMessage }
: {})
: {}),
...(args.origin ? { origin: args.origin } : {})
}
}
@@ -1058,6 +1063,42 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
})
},
captureAllSleepingAgentSessions: () => {
// Why: the quit flush must persist provider session ids for every live
// agent pane — otherwise agents whose daemon PTYs die while the app is
// closed have nothing to `--resume` from (#5232). Only live entries are
// captured: retained rows belong to panes the user already closed, and
// `done` sessions have nothing to resume.
set((s) => {
const capturedAt = Date.now()
const next: Record<string, SleepingAgentSessionRecord> = {
...s.sleepingAgentSessionsByPaneKey
}
let changed = false
for (const entry of Object.values(s.agentStatusByPaneKey)) {
if (entry.state === 'done') {
continue
}
const worktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey)
if (!worktreeId) {
continue
}
const record = sleepingRecordFromEntry({
state: s,
entry,
worktreeId,
capturedAt,
origin: 'quit'
})
if (record && next[record.paneKey] !== record) {
next[record.paneKey] = record
changed = true
}
}
return changed ? { sleepingAgentSessionsByPaneKey: next } : s
})
},
clearSleepingAgentSession: (paneKey) => {
set((s) => {
if (!(paneKey in s.sleepingAgentSessionsByPaneKey)) {
+6
View File
@@ -34,6 +34,12 @@ export type SleepingAgentSessionRecord = {
terminalTitle?: string
lastAssistantMessage?: string
connectionId?: string | null
/** How the record was captured. Worktree-sleep records (legacy records have
* no origin) are consumed by worktree activation, which opens a fresh tab.
* Quit records describe panes that still exist in the restored session, so
* only the pane's own cold-restore path may consume them — activation
* launching a tab too would duplicate a warm-reattached session (#5232). */
origin?: 'worktree-sleep' | 'quit'
}
const RESUMABLE_TUI_AGENT_SET: ReadonlySet<string> = new Set(RESUMABLE_TUI_AGENTS)
+130
View File
@@ -0,0 +1,130 @@
import { existsSync, readFileSync } from 'fs'
import path from 'path'
import type { ElectronApplication } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { TEST_REPO_PATH_FILE } from './global-setup'
import {
execInTerminal,
waitForActivePaneHookDescriptor,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForPaneCount,
waitForTerminalOutput
} from './helpers/terminal'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
const PROVIDER_SESSION_ID = 'e2e-quit-resume-session'
function readDaemonPid(userDataDir: string): number {
const raw = readFileSync(
path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`),
'utf8'
)
const parsed = JSON.parse(raw) as { pid?: unknown }
if (typeof parsed.pid !== 'number') {
throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`)
}
return parsed.pid
}
test.describe.configure({ mode: 'serial' })
test('resumes an agent session after quit when its daemon PTY died while the app was closed', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
test.skip(process.platform === 'win32', 'Uses POSIX SIGKILL to simulate daemon death')
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
let secondApp: ElectronApplication | null = null
try {
const firstLaunch = await session.launch()
firstApp = firstLaunch.app
const page = await firstApp.firstWindow()
const worktreeId = await attachRepoAndOpenTerminal(page, repoPath)
await waitForSessionReady(page)
await waitForActiveWorktree(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
await waitForPaneCount(page, 1, 30_000)
const marker = `AGENT_QUIT_RESUME_${Date.now()}`
const descriptor = await waitForActivePaneHookDescriptor(page)
const firstPtyId = await waitForActivePanePtyId(page)
await execInTerminal(page, firstPtyId, `echo ${marker}`)
await waitForTerminalOutput(page, marker)
// Why: a real agent run reports its provider session id over the hook
// server; seeding the same store entry keeps this test hermetic (no agent
// CLI install or auth) while exercising the identical persistence path.
await page.evaluate(
({ paneKey, worktreeId: wtId, providerSessionId }) => {
window.__store
?.getState()
.setAgentStatus(
paneKey,
{ state: 'working', prompt: 'finish the task', agentType: 'codex' },
'Codex',
undefined,
{ worktreeId: wtId },
{ providerSession: { key: 'session_id', id: providerSessionId } }
)
},
{
paneKey: descriptor.paneKey,
worktreeId: descriptor.worktreeId,
providerSessionId: PROVIDER_SESSION_ID
}
)
const daemonPid = readDaemonPid(session.userDataDir)
await session.close(firstApp)
firstApp = null
// Why: simulates the daemon (and the agent CLI inside it) dying while the
// app is closed — reboot, crash, or update kill. SIGKILL leaves history
// checkpoints unclean so the relaunch takes the cold-restore path.
process.kill(daemonPid, 'SIGKILL')
const secondLaunch = await session.launch()
secondApp = secondLaunch.app
await waitForSessionReady(secondLaunch.page)
await expect
.poll(
async () => secondLaunch.page.evaluate(() => window.__store?.getState().activeWorktreeId),
{ timeout: 15_000 }
)
.toBe(worktreeId)
await ensureTerminalVisible(secondLaunch.page)
await waitForActiveTerminalManager(secondLaunch.page, 30_000)
await waitForPaneCount(secondLaunch.page, 1, 30_000)
// The quit-captured provider session id must drive a resume command into
// the cold-restored pane (the command text echoes in the terminal).
await waitForTerminalOutput(secondLaunch.page, PROVIDER_SESSION_ID, 30_000)
// No duplicate resume tab: the quit-origin record must not be consumed by
// worktree activation on top of the pane-level cold-restore.
const terminalTabCount = await secondLaunch.page.evaluate(
(wtId) => (window.__store?.getState().tabsByWorktree[wtId] ?? []).length,
worktreeId
)
expect(terminalTabCount).toBe(1)
} finally {
if (secondApp) {
await session.close(secondApp)
}
if (firstApp) {
await session.close(firstApp)
}
await session.dispose()
}
})