diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index bbb83ea5cca..855fc5ff2b2 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -543,6 +543,53 @@ describe('agent sleep coordinator', () => { ).toHaveLength(3) }) + it('revalidates a confirmed pane without listing unrelated runtime worktrees', async () => { + installRuntimeListResponses(...Array.from({ length: 5 }, () => runtimeListResult(['pty-1']))) + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS, + activeRuntimeEnvironmentId: 'runtime-1' + } as never, + worktreesByRepo: { + 'fixture-repo': [ + { + id: 'wt-bg', + repoId: 'fixture-repo', + hostId: 'runtime:runtime-1', + runtimeOwnerEnvironmentId: 'runtime-1' + }, + { + id: 'wt-unrelated', + repoId: 'fixture-repo', + hostId: 'runtime:runtime-2', + runtimeOwnerEnvironmentId: 'runtime-2' + } + ] + } as never, + tabsByWorktree: { + 'wt-bg': [tab()], + 'wt-unrelated': [{ ...tab(), id: 'tab-2', worktreeId: 'wt-unrelated' }] + }, + ptyIdsByTabId: { 'tab-1': [] } + }) + + await runAgentHibernationTick() + await runAgentHibernationTick() + + expect(shutdown).toHaveBeenCalledTimes(1) + const listCalls = mockRuntimeEnvironmentCall.mock.calls.filter( + ([args]) => args.method === 'terminal.list' + ) + // Two global confirmation samples list both worktrees; the destructive + // recheck lists only the candidate's owner: 2W + C, not 2W + C×W. + expect(listCalls).toHaveLength(5) + expect(listCalls.at(-1)?.[0]).toMatchObject({ + selector: 'runtime-1', + params: { worktree: expect.anything() } + }) + }) + it('uses fresh store state after awaiting runtime liveness before shutdown', async () => { vi.useFakeTimers() const delayed = deferred>() diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index 2dfc59c20f5..63dbde0b374 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -63,13 +63,16 @@ type RuntimePtyLivenessSample = { function snapshotFromState( state: AppState, now: number, - runtimeLiveness: RuntimePtyLivenessSample + runtimeLiveness: RuntimePtyLivenessSample, + targetWorktreeId?: string ): AgentHibernationPlannerSnapshot { return { settings: state.settings, activeWorktreeId: state.activeWorktreeId, foregroundTerminalTabIds: getForegroundTerminalTabIds(), - tabsByWorktree: state.tabsByWorktree, + tabsByWorktree: targetWorktreeId + ? { [targetWorktreeId]: state.tabsByWorktree[targetWorktreeId] ?? [] } + : state.tabsByWorktree, terminalLayoutsByTabId: state.terminalLayoutsByTabId, ptyIdsByTabId: state.ptyIdsByTabId, runtimeLivePtyIdsByWorktreeId: runtimeLiveness.runtimeLivePtyIdsByWorktreeId, @@ -90,9 +93,17 @@ function snapshotFromState( } } -function getRuntimeLivenessTargetWorktrees(state: AppState): Map { +function getRuntimeLivenessTargetWorktrees( + state: AppState, + targetWorktreeId?: string +): Map { const targets = new Map() - for (const worktreeId of Object.keys(state.tabsByWorktree)) { + const worktreeIds = targetWorktreeId + ? Object.hasOwn(state.tabsByWorktree, targetWorktreeId) + ? [targetWorktreeId] + : [] + : Object.keys(state.tabsByWorktree) + for (const worktreeId of worktreeIds) { const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) if (runtimeEnvironmentId) { targets.set(worktreeId, runtimeEnvironmentId) @@ -111,8 +122,11 @@ function getTypedRuntimePtyId(terminal: RuntimeTerminalSummary): string | null { return null } -async function collectRuntimePtyLiveness(state: AppState): Promise { - const targets = getRuntimeLivenessTargetWorktrees(state) +async function collectRuntimePtyLiveness( + state: AppState, + targetWorktreeId?: string +): Promise { + const targets = getRuntimeLivenessTargetWorktrees(state, targetWorktreeId) const runtimeLivePtyIdsByWorktreeId: Record = {} const runtimeLivenessRequiredWorktreeIds = [...targets.keys()] await Promise.all( @@ -152,8 +166,8 @@ async function collectRuntimePtyLiveness(state: AppState): Promise { const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( freshState, @@ -186,7 +202,9 @@ async function hibernatePaneIfStillEligible( if (coordinator.shuttingDownCandidateIds.has(id)) { return } - const candidates = await currentCandidates(coordinator.now()) + // Why: the confirmed pane can only be authorized by its owning worktree. A + // global sweep here made C pane teardowns issue C×W fresh runtime listings. + const candidates = await currentCandidates(coordinator.now(), worktreeId) const stillEligible = candidates.some( (candidate) => candidate.id === confirmedCandidate.id && candidate.signature === confirmedCandidate.signature diff --git a/src/renderer/src/lib/agent-hibernation-pane-age.test.ts b/src/renderer/src/lib/agent-hibernation-pane-age.test.ts index ba1c61184d0..a23d6a730dc 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-age.test.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-age.test.ts @@ -65,16 +65,21 @@ describe('hibernation PTY binding age', () => { // The planner already fails closed during the gap; dropping the binding here would // hand the same PTY a fresh idle window every time the layout flickers. observe('pty-1', 1_000) + recordHibernationBoundaryResolved(PANE, 1_500) observe(null, 2_000) expect(getHibernationPtyBindingFirstSeenAtByPaneKey()[PANE]).toBe(1_000) + expect(getHibernationBoundaryResolvedAtByPaneKey()[PANE]).toBe(1_500) observe('pty-1', 3_000) expect(getHibernationPtyBindingFirstSeenAtByPaneKey()[PANE]).toBe(1_000) + expect(getHibernationBoundaryResolvedAtByPaneKey()[PANE]).toBe(1_500) }) it('expires an unseen binding once it is older than the idle window', () => { observe('pty-1', 1_000) + recordHibernationBoundaryResolved(PANE, 1_000) observe(null, 1_000 + IDLE_MS + 1) expect(getHibernationPtyBindingFirstSeenAtByPaneKey()[PANE]).toBeUndefined() + expect(getHibernationBoundaryResolvedAtByPaneKey()[PANE]).toBeUndefined() }) it('drops bindings and boundary stamps when the tab is authoritatively gone', () => { diff --git a/src/renderer/src/lib/agent-hibernation-pane-age.ts b/src/renderer/src/lib/agent-hibernation-pane-age.ts index 882e1a63f12..21b940b4600 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-age.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-age.ts @@ -63,7 +63,10 @@ export function observeHibernationPtyBindings(args: { } for (const paneKey of boundaryResolvedAtByPaneKey.keys()) { const tabId = paneKey.slice(0, paneKey.indexOf(':')) - if (!liveTabIds.has(tabId)) { + // Why: closed split panes mint fresh leaf ids when reopened. Once their + // retained binding expires, keeping the boundary stamp would leak one map + // entry per closed pane for the renderer's lifetime. + if (!liveTabIds.has(tabId) || !ptyBindingByPaneKey.has(paneKey)) { boundaryResolvedAtByPaneKey.delete(paneKey) } }