fix(terminal): retain sleeping agent records until spawn

This commit is contained in:
Brennan Benson
2026-08-24 03:17:49 -07:00
parent afd76a4df9
commit bf13ae12c8
10 changed files with 149 additions and 23 deletions
@@ -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<string, string> } | null } = {
@@ -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.
@@ -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', () => {
@@ -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)
})
})
@@ -34,6 +34,23 @@ function makeTerminalTab(id: string): Record<string, unknown> {
}
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', () => {
@@ -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', () => {
@@ -132,6 +132,29 @@ function activeOrQueuedResumeClaimsProviderSession(
return false
}
function isSleepingResumeStartupInFlight(
record: SleepingAgentSessionRecord,
state: ReturnType<typeof useAppStore.getState>
): 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.
@@ -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')
}
@@ -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',
@@ -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