From 88da1808f73bade5e4010cd24843d794846044dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:39:09 -0700 Subject: [PATCH] Prevent stale sleeping agents from resuming (#6018) Co-authored-by: Orca --- .../src/lib/agent-hibernation-planner.test.ts | 4 + .../src/lib/agent-hibernation-planner.ts | 6 +- .../lib/resume-sleeping-agent-session.test.ts | 89 ++++++++ .../src/lib/resume-sleeping-agent-session.ts | 17 ++ .../agent-status-manual-sleep-capture.test.ts | 192 ++++++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 105 +++++++++- .../src/store/slices/store-cascades.test.ts | 28 ++- src/renderer/src/store/slices/terminals.ts | 34 +++- src/shared/agent-session-resume.ts | 1 + src/shared/workspace-session-schema.test.ts | 30 +++ .../workspace-session-sleeping-agents.ts | 1 + 11 files changed, 480 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 73a227fff72..1c629053474 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -109,6 +109,10 @@ describe('agent sleep planner', () => { const e = entry({ state }) expect(plannedWorktrees(snapshot({ agentStatusByPaneKey: { [e.paneKey]: e } }))).toEqual([]) } + const interrupted = entry({ interrupted: true }) + expect( + plannedWorktrees(snapshot({ agentStatusByPaneKey: { [interrupted.paneKey]: interrupted } })) + ).toEqual([]) const noSession = entry({ providerSession: undefined }) expect( plannedWorktrees(snapshot({ agentStatusByPaneKey: { [noSession.paneKey]: noSession } })) diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 1f9560d10e2..43f41bd5729 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -133,7 +133,11 @@ function getEligiblePane(args: { lastTerminalInputAtByPaneKey, mobileLockedPtyIds } = args - if (entry.state !== 'done' || sleepingAgentSessionsByPaneKey[entry.paneKey]) { + if ( + entry.state !== 'done' || + entry.interrupted === true || + sleepingAgentSessionsByPaneKey[entry.paneKey] + ) { return null } if ( diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index 61a30d222d6..969339c816e 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { makePaneKey } from '../../../shared/stable-pane-id' +import { parseWorkspaceSession } from '../../../shared/workspace-session-schema' import { useAppStore } from '@/store' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' @@ -322,6 +323,94 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) + it('resumes intentional completed worktree-sleep records', () => { + const record = makeRecord({ origin: 'worktree-sleep', state: 'done' }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(1) + expect(useAppStore.getState().tabsByWorktree['wt-1']?.[0]?.launchAgent).toBe('claude') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('clears stale manual records without launching a tab', () => { + const record = makeRecord({ capturedAt: 3_000_000, updatedAt: 1 }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([]) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('clears interrupted manual records without launching a tab', () => { + const record = makeRecord({ origin: 'worktree-sleep', interrupted: true }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([]) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('clears hydrated interrupted worktree-sleep records without launching a tab', () => { + const parsed = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': makeRecord({ + state: 'done', + origin: 'worktree-sleep', + interrupted: true + }) + } + }) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + const record = parsed.value.sleepingAgentSessionsByPaneKey!['tab-1:leaf-1']! + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([]) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('clears legacy completed live records without launching a tab', () => { + const record = makeRecord({ state: 'done' }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([]) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + it('uses WSL resume quoting for Windows-path projects forced to WSL', () => { const record = makeRecord({ providerSession: { key: 'session_id', id: "sess-1's" }, diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 89fba226855..c136b01e263 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -14,6 +14,7 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-r import type { TerminalTab } from '../../../shared/types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { translate } from '@/i18n/i18n' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types' function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform { const state = useAppStore.getState() @@ -187,6 +188,18 @@ function recordPaneIsOwnedByPreservedPane( ) } +function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): boolean { + if (record.interrupted === true) { + return true + } + if (!record.origin && record.state === 'done') { + return true + } + return ( + record.state !== 'done' && record.capturedAt - record.updatedAt > AGENT_STATUS_STALE_AFTER_MS + ) +} + export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number { const state = useAppStore.getState() const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey) @@ -207,6 +220,10 @@ export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): numb let launched = 0 for (const record of records) { const claimKey = getProviderSessionClaimKey(record) + if (isInvalidWorktreeActivationRecord(record)) { + state.clearSleepingAgentSession(record.paneKey) + continue + } if (paneOwnedClaimKeys.has(claimKey)) { if (!recordPaneIsOwnedByPreservedPane(record, state)) { state.clearSleepingAgentSession(record.paneKey) diff --git a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts new file mode 100644 index 00000000000..d5226e11953 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import type { AppState } from '../types' +import { createTestStore, makeTab } from './store-test-helpers' + +const NOW = 1_800_000_000_000 + +afterEach(() => { + vi.useRealTimers() +}) + +function makeAgentEntry(overrides: Partial = {}): AgentStatusEntry { + const paneKey = overrides.paneKey ?? 'tab-1:leaf-1' + return { + state: 'working', + prompt: 'finish the task', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'codex', + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: 'wt-1', + providerSession: { key: 'session_id', id: `session-${paneKey}` }, + ...overrides + } +} + +function seedTabs(store: ReturnType): void { + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) +} + +function makeSleepingRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + const paneKey = overrides.paneKey ?? 'tab-1:leaf-1' + return { + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: `sleeping-${paneKey}` }, + prompt: 'old prompt', + state: 'working', + capturedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1, + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1, + origin: 'live', + ...overrides + } +} + +describe('manual sleep agent session capture', () => { + it('captures only fresh active live rows as worktree-sleep records', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + agentStatusByPaneKey: { + 'tab-1:fresh': makeAgentEntry({ paneKey: 'tab-1:fresh' }), + 'tab-1:stale': makeAgentEntry({ + paneKey: 'tab-1:stale', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }), + 'tab-1:done': makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' }), + 'tab-1:interrupted': makeAgentEntry({ + paneKey: 'tab-1:interrupted', + state: 'done', + interrupted: true + }), + 'tab-1:post-input': makeAgentEntry({ + paneKey: 'tab-1:post-input', + updatedAt: NOW - 1_000 + }) + }, + lastTerminalInputAtByPaneKey: { 'tab-1:post-input': NOW } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + const records = store.getState().sleepingAgentSessionsByPaneKey + expect(Object.keys(records).sort()).toEqual(['tab-1:fresh']) + expect(records['tab-1:fresh']).toMatchObject({ + origin: 'worktree-sleep', + state: 'working', + providerSession: { key: 'session_id', id: 'session-tab-1:fresh' } + }) + }) + + it('preserves retained completed sessions as intentional sleep records', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + const entry = makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' }) + const tab = makeTab({ id: 'tab-1', worktreeId: 'wt-1' }) + store.setState({ + retainedAgentsByPaneKey: { + 'tab-1:done': { + entry, + tab, + worktreeId: 'wt-1', + agentType: 'codex', + startedAt: entry.stateStartedAt + } + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:done']).toMatchObject({ + origin: 'worktree-sleep', + state: 'done', + providerSession: { key: 'session_id', id: 'session-tab-1:done' } + }) + }) + + it('clears pre-existing records for rows skipped by manual capture', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + agentStatusByPaneKey: { + 'tab-1:stale': makeAgentEntry({ + paneKey: 'tab-1:stale', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + }, + sleepingAgentSessionsByPaneKey: { + 'tab-1:stale': makeSleepingRecord({ paneKey: 'tab-1:stale' }) + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toBeUndefined() + }) + + it('uses manual sleep filtering when terminal shutdown captures sleeping records', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + agentStatusByPaneKey: { + 'tab-1:fresh': makeAgentEntry({ paneKey: 'tab-1:fresh' }), + 'tab-1:done': makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' }) + } + } as Partial) + + await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true }) + + const records = store.getState().sleepingAgentSessionsByPaneKey + expect(Object.keys(records)).toEqual(['tab-1:fresh']) + expect(records['tab-1:fresh']).toMatchObject({ + origin: 'worktree-sleep', + state: 'working' + }) + }) + + it('clears pre-existing records for rows skipped during terminal shutdown capture', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + ptyIdsByTabId: { 'tab-1': [] }, + agentStatusByPaneKey: { + 'tab-1:stale': makeAgentEntry({ + paneKey: 'tab-1:stale', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + }, + sleepingAgentSessionsByPaneKey: { + 'tab-1:stale': makeSleepingRecord({ paneKey: 'tab-1:stale' }) + } + } as Partial) + + await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true }) + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 59e802aedce..9103f548f28 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -344,21 +344,94 @@ function sleepingRecordFromEntry(args: { ? { lastAssistantMessage: args.entry.lastAssistantMessage } : {}), ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), + ...(args.entry.interrupted ? { interrupted: true } : {}), ...(args.origin ? { origin: args.origin } : {}) } } +type CollectSleepingAgentSessionRecordsOptions = { + paneKeys?: readonly string[] + captureMode?: 'manual-worktree-sleep' | 'completed-agent-hibernation' +} + +function normalizeSleepingAgentSessionCollectOptions( + options: readonly string[] | CollectSleepingAgentSessionRecordsOptions | undefined +): CollectSleepingAgentSessionRecordsOptions { + if (!options) { + return {} + } + return Array.isArray(options) + ? { paneKeys: options } + : (options as CollectSleepingAgentSessionRecordsOptions) +} + +function isValidManualSleepLiveAgentEntry( + state: AppState, + entry: AgentStatusEntry, + capturedAt: number +): boolean { + if (entry.interrupted === true || entry.state === 'done') { + return false + } + const lastInputAt = state.lastTerminalInputAtByPaneKey[entry.paneKey] + if ( + typeof lastInputAt === 'number' && + Number.isFinite(lastInputAt) && + lastInputAt > entry.updatedAt + ) { + return false + } + return isExplicitAgentStatusFresh(entry, capturedAt, AGENT_STATUS_STALE_AFTER_MS) +} + +function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean { + return entry.state === 'done' && entry.interrupted !== true +} + +export function removeSleepingRecordsReplacedByManualWorktreeSleep( + records: Record, + worktreeId: string, + paneKeys?: readonly string[] +): { records: Record; changed: boolean } { + const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null + let next = records + let changed = false + for (const [paneKey, record] of Object.entries(records)) { + if (record.worktreeId !== worktreeId || (allowedPaneKeys && !allowedPaneKeys.has(paneKey))) { + continue + } + if (next === records) { + next = { ...records } + } + delete next[paneKey] + changed = true + } + return { records: next, changed } +} + export function collectSleepingAgentSessionRecordsForWorktree( state: AppState, worktreeId: string, - paneKeys?: string[] + options?: readonly string[] | CollectSleepingAgentSessionRecordsOptions ): Record { const capturedAt = Date.now() - const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null + const collectOptions = normalizeSleepingAgentSessionCollectOptions(options) + const allowedPaneKeys = collectOptions.paneKeys ? new Set(collectOptions.paneKeys) : null + const isManualWorktreeSleep = collectOptions.captureMode === 'manual-worktree-sleep' + const isCompletedAgentHibernation = collectOptions.captureMode === 'completed-agent-hibernation' + const isWorktreeOwnedCapture = isManualWorktreeSleep || isCompletedAgentHibernation + // Why: hibernated completions are intentional worktree-owned records; wake + // treats originless completed records as ambiguous legacy captures. + const origin: SleepingAgentSessionRecord['origin'] | undefined = isWorktreeOwnedCapture + ? 'worktree-sleep' + : undefined const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) const records: Record = {} for (const retained of Object.values(state.retainedAgentsByPaneKey)) { + if (isCompletedAgentHibernation) { + continue + } if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) { continue } @@ -371,7 +444,8 @@ export function collectSleepingAgentSessionRecordsForWorktree( worktreeId, tab: retained.tab, capturedAt, - launchConfig: getLaunchConfigForEntry(state, retained.entry) + launchConfig: getLaunchConfigForEntry(state, retained.entry), + origin }) if (record) { records[record.paneKey] = record @@ -387,12 +461,19 @@ export function collectSleepingAgentSessionRecordsForWorktree( if (!belongsToWorktree) { continue } + if (isManualWorktreeSleep && !isValidManualSleepLiveAgentEntry(state, entry, capturedAt)) { + continue + } + if (isCompletedAgentHibernation && !isValidCompletedAgentHibernationEntry(entry)) { + continue + } const record = sleepingRecordFromEntry({ state, entry, worktreeId, capturedAt, - launchConfig: getLaunchConfigForEntry(state, entry) + launchConfig: getLaunchConfigForEntry(state, entry), + origin }) if (record) { records[record.paneKey] = record @@ -1787,11 +1868,17 @@ export const createAgentStatusSlice: StateCreator { set((s) => { - const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, paneKeys) - const next: Record = { - ...s.sleepingAgentSessionsByPaneKey - } - let changed = false + const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, { + paneKeys, + captureMode: 'manual-worktree-sleep' + }) + const replaced = removeSleepingRecordsReplacedByManualWorktreeSleep( + s.sleepingAgentSessionsByPaneKey, + worktreeId, + paneKeys + ) + const next: Record = { ...replaced.records } + let changed = replaced.changed for (const record of Object.values(records)) { if (next[record.paneKey] !== record) { diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 663b4df976d..032fa0d81e5 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -2697,6 +2697,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { [siblingLeaf]: 'pty-shell' }) expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({ + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'target-session' } }) expect(state.sleepingAgentSessionsByPaneKey[siblingPaneKey]).toBe(siblingSleepingRecordBefore) @@ -3218,6 +3219,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { it('commits sleep state after exact runtime stop for runtime-backed PTYs', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' + const now = Date.now() mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => Promise.resolve( createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { @@ -3245,12 +3247,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { store.getState().setAgentStatus( 'tab-1:live', { - state: 'done', + state: 'working', prompt: 'resume live', agentType: 'codex' }, 'Codex', - { updatedAt: 1000, stateStartedAt: 1000 }, + { updatedAt: now, stateStartedAt: now }, { tabId: 'tab-1', worktreeId: wt }, { providerSession: { key: 'session_id', id: 'live-session' } } ) @@ -3269,6 +3271,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) ) expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'live-session' } }) expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeUndefined() @@ -3532,6 +3535,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { it('commits the pre-stop sleeping record when exact-stop exit clears live status', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' + const now = Date.now() mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => { if (args.method === 'terminal.stopExact') { store.getState().removeAgentStatus('tab-1:live') @@ -3565,12 +3569,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { store.getState().setAgentStatus( 'tab-1:live', { - state: 'done', + state: 'working', prompt: 'resume live', agentType: 'codex' }, 'Codex', - { updatedAt: 1000, stateStartedAt: 1000 }, + { updatedAt: now, stateStartedAt: now }, { tabId: 'tab-1', worktreeId: wt }, { providerSession: { key: 'session_id', id: 'live-session' } } ) @@ -3582,6 +3586,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'live-session' } }) }) @@ -3856,6 +3861,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { it('captures resumable provider session metadata before dropping sleep-time rows', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' + const now = Date.now() seedStore(store, { worktreesByRepo: { @@ -3875,7 +3881,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { agentType: 'codex' }, 'Codex', - { updatedAt: 1000, stateStartedAt: 1000 }, + { updatedAt: now, stateStartedAt: now }, { tabId: 'tab-1', worktreeId: wt }, { providerSession: { key: 'session_id', id: 'codex-session-1' } } ) @@ -3889,13 +3895,14 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { tabId: 'tab-1', worktreeId: wt, agent: 'codex', + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'codex-session-1' }, prompt: 'resume this', terminalTitle: 'Codex' }) }) - it('captures only allowlisted sleeping pane sessions when requested', async () => { + it('skips allowlisted done live sleeping pane sessions during manual sleep', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -3946,10 +3953,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) const state = store.getState() - expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ - paneKey: 'tab-1:live', - providerSession: { key: 'session_id', id: 'live-session' } - }) + expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined() expect(state.sleepingAgentSessionsByPaneKey['tab-1:retained']).toBeUndefined() }) @@ -4076,6 +4080,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ paneKey: 'tab-1:live', + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'live-session' } }) expect(state.retainedAgentsByPaneKey['tab-1:retained']).toBeUndefined() @@ -4140,6 +4145,9 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) const state = store.getState() + expect(state.sleepingAgentSessionsByPaneKey['tab-1:interrupted']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey['tab-1:working']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey['tab-1:retained-only']).toBeUndefined() expect(state.retainedAgentsByPaneKey['tab-1:interrupted']).toBeUndefined() expect(state.retainedAgentsByPaneKey['tab-1:working']).toBeUndefined() expect(state.retainedAgentsByPaneKey['tab-1:retained-only']).toBeUndefined() diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 1c53d3e671d..0b1d68ac113 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -60,6 +60,7 @@ import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-co import { collectHibernatedCompletionEvidenceForWorktree, collectSleepingAgentSessionRecordsForWorktree, + removeSleepingRecordsReplacedByManualWorktreeSleep, type AgentStatusWorktreeShutdownReason } from './agent-status' @@ -1727,7 +1728,10 @@ export const createTerminalSlice: StateCreator const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree( state, worktreeId, - paneKeys + { + paneKeys, + captureMode: 'completed-agent-hibernation' + } ) const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree( state, @@ -1912,7 +1916,13 @@ export const createTerminalSlice: StateCreator const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) const shutdownPtyIds = sortedUniquePtyIds([...ptyIds, ...expectedRuntimePtyIds]) const sleepingAgentSessionRecords = keepIdentifiers - ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, opts?.sleepingPaneKeys) + ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, { + paneKeys: opts?.sleepingPaneKeys, + ...(shutdownReason === 'manual-sleep' ? { captureMode: 'manual-worktree-sleep' } : {}), + ...(shutdownReason === 'auto-hibernate-completed-agent' + ? { captureMode: 'completed-agent-hibernation' } + : {}) + }) : {} const retainedCompletionEvidence = shutdownReason === 'auto-hibernate-completed-agent' @@ -2178,12 +2188,22 @@ export const createTerminalSlice: StateCreator }) if (keepIdentifiers) { - set((s) => ({ - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - ...sleepingAgentSessionRecords + set((s) => { + const base = + shutdownReason === 'manual-sleep' + ? removeSleepingRecordsReplacedByManualWorktreeSleep( + s.sleepingAgentSessionsByPaneKey, + worktreeId, + opts?.sleepingPaneKeys + ).records + : s.sleepingAgentSessionsByPaneKey + return { + sleepingAgentSessionsByPaneKey: { + ...base, + ...sleepingAgentSessionRecords + } } - })) + }) } else { get().clearSleepingAgentSessionsByWorktree(worktreeId) } diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index ae3568372c2..aacd548dfed 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -40,6 +40,7 @@ export type SleepingAgentSessionRecord = { updatedAt: number terminalTitle?: string lastAssistantMessage?: string + interrupted?: boolean connectionId?: string | null launchConfig?: SleepingAgentLaunchConfig /** How the record was captured. Worktree-sleep records (legacy records have diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 76870f1d0b5..05ddd0810f1 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -329,6 +329,36 @@ describe('parseWorkspaceSession', () => { } }) + it('preserves interrupted sleeping agent records across hydration', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'done', + capturedAt: 10, + updatedAt: 9, + interrupted: true, + origin: 'worktree-sleep' + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.interrupted).toBe(true) + } + }) + it('preserves legacy live sleeping agent origins across hydration', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index a05731a89cb..ca110178481 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -77,6 +77,7 @@ const sleepingAgentSessionRecordSchema = z.object({ updatedAt: z.number().finite().positive(), terminalTitle: z.string().optional(), lastAssistantMessage: z.string().optional(), + interrupted: z.boolean().optional(), connectionId: z.string().nullable().optional(), launchConfig: sleepingAgentLaunchConfigSchema.optional(), origin: z.enum(['worktree-sleep', 'quit', 'live']).optional()