fix(sleep): let non-Pi agents hibernate again, and stop repaints resetting the idle clock

Auto-hibernation could never fire for claude, codex, gemini, opencode, grok, or
any other resumable TUI agent — only pi/omp/prime-agent.

#10238 broadened the `origin: 'live'` resume anchor so every resumable agent
keeps its `--resume` handle when a turn ends. The planner rejects any pane that
already has a sleeping record, and its exemption was still Pi-only. Since the
planner's eligibility conditions are the same conditions that write the anchor,
that rejection covered every otherwise-eligible non-Pi pane.

- Split the conflated predicate. `isLiveResumeAnchorForCompletedAgent` answers
  "is this record just this pane's own live anchor?" with no vendor gate; the
  Pi-gated wrapper keeps today's exact semantics for the manual-sleep and quit
  capture call sites; `isAutomaticHibernationAllowed` carries the
  `automaticResumeBlockedBy` fence on its own.
- Fence automatic hibernation. A fenced worker must not be auto-relaunched, and
  the capture does not copy the flag — so hibernating one would erase it. Checked
  in the planner and again inside the shutdown action against freshest state,
  re-evaluated after the synchronous capture callback that could itself fence it.
- Anchor the idle clock on `stateStartedAt`, not `updatedAt`. Same-state
  repaints (OSC 9999, reconnect replays) advance `updatedAt`, restarting the
  30-minute countdown and invalidating the two-tick confirmation.
- Floor that anchor on PTY-binding age and a boundary-resolution stamp, so a
  wake or app restart still gets a full idle window instead of sleeping the
  whole backlog on the ancient timing main replays. The boundary stamp is
  written synchronously where the flag clears; sampling it per tick would miss
  a boundary written and cleared between two samples.
- Signature drops `updatedAt` and gains agent kind plus full resume identity,
  which is the change detection `updatedAt` was providing by accident.

Splits the planner into planner / pane-eligibility / snapshot to stay under the
file length limit.
This commit is contained in:
Brennan Benson
2026-08-25 02:18:15 -07:00
parent 788575e300
commit 560f80da05
13 changed files with 885 additions and 197 deletions
@@ -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()
@@ -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<RuntimePtyLiv
async function currentCandidates(now: number) {
const runtimeLiveness = await collectRuntimePtyLiveness(useAppStore.getState())
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.
observeHibernationPtyBindings({
tabsByWorktree: freshState.tabsByWorktree,
terminalLayoutsByTabId: freshState.terminalLayoutsByTabId,
now,
idleMs: getEffectiveAgentHibernationIdleMs(freshState.settings?.agentHibernationIdleMs)
})
return planAgentHibernationCandidates(snapshotFromState(freshState, now, runtimeLiveness))
.filter((candidate) => {
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
@@ -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()
})
})
@@ -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<string, PtyBinding>()
const boundaryResolvedAtByPaneKey = new Map<string, number>()
/**
* 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<string, TerminalTab[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
now: number
idleMs: number
}): void {
const liveTabIds = new Set<string>()
for (const tabs of Object.values(args.tabsByWorktree)) {
for (const tab of tabs) {
liveTabIds.add(tab.id)
}
}
const seenPaneKeys = new Set<string>()
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<string, number> {
const out: Record<string, number> = {}
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<string, number> {
return Object.fromEntries(boundaryResolvedAtByPaneKey)
}
export function forgetHibernationPaneAge(paneKey: string): void {
ptyBindingByPaneKey.delete(paneKey)
boundaryResolvedAtByPaneKey.delete(paneKey)
}
export function resetHibernationPaneAgeForTests(): void {
ptyBindingByPaneKey.clear()
boundaryResolvedAtByPaneKey.clear()
}
@@ -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<string>
sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey']
lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey']
foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId']
ptyBindingFirstSeenAtByPaneKey: Record<string, number | undefined>
boundaryResolvedAtByPaneKey: Record<string, number | undefined>
mobileLockedPtyIds: Set<string>
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
}
}
@@ -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<GlobalSettings, 'experimentalAgentHibernation' | 'agentHibernationIdleMs'> | null
activeWorktreeId: string | null
foregroundTerminalTabIds: string[]
tabsByWorktree: Record<string, TerminalTab[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
ptyIdsByTabId: Record<string, string[] | undefined>
runtimeLivePtyIdsByWorktreeId?: Record<string, string[] | undefined>
runtimeLivenessRequiredWorktreeIds?: string[]
mobileLockedPtyIds: string[]
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord | undefined>
lastTerminalInputAtByPaneKey: Record<string, number | undefined>
foregroundTerminalLastSeenAtByTabId: Record<string, number | undefined>
ptyBindingFirstSeenAtByPaneKey?: Record<string, number | undefined>
boundaryResolvedAtByPaneKey?: Record<string, number | undefined>
now: number
}
@@ -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([])
})
})
+15 -164
View File
@@ -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<GlobalSettings, 'experimentalAgentHibernation' | 'agentHibernationIdleMs'> | null
activeWorktreeId: string | null
foregroundTerminalTabIds: string[]
tabsByWorktree: Record<string, TerminalTab[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
ptyIdsByTabId: Record<string, string[] | undefined>
runtimeLivePtyIdsByWorktreeId?: Record<string, string[] | undefined>
runtimeLivenessRequiredWorktreeIds?: string[]
mobileLockedPtyIds: string[]
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord | undefined>
lastTerminalInputAtByPaneKey: Record<string, number | undefined>
foregroundTerminalLastSeenAtByTabId: Record<string, number | undefined>
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<string>
sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey']
lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey']
foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId']
mobileLockedPtyIds: Set<string>
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
@@ -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
}
@@ -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)
)
}
@@ -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<typeof createTestStore>): 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<AppState>)
}
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([])
})
})
+13 -1
View File
@@ -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<AppState, [], [], AgentStatusS
? existing.sessionBoundary
: undefined)
}
// Why: a boundary `done` becoming a REAL completion does not advance
// `stateStartedAt`, so hibernation would still judge the row by its ancient
// anchor. Stamp it here, synchronously — sampling on the 60s coordinator tick
// misses a boundary written and cleared between two samples.
if (
entry.state === 'done' &&
entry.sessionBoundary !== true &&
existing?.sessionBoundary === true
) {
recordHibernationBoundaryResolved(paneKey, updatedAt)
}
generatedTitleEntry.current = entry
if (
isAgentCompletionState(entry.state) &&
@@ -4,6 +4,7 @@ import {
unregisterPtyDataHandlers
} from '@/components/terminal-pane/pty-transport'
import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures'
import { isAutomaticHibernationAllowed } from '@/lib/live-resume-anchor-record'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import {
@@ -62,6 +63,17 @@ export function createTerminalPaneHibernationActions(
// Why: killing the PTY with no persisted resume record strands the pane unwakeable; abort instead of hibernating unrecoverably.
throw new Error('agent_hibernation_capture_missing')
}
// Why: the planner's fence check happened before the coordinator's async re-plan;
// the fence can be set in that window. Capture below OVERWRITES the record, and
// sleepingRecordFromEntry does not copy the flag, so losing this race erases the
// fence and later auto-resumes work whose relaunch was explicitly prohibited.
const assertAutomaticHibernationStillAllowed = (): void => {
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<string, (typeof sleepingAgentSessionRecords)[string]> =