perf(sleep): scope hibernation rechecks to pane owner

This commit is contained in:
Brennan Benson
2026-08-25 15:03:56 -07:00
parent 392db93e2f
commit 3e314183f3
4 changed files with 84 additions and 11 deletions
@@ -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<ReturnType<typeof runtimeListResult>>()
@@ -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<string, string> {
function getRuntimeLivenessTargetWorktrees(
state: AppState,
targetWorktreeId?: string
): Map<string, string> {
const targets = new Map<string, string>()
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<RuntimePtyLivenessSample> {
const targets = getRuntimeLivenessTargetWorktrees(state)
async function collectRuntimePtyLiveness(
state: AppState,
targetWorktreeId?: string
): Promise<RuntimePtyLivenessSample> {
const targets = getRuntimeLivenessTargetWorktrees(state, targetWorktreeId)
const runtimeLivePtyIdsByWorktreeId: Record<string, string[]> = {}
const runtimeLivenessRequiredWorktreeIds = [...targets.keys()]
await Promise.all(
@@ -152,8 +166,8 @@ async function collectRuntimePtyLiveness(state: AppState): Promise<RuntimePtyLiv
return { runtimeLivePtyIdsByWorktreeId, runtimeLivenessRequiredWorktreeIds }
}
async function currentCandidates(now: number) {
const runtimeLiveness = await collectRuntimePtyLiveness(useAppStore.getState())
async function currentCandidates(now: number, targetWorktreeId?: string) {
const runtimeLiveness = await collectRuntimePtyLiveness(useAppStore.getState(), targetWorktreeId)
const freshState = useAppStore.getState()
// Why: age the PTY bindings from the same state the plan is built from, so a pane
// observed for the first time this pass cannot also be judged long-idle in it.
@@ -163,7 +177,9 @@ async function currentCandidates(now: number) {
now,
idleMs: getEffectiveAgentHibernationIdleMs(freshState.settings?.agentHibernationIdleMs)
})
return planAgentHibernationCandidates(snapshotFromState(freshState, now, runtimeLiveness))
return planAgentHibernationCandidates(
snapshotFromState(freshState, now, runtimeLiveness, targetWorktreeId)
)
.filter((candidate) => {
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
@@ -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', () => {
@@ -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)
}
}