diff --git a/src/renderer/src/lib/finished-agent-resume-resurrection.test.ts b/src/renderer/src/lib/finished-agent-resume-resurrection.test.ts new file mode 100644 index 00000000000..d0132f53175 --- /dev/null +++ b/src/renderer/src/lib/finished-agent-resume-resurrection.test.ts @@ -0,0 +1,173 @@ +/** + * A LOCAL workspace agent that FINISHED its turn must keep its resume identity + * without being treated as unfinished work. + * + * `retainsResumableRecoveryIdentity` (store/slices/agent-status.ts) records a + * completed turn so a cold restore after an abrupt app death re-enters the agent + * instead of a bare shell (#9454). It used to do that by restating `done` as + * `state: 'working'`, which left nothing able to tell "finished" from + * "interrupted": once the pane was killed, activation opened a fresh tab running + * `--resume` for every completed agent. + * + * No paired runtime here: the host-mirror park added in #15644 gates on a web + * surface tab id, so this path never reaches it. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { useAppStore } from '@/store' +import { isPassiveCompletedHibernationEvidence } from './sleeping-agent-pane-ownership' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const WORKTREE_ID = 'wt-local' +const TAB_ID = 'tab-reviewer' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const SESSION_ID = 'ses_fdc9b294effeBRR2JwiALSLpwy' + +afterEach(() => { + useAppStore.setState(initialAppStoreState, true) +}) + +/** A local codex pane with a live PTY, mid-turn. */ +function seedLiveLocalCodexPane(): void { + useAppStore.setState({ + activeWorktreeId: WORKTREE_ID, + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TAB_ID, + ptyId: 'pty-1', + worktreeId: WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + }, + activeTabIdByWorktree: { [WORKTREE_ID]: TAB_ID }, + ptyIdsByTabId: { [TAB_ID]: ['pty-1'] }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + } + } as never) +} + +function reportTurnFinished(interrupted = false): void { + useAppStore.getState().setAgentStatus( + PANE_KEY, + { + state: 'done', + agentType: 'codex', + prompt: 'review the diff', + ...(interrupted ? { interrupted: true } : {}) + } as never, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: 'pty-1' } as never, + { providerSession: { key: 'session_id', id: SESSION_ID } } as never + ) +} + +describe('a finished local agent', () => { + it('keeps completed quit records resumable', () => { + expect( + isPassiveCompletedHibernationEvidence({ + paneKey: 'quit-tab:quit-leaf', + tabId: 'quit-tab', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: SESSION_ID }, + prompt: '', + state: 'done', + origin: 'quit', + capturedAt: 1, + updatedAt: 1 + }) + ).toBe(false) + }) + + it('keeps its resume identity and is recorded as completed history', () => { + seedLiveLocalCodexPane() + + reportTurnFinished() + + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[PANE_KEY] + expect(record, 'a finished codex turn leaves a resume record').toBeDefined() + expect(record?.state, 'the done turn stays done').toBe('done') + expect(record?.origin).toBe('live') + // The identity a cold restore needs survives; only the turn text is dropped. + expect(record?.agent).toBe('codex') + expect(record?.providerSession).toEqual({ key: 'session_id', id: SESSION_ID }) + expect( + isPassiveCompletedHibernationEvidence(record!), + 'a finished agent must read as history, or activation restarts it' + ).toBe(true) + }) + + it('keeps an interrupted done turn resumable after its pane is killed', () => { + seedLiveLocalCodexPane() + reportTurnFinished(true) + + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[PANE_KEY] + expect(record?.state).toBe('done') + expect(record?.interrupted).toBe(true) + expect(isPassiveCompletedHibernationEvidence(record!)).toBe(false) + + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {} + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree(WORKTREE_ID) + + expect(launched).toBe(1) + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree[WORKTREE_ID]?.[0] + expect(resumedTab?.launchAgent).toBe('codex') + expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) + }) + + it('is not respawned into a new tab once its pane is killed', () => { + seedLiveLocalCodexPane() + reportTurnFinished() + + // `orca terminal stop` / app death: the PTY and pane go, the record stays. + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {} + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree(WORKTREE_ID) + + expect(launched, 'a finished agent must never be respawned').toBe(0) + const state = useAppStore.getState() + expect(state.tabsByWorktree[WORKTREE_ID] ?? []).toEqual([]) + // The orphaned record is retired rather than left queued for the next visit. + expect(state.sleepingAgentSessionsByPaneKey[PANE_KEY]).toBeUndefined() + }) + + it('still holds its resume identity for a cold restore while its pane exists', () => { + seedLiveLocalCodexPane() + reportTurnFinished() + + // The pane survives (app relaunch restored the tab): #9454's crash-recovery + // case. The record must NOT be cleared, or the pane cold-restores to a shell. + const launched = resumeSleepingAgentSessionsForWorktree(WORKTREE_ID) + + expect(launched, 'an owned pane resumes in place, never in a new tab').toBe(0) + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[PANE_KEY] + expect(record, 'the pane still owns a record to cold-restore from').toBeDefined() + expect(record?.providerSession).toEqual({ key: 'session_id', id: SESSION_ID }) + }) +}) diff --git a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts index 4ebed782f56..95a6c988b31 100644 --- a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts +++ b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts @@ -17,8 +17,15 @@ export function getProviderSessionClaimKey(record: SleepingAgentSessionRecord): : base } +// Why quit is excluded: it is an explicit request to keep resumable work. A +// live interrupted checkpoint is also active work; interrupted worktree-sleep +// records retain their existing passive/cleanup semantics. export function isPassiveCompletedHibernationEvidence(record: SleepingAgentSessionRecord): boolean { - return record.origin !== 'quit' && record.origin !== 'live' && record.state === 'done' + return ( + record.origin !== 'quit' && + !(record.origin === 'live' && record.interrupted === true) && + record.state === 'done' + ) } function getLegacyPaneTabId(record: SleepingAgentSessionRecord): string | null { diff --git a/src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts b/src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts new file mode 100644 index 00000000000..8479bb52d30 --- /dev/null +++ b/src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + type BackgroundMountTerminalWorktreeDetail +} from '@/constants/terminal' + +const { resumeSpy, clearSleepingAgentSessionsByPaneKey } = vi.hoisted(() => ({ + resumeSpy: vi.fn(() => 0), + clearSleepingAgentSessionsByPaneKey: vi.fn() +})) +vi.mock('./resume-sleeping-agent-session', () => ({ + resumeSleepingAgentSessionsForWorktree: resumeSpy +})) + +let sleepingRecords: Record> = {} +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + sleepingAgentSessionsByPaneKey: sleepingRecords, + tabsByWorktree: {}, + clearSleepingAgentSessionsByPaneKey + }) + } +})) + +import { wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background' + +afterEach(() => { + sleepingRecords = {} + clearSleepingAgentSessionsByPaneKey.mockClear() + resumeSpy.mockClear() +}) + +describe('background wake of a finished live checkpoint', () => { + it('mounts its saved tab as passive history without launching a resume tab', () => { + sleepingRecords = { + 'tab-done:leaf-1': { + paneKey: 'tab-done:leaf-1', + tabId: 'tab-done', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'finished-session' }, + state: 'done', + origin: 'live', + capturedAt: 1, + updatedAt: 1 + } + } + const mounted: BackgroundMountTerminalWorktreeDetail[] = [] + const onMount = (event: Event): void => { + mounted.push((event as CustomEvent).detail) + } + window.addEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + + try { + wakeSleepingAgentsForWorktreeInBackground('wt-1') + } finally { + window.removeEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + } + + expect(mounted).toEqual([{ worktreeId: 'wt-1', tabIds: ['tab-done'] }]) + expect(resumeSpy).toHaveBeenCalledOnce() + expect(resumeSpy.mock.results[0]?.value).toBe(0) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status-provider-session.test.ts index b4a9e14842b..2ad173eac6d 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session.test.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session.test.ts @@ -371,7 +371,7 @@ describe('recordAgentProviderSession', () => { agent, providerSession, connectionId: 'ssh-connection-1', - state: 'working', + state: 'done', origin: 'live' }) @@ -395,6 +395,120 @@ describe('recordAgentProviderSession', () => { } ) + it('does not turn a completed recovery record back into working on a same-session update', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + const providerSession = makePiCompatibleProviderSession('pi') + + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + providerSession, + { updatedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 20, stateStartedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'done', prompt: 'finish the task', agentType: 'pi', interrupted: true }, + 'Pi', + { updatedAt: 30, stateStartedAt: 30 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + providerSession, + { updatedAt: 40 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + providerSession, + state: 'done', + interrupted: true, + origin: 'live' + }) + }) + + it('does not downgrade a quit recovery record on a same-session update', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + const providerSession = makePiCompatibleProviderSession('pi') + + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + providerSession, + { updatedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 20, stateStartedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'done', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 30, stateStartedAt: 30 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store.getState().captureAllSleepingAgentSessions('quit') + + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + providerSession, + { updatedAt: 40 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + providerSession, + state: 'done', + origin: 'quit' + }) + }) + it.each(PI_COMPATIBLE_CASES)( 'keeps a completed $label session resumable through quit capture', ({ agent, label }) => { @@ -450,7 +564,7 @@ describe('recordAgentProviderSession', () => { agent, providerSession, connectionId: 'ssh-connection-1', - state: 'working', + state: 'done', origin: 'quit' }) diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index 061daea2f21..33e43e6dff2 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -687,8 +687,8 @@ describe('captureAllSleepingAgentSessions', () => { }) // Why: a finished resumable-agent turn leaves the TUI alive at its prompt, so the persisted - // recovery anchor must survive `done` (state remapped to 'working' to stay cold-restore - // eligible) — else logout→relaunch cold-restores to a bare shell instead of `--resume` (#9454). + // recovery anchor must survive `done` without relabeling completed work as pending — else + // logout→relaunch cold-restores to a bare shell instead of `--resume` (#9454). // Covers claude (the reported agent) and codex; previously this was Pi-only. it.each([ ['claude', 'Claude'], @@ -722,7 +722,7 @@ describe('captureAllSleepingAgentSessions', () => { agent: agentType, providerSession, origin: 'live', - state: 'working' + state: 'done' }) // Launch config is still cleared on done: tokens must no longer authorize config reuse. expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 96476583f77..8dbd29172e9 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -611,6 +611,7 @@ function sleepingRecordFromEntry(args: { worktreeId: args.worktreeId, agent, providerSession: args.entry.providerSession, + ...(args.entry.connectionId !== undefined ? { connectionId: args.entry.connectionId } : {}), prompt: args.entry.prompt, state: args.entry.state, capturedAt: args.capturedAt, @@ -897,11 +898,14 @@ function recoveryRecordMatches( if (!existing) { return false } + // Why: completion or interruption must replace a pre-status working checkpoint. return ( existing.origin === next.origin && existing.agent === next.agent && existing.worktreeId === next.worktreeId && existing.tabId === next.tabId && + existing.state === next.state && + existing.interrupted === next.interrupted && agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && launchConfigsEqual(existing.launchConfig, next.launchConfig) ) @@ -2017,6 +2021,13 @@ export const createAgentStatusSlice: StateCreator { expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ origin: 'live', - state: 'working', + state: 'done', providerSession: { key: 'session_id', id: PROVIDER_SESSION_ID } }) await releaseCompletedWorker('exited') diff --git a/tests/e2e/finished-agent-ghost-resume.spec.ts b/tests/e2e/finished-agent-ghost-resume.spec.ts new file mode 100644 index 00000000000..5d135161f08 --- /dev/null +++ b/tests/e2e/finished-agent-ghost-resume.spec.ts @@ -0,0 +1,217 @@ +/** + * A LOCAL agent that FINISHED its turn must not be respawned when the app + * reopens the workspace. + * + * A completed turn is persisted with its state rewritten to 'working' and + * origin 'live' (store/slices/agent-status.ts, `retainsResumableRecoveryIdentity`) + * so an abrupt app death cold-restores into the agent instead of a bare shell + * (#9454). Nothing downstream can then tell "finished" from "interrupted": once + * the pane is gone, worktree activation reads the record as unfinished work and + * opens a fresh tab running `--resume`. Killing the PTY removes the pane but + * never the record, so `orca terminal stop`, a crash, or a pty-exit tab close + * all leave one queued respawn per finished agent. + * + * Run: + * pnpm exec playwright test tests/e2e/finished-agent-ghost-resume.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node: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 { createHostRendererTerminalTab } from './helpers/host-created-terminal-retention-oracle' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' + +const PROVIDER_SESSION_ID = 'e2e-finished-agent-session' + +type PersistedRecord = { + state?: unknown + origin?: unknown + providerSession?: { id?: unknown } + launchConfig?: { agentCommand?: string; agentArgs?: string; agentEnv?: Record } +} + +function readPersistedRecords(userDataDir: string): Record { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } + } + return data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {} +} + +/** Make the resume hermetic: the respawned tab echoes instead of running codex. */ +function stubPersistedResumeCommand(userDataDir: string): PersistedRecord { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } + } + const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + if (!record) { + throw new Error('Expected the finished agent turn to leave a persisted record') + } + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') + return record +} + +test.describe.configure({ mode: 'serial' }) + +test('does not respawn an agent whose turn already finished', 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 + } + + 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 = `FINISHED_AGENT_${Date.now()}` + const descriptor = await waitForActivePaneHookDescriptor(page) + const ptyId = await waitForActivePanePtyId(page) + const transcriptPath = session.seedCodexResumeRollout(PROVIDER_SESSION_ID, repoPath) + await execInTerminal(page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(page, marker) + + // The agent reports its turn FINISHED — the ordinary end of an agent run. + await page.evaluate( + ({ paneKey, worktreeId: wtId, providerSessionId, transcriptPath }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'done', prompt: 'review the diff', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: wtId }, + { providerSession: { key: 'session_id', id: providerSessionId, transcriptPath } } + ) + }, + { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + providerSessionId: PROVIDER_SESSION_ID, + transcriptPath + } + ) + + // The finished turn keeps its resume identity without restating done as work. + const liveRecord = await page.evaluate((paneKey) => { + const record = window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] + return record ? { state: record.state, origin: record.origin } : null + }, descriptor.paneKey) + expect(liveRecord, 'a finished turn leaves a resume record').not.toBeNull() + expect(liveRecord?.state, 'the done turn stays done').toBe('done') + expect(liveRecord?.origin).toBe('live') + + // A second, ordinary terminal: the reported workspaces were never empty, and + // an empty one does not reactivate on relaunch at all. + const survivingTabId = await createHostRendererTerminalTab(page, worktreeId) + + // The PTY dies and its tab closes with it — `orca terminal stop`, a crash, + // or the pty-exit auto close. This reason deliberately keeps the record. + const tabId = await page.evaluate( + (wtId) => (window.__store?.getState().tabsByWorktree[wtId] ?? [])[0]?.id ?? null, + worktreeId + ) + expect(tabId, 'the agent pane must have a tab to close').not.toBeNull() + expect(tabId).not.toBe(survivingTabId) + await page.evaluate( + (id) => window.__store?.getState().closeTab(id, { reason: 'pty-exit' }), + tabId! + ) + await expect + .poll( + async () => + page.evaluate( + (wtId) => (window.__store?.getState().tabsByWorktree[wtId] ?? []).map((tab) => tab.id), + worktreeId + ), + { timeout: 15_000, message: 'the pty-exit close never removed the agent tab' } + ) + .toEqual([survivingTabId]) + + await session.close(firstApp) + firstApp = null + + // The record outlived the pane it belonged to. + const persisted = readPersistedRecords(session.userDataDir) + const survivor = Object.values(persisted).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + expect(survivor, 'killing the pane left the resume record behind').toBeDefined() + stubPersistedResumeCommand(session.userDataDir) + + // Reopening the workspace. + 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) + + // THE CLAIM: no tab was opened to resume an agent that had already finished. + const respawned = await secondLaunch.page.evaluate((wtId) => { + const state = window.__store?.getState() + const tabs = state?.tabsByWorktree[wtId] ?? [] + return tabs.map((tab) => ({ + id: tab.id, + launchAgent: tab.launchAgent ?? null, + startup: state?.pendingStartupByTabId[tab.id]?.command ?? null, + banner: state?.pendingStartupByTabId[tab.id]?.showSessionRestoredBanner ?? false + })) + }, worktreeId) + const resumeTabs = respawned.filter( + (tab) => tab.launchAgent === 'codex' || tab.startup?.includes(PROVIDER_SESSION_ID) + ) + expect(resumeTabs, `a finished agent was respawned: ${JSON.stringify(respawned)}`).toEqual([]) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +})