Fix agent resume after force-exit restore (#6391)
This commit is contained in:
parent
c0fd54c341
commit
ff367431ff
|
|
@ -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<string>())
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -53,8 +53,27 @@ function makeLayout(leafId: string, ptyId = 'pty-1'): Record<string, unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
function makeSplitLayout(
|
||||
leafId: string,
|
||||
otherLeafId: string,
|
||||
ptyIdsByLeafId: Record<string, string>
|
||||
): Record<string, unknown> {
|
||||
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' })
|
||||
|
|
|
|||
|
|
@ -168,6 +168,22 @@ function hasMatchingStablePaneLayout(
|
|||
return layoutContainsLeaf(terminalLayoutsByTabId[tabId]?.root, leafId)
|
||||
}
|
||||
|
||||
function hasRestorableStablePanePty(
|
||||
tab: TerminalTab,
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
ptyIdsByTabId: Record<string, string[] | undefined>,
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
): 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)
|
||||
|
|
|
|||
|
|
@ -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<string, { id?: unknown; ptyId?: unknown }[]>
|
||||
terminalLayoutsByTabId?: Record<string, unknown>
|
||||
activeWorktreeIdsOnShutdown?: unknown
|
||||
sleepingAgentSessionsByPaneKey?: Record<
|
||||
string,
|
||||
{
|
||||
providerSession?: { id?: unknown }
|
||||
launchConfig?: {
|
||||
agentCommand?: string
|
||||
agentArgs?: string
|
||||
agentEnv?: Record<string, string>
|
||||
}
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
|
@ -37,6 +37,29 @@ type RestartSession = {
|
|||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
async function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, ms)
|
||||
timeout.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
async function removeProfileDir(userDataDir: string): Promise<void> {
|
||||
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<void> => {
|
||||
await cleanupE2EDaemons(userDataDir)
|
||||
if (existsSync(userDataDir)) {
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
await removeProfileDir(userDataDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue