From bf13ae12c8a6b440f819d734396f5f68285cadee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:17:49 -0700 Subject: [PATCH] fix(terminal): retain sleeping agent records until spawn --- .../use-terminal-pane-lifecycle.test.ts | 44 +++++++++++++++++++ .../use-terminal-pane-lifecycle.ts | 26 ++++++++++- .../host-mirrored-pane-resume-replay.test.ts | 2 +- ...eeping-agent-session-remote-compat.test.ts | 4 +- ...sume-sleeping-agent-session-replay.test.ts | 23 +++++++++- .../lib/resume-sleeping-agent-session.test.ts | 32 +++++++------- .../src/lib/resume-sleeping-agent-session.ts | 28 ++++++++++++ .../src/lib/sleeping-agent-session-launch.ts | 5 ++- ...ivation-preserved-pane-replacement.test.ts | 2 +- src/renderer/src/store/slices/terminals.ts | 6 +++ 10 files changed, 149 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 053a22405d5..8d87272cfe2 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -3,6 +3,7 @@ import { applyTerminalPaneCloseRequest, applyTerminalScrollbackRowsToMountedPanes, clearQueuedInitialCwdAfterFirstPane, + consumeQueuedStartupAndClearSleepingRecord, createQueuedStartupConsumer, getPreviousVisibleForTerminalPane, isTerminalPaneVisibilityResume, @@ -301,6 +302,49 @@ describe('createQueuedStartupConsumer', () => { }) }) +describe('consumeQueuedStartupAndClearSleepingRecord', () => { + it('clears the matching record once after fresh-spawn startup consumption', () => { + const identity = { paneKey: 'old-tab:leaf-1', capturedAt: 42 } + let consumed = true + const clearRecord = vi.fn() + + const consume = (): { sleepingAgentResumeIdentity: typeof identity } | null => { + if (!consumed) { + return null + } + consumed = false + return { sleepingAgentResumeIdentity: identity } + } + + consumeQueuedStartupAndClearSleepingRecord( + 'tab-1', + consume, + () => ({ capturedAt: 42 }), + clearRecord + ) + consumeQueuedStartupAndClearSleepingRecord( + 'tab-1', + consume, + () => ({ capturedAt: 42 }), + clearRecord + ) + + expect(clearRecord).toHaveBeenCalledOnce() + expect(clearRecord).toHaveBeenCalledWith(identity.paneKey) + }) + + it('does not clear a replacement capture on the same pane key', () => { + const clearRecord = vi.fn() + consumeQueuedStartupAndClearSleepingRecord( + 'tab-1', + () => ({ sleepingAgentResumeIdentity: { paneKey: 'tab-1:leaf-1', capturedAt: 41 } }), + () => ({ capturedAt: 42 }), + clearRecord + ) + expect(clearRecord).not.toHaveBeenCalled() + }) +}) + describe('splitPaneWithOneShotStartup', () => { it('only exposes startup to the intentional split and clears it afterwards', () => { const deps: { startup?: { command: string; env?: Record } | null } = { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 16c00e6c64e..5e2c3c03011 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -494,6 +494,22 @@ export function paneOwnsQueuedStartup( return queuedStartup != null && paneStartup === queuedStartup } +export type SleepingAgentResumeIdentity = { paneKey: string; capturedAt: number } + +export function consumeQueuedStartupAndClearSleepingRecord( + tabId: string, + consume: (tabId: string) => { sleepingAgentResumeIdentity?: SleepingAgentResumeIdentity } | null, + getRecord: (paneKey: string) => { capturedAt: number } | undefined, + clearRecord: (paneKey: string) => void +): void { + const consumed = consume(tabId) + const identity = consumed?.sleepingAgentResumeIdentity + const record = identity ? getRecord(identity.paneKey) : undefined + if (identity && record?.capturedAt === identity.capturedAt) { + clearRecord(identity.paneKey) + } +} + /** * The callback that spends the tab's queued startup command, or `undefined` when this pane does * not own it. @@ -1351,7 +1367,15 @@ export function useTerminalPaneLifecycle({ const onQueuedStartupSpawned = createQueuedStartupConsumer( ptyDeps.startup, startupWithSetupSplitWait, - () => useAppStore.getState().consumeTabStartupCommand(tabId), + () => { + const state = useAppStore.getState() + consumeQueuedStartupAndClearSleepingRecord( + tabId, + state.consumeTabStartupCommand, + (paneKey) => state.sleepingAgentSessionsByPaneKey[paneKey], + state.clearSleepingAgentSession + ) + }, // Why `startup` and not startupWithSetupSplitWait: setup-split hands the pane a copy, so only // the raw prop still matches the object the store holds. Read and consume run in one // synchronous step, so nothing can queue in between. diff --git a/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts b/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts index d5fca82d19b..2d5eaa12d03 100644 --- a/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts +++ b/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts @@ -174,7 +174,7 @@ describe('parked mirrored-pane resume replay', () => { key: 'session_id', id: 'codex-session-replay-1' }) - expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) it('replays without relaunching when the mirror reports the host PTY live', () => { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts index 247b8c475f9..81594f58ba6 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts @@ -51,7 +51,7 @@ describe('remote sleeping-agent compatibility', () => { setRemoteSleepRecord(value) expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBe(value) }) it('preserves legacy automatic wake when host authority is not known', () => { @@ -59,6 +59,6 @@ describe('remote sleeping-agent compatibility', () => { setRemoteSleepRecord(value) expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBe(value) }) }) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts index 195fa721812..1de7bd4836b 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts @@ -34,6 +34,23 @@ function makeTerminalTab(id: string): Record { } describe('resumeSleepingAgentSessionsForWorktree replay protection', () => { + it('keeps a sleeping record retryable when the first resume spawn never arrives', () => { + const record = makeRecord() + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + + // A second activation can observe the failed/in-flight attempt, but must not + // clear the record or launch a duplicate before the execution host answers. + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + }) + it('stores provider-session metadata in the queued startup and runtime claim', () => { const record = makeRecord() useAppStore.setState({ @@ -47,6 +64,10 @@ describe('resumeSleepingAgentSessionsForWorktree replay protection', () => { expect(state.pendingStartupByTabId[resumedTab.id]?.resumeProviderSession).toEqual( record.providerSession ) + expect(state.pendingStartupByTabId[resumedTab.id]?.sleepingAgentResumeIdentity).toEqual({ + paneKey: record.paneKey, + capturedAt: record.capturedAt + }) expect(state.automaticAgentResumeClaimsByTabId[resumedTab.id]).toEqual({ worktreeId: record.worktreeId, launchAgent: record.agent, @@ -71,7 +92,7 @@ describe('resumeSleepingAgentSessionsForWorktree replay protection', () => { expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0) expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('does not fork after startup is consumed but before hooks report live status', () => { 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 c90aff730c4..b09be5d25dd 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -96,7 +96,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') expect(resumedTab?.launchAgent).toBe('claude') expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) - expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('resumes live-checkpoint records when no preserved pane can own recovery', () => { @@ -113,7 +113,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') expect(resumedTab?.launchAgent).toBe('claude') expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) - expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('resumes legacy sleep records without an origin when no preserved pane can own recovery', () => { @@ -130,7 +130,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { const resumedTab = (state.tabsByWorktree['wt-1'] ?? []).find((tab) => tab.id !== 'tab-1') expect(resumedTab?.launchAgent).toBe('claude') expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) - expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('skips worktree-sleep records owned by a preserved stable UUID pane', () => { @@ -262,8 +262,8 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(launched).toBe(2) expect(resumedTabs).toHaveLength(2) expect(Object.keys(state.pendingStartupByTabId)).toHaveLength(2) - expect(state.sleepingAgentSessionsByPaneKey[initiallyUnowned.paneKey]).toBeUndefined() - expect(state.sleepingAgentSessionsByPaneKey[initiallyOwned.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[initiallyUnowned.paneKey]).toBeDefined() + expect(state.sleepingAgentSessionsByPaneKey[initiallyOwned.paneKey]).toBeDefined() }) it('skips active stable-pane records in an inactive split leaf of the active terminal tab', () => { @@ -368,7 +368,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(launched).toBe(1) expect(resumedTab?.launchAgent).toBe('claude') expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) - expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('resumes live stable-pane records when the preserved leaf has no PTY to cold-restore', () => { @@ -394,7 +394,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(launched).toBe(1) expect(resumedTab?.launchAgent).toBe('claude') expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) - expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('does not let a sibling split-pane PTY claim a live stable-pane record', () => { @@ -418,7 +418,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(launched).toBe(1) expect(resumedTab?.launchAgent).toBe('claude') expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) - expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('skips hibernated stable panes after their live PTY binding is cleared', () => { @@ -491,8 +491,8 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { const state = useAppStore.getState() expect(launched).toBe(2) expect(state.tabsByWorktree['wt-1']).toHaveLength(3) - expect(state.sleepingAgentSessionsByPaneKey[first.paneKey]).toBeUndefined() - expect(state.sleepingAgentSessionsByPaneKey[second.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[first.paneKey]).toBeDefined() + expect(state.sleepingAgentSessionsByPaneKey[second.paneKey]).toBeDefined() }) it('resumes worktree-sleep records into a fresh tab', () => { @@ -510,7 +510,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(tabs).toHaveLength(1) expect(tabs[0]?.launchAgent).toBe('claude') expect(state.pendingStartupByTabId[tabs[0]!.id]?.showSessionRestoredBanner).toBe(true) - expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('uses captured launch config instead of changed settings when resuming worktree sleep', () => { @@ -579,7 +579,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { agentArgs: '--newer' }) expect(state.sleepingAgentSessionsByPaneKey[first.paneKey]).toBeUndefined() - expect(state.sleepingAgentSessionsByPaneKey[duplicate.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[duplicate.paneKey]).toBeDefined() }) it('lets a preserved pane claim its provider session and clears only stale duplicates', () => { @@ -689,7 +689,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { agentArgs: '--active' }) expect(state.sleepingAgentSessionsByPaneKey[completed.paneKey]).toBeUndefined() - expect(state.sleepingAgentSessionsByPaneKey[active.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[active.paneKey]).toBeDefined() }) it('does not let invalid pane-owned records block a valid duplicate resume', () => { @@ -724,7 +724,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(launched).toBe(1) expect(resumedTab?.launchAgent).toBe('claude') expect(state.sleepingAgentSessionsByPaneKey[invalid.paneKey]).toBeUndefined() - expect(state.sleepingAgentSessionsByPaneKey[valid.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[valid.paneKey]).toBeDefined() }) it('fresh-resumes when explicit tabId disagrees with the stable pane-key tab id', () => { @@ -749,7 +749,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { ) expect(launched).toBe(1) expect(resumedTab?.launchAgent).toBe('claude') - expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('clears completed worktree-sleep records without preserved panes instead of resuming', () => { @@ -794,7 +794,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { const state = useAppStore.getState() expect(launched).toBe(1) expect(state.tabsByWorktree['wt-1']?.[0]?.launchAgent).toBe('claude') - expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) it('clears hydrated interrupted worktree-sleep records without launching a tab', () => { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index bde0cca02c9..2732fbc3345 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -132,6 +132,29 @@ function activeOrQueuedResumeClaimsProviderSession( return false } +function isSleepingResumeStartupInFlight( + record: SleepingAgentSessionRecord, + state: ReturnType +): boolean { + const worktreeTabIds = new Set( + (state.tabsByWorktree[record.worktreeId] ?? []).map((tab) => tab.id) + ) + return Object.entries(state.pendingStartupByTabId).some(([tabId, startup]) => { + const identity = startup.sleepingAgentResumeIdentity + return ( + worktreeTabIds.has(tabId) && + identity?.paneKey === record.paneKey && + identity.capturedAt === record.capturedAt && + startup.launchAgent === record.agent && + agentProviderSessionsEqual( + record.agent, + startup.resumeProviderSession, + record.providerSession + ) + ) + }) +} + // Why: an interrupted turn is still resumable — `claude --resume` reopens the transcript at the // prompt — so discarding those records only stranded the session across wake and restart. function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): boolean { @@ -225,6 +248,11 @@ export function resumeSleepingAgentSessionsForWorktree( } continue } + if (isSleepingResumeStartupInFlight(record, currentState)) { + // The queued startup is the retry path while the execution host has not + // proved a fresh PTY; preserve the record for a later spawn or retry. + continue + } if (activeOrQueuedResumeClaimsProviderSession(record, currentState, isPaneOwned)) { // Why: main can replay the old wake record after the same provider // session was already queued in a fresh tab; clear the stale replay. diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 6d5f0d5ded2..fffd634b93c 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -109,6 +109,10 @@ export function launchSleepingAgentSession( resumeProviderSession: record.providerSession, launchAgent: record.agent, ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), + sleepingAgentResumeIdentity: { + paneKey: record.paneKey, + capturedAt: record.capturedAt + }, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), @@ -124,7 +128,6 @@ export function launchSleepingAgentSession( launchAgent: record.agent, providerSession: record.providerSession }) - state.clearSleepingAgentSession(record.paneKey) if (!options?.suppressNavigation) { state.setActiveTabType('terminal') } diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index ff22ddee07d..6c812d13596 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -383,7 +383,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { const after = useAppStore.getState() const tabs = after.tabsByWorktree[worktree.id] ?? [] expect(tabs).toHaveLength(1) - expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() const replacement = tabs[0]! expect(after.automaticAgentResumeClaimsByTabId[replacement.id]?.providerSession).toEqual({ key: 'session_id', diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 4e570420a11..b7ac523c6c0 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -566,6 +566,8 @@ export type TerminalSlice = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + /** Stable identity of the sleeping record held until a fresh resume PTY spawns. */ + sleepingAgentResumeIdentity?: { paneKey: string; capturedAt: number } launchToken?: string launchAgent?: TuiAgent /** Explicit CLI override for host-owned agent launches; omission uses host settings. */ @@ -748,6 +750,8 @@ export type TerminalSlice = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + /** Stable identity of the sleeping record held until a fresh resume PTY spawns. */ + sleepingAgentResumeIdentity?: { paneKey: string; capturedAt: number } launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null @@ -768,6 +772,8 @@ export type TerminalSlice = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + /** Stable identity of the sleeping record held until a fresh resume PTY spawns. */ + sleepingAgentResumeIdentity?: { paneKey: string; capturedAt: number } launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null