From b09d7124c44c907533935e37e3c8a0ce5bc96d6f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:17:23 -0700 Subject: [PATCH] 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 --- src/renderer/src/App.tsx | 5 + .../terminal-pane/pty-connection.test.ts | 110 +++++++++++++++ .../terminal-pane/pty-connection.ts | 34 +++-- .../lib/resume-sleeping-agent-session.test.ts | 93 +++++++++++++ .../src/lib/resume-sleeping-agent-session.ts | 5 + .../slices/agent-status-quit-capture.test.ts | 125 +++++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 43 +++++- src/shared/agent-session-resume.ts | 6 + tests/e2e/agent-session-quit-resume.spec.ts | 130 ++++++++++++++++++ 9 files changed, 537 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/resume-sleeping-agent-session.test.ts create mode 100644 src/renderer/src/store/slices/agent-status-quit-capture.test.ts create mode 100644 tests/e2e/agent-session-quit-resume.spec.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f32075604c8..d4a2d1320a4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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. diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 6ff87b1286a..bdcd2ce2c8d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -80,6 +80,8 @@ type StoreState = { consumePendingSnapshot: ReturnType runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record + sleepingAgentSessionsByPaneKey: Record + clearSleepingAgentSession: ReturnType markWorktreeUnread: ReturnType observeTerminalGitHubPullRequestLink: ReturnType setAgentStatus: ReturnType @@ -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) => { @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f9caa5d9085..3a0429622f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts new file mode 100644 index 00000000000..f808bb54090 --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -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 { + 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 { + 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() + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index dc379128606..a428fbcf4e1 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -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 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 new file mode 100644 index 00000000000..a49fd479d33 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -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) + + 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) + + 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) + + 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) + + store.getState().captureAllSleepingAgentSessions() + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + worktreeId: 'wt-1', + providerSession: { key: 'session_id', id: 'sess-1' } + }) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 8cf38172044..5f950320a94 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -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) => 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 { + // 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 = { + ...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)) { diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index ccf174fcdcc..f5ba56529a8 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -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 = new Set(RESUMABLE_TUI_AGENTS) diff --git a/tests/e2e/agent-session-quit-resume.spec.ts b/tests/e2e/agent-session-quit-resume.spec.ts new file mode 100644 index 00000000000..fe059d015f2 --- /dev/null +++ b/tests/e2e/agent-session-quit-resume.spec.ts @@ -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() + } +})