From 4a604d70c9fa8a582808fcf868c84d2c1328a578 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:08:32 -0700 Subject: [PATCH] fix(agent-status): resume finished agents after macOS logout instead of a bare shell (#9454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' and restores the conversation logged in. --- .../slices/agent-status-quit-capture.test.ts | 74 ++++++++++--------- src/renderer/src/store/slices/agent-status.ts | 18 +++-- 2 files changed, 51 insertions(+), 41 deletions(-) diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index c125bb369e7..061daea2f21 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -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) + 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) - 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() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 49150a4a54f..44ff696a26a 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1801,13 +1801,17 @@ export const createAgentStatusSlice: StateCreator 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,