diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index d9439d07c..3c7424de1 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -83,6 +83,7 @@ import { } from '@/runtime/web-runtime-session' import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' import { listBoundAgentTabActions, resolveDefaultAgentForNewTab } from '@/lib/agent-tab-shortcuts' import { createFloatingWorkspaceBrowserTab, @@ -231,6 +232,7 @@ function Terminal(): React.JSX.Element | null { const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit) const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const hydrationSucceeded = useAppStore((s) => s.hydrationSucceeded) const openFiles = useAppStore((s) => s.openFiles) const activeFileId = useAppStore((s) => s.activeFileId) const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId) @@ -808,6 +810,21 @@ function Terminal(): React.JSX.Element | null { createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) }, [workspaceSessionReady, activeWorktreeId, createTab, reconcileWorktreeTabModel]) + const startupResumeWorktreeIdsRef = useRef(new Set()) + useEffect(() => { + if (!workspaceSessionReady || !hydrationSucceeded || !activeWorktreeId) { + return + } + if (startupResumeWorktreeIdsRef.current.has(activeWorktreeId)) { + return + } + startupResumeWorktreeIdsRef.current.add(activeWorktreeId) + // Why: startup hydration restores the active worktree without calling + // activateAndRevealWorktree, so orphaned live/quit records need a terminal + // surface pass after pane-level cold restore had first chance. + resumeSleepingAgentSessionsForWorktree(activeWorktreeId) + }, [activeWorktreeId, hydrationSucceeded, workspaceSessionReady]) + const handleNewTab = useCallback( (shellOverride?: string) => { if (!activeWorktreeId) { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index f0cbbfacd..9fbba1505 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -53,8 +53,27 @@ function makeLayout(leafId: string, ptyId = 'pty-1'): Record { } } +function makeSplitLayout( + leafId: string, + otherLeafId: string, + ptyIdsByLeafId: Record +): Record { + return { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId }, + second: { type: 'leaf', leafId: otherLeafId }, + ratio: 0.5 + }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId + } +} + describe('resumeSleepingAgentSessionsForWorktree', () => { - it('skips quit-captured records — their restored pane owns recovery', () => { + it('resumes quit-captured records when no preserved pane can own recovery', () => { const record = makeRecord({ origin: 'quit' }) useAppStore.setState({ tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, @@ -63,15 +82,15 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { 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) + 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.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) - it('skips live-checkpoint records — their restored pane owns recovery', () => { + it('resumes live-checkpoint records when no preserved pane can own recovery', () => { const record = makeRecord({ origin: 'live' }) useAppStore.setState({ tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, @@ -80,9 +99,12 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { const launched = resumeSleepingAgentSessionsForWorktree('wt-1') - expect(launched).toBe(0) - expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + 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.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) it('resumes legacy sleep records without an origin when no preserved pane can own recovery', () => { @@ -119,6 +141,55 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) + it('resumes live stable-pane records when the preserved leaf has no PTY to cold-restore', () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + const record = makeRecord({ paneKey, origin: 'live' }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null + } + }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') + expect(launched).toBe(1) + expect(resumedTab?.launchAgent).toBe('claude') + expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('does not let a sibling split-pane PTY claim a live stable-pane record', () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + const record = makeRecord({ paneKey, origin: 'live' }) + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': [{ ...makeTerminalTab('tab-1', 'wt-1'), ptyId: 'tab-level-wake-hint' }] + }, + ptyIdsByTabId: { 'tab-1': ['sibling-pty'] }, + terminalLayoutsByTabId: { + 'tab-1': makeSplitLayout(LEAF_ID, OTHER_LEAF_ID, { [OTHER_LEAF_ID]: 'sibling-pty' }) + }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') + expect(launched).toBe(1) + expect(resumedTab?.launchAgent).toBe('claude') + expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + it('skips hibernated stable panes after their live PTY binding is cleared', () => { const paneKey = makePaneKey('tab-1', LEAF_ID) const record = makeRecord({ paneKey, origin: 'worktree-sleep', state: 'done' }) @@ -322,6 +393,41 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(state.sleepingAgentSessionsByPaneKey[stale.paneKey]).toBeUndefined() }) + it('does not let invalid pane-owned records block a valid duplicate resume', () => { + const invalidPaneKey = makePaneKey('tab-1', LEAF_ID) + const validPaneKey = makePaneKey('missing-tab', OTHER_LEAF_ID) + const invalid = makeRecord({ + paneKey: invalidPaneKey, + origin: 'live', + capturedAt: 3_000_000, + updatedAt: 1 + }) + const valid = makeRecord({ + paneKey: validPaneKey, + tabId: 'missing-tab', + origin: 'worktree-sleep', + capturedAt: 3_000_001, + updatedAt: 3_000_001 + }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + terminalLayoutsByTabId: { 'tab-1': makeLayout(LEAF_ID) }, + sleepingAgentSessionsByPaneKey: { + [invalid.paneKey]: invalid, + [valid.paneKey]: valid + } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') + expect(launched).toBe(1) + expect(resumedTab?.launchAgent).toBe('claude') + expect(state.sleepingAgentSessionsByPaneKey[invalid.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[valid.paneKey]).toBeUndefined() + }) + it('fresh-resumes when explicit tabId disagrees with the stable pane-key tab id', () => { const paneKey = makePaneKey('parsed-tab', LEAF_ID) const record = makeRecord({ paneKey, tabId: 'explicit-tab', origin: 'worktree-sleep' }) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 64c8c1388..1cf401ac1 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -168,6 +168,22 @@ function hasMatchingStablePaneLayout( return layoutContainsLeaf(terminalLayoutsByTabId[tabId]?.root, leafId) } +function hasRestorableStablePanePty( + tab: TerminalTab, + tabId: string, + leafId: string, + ptyIdsByTabId: Record, + terminalLayoutsByTabId: Record +): boolean { + const layout = terminalLayoutsByTabId[tabId] + const hasLeafPty = Boolean(layout?.ptyIdsByLeafId?.[leafId]) + const isSingleLeafLayout = layout?.root?.type === 'leaf' && layout.root.leafId === leafId + + return Boolean( + hasLeafPty || (isSingleLeafLayout && (tab.ptyId || (ptyIdsByTabId[tabId]?.length ?? 0) > 0)) + ) +} + function findSameWorktreeTab( worktreeTabs: readonly TerminalTab[], tabId: string @@ -187,8 +203,20 @@ function recordPaneIsOwnedByPreservedPane( } const tabId = record.tabId ?? stable.tabId const tab = findSameWorktreeTab(worktreeTabs, tabId) - return Boolean( - tab && hasMatchingStablePaneLayout(tabId, stable.leafId, state.terminalLayoutsByTabId) + if (!tab || !hasMatchingStablePaneLayout(tabId, stable.leafId, state.terminalLayoutsByTabId)) { + return false + } + if (record.origin !== 'quit' && record.origin !== 'live') { + return true + } + // Why: live/quit captures rely on pane-level cold restore. A preserved + // leaf without a PTY/session id can repaint scrollback but cannot resume. + return hasRestorableStablePanePty( + tab, + tabId, + stable.leafId, + state.ptyIdsByTabId, + state.terminalLayoutsByTabId ) } @@ -223,20 +251,16 @@ export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): numb const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey) .filter((record) => record.worktreeId === worktreeId) .sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt) - const records = worktreeRecords - // Why: pane-owned captures (#5232/#5626) 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 pane-level cold-restore resume. - .filter((record) => record.origin !== 'quit' && record.origin !== 'live') const paneOwnedClaimKeys = new Set( worktreeRecords + .filter((record) => !isInvalidWorktreeActivationRecord(record)) .filter((record) => recordPaneIsOwnedByPreservedPane(record, state)) .map(getProviderSessionClaimKey) ) let launched = 0 - for (const record of records) { + for (const record of worktreeRecords) { const claimKey = getProviderSessionClaimKey(record) if (isInvalidWorktreeActivationRecord(record)) { state.clearSleepingAgentSession(record.paneKey) diff --git a/tests/e2e/agent-session-live-force-exit-resume.spec.ts b/tests/e2e/agent-session-live-force-exit-resume.spec.ts new file mode 100644 index 000000000..ef95125c9 --- /dev/null +++ b/tests/e2e/agent-session-live-force-exit-resume.spec.ts @@ -0,0 +1,240 @@ +import { execFileSync } from 'child_process' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import path from 'path' +import type { ChildProcess } from 'node:child_process' +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-live-force-exit-session' + +type PersistedWorkspaceSession = { + tabsByWorktree?: Record + terminalLayoutsByTabId?: Record + activeWorktreeIdsOnShutdown?: unknown + sleepingAgentSessionsByPaneKey?: Record< + string, + { + providerSession?: { id?: unknown } + launchConfig?: { + agentCommand?: string + agentArgs?: string + agentEnv?: Record + } + } + > +} + +type PersistedData = { + workspaceSession?: PersistedWorkspaceSession +} + +function dataFilePath(userDataDir: string): string { + return path.join(userDataDir, 'orca-data.json') +} + +function readPersistedData(userDataDir: string): PersistedData { + return JSON.parse(readFileSync(dataFilePath(userDataDir), 'utf8')) as PersistedData +} + +function writePersistedData(userDataDir: string, data: PersistedData): void { + writeFileSync(dataFilePath(userDataDir), `${JSON.stringify(data, null, 2)}\n`, 'utf8') +} + +function daemonPidPath(userDataDir: string): string { + return path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`) +} + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync(daemonPidPath(userDataDir), '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 +} + +function hasExited(proc: ChildProcess): boolean { + return proc.exitCode !== null || proc.signalCode !== null +} + +function waitForExit(proc: ChildProcess, timeoutMs = 5000): Promise { + if (hasExited(proc)) { + return Promise.resolve() + } + return new Promise((resolve) => { + const timeout = setTimeout(resolve, timeoutMs) + timeout.unref?.() + proc.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + }) +} + +async function forceKillElectronApp(app: ElectronApplication): Promise { + const proc = app.process() + if (!proc.pid || hasExited(proc)) { + return + } + try { + if (process.platform === 'win32') { + execFileSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }) + } else { + process.kill(proc.pid, 'SIGKILL') + } + } catch { + // Already gone. + } + await waitForExit(proc) +} + +function killPid(pid: number): void { + try { + if (process.platform === 'win32') { + execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) + return + } + process.kill(pid, 'SIGKILL') + } catch { + // Already gone. + } +} + +function stripPersistedPtyOwnership(userDataDir: string): void { + const data = readPersistedData(userDataDir) + const session = data.workspaceSession + if (!session) { + throw new Error('Expected persisted workspace session') + } + for (const tabs of Object.values(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + tab.ptyId = null + } + } + // Why: this models the updater/crash artifact from #6370: the UI tab and + // live resume record survive, but no pane has the old stable leaf key or + // daemon session to own resume. + session.terminalLayoutsByTabId = {} + session.activeWorktreeIdsOnShutdown = [] + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + if (record.providerSession?.id === PROVIDER_SESSION_ID) { + // Why: the e2e proof should verify Orca launches the resumed command, + // not depend on a developer machine having a real Codex CLI installed. + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + } + } + writePersistedData(userDataDir, data) +} + +function persistedLiveRecordExists(userDataDir: string): boolean { + const records = readPersistedData(userDataDir).workspaceSession?.sleepingAgentSessionsByPaneKey + return Object.values(records ?? {}).some( + (record) => record.providerSession?.id === PROVIDER_SESSION_ID + ) +} + +test.describe.configure({ mode: 'serial' }) + +test('resumes a live agent record after force-exit restart when pane PTY ownership is gone', 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 = firstLaunch.page + 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 descriptor = await waitForActivePaneHookDescriptor(page) + const ptyId = await waitForActivePanePtyId(page) + const marker = `AGENT_LIVE_FORCE_EXIT_${Date.now()}` + await execInTerminal(page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(page, marker) + + 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 + } + ) + + await expect + .poll(() => persistedLiveRecordExists(session.userDataDir), { + timeout: 15_000, + message: 'Live sleeping-agent record was not persisted before force exit' + }) + .toBe(true) + + const daemonPid = readDaemonPid(session.userDataDir) + await forceKillElectronApp(firstApp) + firstApp = null + killPid(daemonPid) + stripPersistedPtyOwnership(session.userDataDir) + + 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 waitForTerminalOutput(secondLaunch.page, PROVIDER_SESSION_ID, 30_000) + + const terminalTabCount = await secondLaunch.page.evaluate( + (wtId) => (window.__store?.getState().tabsByWorktree[wtId] ?? []).length, + worktreeId + ) + expect(terminalTabCount).toBe(2) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await forceKillElectronApp(firstApp) + } + await session.dispose() + } +}) diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index 1e54432ca..2640397e4 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -37,6 +37,29 @@ type RestartSession = { dispose: () => Promise } +async function delay(ms: number): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(resolve, ms) + timeout.unref?.() + }) +} + +async function removeProfileDir(userDataDir: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + rmSync(userDataDir, { recursive: true, force: true }) + return + } catch (error) { + if (attempt === 4) { + throw error + } + // Why: on Windows, taskkill can return before Electron/PTY handles are + // fully released, making immediate temp-profile deletion flaky. + await delay(250) + } + } +} + function shouldLaunchHeadful(testInfo: TestInfo): boolean { return testInfo.project.metadata.orcaHeadful === true } @@ -90,7 +113,7 @@ export function createRestartSession(testInfo: TestInfo): RestartSession { const dispose = async (): Promise => { await cleanupE2EDaemons(userDataDir) if (existsSync(userDataDir)) { - rmSync(userDataDir, { recursive: true, force: true }) + await removeProfileDir(userDataDir) } }