diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index f87c59591b6..4bfdf6ff7ce 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -20,6 +20,10 @@ import { recordAgentHibernationPaneOutput, resetAgentHibernationOutputActivityForTests } from './agent-hibernation-output-activity' +import { + observeHibernationPtyBindings, + resetHibernationPaneAgeForTests +} from './agent-hibernation-pane-age' import { createCompatibleRuntimeStatusResponseIfNeeded } from '../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../runtime/runtime-rpc-client' import type { AppState } from '@/store/types' @@ -104,6 +108,17 @@ function installEligibleState( shutdownWorktreeTerminals: vi.fn() as never, ...overrides }) + // Why: a pane idle long enough to hibernate has necessarily been observed by earlier + // coordinator passes, so its PTY binding is old. Seed that here — otherwise the + // binding-age floor (which exists to stop a wake or app restart sleeping the whole + // backlog immediately) would defer every candidate on its first observed tick. + const state = useAppStore.getState() + observeHibernationPtyBindings({ + tabsByWorktree: state.tabsByWorktree, + terminalLayoutsByTabId: state.terminalLayoutsByTabId, + now: NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 60_000, + idleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS + }) return shutdownCompletedAgentPaneForHibernation } @@ -177,6 +192,7 @@ afterEach(() => { clearRuntimeCompatibilityCacheForTests() resetForegroundTerminalTabIdsForTests() resetAgentHibernationOutputActivityForTests() + resetHibernationPaneAgeForTests() hydrateDrivers([]) mockRuntimeEnvironmentCall.mockReset() vi.useRealTimers() diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index 5f9f3278706..47759795897 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' import { + getEffectiveAgentHibernationIdleMs, planAgentHibernationCandidates, type AgentHibernationCandidate, type AgentHibernationPlannerSnapshot @@ -15,6 +16,11 @@ import { getForegroundTerminalTabLastSeenAtById } from './foreground-terminal-tabs' import { getAgentHibernationOutputSignature } from './agent-hibernation-output-activity' +import { + getHibernationBoundaryResolvedAtByPaneKey, + getHibernationPtyBindingFirstSeenAtByPaneKey, + observeHibernationPtyBindings +} from './agent-hibernation-pane-age' import { mergePendingTerminalInputActivity } from './terminal-input-activity-coalescing' import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -78,6 +84,8 @@ function snapshotFromState( state.lastTerminalInputAtByPaneKey ), foregroundTerminalLastSeenAtByTabId: getForegroundTerminalTabLastSeenAtById(), + ptyBindingFirstSeenAtByPaneKey: getHibernationPtyBindingFirstSeenAtByPaneKey(), + boundaryResolvedAtByPaneKey: getHibernationBoundaryResolvedAtByPaneKey(), now } } @@ -147,6 +155,14 @@ async function collectRuntimePtyLiveness(state: AppState): Promise { const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( diff --git a/src/renderer/src/lib/agent-hibernation-pane-age.test.ts b/src/renderer/src/lib/agent-hibernation-pane-age.test.ts new file mode 100644 index 00000000000..ba1c61184d0 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-pane-age.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import { + getHibernationBoundaryResolvedAtByPaneKey, + getHibernationPtyBindingFirstSeenAtByPaneKey, + observeHibernationPtyBindings, + recordHibernationBoundaryResolved, + resetHibernationPaneAgeForTests +} from './agent-hibernation-pane-age' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = `tab-1:${LEAF}` +const IDLE_MS = 30 * 60 * 1000 + +function tab(id = 'tab-1'): TerminalTab { + return { + id, + ptyId: null, + worktreeId: 'wt-bg', + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function layout(ptyId: string | null): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: ptyId ? { [LEAF]: ptyId } : {} + } +} + +function observe(ptyId: string | null, now: number, tabs: TerminalTab[] = [tab()]): void { + observeHibernationPtyBindings({ + tabsByWorktree: { 'wt-bg': tabs }, + terminalLayoutsByTabId: { 'tab-1': layout(ptyId) }, + now, + idleMs: IDLE_MS + }) +} + +describe('hibernation PTY binding age', () => { + beforeEach(() => { + resetHibernationPaneAgeForTests() + }) + + it('stamps first sight and carries it while the PTY is unchanged', () => { + observe('pty-1', 1_000) + observe('pty-1', 500_000) + expect(getHibernationPtyBindingFirstSeenAtByPaneKey()[PANE]).toBe(1_000) + }) + + it('resets the stamp when the pane rebinds to a new PTY', () => { + // A wake spawns a fresh PTY; the pane must get a full idle window again. + observe('pty-1', 1_000) + observe('pty-2', 500_000) + expect(getHibernationPtyBindingFirstSeenAtByPaneKey()[PANE]).toBe(500_000) + }) + + it('keeps accumulated age across a transient layout gap', () => { + // 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) + observe(null, 2_000) + expect(getHibernationPtyBindingFirstSeenAtByPaneKey()[PANE]).toBe(1_000) + observe('pty-1', 3_000) + expect(getHibernationPtyBindingFirstSeenAtByPaneKey()[PANE]).toBe(1_000) + }) + + it('expires an unseen binding once it is older than the idle window', () => { + observe('pty-1', 1_000) + observe(null, 1_000 + IDLE_MS + 1) + expect(getHibernationPtyBindingFirstSeenAtByPaneKey()[PANE]).toBeUndefined() + }) + + it('drops bindings and boundary stamps when the tab is authoritatively gone', () => { + observe('pty-1', 1_000) + recordHibernationBoundaryResolved(PANE, 1_000) + observe(null, 2_000, []) + expect(getHibernationPtyBindingFirstSeenAtByPaneKey()[PANE]).toBeUndefined() + expect(getHibernationBoundaryResolvedAtByPaneKey()[PANE]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/agent-hibernation-pane-age.ts b/src/renderer/src/lib/agent-hibernation-pane-age.ts new file mode 100644 index 00000000000..882e1a63f12 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-pane-age.ts @@ -0,0 +1,104 @@ +import { makePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' + +type PtyBinding = { ptyId: string; firstSeenAt: number; lastSeenAt: number } + +const ptyBindingByPaneKey = new Map() +const boundaryResolvedAtByPaneKey = new Map() + +/** + * Floors the hibernation idle clock at the age of a pane's current PTY binding. + * + * Why: `dropHibernatedAgentStatusPane` never notifies main, and main's + * `lastStatusByPaneKey` is persisted — so a woken or post-restart pane replays an + * ancient `stateStartedAt` and is instantly past the idle window. A wake spawns a + * fresh PTY and an app launch starts this map empty, so binding age restores the + * grace `updatedAt` used to provide by accident. + */ +export function observeHibernationPtyBindings(args: { + tabsByWorktree: Record + terminalLayoutsByTabId: Record + now: number + idleMs: number +}): void { + const liveTabIds = new Set() + for (const tabs of Object.values(args.tabsByWorktree)) { + for (const tab of tabs) { + liveTabIds.add(tab.id) + } + } + const seenPaneKeys = new Set() + for (const tabId of liveTabIds) { + const ptyIdsByLeafId = args.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId + for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId ?? {})) { + if (!ptyId) { + continue + } + let paneKey: string + try { + paneKey = makePaneKey(tabId, leafId) + } catch { + continue + } + seenPaneKeys.add(paneKey) + const existing = ptyBindingByPaneKey.get(paneKey) + if (existing && existing.ptyId === ptyId) { + existing.lastSeenAt = args.now + } else { + ptyBindingByPaneKey.set(paneKey, { ptyId, firstSeenAt: args.now, lastSeenAt: args.now }) + } + } + } + // Why: a transient layout gap already makes the planner fail closed, so dropping + // the binding there would hand the same PTY a fresh idle window on reappearance. + // Retain unseen entries; expire only on authoritative tab removal or by age. + for (const [paneKey, binding] of ptyBindingByPaneKey) { + if (seenPaneKeys.has(paneKey)) { + continue + } + const tabId = paneKey.slice(0, paneKey.indexOf(':')) + if (!liveTabIds.has(tabId) || args.now - binding.lastSeenAt > args.idleMs) { + ptyBindingByPaneKey.delete(paneKey) + } + } + for (const paneKey of boundaryResolvedAtByPaneKey.keys()) { + const tabId = paneKey.slice(0, paneKey.indexOf(':')) + if (!liveTabIds.has(tabId)) { + boundaryResolvedAtByPaneKey.delete(paneKey) + } + } +} + +export function getHibernationPtyBindingFirstSeenAtByPaneKey(): Record { + const out: Record = {} + for (const [paneKey, binding] of ptyBindingByPaneKey) { + out[paneKey] = binding.firstSeenAt + } + return out +} + +/** + * Stamped synchronously by the status store when a session-boundary `done` turns + * into a real completion. Sampling this on the 60s coordinator tick would miss a + * boundary written and cleared between two samples, leaving the pane on its + * ancient anchor. + */ +export function recordHibernationBoundaryResolved(paneKey: string, now: number): void { + if (paneKey) { + boundaryResolvedAtByPaneKey.set(paneKey, now) + } +} + +export function getHibernationBoundaryResolvedAtByPaneKey(): Record { + return Object.fromEntries(boundaryResolvedAtByPaneKey) +} + +export function forgetHibernationPaneAge(paneKey: string): void { + ptyBindingByPaneKey.delete(paneKey) + boundaryResolvedAtByPaneKey.delete(paneKey) +} + +export function resetHibernationPaneAgeForTests(): void { + ptyBindingByPaneKey.clear() + boundaryResolvedAtByPaneKey.clear() +} diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts new file mode 100644 index 00000000000..839209ea809 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts @@ -0,0 +1,169 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { getAgentResumeArgv, isResumableTuiAgent } from '../../../shared/agent-session-resume' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { lastInputBlocksHibernation } from './agent-hibernation-input-guard' +import { + isAutomaticHibernationAllowed, + isLiveResumeAnchorForCompletedAgent +} from './live-resume-anchor-record' +import type { AgentHibernationPlannerSnapshot } from './agent-hibernation-planner-snapshot' + +export type EligiblePane = { + paneKey: string + tabId: string + leafId: string + ptyId: string + runtimePtyId: string + agentType: string + providerSessionKey: string + providerSessionId: string + providerTranscriptPath: string + state: AgentStatusEntry['state'] + stateStartedAt: number + effectiveIdleStart: number + inputAt: number +} + +export function toRuntimePtyId(ptyId: string): string { + return parseRemoteRuntimePtyId(ptyId)?.handle ?? ptyId +} + +export function getEntryTabId(entry: AgentStatusEntry): string | null { + if (entry.tabId) { + return entry.tabId + } + return parsePaneKey(entry.paneKey)?.tabId ?? null +} + +function getPaneLivePtyId( + entry: AgentStatusEntry, + layout: TerminalLayoutSnapshot | undefined +): { leafId: string; ptyId: string } | null { + const parsed = parsePaneKey(entry.paneKey) + if (!parsed || (entry.tabId && parsed.tabId !== entry.tabId)) { + return null + } + const ptyId = layout?.ptyIdsByLeafId?.[parsed.leafId] + return ptyId ? { leafId: parsed.leafId, ptyId } : null +} + +// Why: provider done hooks can fire mid-Dispatch; only runtime-confirmed settlement makes sleep safe. +const hasUnsettledOrUnknownDispatch = ({ orchestration }: AgentStatusEntry): boolean => + orchestration + ? !['completed', 'failed', 'circuit_broken'].includes(orchestration.dispatchStatus ?? '') + : false + +export function getEligiblePane(args: { + entry: AgentStatusEntry + tab: TerminalTab + layout: TerminalLayoutSnapshot | undefined + livePtyIds: Set + sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey'] + lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey'] + foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId'] + ptyBindingFirstSeenAtByPaneKey: Record + boundaryResolvedAtByPaneKey: Record + mobileLockedPtyIds: Set + now: number + idleMs: number +}): EligiblePane | null { + const { + entry, + tab, + layout, + livePtyIds, + sleepingAgentSessionsByPaneKey, + lastTerminalInputAtByPaneKey, + foregroundTerminalLastSeenAtByTabId, + ptyBindingFirstSeenAtByPaneKey, + boundaryResolvedAtByPaneKey, + mobileLockedPtyIds + } = args + const sleepingRecord = sleepingAgentSessionsByPaneKey[entry.paneKey] + // Why: a completed turn leaves the TUI alive and resumable, so every resumable + // agent keeps a live resume anchor (#10238). That anchor is not a sleep record — + // treating it as one is what stopped non-Pi agents hibernating at all. + const hasOnlyLiveResumeAnchor = isLiveResumeAnchorForCompletedAgent( + entry, + sleepingRecord, + tab.worktreeId + ) + if ( + entry.state !== 'done' || + entry.interrupted === true || + Boolean(entry.subagents?.length) || + hasUnsettledOrUnknownDispatch(entry) || + (sleepingRecord && !hasOnlyLiveResumeAnchor) || + // Why: a fenced worker must never be auto-relaunched; killing it would also + // erase the fence, since the capture does not copy it. + !isAutomaticHibernationAllowed(sleepingRecord) + ) { + return null + } + if ( + getEntryTabId(entry) !== tab.id || + (entry.worktreeId && entry.worktreeId !== tab.worktreeId) + ) { + return null + } + if (!entry.agentType || !isResumableTuiAgent(entry.agentType) || !entry.providerSession) { + return null + } + if (!getAgentResumeArgv(entry.agentType, entry.providerSession)) { + return null + } + // Why: anchor on when `done` was first reported, not on the last status write — + // OSC 9999 repaints and reconnect replays advance `updatedAt` and would restart + // the countdown forever. Floors then restore the grace `updatedAt` gave by accident: + // returning to the tab, a fresh PTY binding (wake / app restart), and the moment a + // session-boundary done became a real completion. + const floors = [ + entry.stateStartedAt, + foregroundTerminalLastSeenAtByTabId[tab.id], + ptyBindingFirstSeenAtByPaneKey[entry.paneKey], + boundaryResolvedAtByPaneKey[entry.paneKey] + ] + const effectiveIdleStart = Math.max( + ...floors.map((value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)) + ) + if (args.now - effectiveIdleStart < args.idleMs) { + return null + } + const inputAt = lastTerminalInputAtByPaneKey[entry.paneKey] + // Why: killing the PTY discards the TUI composer's draft and any queued + // messages. The old input-after-done compare missed drafts typed while the + // agent was still working — the class that lost a user's draft in prod. + if ( + typeof inputAt === 'number' && + Number.isFinite(inputAt) && + lastInputBlocksHibernation(entry, inputAt) + ) { + return null + } + const livePane = getPaneLivePtyId(entry, layout) + if (!livePane) { + return null + } + const { leafId, ptyId } = livePane + const runtimePtyId = toRuntimePtyId(ptyId) + if (!livePtyIds.has(runtimePtyId) || mobileLockedPtyIds.has(runtimePtyId)) { + return null + } + return { + paneKey: entry.paneKey, + tabId: tab.id, + leafId, + ptyId, + runtimePtyId, + agentType: entry.agentType, + providerSessionKey: entry.providerSession.key, + providerSessionId: entry.providerSession.id, + providerTranscriptPath: entry.providerSession.transcriptPath ?? '', + state: entry.state, + stateStartedAt: entry.stateStartedAt, + effectiveIdleStart, + inputAt: typeof inputAt === 'number' && Number.isFinite(inputAt) ? inputAt : 0 + } +} diff --git a/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts b/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts new file mode 100644 index 00000000000..715500d9fb7 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts @@ -0,0 +1,23 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' + +export type AgentHibernationPlannerSnapshot = { + settings: Pick | null + activeWorktreeId: string | null + foregroundTerminalTabIds: string[] + tabsByWorktree: Record + terminalLayoutsByTabId: Record + ptyIdsByTabId: Record + runtimeLivePtyIdsByWorktreeId?: Record + runtimeLivenessRequiredWorktreeIds?: string[] + mobileLockedPtyIds: string[] + agentStatusByPaneKey: Record + sleepingAgentSessionsByPaneKey: Record + lastTerminalInputAtByPaneKey: Record + foregroundTerminalLastSeenAtByTabId: Record + ptyBindingFirstSeenAtByPaneKey?: Record + boundaryResolvedAtByPaneKey?: Record + now: number +} diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 632332f3995..675210bcf90 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -206,7 +206,7 @@ describe('agent sleep planner', () => { ) it('requires the idle threshold and blocks input after done', () => { - const fresh = entry({ updatedAt: NOW - 1_000 }) + const fresh = entry({ stateStartedAt: NOW - 1_000, updatedAt: NOW - 1_000 }) expect( plannedWorktrees(snapshot({ agentStatusByPaneKey: { [fresh.paneKey]: fresh } })) ).toEqual([]) @@ -434,7 +434,23 @@ describe('agent sleep planner', () => { expect(plannedWorktrees(snapshot({ ptyIdsByTabId: { 'tab-1': [] } }))).toEqual([]) expect( plannedWorktrees( - snapshot({ sleepingAgentSessionsByPaneKey: { [`tab-1:${LEAF}`]: {} as never } }) + snapshot({ + sleepingAgentSessionsByPaneKey: { + [`tab-1:${LEAF}`]: { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + worktreeId: 'wt-bg', + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: '', + state: 'done', + capturedAt: OLD, + updatedAt: OLD, + // A real sleep capture, not this pane's own live anchor. + origin: 'worktree-sleep' + } as never + } + }) ) ).toEqual([]) }) @@ -677,3 +693,176 @@ describe('agent sleep planner', () => { ) }) }) + +describe('live resume anchors do not block hibernation (#10238 regression)', () => { + // Why: setAgentStatus writes an `origin: 'live'` anchor for EVERY resumable agent the + // moment its turn ends, but only Pi/OMP/prime-agent were exempted from the + // already-sleeping rejection — so no Claude or Codex pane could ever hibernate. + const NON_PI_AGENTS = [ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'opencode', + 'mimo-code', + 'droid', + 'grok', + 'devin', + 'copilot', + 'kimi' + ] as const + + function liveAnchor( + agent: string, + providerSession: { key: 'session_id' | 'conversation_id'; id: string } + ) { + // Shaped exactly as sleepingRecordFromEntry builds it for a completed turn. + return { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + worktreeId: 'wt-bg', + agent, + providerSession, + prompt: '', + state: 'done' as const, + capturedAt: OLD, + updatedAt: OLD, + origin: 'live' as const + } + } + + it.each(NON_PI_AGENTS)('plans a long-idle done %s pane holding only its live anchor', (agent) => { + // Antigravity resumes by conversation id; the rest resume by session id. + const providerSession = + agent === 'antigravity' + ? ({ key: 'conversation_id', id: `${agent}-conversation-1` } as const) + : ({ key: 'session_id', id: `${agent}-session-1` } as const) + const agentEntry = entry({ agentType: agent, providerSession }) + expect( + plannedPaneKeys( + snapshot({ + agentStatusByPaneKey: { [agentEntry.paneKey]: agentEntry }, + sleepingAgentSessionsByPaneKey: { + [agentEntry.paneKey]: liveAnchor(agent, providerSession) as never + }, + ptyBindingFirstSeenAtByPaneKey: { [agentEntry.paneKey]: OLD } + }) + ) + ).toEqual([agentEntry.paneKey]) + }) + + it('still refuses a pane fenced against automatic resume', () => { + const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } + const agentEntry = entry({ agentType: 'claude', providerSession }) + const fenced = { + ...liveAnchor('claude', providerSession), + automaticResumeBlockedBy: 'legacy-orchestration-worker' + } + expect( + plannedPaneKeys( + snapshot({ + agentStatusByPaneKey: { [agentEntry.paneKey]: agentEntry }, + sleepingAgentSessionsByPaneKey: { [agentEntry.paneKey]: fenced as never }, + ptyBindingFirstSeenAtByPaneKey: { [agentEntry.paneKey]: OLD } + }) + ) + ).toEqual([]) + // Control: the identical pane IS planned once the fence is gone, so the rejection + // above isolates the fence rather than some other guard. + expect( + plannedPaneKeys( + snapshot({ + agentStatusByPaneKey: { [agentEntry.paneKey]: agentEntry }, + sleepingAgentSessionsByPaneKey: { + [agentEntry.paneKey]: liveAnchor('claude', providerSession) as never + }, + ptyBindingFirstSeenAtByPaneKey: { [agentEntry.paneKey]: OLD } + }) + ) + ).toEqual([agentEntry.paneKey]) + }) +}) + +describe('idle clock anchors on stateStartedAt, not updatedAt', () => { + it('is not deferred by a done→done repaint that only advances updatedAt', () => { + // A metadata-less redelivery (OSC 9999 repaint / reconnect replay) bumps updatedAt + // while stateStartedAt correctly holds; the old anchor restarted the countdown. + const repainted = entry({ stateStartedAt: OLD, updatedAt: NOW }) + expect( + plannedPaneKeys( + snapshot({ + agentStatusByPaneKey: { [repainted.paneKey]: repainted }, + ptyBindingFirstSeenAtByPaneKey: { [repainted.paneKey]: OLD } + }) + ) + ).toEqual([repainted.paneKey]) + }) + + it('keeps the signature stable across such a repaint so confirmation can match', () => { + const before = entry({ stateStartedAt: OLD, updatedAt: OLD }) + const after = entry({ stateStartedAt: OLD, updatedAt: NOW - 1 }) + const sigFor = (e: AgentStatusEntry): string => + planAgentHibernationCandidates( + snapshot({ + agentStatusByPaneKey: { [e.paneKey]: e }, + ptyBindingFirstSeenAtByPaneKey: { [e.paneKey]: OLD } + }) + )[0]!.signature + expect(sigFor(after)).toBe(sigFor(before)) + }) + + it('invalidates the signature when agent kind or resume identity changes', () => { + const base = entry({ stateStartedAt: OLD, updatedAt: OLD }) + const sigFor = (e: AgentStatusEntry): string => + planAgentHibernationCandidates( + snapshot({ + agentStatusByPaneKey: { [e.paneKey]: e }, + ptyBindingFirstSeenAtByPaneKey: { [e.paneKey]: OLD } + }) + )[0]!.signature + const baseline = sigFor(base) + // updatedAt held constant throughout, so today's signature would NOT change. + expect(sigFor(entry({ stateStartedAt: OLD, updatedAt: OLD, agentType: 'codex' }))).not.toBe( + baseline + ) + expect( + sigFor( + entry({ + stateStartedAt: OLD, + updatedAt: OLD, + providerSession: { + key: 'session_id', + id: 'session-1', + transcriptPath: '/tmp/moved.jsonl' + } + }) + ) + ).not.toBe(baseline) + }) +}) + +describe('PTY binding and boundary floors', () => { + it('defers a pane whose PTY binding was first seen this tick', () => { + // App restart / wake: main replays an ancient stateStartedAt, but the binding is new. + expect( + plannedPaneKeys(snapshot({ ptyBindingFirstSeenAtByPaneKey: { [`tab-1:${LEAF}`]: NOW } })) + ).toEqual([]) + }) + + it('plans it once the binding has aged past the idle window', () => { + expect( + plannedPaneKeys(snapshot({ ptyBindingFirstSeenAtByPaneKey: { [`tab-1:${LEAF}`]: OLD } })) + ).toEqual([`tab-1:${LEAF}`]) + }) + + it('defers a boundary done that only just became a real completion', () => { + expect( + plannedPaneKeys( + snapshot({ + ptyBindingFirstSeenAtByPaneKey: { [`tab-1:${LEAF}`]: OLD }, + boundaryResolvedAtByPaneKey: { [`tab-1:${LEAF}`]: NOW } + }) + ) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index c350296d9b3..1e79241f654 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -1,37 +1,19 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { - getAgentResumeArgv, - isResumableTuiAgent, - type SleepingAgentSessionRecord -} from '../../../shared/agent-session-resume' -import { parsePaneKey } from '../../../shared/stable-pane-id' -import { lastInputBlocksHibernation } from './agent-hibernation-input-guard' -import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from './pi-compatible-live-recovery-record' -import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' + getEligiblePane, + getEntryTabId, + toRuntimePtyId, + type EligiblePane +} from './agent-hibernation-pane-eligibility' +import type { AgentHibernationPlannerSnapshot } from './agent-hibernation-planner-snapshot' + +export type { AgentHibernationPlannerSnapshot } from './agent-hibernation-planner-snapshot' export const DEFAULT_AGENT_HIBERNATION_IDLE_MS = 30 * 60 * 1000 export const MIN_AGENT_HIBERNATION_IDLE_MS = 60 * 1000 export const MAX_AGENT_HIBERNATION_IDLE_MS = 24 * 60 * 60 * 1000 -export type AgentHibernationPlannerSnapshot = { - settings: Pick | null - activeWorktreeId: string | null - foregroundTerminalTabIds: string[] - tabsByWorktree: Record - terminalLayoutsByTabId: Record - ptyIdsByTabId: Record - runtimeLivePtyIdsByWorktreeId?: Record - runtimeLivenessRequiredWorktreeIds?: string[] - mobileLockedPtyIds: string[] - agentStatusByPaneKey: Record - sleepingAgentSessionsByPaneKey: Record - lastTerminalInputAtByPaneKey: Record - foregroundTerminalLastSeenAtByTabId: Record - now: number -} - export type AgentHibernationCandidate = { id: string worktreeId: string @@ -44,23 +26,6 @@ export type AgentHibernationCandidate = { signature: string } -type EligiblePane = { - paneKey: string - tabId: string - leafId: string - ptyId: string - runtimePtyId: string - providerSessionId: string - state: AgentStatusEntry['state'] - updatedAt: number - effectiveIdleStart: number - inputAt: number -} - -function toRuntimePtyId(ptyId: string): string { - return parseRemoteRuntimePtyId(ptyId)?.handle ?? ptyId -} - export function getEffectiveAgentHibernationIdleMs(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) && @@ -92,132 +57,16 @@ function getLivePtyIdsForTab( return [...ids] } -function getPaneLivePtyId( - entry: AgentStatusEntry, - layout: TerminalLayoutSnapshot | undefined -): { leafId: string; ptyId: string } | null { - const parsed = parsePaneKey(entry.paneKey) - if (!parsed || (entry.tabId && parsed.tabId !== entry.tabId)) { - return null - } - const ptyId = layout?.ptyIdsByLeafId?.[parsed.leafId] - return ptyId ? { leafId: parsed.leafId, ptyId } : null -} - -function getEntryTabId(entry: AgentStatusEntry): string | null { - if (entry.tabId) { - return entry.tabId - } - return parsePaneKey(entry.paneKey)?.tabId ?? null -} - -// Why: provider done hooks can fire mid-Dispatch; only runtime-confirmed settlement makes sleep safe. -const hasUnsettledOrUnknownDispatch = ({ orchestration }: AgentStatusEntry): boolean => - orchestration - ? !['completed', 'failed', 'circuit_broken'].includes(orchestration.dispatchStatus ?? '') - : false - -function getEligiblePane(args: { - entry: AgentStatusEntry - tab: TerminalTab - layout: TerminalLayoutSnapshot | undefined - livePtyIds: Set - sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey'] - lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey'] - foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId'] - mobileLockedPtyIds: Set - now: number - idleMs: number -}): EligiblePane | null { - const { - entry, - tab, - layout, - livePtyIds, - sleepingAgentSessionsByPaneKey, - lastTerminalInputAtByPaneKey, - foregroundTerminalLastSeenAtByTabId, - mobileLockedPtyIds - } = args - const sleepingRecord = sleepingAgentSessionsByPaneKey[entry.paneKey] - // Why: a Pi-compatible done hook ends a turn, not its TUI. Its live - // recovery checkpoint must not make the pane look already hibernated. - const hasOnlyLivePiCompatibleRecoveryIdentity = - isCompletedPiCompatibleAgentWithLiveRecoveryRecord(entry, sleepingRecord, tab.worktreeId) - if ( - entry.state !== 'done' || - entry.interrupted === true || - Boolean(entry.subagents?.length) || - hasUnsettledOrUnknownDispatch(entry) || - (sleepingRecord && !hasOnlyLivePiCompatibleRecoveryIdentity) - ) { - return null - } - if ( - getEntryTabId(entry) !== tab.id || - (entry.worktreeId && entry.worktreeId !== tab.worktreeId) - ) { - return null - } - if (!entry.agentType || !isResumableTuiAgent(entry.agentType) || !entry.providerSession) { - return null - } - if (!getAgentResumeArgv(entry.agentType, entry.providerSession)) { - return null - } - // Why: returning to the containing terminal tab should restart sleep even - // without pane input; sibling tabs in the worktree should keep their age. - const foregroundLastSeenAt = foregroundTerminalLastSeenAtByTabId[tab.id] - const effectiveIdleStart = Math.max( - entry.updatedAt, - typeof foregroundLastSeenAt === 'number' && Number.isFinite(foregroundLastSeenAt) - ? foregroundLastSeenAt - : 0 - ) - if (args.now - effectiveIdleStart < args.idleMs) { - return null - } - const inputAt = lastTerminalInputAtByPaneKey[entry.paneKey] - // Why: killing the PTY discards the TUI composer's draft and any queued - // messages. The old input-after-done compare missed drafts typed while the - // agent was still working — the class that lost a user's draft in prod. - if ( - typeof inputAt === 'number' && - Number.isFinite(inputAt) && - lastInputBlocksHibernation(entry, inputAt) - ) { - return null - } - const livePane = getPaneLivePtyId(entry, layout) - if (!livePane) { - return null - } - const { leafId, ptyId } = livePane - const runtimePtyId = toRuntimePtyId(ptyId) - if (!livePtyIds.has(runtimePtyId) || mobileLockedPtyIds.has(runtimePtyId)) { - return null - } - return { - paneKey: entry.paneKey, - tabId: tab.id, - leafId, - ptyId, - runtimePtyId, - providerSessionId: entry.providerSession.id, - state: entry.state, - updatedAt: entry.updatedAt, - effectiveIdleStart, - inputAt: typeof inputAt === 'number' && Number.isFinite(inputAt) ? inputAt : 0 - } -} - function signatureFor(worktreeId: string, panes: EligiblePane[]): string { const parts = panes .slice() .sort((a, b) => a.paneKey.localeCompare(b.paneKey)) .map( (pane) => - `${pane.paneKey}:${pane.ptyId}:${pane.runtimePtyId}:${pane.providerSessionId}:${pane.state}:${pane.updatedAt}:${pane.effectiveIdleStart}:${pane.inputAt}` + // `updatedAt` is deliberately absent: same-state repaints advance it and would + // stop two consecutive ticks ever matching. Agent kind and full resume identity + // replace the change detection it incidentally provided. + `${pane.paneKey}:${pane.ptyId}:${pane.runtimePtyId}:${pane.agentType}:${pane.providerSessionKey}:${pane.providerSessionId}:${pane.providerTranscriptPath}:${pane.state}:${pane.stateStartedAt}:${pane.effectiveIdleStart}:${pane.inputAt}` ) return `${worktreeId}|${parts.join('|')}` } @@ -295,6 +144,8 @@ export function planAgentHibernationCandidates( sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey, lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey, foregroundTerminalLastSeenAtByTabId: snapshot.foregroundTerminalLastSeenAtByTabId, + ptyBindingFirstSeenAtByPaneKey: snapshot.ptyBindingFirstSeenAtByPaneKey ?? {}, + boundaryResolvedAtByPaneKey: snapshot.boundaryResolvedAtByPaneKey ?? {}, mobileLockedPtyIds, now: snapshot.now, idleMs diff --git a/src/renderer/src/lib/live-resume-anchor-record.ts b/src/renderer/src/lib/live-resume-anchor-record.ts new file mode 100644 index 00000000000..7fa9d3f88ed --- /dev/null +++ b/src/renderer/src/lib/live-resume-anchor-record.ts @@ -0,0 +1,63 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + getAgentResumeArgv, + isResumableTuiAgent, + type SleepingAgentSessionRecord +} from '../../../shared/agent-session-resume' +import { isPiCompatibleAgentType } from '../../../shared/pi-agent-kind' + +/** + * True when `record` is nothing more than this completed pane's own live resume + * anchor — the checkpoint `setAgentStatus` writes for every resumable agent the + * moment its turn ends — rather than a real sleep capture. + * + * Deliberately carries no vendor gate and no automatic-resume check: it answers + * "does this record match the pane?", not "may this pane be hibernated?". + */ +export function isLiveResumeAnchorForCompletedAgent( + entry: AgentStatusEntry | undefined, + record: SleepingAgentSessionRecord | undefined, + worktreeId?: string +): record is SleepingAgentSessionRecord { + if ( + entry?.state !== 'done' || + !isResumableTuiAgent(entry.agentType) || + !entry.providerSession || + record?.agent !== entry.agentType || + record.origin !== 'live' + ) { + return false + } + const agent = entry.agentType + return Boolean( + (!entry.worktreeId || entry.worktreeId === record.worktreeId) && + (!worktreeId || worktreeId === record.worktreeId) && + agentProviderSessionsEqual(agent, entry.providerSession, record.providerSession) && + getAgentResumeArgv(agent, record.providerSession) + ) +} + +// Why: retention call sites (manual-sleep promotion, quit capture) keep the +// Pi-only meaning they shipped with; only automatic hibernation broadened. +export function isCompletedPiCompatibleAgentWithLiveRecoveryRecord( + entry: AgentStatusEntry | undefined, + record: SleepingAgentSessionRecord | undefined, + worktreeId?: string +): record is SleepingAgentSessionRecord { + return ( + isPiCompatibleAgentType(entry?.agentType) && + isLiveResumeAnchorForCompletedAgent(entry, record, worktreeId) + ) +} + +/** + * A durable orchestration fence against automatic provider relaunch. Hibernating + * a fenced pane would strand it or — since `sleepingRecordFromEntry` does not copy + * the flag — erase the fence and later auto-resume prohibited work. + */ +export function isAutomaticHibernationAllowed( + record: SleepingAgentSessionRecord | undefined +): boolean { + return !record?.automaticResumeBlockedBy +} diff --git a/src/renderer/src/lib/pi-compatible-live-recovery-record.ts b/src/renderer/src/lib/pi-compatible-live-recovery-record.ts deleted file mode 100644 index 7201c85d8cc..00000000000 --- a/src/renderer/src/lib/pi-compatible-live-recovery-record.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { AgentStatusEntry } from '../../../shared/agent-status-types' -import { - agentProviderSessionsEqual, - getAgentResumeArgv, - type SleepingAgentSessionRecord -} from '../../../shared/agent-session-resume' -import { isPiCompatibleAgentType } from '../../../shared/pi-agent-kind' - -export function isCompletedPiCompatibleAgentWithLiveRecoveryRecord( - entry: AgentStatusEntry | undefined, - record: SleepingAgentSessionRecord | undefined, - worktreeId?: string -): record is SleepingAgentSessionRecord { - if ( - entry?.state !== 'done' || - !isPiCompatibleAgentType(entry.agentType) || - !entry.providerSession || - record?.agent !== entry.agentType || - record.origin !== 'live' - ) { - return false - } - const agent = entry.agentType - return Boolean( - (!entry.worktreeId || entry.worktreeId === record.worktreeId) && - (!worktreeId || worktreeId === record.worktreeId) && - agentProviderSessionsEqual(agent, entry.providerSession, record.providerSession) && - getAgentResumeArgv(agent, record.providerSession) - ) -} diff --git a/src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts b/src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts new file mode 100644 index 00000000000..977fc9ccfd8 --- /dev/null +++ b/src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { AppState } from '../types' +import { createTestStore, makeTab } from './store-test-helpers' +import { + DEFAULT_AGENT_HIBERNATION_IDLE_MS, + planAgentHibernationCandidates +} from '../../lib/agent-hibernation-planner' +import { + getHibernationBoundaryResolvedAtByPaneKey, + resetHibernationPaneAgeForTests +} from '../../lib/agent-hibernation-pane-age' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = `tab-1:${LEAF}` +const DONE_AT = 1_000_000 +const NOW = DONE_AT + DEFAULT_AGENT_HIBERNATION_IDLE_MS + 60_000 +const ROUTING = { tabId: 'tab-1', worktreeId: 'wt-bg', connectionId: null } +const PROVIDER_SESSION = { key: 'session_id' as const, id: 'claude-session-1' } + +function seedWorkspace(store: ReturnType): void { + store.setState({ + activeWorktreeId: 'wt-active', + tabsByWorktree: { 'wt-bg': [makeTab({ id: 'tab-1', worktreeId: 'wt-bg' })] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'pty-1' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS + } + } as unknown as Partial) +} + +function planFrom(state: AppState, ptyBindingFirstSeenAt: number): string[] { + return planAgentHibernationCandidates({ + settings: state.settings, + activeWorktreeId: state.activeWorktreeId, + foregroundTerminalTabIds: [], + tabsByWorktree: state.tabsByWorktree, + terminalLayoutsByTabId: state.terminalLayoutsByTabId, + ptyIdsByTabId: state.ptyIdsByTabId, + mobileLockedPtyIds: [], + agentStatusByPaneKey: state.agentStatusByPaneKey, + sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, + lastTerminalInputAtByPaneKey: {}, + foregroundTerminalLastSeenAtByTabId: {}, + ptyBindingFirstSeenAtByPaneKey: { [PANE]: ptyBindingFirstSeenAt }, + boundaryResolvedAtByPaneKey: getHibernationBoundaryResolvedAtByPaneKey(), + now: NOW + }).map((candidate) => candidate.paneKey) +} + +describe('a finished Claude turn reaches the hibernation planner', () => { + beforeEach(() => { + resetHibernationPaneAgeForTests() + }) + + // Why: #10238 broadened the live recovery anchor to every resumable agent but left the + // planner's exemption Pi-only, so the two halves disagreed and no Claude pane could sleep. + // This pins the seam, not either half alone. + it('writes a live resume anchor that no longer blocks it', () => { + const store = createTestStore() + seedWorkspace(store) + store + .getState() + .setAgentStatus( + PANE, + { state: 'working', prompt: 'summarize the diff', agentType: 'claude' }, + 'Claude', + { updatedAt: DONE_AT - 5_000, stateStartedAt: DONE_AT - 5_000 }, + ROUTING, + { providerSession: PROVIDER_SESSION } + ) + store + .getState() + .setAgentStatus( + PANE, + { state: 'done', prompt: 'summarize the diff', agentType: 'claude' }, + 'Claude', + { updatedAt: DONE_AT, stateStartedAt: DONE_AT }, + ROUTING, + { providerSession: PROVIDER_SESSION } + ) + + const state = store.getState() + expect(state.agentStatusByPaneKey[PANE]?.state).toBe('done') + expect(state.sleepingAgentSessionsByPaneKey[PANE]).toMatchObject({ + agent: 'claude', + origin: 'live' + }) + expect(planFrom(state, DONE_AT)).toEqual([PANE]) + }) + + it('is not restarted by a done→done repaint carrying a fresh updatedAt', () => { + const store = createTestStore() + seedWorkspace(store) + for (const timing of [ + { updatedAt: DONE_AT - 5_000, stateStartedAt: DONE_AT - 5_000 }, + { updatedAt: DONE_AT, stateStartedAt: DONE_AT } + ]) { + store.getState().setAgentStatus( + PANE, + { + state: timing.updatedAt === DONE_AT ? 'done' : 'working', + prompt: 'ship it', + agentType: 'claude' + }, + 'Claude', + timing, + ROUTING, + { providerSession: PROVIDER_SESSION } + ) + } + // A metadata-less redelivery: main resends `done` with a fresh updatedAt and the + // ORIGINAL stateStartedAt, exactly as an OSC 9999 repaint or reconnect replay does. + store + .getState() + .setAgentStatus( + PANE, + { state: 'done', prompt: 'ship it', agentType: 'claude' }, + 'Claude', + { updatedAt: NOW - 1_000, stateStartedAt: DONE_AT }, + ROUTING + ) + const state = store.getState() + expect(state.agentStatusByPaneKey[PANE]?.updatedAt).toBe(NOW - 1_000) + expect(state.agentStatusByPaneKey[PANE]?.stateStartedAt).toBe(DONE_AT) + expect(planFrom(state, DONE_AT)).toEqual([PANE]) + }) + + it('stamps boundaryResolvedAt when a boundary done becomes a real completion', () => { + const store = createTestStore() + seedWorkspace(store) + store.getState().setAgentStatus( + PANE, + { + state: 'done', + prompt: 'ship it', + agentType: 'claude', + sessionBoundary: true + }, + 'Claude', + { updatedAt: DONE_AT, stateStartedAt: DONE_AT }, + ROUTING, + { providerSession: PROVIDER_SESSION } + ) + expect(getHibernationBoundaryResolvedAtByPaneKey()[PANE]).toBeUndefined() + + // Assistant evidence proves a REAL completion; the store clears the boundary flag in + // place without advancing stateStartedAt, so the stamp is the only signal of it. + store.getState().setAgentStatus( + PANE, + { + state: 'done', + prompt: 'ship it', + agentType: 'claude', + lastAssistantMessage: 'done and pushed' + }, + 'Claude', + { updatedAt: NOW - 1_000, stateStartedAt: DONE_AT }, + ROUTING, + { providerSession: PROVIDER_SESSION } + ) + expect(getHibernationBoundaryResolvedAtByPaneKey()[PANE]).toBe(NOW - 1_000) + // Ancient stateStartedAt, but the completion just happened — must not sleep yet. + expect(planFrom(store.getState(), DONE_AT)).toEqual([]) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 8dbd29172e9..4405e8041c8 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -41,7 +41,8 @@ import { isOrcaDispatchPrompt, orchestrationLabelsMatchLiveDispatch } from '@/lib/agent-row-primary-text' -import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/pi-compatible-live-recovery-record' +import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' +import { recordHibernationBoundaryResolved } from '@/lib/agent-hibernation-pane-age' import { resolveAgentPaneAuthorityKey, retireAgentPaneAuthorityAliases, @@ -2388,6 +2389,17 @@ export const createAgentStatusSlice: StateCreator { + const record = get().sleepingAgentSessionsByPaneKey[opts.paneKey] + if (!isAutomaticHibernationAllowed(record)) { + throw new Error('agent_hibernation_automatic_resume_blocked') + } + } + assertAutomaticHibernationStillAllowed() const capture = shutdownBufferCaptures.get(opts.tabId) if (capture) { try { @@ -70,6 +82,8 @@ export function createTerminalPaneHibernationActions( // Don't let one tab's capture failure block the pane hibernation. } } + // Why: the capture callback runs synchronously above and can itself fence the pane. + assertAutomaticHibernationStillAllowed() // Why: store sleeping records before kill, since pty:exit can arrive first. const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords) const replacedSleepingRecords: Record =