mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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 <help@stably.ai>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -80,6 +80,8 @@ type StoreState = {
|
||||
consumePendingSnapshot: ReturnType<typeof vi.fn>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
agentStatusByPaneKey: Record<string, unknown>
|
||||
sleepingAgentSessionsByPaneKey: Record<string, unknown>
|
||||
clearSleepingAgentSession: ReturnType<typeof vi.fn>
|
||||
markWorktreeUnread: ReturnType<typeof vi.fn>
|
||||
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
|
||||
setAgentStatus: ReturnType<typeof vi.fn>
|
||||
@@ -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<string, unknown>) => {
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {}
|
||||
): 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<string, unknown> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
store.getState().captureAllSleepingAgentSessions()
|
||||
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
|
||||
worktreeId: 'wt-1',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string>) => 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<AppState, [], [], AgentStatusS
|
||||
})
|
||||
},
|
||||
|
||||
captureAllSleepingAgentSessions: () => {
|
||||
// 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<string, SleepingAgentSessionRecord> = {
|
||||
...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)) {
|
||||
|
||||
@@ -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<string> = new Set(RESUMABLE_TUI_AGENTS)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user