fix(agent-resume): stop ghost resume tabs after finished turns (#16308)

This commit is contained in:
Jinwoo Hong
2026-08-24 19:32:52 -07:00
committed by GitHub
parent cc4801320a
commit c60d2ba895
8 changed files with 610 additions and 13 deletions
@@ -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 })
})
})
@@ -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 {
@@ -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<string, Record<string, unknown>> = {}
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<BackgroundMountTerminalWorktreeDetail>).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)
})
})
@@ -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<AppState>)
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<AppState>)
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'
})
@@ -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()
+24 -5
View File
@@ -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<AppState, [], [], AgentStatusS
const existingRecordMatchesProviderSession =
existingRecord?.agent === agent &&
agentProviderSessionsEqual(agent, existingRecord.providerSession, providerSession)
// Why: provider-session heartbeats can arrive after the turn is complete; preserve the
// completed checkpoint so a late heartbeat cannot make it eligible for ghost resume.
const preservesCompletedRecoveryRecord =
existingRecordMatchesProviderSession && existingRecord?.state === 'done'
// Why: an explicit quit capture must remain the resume handle until a new provider session replaces it.
const preservesQuitOrigin =
existingRecordMatchesProviderSession && existingRecord?.origin === 'quit'
const launchConfig =
(registryMatches ? registryEntry?.launchConfig : undefined) ??
(existingRecordMatchesProviderSession ? existingRecord.launchConfig : undefined)
@@ -2028,7 +2039,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
providerSession,
prompt: '',
// Why: durable process/session identity, not visible turn state; a non-done value keeps cold restore eligible.
state: 'working',
state: preservesCompletedRecoveryRecord ? 'done' : 'working',
capturedAt: updatedAt,
updatedAt,
...(existingStatus?.terminalTitle
@@ -2046,7 +2057,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker'
? { automaticResumeBlockedBy: 'legacy-orchestration-worker' }
: {}),
origin: 'live'
...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined
? { interrupted: existingRecord.interrupted }
: {}),
origin: preservesQuitOrigin ? 'quit' : 'live'
}
removedLiveStatus = existingStatus !== undefined
const nextLive = removedLiveStatus ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey
@@ -2323,7 +2337,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
? { connectionId: routing.connectionId }
: existing?.connectionId !== undefined
? { connectionId: existing.connectionId }
: {}),
: s.sleepingAgentSessionsByPaneKey[paneKey]?.connectionId !== undefined
? { connectionId: s.sleepingAgentSessionsByPaneKey[paneKey].connectionId }
: {}),
tabId: statusTabId,
terminalTitle: effectiveTitle,
stateHistory: history,
@@ -2456,9 +2472,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const liveRecoveryRecord = liveRecoveryWorktreeId
? sleepingRecordFromEntry({
state: s,
// Why: a completed resumable-agent turn leaves the TUI session alive — keep resume identity active without representing done as pending work.
// Why: keep the resume identity of a finished turn without its text,
// but never restate `done` as pending work — the resume sweep reads
// that state to tell an interrupted agent from a completed one, and
// a lie there respawns every finished agent whose pane was killed.
entry: retainsResumableRecoveryIdentity
? { ...entry, state: 'working', prompt: '', lastAssistantMessage: undefined }
? { ...entry, prompt: '', lastAssistantMessage: undefined }
: entry,
worktreeId: liveRecoveryWorktreeId,
capturedAt: updatedAt,
@@ -206,7 +206,7 @@ function completeRecordedWorker(
worktreeId: WORKTREE_ID,
agent: 'codex',
providerSession,
state: 'working',
state: 'done',
origin: 'live'
})
return record!
@@ -424,7 +424,7 @@ describe('completed background-worker retirement resume matrix', () => {
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')
@@ -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<string, string> }
}
function readPersistedRecords(userDataDir: string): Record<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<string, PersistedRecord> }
}
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<string, PersistedRecord> }
}
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()
}
})