mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(agent-resume): stop ghost resume tabs after finished turns (#16308)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user