mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
fix(agent-status): resume finished agents after macOS logout instead of a bare shell (#9454)
After the daemon-retirement fix (#9826) recovers terminals across a macOS logout, a running Claude Code (or other resumable TUI agent) that had finished its turn came back as a BARE SHELL — the user had to open a new tab and `claude --resume` in every worktree to get logged back in. Root cause: setAgentStatus writes a persisted 'live recovery' sleeping record (the cold-restore --resume anchor) for a live resumable agent, but drops it once the turn is 'done' — except for Pi, which was special-cased (retainsPiRecoveryIdentity). A finished Claude/Codex turn leaves the TUI alive and resumable at its prompt just like Pi, so an idle agent lost its anchor; a cold restore after an abrupt app death (logout) then found nothing to resume and dropped to a bare shell. Fix: generalize the Pi-only recovery-identity retention to every resumable TUI agent (isResumableTuiAgent), gated on a valid resume argv, so a finished agent keeps its persisted providerSession anchor and the already-shipped cold-restore --resume path brings it back logged in. Extends an existing pattern; no new subsystem. Reproduced + validated live in Electron via the login-session-death seam: idle Claude -> daemon retire -> app relaunch previously bare-shelled; now spawns 'claude --resume <session>' and restores the conversation logged in.
This commit is contained in:
@@ -686,42 +686,48 @@ describe('captureAllSleepingAgentSessions', () => {
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(firstRecord)
|
||||
})
|
||||
|
||||
it('clears the live checkpoint when the agent finishes', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
|
||||
// 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).
|
||||
// Covers claude (the reported agent) and codex; previously this was Pi-only.
|
||||
it.each([
|
||||
['claude', 'Claude'],
|
||||
['codex', 'Codex']
|
||||
] as const)(
|
||||
'retains the recovery anchor when a finished %s session stays resumable (#9454)',
|
||||
(agentType, title) => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }
|
||||
} as Partial<AppState>)
|
||||
const providerSession = { key: 'session_id', id: `${agentType}-session-1` } as const
|
||||
|
||||
for (const [state, updatedAt] of [
|
||||
['working', 10],
|
||||
['done', 20]
|
||||
] as const) {
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
'tab-1:leaf-1',
|
||||
{ state, prompt: 'finish the task', agentType },
|
||||
title,
|
||||
{ updatedAt, stateStartedAt: 10 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' },
|
||||
{ providerSession }
|
||||
)
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().setAgentStatus(
|
||||
'tab-1:leaf-1',
|
||||
{
|
||||
state: 'working',
|
||||
prompt: 'finish the task',
|
||||
agentType: 'codex'
|
||||
},
|
||||
'Codex',
|
||||
{ updatedAt: 10, stateStartedAt: 10 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' },
|
||||
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
|
||||
)
|
||||
store.getState().setAgentStatus(
|
||||
'tab-1:leaf-1',
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'finish the task',
|
||||
agentType: 'codex'
|
||||
},
|
||||
'Codex',
|
||||
{ updatedAt: 20, stateStartedAt: 10 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' },
|
||||
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
|
||||
)
|
||||
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
|
||||
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
|
||||
})
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
|
||||
agent: agentType,
|
||||
providerSession,
|
||||
origin: 'live',
|
||||
state: 'working'
|
||||
})
|
||||
// Launch config is still cleared on done: tokens must no longer authorize config reuse.
|
||||
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not reuse launch config from a completed same-pane agent', () => {
|
||||
const store = createTestStore()
|
||||
|
||||
@@ -1801,13 +1801,17 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
? registryEntry?.launchConfig
|
||||
: undefined
|
||||
const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
|
||||
const retainsPiRecoveryIdentity =
|
||||
// Why: a completed turn leaves the TUI session alive and resumable at its prompt for any
|
||||
// resumable agent (Claude/Codex/Pi/…), not just Pi — so keep its persisted recovery anchor
|
||||
// even when done. Else a cold restore after an abrupt app death (macOS logout, #9454) drops
|
||||
// the pane to a bare shell instead of `--resume`-ing the agent logged in.
|
||||
const retainsResumableRecoveryIdentity =
|
||||
payload.state === 'done' &&
|
||||
identity.agentType === 'pi' &&
|
||||
isResumableTuiAgent(identity.agentType) &&
|
||||
providerSession !== undefined &&
|
||||
getAgentResumeArgv('pi', providerSession) !== null
|
||||
getAgentResumeArgv(identity.agentType, providerSession) !== null
|
||||
const matchedSleepingLaunchConfig =
|
||||
(payload.state !== 'done' || retainsPiRecoveryIdentity) &&
|
||||
(payload.state !== 'done' || retainsResumableRecoveryIdentity) &&
|
||||
existingSleepingRecord?.launchConfig &&
|
||||
existingSleepingRecord.agent === identity.agentType &&
|
||||
providerSession &&
|
||||
@@ -1935,14 +1939,14 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
(entry) => entry.paneKey === paneKey
|
||||
)
|
||||
const liveRecoveryWorktreeId =
|
||||
entry.state === 'done' && !retainsPiRecoveryIdentity
|
||||
entry.state === 'done' && !retainsResumableRecoveryIdentity
|
||||
? null
|
||||
: (entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey))
|
||||
const liveRecoveryRecord = liveRecoveryWorktreeId
|
||||
? sleepingRecordFromEntry({
|
||||
state: s,
|
||||
// Why: a completed Pi turn leaves the TUI session alive — keep resume identity active without representing done as pending work.
|
||||
entry: retainsPiRecoveryIdentity
|
||||
// Why: a completed resumable-agent turn leaves the TUI session alive — keep resume identity active without representing done as pending work.
|
||||
entry: retainsResumableRecoveryIdentity
|
||||
? { ...entry, state: 'working', prompt: '', lastAssistantMessage: undefined }
|
||||
: entry,
|
||||
worktreeId: liveRecoveryWorktreeId,
|
||||
|
||||
Reference in New Issue
Block a user