mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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]> =
|
||||
|
||||
Reference in New Issue
Block a user