diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c17547b2a..77705cba6 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -12949,6 +12949,80 @@ describe('OrcaRuntimeService', () => { expect(stopped).toEqual([]) }) + it('allows target-only exact terminal stop when sibling PTYs remain live', async () => { + const runtime = new OrcaRuntimeService(store) + const stopped: string[] = [] + const processLists = [ + [ + { id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' }, + { id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' } + ], + [{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' }] + ] + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId, opts) => { + stopped.push(ptyId) + expect(opts).toEqual({ keepHistory: true }) + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] + }) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + }, + { + tabId: 'tab-2', + worktreeId: TEST_WORKTREE_ID, + title: 'Shell', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + }, + { + tabId: 'tab-2', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 2, + ptyId: 'pty-shell' + } + ] + }) + + await expect( + runtime.stopExactTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`, ['pty-1'], { + keepHistory: true, + targetOnly: true + }) + ).resolves.toEqual({ + stopped: 1, + stoppedPtyIds: ['pty-1'], + livePtyIds: ['pty-1', 'pty-shell'], + postStopVerified: true, + remainingLivePtyIds: ['pty-shell'] + }) + expect(stopped).toEqual(['pty-1']) + }) + it('rejects exact terminal stop for multiple expected PTYs before stopping anything', async () => { const runtime = new OrcaRuntimeService(store) const stopped: string[] = [] diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aab4b8255..844f10d85 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -13732,7 +13732,7 @@ export class OrcaRuntimeService { async stopExactTerminalsForWorktree( worktreeSelector: string, expectedPtyIds: readonly string[], - opts: { keepHistory?: boolean } = {} + opts: { keepHistory?: boolean; targetOnly?: boolean } = {} ): Promise<{ stopped: number stoppedPtyIds: string[] @@ -13741,8 +13741,8 @@ export class OrcaRuntimeService { postStopFailure?: string remainingLivePtyIds?: string[] }> { - // Why: hibernation may commit sleeping state only after the runtime proves - // the selected PTYs are still the complete live set for this worktree. + // Why: worktree sleep needs proof of the complete live set; pane hibernation + // only needs proof that its target PTY was live and is now gone. const graphEpoch = this.captureReadyGraphEpoch() const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) @@ -13757,7 +13757,9 @@ export class OrcaRuntimeService { throw new Error('terminal_liveness_unavailable') } const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) - if (!setsEqual(livePtyIds, expected)) { + const targetOnly = opts.targetOnly === true + const expectedIsLive = [...expected].every((ptyId) => livePtyIds.has(ptyId)) + if (targetOnly ? !expectedIsLive : !setsEqual(livePtyIds, expected)) { const error = Object.assign(new Error('terminal_stop_pty_set_mismatch'), { livePtyIds: [...livePtyIds].sort(), expectedPtyIds: [...expected].sort() @@ -13787,7 +13789,8 @@ export class OrcaRuntimeService { } } const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) - if (remainingLivePtyIds.size > 0) { + const stoppedTargetsStillLive = [...expected].filter((ptyId) => remainingLivePtyIds.has(ptyId)) + if (targetOnly ? stoppedTargetsStillLive.length > 0 : remainingLivePtyIds.size > 0) { return { stopped: stoppedPtyIds.length, stoppedPtyIds, @@ -13801,7 +13804,10 @@ export class OrcaRuntimeService { stopped: stoppedPtyIds.length, stoppedPtyIds, livePtyIds: [...livePtyIds].sort(), - postStopVerified: true + postStopVerified: true, + ...(targetOnly && remainingLivePtyIds.size > 0 + ? { remainingLivePtyIds: [...remainingLivePtyIds].sort() } + : {}) } } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 64fdd9b0e..123012991 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -539,7 +539,8 @@ const TerminalStop = z.object({ const TerminalStopExact = TerminalStop.extend({ expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), - keepHistory: z.boolean().optional() + keepHistory: z.boolean().optional(), + targetOnly: z.boolean().optional() }) const AgentTeamsTmuxCompat = z.object({ @@ -813,7 +814,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalStopExact, handler: async (params, { runtime }) => runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { - keepHistory: params.keepHistory + keepHistory: params.keepHistory, + targetOnly: params.targetOnly }) }), defineMethod({ diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 916fe63c3..3cd7a70b9 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -72,6 +72,13 @@ const ptyExitSidecars = new Map void>>() export const ptyTeardownHandlers = new Map void>() let ptyDispatcherAttached = false +export type PtyDataHandlerShutdownSnapshot = { + ptyId: string + dataHandler?: (data: string, meta?: PtyDataMeta) => void + replayHandler?: (data: string) => void + teardownHandler?: () => void +} + /** * Remove data and status handlers for the given PTY IDs so that any final * data flushed by the main process during PTY teardown cannot trigger @@ -82,13 +89,37 @@ let ptyDispatcherAttached = false * Exit handlers are intentionally kept alive so the normal exit-cleanup * path (unregister, clear stale timers, update store) still runs. */ -export function unregisterPtyDataHandlers(ptyIds: string[]): void { +export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutdownSnapshot[] { + const snapshots: PtyDataHandlerShutdownSnapshot[] = [] for (const id of ptyIds) { + snapshots.push({ + ptyId: id, + dataHandler: ptyDataHandlers.get(id), + replayHandler: ptyReplayHandlers.get(id), + teardownHandler: ptyTeardownHandlers.get(id) + }) ptyDataHandlers.delete(id) ptyReplayHandlers.delete(id) ptyTeardownHandlers.get(id)?.() ptyTeardownHandlers.delete(id) } + return snapshots +} + +export function restorePtyDataHandlersAfterFailedShutdown( + snapshots: readonly PtyDataHandlerShutdownSnapshot[] +): void { + for (const snapshot of snapshots) { + if (snapshot.dataHandler) { + ptyDataHandlers.set(snapshot.ptyId, snapshot.dataHandler) + } + if (snapshot.replayHandler) { + ptyReplayHandlers.set(snapshot.ptyId, snapshot.replayHandler) + } + if (snapshot.teardownHandler) { + ptyTeardownHandlers.set(snapshot.ptyId, snapshot.teardownHandler) + } + } } export function ensurePtyDispatcher(): void { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 1c4cd98a7..dc543fd0c 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -746,6 +746,27 @@ describe('createIpcPtyTransport', () => { expect(onPtyExit).toHaveBeenCalledWith('pty-1') }) + it('restores data handlers when an intentional shutdown fails before exit', async () => { + const { + createIpcPtyTransport, + restorePtyDataHandlersAfterFailedShutdown, + unregisterPtyDataHandlers + } = await import('./pty-transport') + const onDataCallback = vi.fn() + const transport = createIpcPtyTransport() + + await transport.connect({ url: '', callbacks: { onData: onDataCallback } }) + + const snapshots = unregisterPtyDataHandlers(['pty-1']) + onData?.({ id: 'pty-1', data: 'final burst while detached' }) + expect(onDataCallback).not.toHaveBeenCalled() + + restorePtyDataHandlersAfterFailedShutdown(snapshots) + onData?.({ id: 'pty-1', data: 'live again' }) + + expect(onDataCallback).toHaveBeenCalledWith('live again') + }) + it('unregisterPtyDataHandlers cancels staleTitleTimer so it cannot fire stale idle transition', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 00eb8dd54..1918ead1a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -35,6 +35,7 @@ export { ensurePtyDispatcher, getEagerPtyBufferHandle, registerEagerPtyBuffer, + restorePtyDataHandlersAfterFailedShutdown, subscribeToPtyExit, unregisterPtyDataHandlers } from './pty-dispatcher' diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 5cfe68e8d..3d5fa5df6 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -71,9 +71,9 @@ function entry(): AgentStatusEntry { } function installEligibleState( - shutdownWorktreeTerminals = vi.fn(), + shutdownCompletedAgentPaneForHibernation = vi.fn(), overrides: Partial = {} -): typeof shutdownWorktreeTerminals { +): typeof shutdownCompletedAgentPaneForHibernation { const e = entry() useAppStore.setState({ settings: { @@ -87,10 +87,11 @@ function installEligibleState( agentStatusByPaneKey: { [e.paneKey]: e }, sleepingAgentSessionsByPaneKey: {}, lastTerminalInputAtByPaneKey: {}, - shutdownWorktreeTerminals: shutdownWorktreeTerminals as never, + shutdownCompletedAgentPaneForHibernation: shutdownCompletedAgentPaneForHibernation as never, + shutdownWorktreeTerminals: vi.fn() as never, ...overrides }) - return shutdownWorktreeTerminals + return shutdownCompletedAgentPaneForHibernation } function runtimeListResult(ptyIds: string[], truncated = false) { @@ -179,9 +180,29 @@ describe('agent hibernation coordinator', () => { await vi.advanceTimersByTimeAsync(1000) expect(shutdown).toHaveBeenCalledWith('wt-bg', { - keepIdentifiers: true, - shutdownReason: 'auto-hibernate-completed-agent', - sleepingPaneKeys: [`tab-1:${LEAF}`] + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1' + }) + expect(useAppStore.getState().shutdownWorktreeTerminals).not.toHaveBeenCalled() + }) + + it('hibernates an eligible pane when a sibling shell PTY is live', async () => { + vi.useFakeTimers() + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } + }) + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1' }) }) @@ -318,10 +339,11 @@ describe('agent hibernation coordinator', () => { await vi.advanceTimersByTimeAsync(1000) expect(shutdown).toHaveBeenCalledWith('wt-bg', { - keepIdentifiers: true, - shutdownReason: 'auto-hibernate-completed-agent', - sleepingPaneKeys: [`tab-1:${LEAF}`], - expectedRuntimePtyIds: ['pty-1'] + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1', + expectedRuntimePtyId: 'pty-1' }) expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( expect.objectContaining({ @@ -336,7 +358,7 @@ describe('agent hibernation coordinator', () => { installRuntimeListResponses( runtimeListResult(['pty-1']), runtimeListResult(['pty-1']), - runtimeListResult(['pty-1', 'pty-shell']) + runtimeListResult(['pty-shell']) ) const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { settings: { @@ -405,9 +427,12 @@ describe('agent hibernation coordinator', () => { expect(shutdown).not.toHaveBeenCalled() }) - it('skips runtime-backed candidates with multiple live PTYs', async () => { + it('hibernates runtime-backed candidates independently when siblings remain live', async () => { vi.useFakeTimers() installRuntimeListResponses( + runtimeListResult(['pty-1', 'pty-2']), + runtimeListResult(['pty-1', 'pty-2']), + runtimeListResult(['pty-1', 'pty-2']), runtimeListResult(['pty-1', 'pty-2']), runtimeListResult(['pty-1', 'pty-2']) ) @@ -440,7 +465,21 @@ describe('agent hibernation coordinator', () => { await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000) - expect(shutdown).not.toHaveBeenCalled() + expect(shutdown).toHaveBeenCalledTimes(2) + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1', + expectedRuntimePtyId: 'pty-1' + }) + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + paneKey: `tab-1:${secondLeaf}`, + tabId: 'tab-1', + leafId: secondLeaf, + ptyId: 'pty-2', + expectedRuntimePtyId: 'pty-2' + }) }) it('fails closed on truncated runtime liveness samples', async () => { diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index d8e39be85..b6d4c12a5 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -31,7 +31,7 @@ type AgentHibernationCoordinatorState = { interval: IntervalHandle | null confirmationState: AgentHibernationConfirmationState tickInFlight: boolean - shuttingDownWorktreeIds: Set + shuttingDownCandidateIds: Set now: () => number } @@ -39,7 +39,7 @@ const coordinator: AgentHibernationCoordinatorState = { interval: null, confirmationState: {}, tickInFlight: false, - shuttingDownWorktreeIds: new Set(), + shuttingDownCandidateIds: new Set(), now: () => Date.now() } @@ -152,37 +152,38 @@ async function currentCandidates(now: number) { })) } -async function hibernateWorktreeIfStillEligible( +async function hibernatePaneIfStillEligible( confirmedCandidate: AgentHibernationCandidate ): Promise { - const { worktreeId } = confirmedCandidate - if (coordinator.shuttingDownWorktreeIds.has(worktreeId)) { + const { id, worktreeId } = confirmedCandidate + if (coordinator.shuttingDownCandidateIds.has(id)) { return } const candidates = await currentCandidates(coordinator.now()) const stillEligible = candidates.some( (candidate) => - candidate.worktreeId === worktreeId && candidate.signature === confirmedCandidate.signature + candidate.id === confirmedCandidate.id && candidate.signature === confirmedCandidate.signature ) if (!stillEligible) { return } - coordinator.shuttingDownWorktreeIds.add(worktreeId) + coordinator.shuttingDownCandidateIds.add(id) try { const state = useAppStore.getState() const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - await state.shutdownWorktreeTerminals(worktreeId, { - keepIdentifiers: true, - shutdownReason: 'auto-hibernate-completed-agent', - sleepingPaneKeys: confirmedCandidate.paneKeys, + await state.shutdownCompletedAgentPaneForHibernation(worktreeId, { + paneKey: confirmedCandidate.paneKey, + tabId: confirmedCandidate.tabId, + leafId: confirmedCandidate.leafId, + ptyId: confirmedCandidate.targetPtyIds[0], ...(runtimeEnvironmentId - ? { expectedRuntimePtyIds: confirmedCandidate.expectedRuntimePtyIds } + ? { expectedRuntimePtyId: confirmedCandidate.expectedRuntimePtyIds[0] } : {}) }) } catch (err) { - console.warn('[agent-hibernation] failed to hibernate worktree:', worktreeId, err) + console.warn('[agent-hibernation] failed to hibernate agent pane:', id, err) } finally { - coordinator.shuttingDownWorktreeIds.delete(worktreeId) + coordinator.shuttingDownCandidateIds.delete(id) } } @@ -198,7 +199,7 @@ export async function runAgentHibernationTick(): Promise { ) coordinator.confirmationState = plan.confirmationState for (const candidate of plan.candidates) { - void hibernateWorktreeIfStillEligible(candidate) + void hibernatePaneIfStillEligible(candidate) } } finally { coordinator.tickInFlight = false @@ -231,7 +232,7 @@ export function isAgentHibernationCoordinatorRunning(): boolean { export function resetAgentHibernationCoordinatorForTests(): void { stopAgentHibernationCoordinator() - coordinator.shuttingDownWorktreeIds.clear() + coordinator.shuttingDownCandidateIds.clear() coordinator.tickInFlight = false coordinator.now = () => Date.now() } diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 11dd00632..9732ef0dc 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -82,6 +82,10 @@ function plannedWorktrees(input: AgentHibernationPlannerSnapshot): string[] { return planAgentHibernationCandidates(input).map((candidate) => candidate.worktreeId) } +function plannedPaneKeys(input: AgentHibernationPlannerSnapshot): string[] { + return planAgentHibernationCandidates(input).map((candidate) => candidate.paneKey) +} + describe('agent hibernation planner', () => { it('selects nothing when disabled, active, or foreground', () => { expect( @@ -128,10 +132,26 @@ describe('agent hibernation planner', () => { ).toEqual(['wt-bg']) }) - it('rejects untracked live PTYs and already-sleeping panes', () => { + it('emits a pane candidate when a sibling shell PTY is live', () => { expect( - plannedWorktrees(snapshot({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } })) - ).toEqual([]) + planAgentHibernationCandidates( + snapshot({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } }) + ) + ).toMatchObject([ + { + id: `wt-bg|tab-1:${LEAF}`, + worktreeId: 'wt-bg', + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + targetPtyIds: ['pty-1'], + expectedRuntimePtyIds: ['pty-1'], + paneKeys: [`tab-1:${LEAF}`] + } + ]) + }) + + it('rejects panes without live PTYs and already-sleeping panes', () => { expect(plannedWorktrees(snapshot({ ptyIdsByTabId: { 'tab-1': [] } }))).toEqual([]) expect( plannedWorktrees( @@ -144,6 +164,17 @@ describe('agent hibernation planner', () => { expect(plannedWorktrees(snapshot({ mobileLockedPtyIds: ['pty-1'] }))).toEqual([]) }) + it('does not let a mobile-locked sibling PTY block an unlocked target pane', () => { + expect( + plannedPaneKeys( + snapshot({ + ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] }, + mobileLockedPtyIds: ['pty-shell'] + }) + ) + ).toEqual([`tab-1:${LEAF}`]) + }) + it('selects runtime-backed live PTYs when the renderer live map is empty', () => { const [candidate] = planAgentHibernationCandidates( snapshot({ @@ -206,16 +237,16 @@ describe('agent hibernation planner', () => { ).toEqual([]) }) - it('rejects runtime-backed worktrees with extra unknown live PTYs', () => { + it('allows runtime-backed worktrees with sibling live PTYs', () => { expect( - plannedWorktrees( + plannedPaneKeys( snapshot({ ptyIdsByTabId: { 'tab-1': [] }, runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1', 'pty-shell'] }, runtimeLivenessRequiredWorktreeIds: ['wt-bg'] }) ) - ).toEqual([]) + ).toEqual([`tab-1:${LEAF}`]) }) it('applies mobile locks to runtime-backed PTYs', () => { @@ -245,14 +276,14 @@ describe('agent hibernation planner', () => { ).toEqual([]) }) - it('selects a worktree when all live PTYs are eligible done agents', () => { + it('selects each eligible done agent pane independently', () => { expect(plannedWorktrees(snapshot())).toEqual(['wt-bg']) const second = entry({ paneKey: `tab-1:${OTHER_LEAF}`, providerSession: { key: 'session_id', id: 'session-2' } }) expect( - plannedWorktrees( + plannedPaneKeys( snapshot({ terminalLayoutsByTabId: { 'tab-1': { @@ -271,7 +302,7 @@ describe('agent hibernation planner', () => { agentStatusByPaneKey: { [`tab-1:${LEAF}`]: entry(), [second.paneKey]: second } }) ) - ).toEqual(['wt-bg']) + ).toEqual([`tab-1:${LEAF}`, `tab-1:${OTHER_LEAF}`]) }) it('requires two stable ticks and resets on signature changes', () => { diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 185798b66..1f9560d10 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -29,8 +29,13 @@ export type AgentHibernationPlannerSnapshot = { } export type AgentHibernationCandidate = { + id: string worktreeId: string + paneKey: string + tabId: string + leafId: string paneKeys: string[] + targetPtyIds: string[] expectedRuntimePtyIds: string[] signature: string } @@ -44,6 +49,8 @@ export type AgentHibernationPlan = { type EligiblePane = { paneKey: string + tabId: string + leafId: string ptyId: string runtimePtyId: string providerSessionId: string @@ -90,12 +97,13 @@ function getLivePtyIdsForTab( function getPaneLivePtyId( entry: AgentStatusEntry, layout: TerminalLayoutSnapshot | undefined -): string | null { +): { leafId: string; ptyId: string } | null { const parsed = parsePaneKey(entry.paneKey) - if (!parsed || parsed.tabId !== entry.tabId) { + if (!parsed || (entry.tabId && parsed.tabId !== entry.tabId)) { return null } - return layout?.ptyIdsByLeafId?.[parsed.leafId] ?? null + const ptyId = layout?.ptyIdsByLeafId?.[parsed.leafId] + return ptyId ? { leafId: parsed.leafId, ptyId } : null } function getEntryTabId(entry: AgentStatusEntry): string | null { @@ -112,6 +120,7 @@ function getEligiblePane(args: { livePtyIds: Set sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey'] lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey'] + mobileLockedPtyIds: Set now: number idleMs: number }): EligiblePane | null { @@ -121,7 +130,8 @@ function getEligiblePane(args: { layout, livePtyIds, sleepingAgentSessionsByPaneKey, - lastTerminalInputAtByPaneKey + lastTerminalInputAtByPaneKey, + mobileLockedPtyIds } = args if (entry.state !== 'done' || sleepingAgentSessionsByPaneKey[entry.paneKey]) { return null @@ -145,16 +155,19 @@ function getEligiblePane(args: { if (typeof inputAt === 'number' && Number.isFinite(inputAt) && inputAt > entry.updatedAt) { return null } - const ptyId = getPaneLivePtyId(entry, layout) - if (!ptyId) { + const livePane = getPaneLivePtyId(entry, layout) + if (!livePane) { return null } + const { leafId, ptyId } = livePane const runtimePtyId = toRuntimePtyId(ptyId) - if (!livePtyIds.has(runtimePtyId)) { + if (!livePtyIds.has(runtimePtyId) || mobileLockedPtyIds.has(runtimePtyId)) { return null } return { paneKey: entry.paneKey, + tabId: tab.id, + leafId, ptyId, runtimePtyId, providerSessionId: entry.providerSession.id, @@ -175,6 +188,10 @@ function signatureFor(worktreeId: string, panes: EligiblePane[]): string { return `${worktreeId}|${parts.join('|')}` } +function candidateIdFor(worktreeId: string, paneKey: string): string { + return `${worktreeId}|${paneKey}` +} + function getAgentEntriesByTabId( agentStatusByPaneKey: AgentHibernationPlannerSnapshot['agentStatusByPaneKey'] ): Map { @@ -229,9 +246,6 @@ export function planAgentHibernationCandidates( ) { continue } - const livePtyIds = new Set() - const eligibleByPtyId = new Map() - let rejected = false for (const tab of tabs) { const tabLivePtyIds = getLivePtyIdsForTab( tab, @@ -239,12 +253,6 @@ export function planAgentHibernationCandidates( snapshot.runtimeLivePtyIdsByWorktreeId, runtimeLivenessRequiredWorktreeIds.has(worktreeId) ) - for (const ptyId of tabLivePtyIds) { - livePtyIds.add(ptyId) - } - if (tabLivePtyIds.some((ptyId) => mobileLockedPtyIds.has(ptyId))) { - rejected = true - } if (tabLivePtyIds.length === 0) { continue } @@ -257,28 +265,29 @@ export function planAgentHibernationCandidates( livePtyIds: new Set(tabLivePtyIds), sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey, lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey, + mobileLockedPtyIds, now: snapshot.now, idleMs }) if (eligible) { - eligibleByPtyId.set(eligible.runtimePtyId, eligible) - } else if (entry.state !== 'done' || getPaneLivePtyId(entry, layout)) { - rejected = true + candidates.push({ + id: candidateIdFor(worktreeId, eligible.paneKey), + worktreeId, + paneKey: eligible.paneKey, + tabId: eligible.tabId, + leafId: eligible.leafId, + paneKeys: [eligible.paneKey], + targetPtyIds: [eligible.ptyId], + expectedRuntimePtyIds: [eligible.runtimePtyId], + signature: signatureFor(worktreeId, [eligible]) + }) } } } - if (rejected || livePtyIds.size === 0 || eligibleByPtyId.size !== livePtyIds.size) { - continue - } - const panes = [...eligibleByPtyId.values()] - candidates.push({ - worktreeId, - paneKeys: panes.map((pane) => pane.paneKey).sort(), - expectedRuntimePtyIds: [...livePtyIds].sort(), - signature: signatureFor(worktreeId, panes) - }) } - return candidates.sort((a, b) => a.worktreeId.localeCompare(b.worktreeId)) + return candidates.sort( + (a, b) => a.worktreeId.localeCompare(b.worktreeId) || a.paneKey.localeCompare(b.paneKey) + ) } export function confirmAgentHibernationCandidates( @@ -288,8 +297,8 @@ export function confirmAgentHibernationCandidates( const confirmationState: AgentHibernationConfirmationState = {} const confirmed: AgentHibernationCandidate[] = [] for (const candidate of candidates) { - confirmationState[candidate.worktreeId] = candidate.signature - if (previous[candidate.worktreeId] === candidate.signature) { + confirmationState[candidate.id] = candidate.signature + if (previous[candidate.id] === candidate.signature) { confirmed.push(candidate) } } diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 11b07cb20..44d4e0559 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -52,6 +52,10 @@ type DropAgentStatusByWorktreeOptions = { retainedCompletionEvidence?: readonly RetainedAgentEntry[] } +type DropHibernatedAgentPaneOptions = { + retainedCompletionEvidence?: readonly RetainedAgentEntry[] +} + export type AgentStatusSlice = { /** Explicit agent status entries keyed by `${tabId}:${leafId}` composite. * Real-time only — lives in renderer memory, not persisted to disk. */ @@ -114,6 +118,14 @@ export type AgentStatusSlice = { * remaining agent rows (live or retained) must not reappear. */ dropAgentStatusByTabPrefix: (tabIdPrefix: string) => void + /** Remove one automatically hibernated completed-agent pane while preserving + * sibling live/retained rows in the same worktree. */ + dropHibernatedAgentStatusPane: ( + worktreeId: string, + paneKey: string, + opts?: DropHibernatedAgentPaneOptions + ) => void + /** Remove all entries for a worktree AND suppress re-retention for live rows. * Used on worktree sleep/remove — the whole worktree surface is folding, so * retained rows must drop even if their original tab is no longer present. @@ -1111,6 +1123,96 @@ export const createAgentStatusSlice: StateCreator { + let hadLive = false + set((s) => { + const liveEntry = s.agentStatusByPaneKey[paneKey] + const hasLive = liveEntry !== undefined + const hasRetained = paneKey in s.retainedAgentsByPaneKey + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey === paneKey + ) + const retainedEvidence = new Map() + for (const retained of opts?.retainedCompletionEvidence ?? []) { + if ( + retained.entry.paneKey === paneKey && + !liveEntry && + shouldReplaceRetainedWithLive(retainedEvidence.get(paneKey), retained) + ) { + retainedEvidence.set(paneKey, retained) + } + } + if ( + liveEntry?.state === 'done' && + liveEntry.agentType !== undefined && + liveEntry.interrupted !== true + ) { + retainedEvidence.set( + paneKey, + retainedAgentEntryFromLive(s, worktreeId, liveEntry, liveEntry.agentType) + ) + } + const keepsCompletionEvidence = retainedEvidence.has(paneKey) + let nextAck = s.acknowledgedAgentsByPaneKey + if (!keepsCompletionEvidence && paneKey in nextAck) { + nextAck = { ...nextAck } + delete nextAck[paneKey] + } + if (!hasLive && !hasRetained && !migrationUnsupported.changed && !keepsCompletionEvidence) { + if (nextAck !== s.acknowledgedAgentsByPaneKey) { + return { acknowledgedAgentsByPaneKey: nextAck } + } + return s + } + hadLive = hasLive + + const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (hasLive) { + delete nextLive[paneKey] + } + + const nextRetained = + hasRetained || keepsCompletionEvidence + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + if (hasRetained && !keepsCompletionEvidence) { + delete nextRetained[paneKey] + } + for (const [key, retained] of retainedEvidence) { + if (shouldReplaceRetainedWithLive(nextRetained[key], retained)) { + nextRetained[key] = retained + } + } + + const needsSuppressor = + hasLive && !keepsCompletionEvidence && !(paneKey in s.retentionSuppressedPaneKeys) + + return { + agentStatusByPaneKey: nextLive, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + ...(needsSuppressor + ? { + retentionSuppressedPaneKeys: { + ...s.retentionSuppressedPaneKeys, + [paneKey]: true + } + } + : {}), + agentStatusEpoch: + hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (hadLive) { + queueMicrotask(() => freshness.schedule()) + } + }, + dropAgentStatusByWorktree: (worktreeId, opts) => { let hadLive = false set((s) => { diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index fd7cc5ab5..2b793feaa 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -8,6 +8,7 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp import { toast } from 'sonner' const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn()) +const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn()) // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ @@ -15,6 +16,7 @@ vi.mock('sonner', () => ({ })) vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: mockRestorePtyDataHandlersAfterFailedShutdown, unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers })) @@ -2627,6 +2629,481 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { shutdownBufferCaptures.clear() }) + it('automatically hibernates only the completed agent pane and preserves siblings', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const siblingLeaf = '22222222-2222-4222-8222-222222222222' + const targetPaneKey = `tab-1:${targetLeaf}` + const siblingPaneKey = `tab-1:${siblingLeaf}` + const dropByWorktree = vi.fn() + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: targetLeaf }, + second: { type: 'leaf', leafId: siblingLeaf } + }, + activeLeafId: siblingLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'pty-agent', [siblingLeaf]: 'pty-shell' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] }, + unreadTerminalTabs: { 'tab-1': true }, + unreadTerminalPanes: { [targetPaneKey]: true, [siblingPaneKey]: true }, + unreadAgentCompletionPanes: { [targetPaneKey]: true, [siblingPaneKey]: true }, + lastTerminalInputAtByPaneKey: { [targetPaneKey]: 1000, [siblingPaneKey]: 1100 }, + pendingSetupSplitByTabId: { 'tab-1': { command: 'setup', direction: 'horizontal' } }, + pendingIssueCommandSplitByTabId: { 'tab-1': { command: 'issue' } } + }) + store.setState({ dropAgentStatusByWorktree: dropByWorktree as never }) + store.getState().setAgentStatus( + targetPaneKey, + { + state: 'done', + prompt: 'resume target', + agentType: 'codex', + lastAssistantMessage: 'done' + }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + store + .getState() + .setAgentStatus( + siblingPaneKey, + { state: 'working', prompt: 'keep running', agentType: 'claude' }, + 'Claude', + { updatedAt: 2100, stateStartedAt: 2100 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'sibling-session' } } + ) + const siblingSleepingRecordBefore = + store.getState().sleepingAgentSessionsByPaneKey[siblingPaneKey] + + await store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'pty-agent' + }) + + const state = store.getState() + expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-agent', { keepHistory: true }) + expect(mockApi.pty.kill).not.toHaveBeenCalledWith('pty-shell', expect.anything()) + expect(mockUnregisterPtyDataHandlers).toHaveBeenCalledWith(['pty-agent']) + expect(mockUnregisterPtyDataHandlers.mock.invocationCallOrder[0]).toBeLessThan( + mockApi.pty.kill.mock.invocationCallOrder[0] + ) + expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-shell']) + expect(state.tabsByWorktree[wt]?.[0]?.ptyId).toBe('pty-shell') + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ + [targetLeaf]: 'pty-agent', + [siblingLeaf]: 'pty-shell' + }) + expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({ + providerSession: { key: 'session_id', id: 'target-session' } + }) + expect(state.sleepingAgentSessionsByPaneKey[siblingPaneKey]).toBe(siblingSleepingRecordBefore) + expect(state.agentStatusByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.agentStatusByPaneKey[siblingPaneKey]).toBeDefined() + expect(state.retainedAgentsByPaneKey[targetPaneKey]).toMatchObject({ + entry: { lastAssistantMessage: 'done' } + }) + expect(state.unreadTerminalTabs['tab-1']).toBe(true) + expect(state.unreadTerminalPanes[targetPaneKey]).toBeUndefined() + expect(state.unreadTerminalPanes[siblingPaneKey]).toBe(true) + expect(state.unreadAgentCompletionPanes[targetPaneKey]).toBeUndefined() + expect(state.unreadAgentCompletionPanes[siblingPaneKey]).toBe(true) + expect(state.lastTerminalInputAtByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.lastTerminalInputAtByPaneKey[siblingPaneKey]).toBe(1100) + expect(state.pendingSetupSplitByTabId['tab-1']).toBeDefined() + expect(state.pendingIssueCommandSplitByTabId['tab-1']).toBeDefined() + expect(dropByWorktree).not.toHaveBeenCalled() + }) + + it('keeps manual sleep worktree-wide', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Terminal', ptyId: 'pty-agent' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] } + }) + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([]) + expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-agent', { keepHistory: true }) + expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-shell', { keepHistory: true }) + }) + + it('does not commit pane sleep state when local target kill fails', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const siblingLeaf = '22222222-2222-4222-8222-222222222222' + const targetPaneKey = `tab-1:${targetLeaf}` + const handlerSnapshots = [{ ptyId: 'pty-agent' }] + + mockApi.pty.kill.mockRejectedValueOnce(new Error('kill failed')) + mockUnregisterPtyDataHandlers.mockReturnValueOnce(handlerSnapshots) + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: targetLeaf }, + second: { type: 'leaf', leafId: siblingLeaf } + }, + activeLeafId: siblingLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'pty-agent', [siblingLeaf]: 'pty-shell' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await expect( + store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'pty-agent' + }) + ).rejects.toThrow('kill failed') + + const state = store.getState() + expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-agent', 'pty-shell']) + expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined() + expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined() + expect(mockRestorePtyDataHandlersAfterFailedShutdown).toHaveBeenCalledWith(handlerSnapshots) + }) + + it('uses target-only runtime stop for automatic pane hibernation', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const siblingLeaf = '22222222-2222-4222-8222-222222222222' + const targetPaneKey = `tab-1:${targetLeaf}` + + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { + stoppedPtyIds: ['terminal-1'], + livePtyIds: ['terminal-1', 'terminal-2'], + postStopVerified: true, + remainingLivePtyIds: ['terminal-2'] + } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: targetLeaf }, + second: { type: 'leaf', leafId: siblingLeaf } + }, + activeLeafId: siblingLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { + [targetLeaf]: 'remote:env-1@@terminal-1', + [siblingLeaf]: 'remote:env-1@@terminal-2' + } + } + }, + ptyIdsByTabId: { + 'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2'] + } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'remote:env-1@@terminal-1', + expectedRuntimePtyId: 'terminal-1' + }) + + expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'runtime-1', + method: 'terminal.stopExact', + params: expect.objectContaining({ + expectedPtyIds: ['terminal-1'], + keepHistory: true, + targetOnly: true + }) + }) + ) + expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.stop' }) + ) + expect(store.getState().ptyIdsByTabId['tab-1']).toEqual(['remote:env-1@@terminal-2']) + expect(mockApi.pty.kill).not.toHaveBeenCalled() + }) + + it('clears stale relay wake hints when pane hibernation leaves no live PTYs in the tab', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const targetPaneKey = `tab-1:${targetLeaf}` + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [ + makeTab({ + id: 'tab-1', + worktreeId: wt, + title: 'Codex', + ptyId: 'ssh:ssh-1@@pty-agent' + }) + ] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: targetLeaf }, + activeLeafId: targetLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'ssh:ssh-1@@pty-agent' } + } + }, + ptyIdsByTabId: { 'tab-1': ['ssh:ssh-1@@pty-agent'] }, + lastKnownRelayPtyIdByTabId: { 'tab-1': 'ssh:ssh-1@@pty-agent' } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'ssh:ssh-1@@pty-agent' + }) + + expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([]) + expect(store.getState().lastKnownRelayPtyIdByTabId['tab-1']).toBeUndefined() + }) + + it('does not retain stale completion evidence when pane status changes during hibernation', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const targetPaneKey = `tab-1:${targetLeaf}` + + mockApi.pty.kill.mockImplementationOnce(async () => { + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'working', prompt: 'still running', agentType: 'codex' }, + 'Codex', + { updatedAt: 3000, stateStartedAt: 3000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + }) + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: targetLeaf }, + activeLeafId: targetLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'pty-agent' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent'] } + }) + store.getState().setAgentStatus( + targetPaneKey, + { + state: 'done', + prompt: 'stale done', + agentType: 'codex', + lastAssistantMessage: 'old done' + }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'pty-agent' + }) + + expect(store.getState().agentStatusByPaneKey[targetPaneKey]).toBeUndefined() + expect(store.getState().retainedAgentsByPaneKey[targetPaneKey]).toBeUndefined() + expect(store.getState().retentionSuppressedPaneKeys[targetPaneKey]).toBe(true) + }) + + it('rolls back target suppressions when target-only runtime stop fails', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const siblingLeaf = '22222222-2222-4222-8222-222222222222' + const targetPaneKey = `tab-1:${targetLeaf}` + + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { + stoppedPtyIds: ['terminal-1'], + livePtyIds: ['terminal-1', 'terminal-2'], + postStopVerified: false, + postStopFailure: 'target_still_live' + } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: targetLeaf }, + second: { type: 'leaf', leafId: siblingLeaf } + }, + activeLeafId: siblingLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { + [targetLeaf]: 'remote:env-1@@terminal-1', + [siblingLeaf]: 'remote:env-1@@terminal-2' + } + } + }, + ptyIdsByTabId: { + 'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2'] + } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await expect( + store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'remote:env-1@@terminal-1', + expectedRuntimePtyId: 'terminal-1' + }) + ).rejects.toThrow('target_still_live') + + const state = store.getState() + expect(state.ptyIdsByTabId['tab-1']).toEqual([ + 'remote:env-1@@terminal-1', + 'remote:env-1@@terminal-2' + ]) + expect(state.suppressedPtyExitIds['remote:env-1@@terminal-1']).toBeUndefined() + expect(state.suppressedPtyExitIds['terminal-1']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined() + }) + it('records terminal input even before agent hibernation is enabled', () => { const store = createTestStore() diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index ff3a76dc2..4f527e932 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -40,6 +40,7 @@ import { } from './tab-group-state' import { ensurePtyDispatcher, + restorePtyDataHandlersAfterFailedShutdown, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/terminal-layout-leaf-ids' @@ -435,6 +436,16 @@ export type TerminalSlice = { expectedRuntimePtyIds?: string[] } ) => Promise + shutdownCompletedAgentPaneForHibernation: ( + worktreeId: string, + opts: { + paneKey: string + tabId: string + leafId: string + ptyId: string + expectedRuntimePtyId?: string + } + ) => Promise suppressPtyExit: (ptyId: string) => void consumeSuppressedPtyExit: (ptyId: string) => boolean queueCodexPaneRestarts: (ptyIds: string[]) => void @@ -1685,6 +1696,209 @@ export const createTerminalSlice: StateCreator } }, + shutdownCompletedAgentPaneForHibernation: async (worktreeId, opts) => { + const paneKeys = [opts.paneKey] + const expectedRuntimePtyIds = sortedUniquePtyIds( + opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : [] + ) + const shutdownPtyIds = sortedUniquePtyIds([opts.ptyId, ...expectedRuntimePtyIds]) + const state = get() + const tab = (state.tabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === opts.tabId + ) + const parsed = parsePaneKey(opts.paneKey) + const layout = state.terminalLayoutsByTabId[opts.tabId] + const liveTabPtyIds = state.ptyIdsByTabId[opts.tabId] ?? [] + if ( + !tab || + !parsed || + parsed.tabId !== opts.tabId || + parsed.leafId !== opts.leafId || + layout?.ptyIdsByLeafId?.[opts.leafId] !== opts.ptyId || + (expectedRuntimePtyIds.length === 0 && !liveTabPtyIds.includes(opts.ptyId)) + ) { + throw new Error('agent_hibernation_pane_binding_mismatch') + } + + const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree( + state, + worktreeId, + paneKeys + ) + const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree( + state, + worktreeId, + paneKeys + ) + + const capture = shutdownBufferCaptures.get(opts.tabId) + if (capture) { + try { + capture({ includeLocalBuffers: false }) + } catch { + // Don't let one tab's capture failure block the pane hibernation. + } + } + + const clearTargetSuppressions = (): void => { + set((s) => { + const next = { ...s.suppressedPtyExitIds } + for (const ptyId of shutdownPtyIds) { + delete next[ptyId] + } + return { suppressedPtyExitIds: next } + }) + } + + set((s) => ({ + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + } + })) + + if (expectedRuntimePtyIds.length > 0) { + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) + if (!runtimeEnvironmentId) { + clearTargetSuppressions() + throw new Error('missing_runtime_for_exact_terminal_stop') + } + let stopResult: { + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string + } + try { + stopResult = await callRuntimeRpc<{ + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string + }>( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.stopExact', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + expectedPtyIds: expectedRuntimePtyIds, + keepHistory: true, + targetOnly: true + }, + { timeoutMs: 15_000 } + ) + } catch (err) { + clearTargetSuppressions() + throw err + } + const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) + const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) + const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId)) + if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) { + clearTargetSuppressions() + throw new Error('exact_terminal_stop_mismatch') + } + if (stopResult.postStopVerified !== true) { + clearTargetSuppressions() + throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') + } + unregisterPtyDataHandlers(shutdownPtyIds) + } else if (!opts.ptyId.startsWith('remote:')) { + // Why: pty.kill can flush final data before exit; unregister first so + // pane hibernation cannot fire phantom notifications from stale handlers. + const handlerSnapshots = unregisterPtyDataHandlers(shutdownPtyIds) + try { + await window.api.pty.kill(opts.ptyId, { keepHistory: true }) + } catch (err) { + restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) + clearTargetSuppressions() + throw err + } + } + + set((s) => { + const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? [] + const shutdownPtyIdSet = new Set(shutdownPtyIds) + const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId)) + const nextTabsByWorktree = { ...s.tabsByWorktree } + const tabs = nextTabsByWorktree[worktreeId] ?? [] + const tabIndex = tabs.findIndex((candidate) => candidate.id === opts.tabId) + if (tabIndex !== -1) { + const nextTabs = [...tabs] + nextTabs[tabIndex] = { + ...nextTabs[tabIndex], + ptyId: remainingPtyIds.at(-1) ?? null + } + nextTabsByWorktree[worktreeId] = nextTabs + } + + const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } + for (const ptyId of shutdownPtyIds) { + delete nextCodexRestartNoticeByPtyId[ptyId] + } + const nextLastKnownRelay = + remainingPtyIds.length === 0 + ? { ...s.lastKnownRelayPtyIdByTabId } + : s.lastKnownRelayPtyIdByTabId + if (remainingPtyIds.length === 0) { + delete nextLastKnownRelay[opts.tabId] + } + + let nextRuntimePaneTitlesByTabId = s.runtimePaneTitlesByTabId + const numericPaneId = Number(opts.leafId) + if ( + Number.isInteger(numericPaneId) && + s.runtimePaneTitlesByTabId[opts.tabId]?.[numericPaneId] + ) { + const nextByPane = { ...s.runtimePaneTitlesByTabId[opts.tabId] } + delete nextByPane[numericPaneId] + nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } + if (Object.keys(nextByPane).length > 0) { + nextRuntimePaneTitlesByTabId[opts.tabId] = nextByPane + } else { + delete nextRuntimePaneTitlesByTabId[opts.tabId] + } + } + + const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } + const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } + delete nextUnreadTerminalPanes[opts.paneKey] + delete nextUnreadAgentCompletionPanes[opts.paneKey] + delete nextLastTerminalInputAtByPaneKey[opts.paneKey] + + return { + tabsByWorktree: nextTabsByWorktree, + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [opts.tabId]: remainingPtyIds + }, + lastKnownRelayPtyIdByTabId: nextLastKnownRelay, + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + ...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId + ? { runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId } + : {}), + unreadTerminalPanes: nextUnreadTerminalPanes, + unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes, + lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey + } + }) + + set((s) => ({ + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + ...sleepingAgentSessionRecords + } + })) + + get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, { + retainedCompletionEvidence + }) + }, + shutdownWorktreeTerminals: async (worktreeId, opts) => { const keepIdentifiers = opts?.keepIdentifiers ?? false const shutdownReason: AgentStatusWorktreeShutdownReason =