mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
perf(runtime): skip hibernation inventories without completed agents (#19391)
* perf(runtime): skip hibernation inventories without completed agents * perf(runtime): skip hibernation status scan when no runtime owners * fix(runtime): require host evidence for workspaces resolved mid-inventory `runtimeLivenessRequiredWorktreeIds` was sampled before the runtime inventory await, while the plan is built from the state after it. A workspace that gained tabs or resolved its runtime owner during that window was therefore absent from the required set, so the planner did not demand fresh host evidence for it and fell back to client PTYs — client bookkeeping answering for the execution host. Union the post-await targets into the required set inside `snapshotFromState`. Union rather than replace: the set only ever grows, so the planner can only skip more workspaces, never authorize a hibernation it would previously have refused. An absent inventory stays a skip; nothing reads it as an exited PTY. Extract the coordinator test fixtures so the regression lives in its own file without pushing the coordinator suite past the 800-line test cap. * fix(types): annotate hibernation fixture mock exports for declaration emit TS2883: the inferred `Mock<Procedure>` types of the fixture's exported `vi.fn()` bindings reference `Procedure` from a transitive `@vitest/spy` path that cannot be named. * chore: keep local-file-sink-memory test formatting as on main The merge commit's pre-commit hook reformatted a file this branch does not own.
This commit is contained in:
@@ -83,6 +83,91 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental until CI soak; investigate fidelity or count failures without relaxing the row budget."
|
||||
},
|
||||
{
|
||||
"id": "agent-session.hibernation-runtime-inventory-budget",
|
||||
"title": "Hibernation skips irrelevant remote inventories and preserves fresh host evidence",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "agent-session-runtime",
|
||||
"layer": "shared-and-renderer-unit",
|
||||
"surfaces": ["automatic agent hibernation", "remote terminal liveness"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "remote-runtime"],
|
||||
"coverageNotes": "Local hibernation, remote inventory authority and folder-workspace identity are covered by coordinator/model contracts on macOS. Daemon, SSH, WSL, relay and native platform execution boundaries and existing RPC payloads are unchanged.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/blob/main/src/renderer/src/lib/agent-hibernation-coordinator.test.ts"
|
||||
],
|
||||
"invariant": "Automatic hibernation must retain two stable confirmations and fresh execution-host evidence before shutdown. Skipping a workspace with no completed agent must not authorize a newly completed pane using stale client PTYs.",
|
||||
"oracle": "100 remote workspaces containing working/waiting agents issue zero runtime calls. A skipped workspace completing while another inventory awaits remains ineligible until two later host-confirmed passes, and so does a workspace that only becomes runtime-owned while an inventory is outstanding — a pass carrying no host evidence for a workspace never counts as one of its two confirmations. Folder and git workspaces hibernate the exact host PTY; rejected/truncated inventories and intervening input/output or state changes block shutdown.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/lib/agent-hibernation-coordinator.test.ts src/renderer/src/lib/agent-hibernation-planner.test.ts src/renderer/src/lib/agent-hibernation-confirmation.test.ts src/renderer/src/lib/agent-hibernation-pane-age.test.ts src/renderer/src/lib/agent-hibernation-output-activity.test.ts src/renderer/src/lib/agent-hibernation-visibility.test.ts src/renderer/src/lib/foreground-terminal-tabs.test.ts src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/lib/agent-hibernation-coordinator.test.ts",
|
||||
"src/renderer/src/lib/agent-hibernation-planner.test.ts",
|
||||
"src/renderer/src/lib/agent-hibernation-confirmation.test.ts",
|
||||
"src/renderer/src/lib/agent-hibernation-pane-age.test.ts",
|
||||
"src/renderer/src/lib/agent-hibernation-output-activity.test.ts",
|
||||
"src/renderer/src/lib/agent-hibernation-visibility.test.ts",
|
||||
"src/renderer/src/lib/foreground-terminal-tabs.test.ts",
|
||||
"src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/lib/agent-hibernation-coordinator.test.ts",
|
||||
"assertions": [
|
||||
"does not request runtime inventories for 100 workspaces without completed agents",
|
||||
"requires host evidence after a skipped workspace completes during another inventory request",
|
||||
"hibernates a runtime-backed candidate in %s with fresh liveness and exact PTYs",
|
||||
"fails closed on truncated runtime liveness samples",
|
||||
"fails closed when fresh runtime liveness rejects after an earlier good sample"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts",
|
||||
"assertions": [
|
||||
"requires host evidence when a workspace becomes runtime-owned during an inventory request"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/lib/agent-hibernation-coordinator.test.ts src/renderer/src/lib/agent-hibernation-planner.test.ts src/renderer/src/lib/agent-hibernation-confirmation.test.ts src/renderer/src/lib/agent-hibernation-pane-age.test.ts src/renderer/src/lib/agent-hibernation-output-activity.test.ts src/renderer/src/lib/agent-hibernation-visibility.test.ts src/renderer/src/lib/foreground-terminal-tabs.test.ts src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 48.18,
|
||||
"summary": "93 tests passed across eight coordinator, liveness-race, planner, confirmation, age, activity and visibility files."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 60,
|
||||
"scope": "Focused unit/provider-contract suite; local runtime budget, not an established p95."
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Initial local validation; no CI soak history."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Baseline made 101 runtime calls in the new no-completed-agent fixture; candidate makes zero. Existing confirmation, final recheck and failure-path assertions continue to pass, including completion during an outstanding request. Separately, baseline hibernated a workspace that became runtime-owned mid-inventory one tick early, taking its first confirmation from a pass planned entirely from client PTYs; candidate withholds that pass and requires two host-confirmed ones."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The 100-workspace fixture falls from 100 terminal.list calls plus one compatibility handshake to zero calls. Two-workspace confirmation/recheck fixture falls from five inventories to three. A single status scan and tab membership lookups precede existing requests. Recomputing the required-worktree set from the post-await state adds one in-memory owner resolution per workspace per tick and no RPC. No new timer, concurrency, subprocess or retry; only active when experimental automatic hibernation is enabled."
|
||||
},
|
||||
"knownGaps": [
|
||||
"Remote contracts use mocked runtime replies; no live SSH/network-fault, Windows/Linux/WSL or relay run.",
|
||||
"This improves the experimental automatic hibernation path only; it does not remove inventories for workspaces that contain completed agents."
|
||||
],
|
||||
"promotionCriteria": [
|
||||
"Complete CI soak with zero unexplained flakes and preserve the observable oracle."
|
||||
],
|
||||
"demotionRule": "Keep experimental until CI soak; investigate failures without relaxing fidelity, liveness or resource-count assertions."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.osc-status-scan-budget",
|
||||
"title": "OSC 9999 status bursts reuse forward terminator searches",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { DEFAULT_AGENT_HIBERNATION_IDLE_MS } from './agent-hibernation-planner'
|
||||
import { resetAgentHibernationCoordinatorForTests } from './agent-hibernation-coordinator'
|
||||
import { hydrateDrivers } from './pane-manager/mobile-driver-state'
|
||||
import { resetForegroundTerminalTabIdsForTests } from './foreground-terminal-tabs'
|
||||
import { resetAgentHibernationOutputActivityForTests } from './agent-hibernation-output-activity'
|
||||
import {
|
||||
observeHibernationPtyBindings,
|
||||
resetHibernationPaneAgeForTests
|
||||
} from './agent-hibernation-pane-age'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../runtime/runtime-rpc-client'
|
||||
|
||||
export const NOW = 10_000_000
|
||||
export const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
export type RuntimeEnvironmentCallStub = Mock<(args: RuntimeEnvironmentCallRequest) => unknown>
|
||||
|
||||
export const mockRuntimeEnvironmentCall: RuntimeEnvironmentCallStub = vi.fn()
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: mockRuntimeEnvironmentCall
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function tab(): TerminalTab {
|
||||
return {
|
||||
id: 'tab-1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-bg',
|
||||
title: 'Agent',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
export function layout(): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: { type: 'leaf', leafId: LEAF },
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: 'pty-1' }
|
||||
}
|
||||
}
|
||||
|
||||
export function entry(): AgentStatusEntry {
|
||||
return {
|
||||
state: 'done',
|
||||
prompt: 'ship it',
|
||||
updatedAt: NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1,
|
||||
stateStartedAt: NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1,
|
||||
paneKey: `tab-1:${LEAF}`,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-bg',
|
||||
agentType: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
|
||||
export type HibernationShutdownStub = Mock<AppState['shutdownCompletedAgentPaneForHibernation']>
|
||||
|
||||
export function installEligibleState(
|
||||
shutdownCompletedAgentPaneForHibernation: HibernationShutdownStub = vi.fn(),
|
||||
overrides: Partial<AppState> = {}
|
||||
): HibernationShutdownStub {
|
||||
const e = entry()
|
||||
const runtimeOwnerEnvironmentId = overrides.settings?.activeRuntimeEnvironmentId ?? undefined
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
experimentalAgentHibernation: true,
|
||||
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS
|
||||
} as never,
|
||||
activeWorktreeId: 'wt-active',
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'fixture-repo': [
|
||||
{
|
||||
id: 'wt-bg',
|
||||
repoId: 'fixture-repo',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId
|
||||
}
|
||||
]
|
||||
} as never,
|
||||
detectedWorktreesByRepo: {},
|
||||
tabsByWorktree: { 'wt-bg': [tab()] },
|
||||
terminalLayoutsByTabId: { 'tab-1': layout() },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
agentStatusByPaneKey: { [e.paneKey]: e },
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
lastTerminalInputAtByPaneKey: {},
|
||||
shutdownCompletedAgentPaneForHibernation: shutdownCompletedAgentPaneForHibernation as never,
|
||||
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
|
||||
}
|
||||
|
||||
export function runtimeListResult(ptyIds: string[], truncated = false) {
|
||||
return {
|
||||
terminals: ptyIds.map((ptyId) => ({
|
||||
handle: `handle-${ptyId}`,
|
||||
ptyId,
|
||||
worktreeId: 'wt-bg',
|
||||
worktreePath: '/tmp/wt-bg',
|
||||
branch: 'feature',
|
||||
tabId: `pty:${ptyId}`,
|
||||
leafId: `pty:${ptyId}`,
|
||||
title: 'Agent',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: ''
|
||||
})),
|
||||
totalCount: ptyIds.length,
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
export function installRuntimeListResponses(
|
||||
...responses: (ReturnType<typeof runtimeListResult> | Error)[]
|
||||
): void {
|
||||
const queue = [...responses]
|
||||
mockRuntimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args)
|
||||
if (compatible) {
|
||||
return Promise.resolve(compatible)
|
||||
}
|
||||
if (args.method === 'terminal.list') {
|
||||
const response = queue.shift() ?? runtimeListResult(['pty-1'])
|
||||
if (response instanceof Error) {
|
||||
return Promise.reject(response)
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'terminal-list',
|
||||
ok: true,
|
||||
result: response,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'default',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: Error) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
export function resetAgentHibernationCoordinatorFixture(): void {
|
||||
resetAgentHibernationCoordinatorForTests()
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
resetForegroundTerminalTabIdsForTests()
|
||||
resetAgentHibernationOutputActivityForTests()
|
||||
resetHibernationPaneAgeForTests()
|
||||
hydrateDrivers([])
|
||||
mockRuntimeEnvironmentCall.mockReset()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -1,202 +1,33 @@
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { DEFAULT_AGENT_HIBERNATION_IDLE_MS } from './agent-hibernation-planner'
|
||||
import {
|
||||
resetAgentHibernationCoordinatorForTests,
|
||||
runAgentHibernationTick,
|
||||
startAgentHibernationCoordinator
|
||||
} from './agent-hibernation-coordinator'
|
||||
import { hydrateDrivers, setDriverForPty } from './pane-manager/mobile-driver-state'
|
||||
import {
|
||||
registerVisibleTerminalTab,
|
||||
resetForegroundTerminalTabIdsForTests,
|
||||
setForegroundTerminalTabIds
|
||||
} from './foreground-terminal-tabs'
|
||||
import {
|
||||
recordAgentHibernationPaneOutput,
|
||||
resetAgentHibernationOutputActivityForTests
|
||||
} from './agent-hibernation-output-activity'
|
||||
import {
|
||||
observeHibernationPtyBindings,
|
||||
resetHibernationPaneAgeForTests
|
||||
} from './agent-hibernation-pane-age'
|
||||
import { setDriverForPty } from './pane-manager/mobile-driver-state'
|
||||
import { registerVisibleTerminalTab, setForegroundTerminalTabIds } from './foreground-terminal-tabs'
|
||||
import { recordAgentHibernationPaneOutput } from './agent-hibernation-output-activity'
|
||||
import { createCompatibleRuntimeStatusResponseIfNeeded } from '../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../runtime/runtime-rpc-client'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
deferred,
|
||||
entry,
|
||||
installEligibleState,
|
||||
installRuntimeListResponses,
|
||||
layout,
|
||||
LEAF,
|
||||
mockRuntimeEnvironmentCall,
|
||||
NOW,
|
||||
resetAgentHibernationCoordinatorFixture,
|
||||
runtimeListResult,
|
||||
tab
|
||||
} from './agent-hibernation-coordinator-test-fixture'
|
||||
|
||||
const NOW = 10_000_000
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const PI_TRANSCRIPT_PATH = join(tmpdir(), 'pi-session-1.jsonl')
|
||||
|
||||
const mockRuntimeEnvironmentCall = vi.fn()
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: mockRuntimeEnvironmentCall
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function tab(): TerminalTab {
|
||||
return {
|
||||
id: 'tab-1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-bg',
|
||||
title: 'Agent',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function layout(): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: { type: 'leaf', leafId: LEAF },
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: 'pty-1' }
|
||||
}
|
||||
}
|
||||
|
||||
function entry(): AgentStatusEntry {
|
||||
return {
|
||||
state: 'done',
|
||||
prompt: 'ship it',
|
||||
updatedAt: NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1,
|
||||
stateStartedAt: NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1,
|
||||
paneKey: `tab-1:${LEAF}`,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-bg',
|
||||
agentType: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
|
||||
function installEligibleState(
|
||||
shutdownCompletedAgentPaneForHibernation = vi.fn(),
|
||||
overrides: Partial<AppState> = {}
|
||||
): typeof shutdownCompletedAgentPaneForHibernation {
|
||||
const e = entry()
|
||||
const runtimeOwnerEnvironmentId = overrides.settings?.activeRuntimeEnvironmentId ?? undefined
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
experimentalAgentHibernation: true,
|
||||
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS
|
||||
} as never,
|
||||
activeWorktreeId: 'wt-active',
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'fixture-repo': [
|
||||
{ id: 'wt-bg', repoId: 'fixture-repo', hostId: 'local', runtimeOwnerEnvironmentId }
|
||||
]
|
||||
} as never,
|
||||
detectedWorktreesByRepo: {},
|
||||
tabsByWorktree: { 'wt-bg': [tab()] },
|
||||
terminalLayoutsByTabId: { 'tab-1': layout() },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
agentStatusByPaneKey: { [e.paneKey]: e },
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
lastTerminalInputAtByPaneKey: {},
|
||||
shutdownCompletedAgentPaneForHibernation: shutdownCompletedAgentPaneForHibernation as never,
|
||||
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
|
||||
}
|
||||
|
||||
function runtimeListResult(ptyIds: string[], truncated = false) {
|
||||
return {
|
||||
terminals: ptyIds.map((ptyId) => ({
|
||||
handle: `handle-${ptyId}`,
|
||||
ptyId,
|
||||
worktreeId: 'wt-bg',
|
||||
worktreePath: '/tmp/wt-bg',
|
||||
branch: 'feature',
|
||||
tabId: `pty:${ptyId}`,
|
||||
leafId: `pty:${ptyId}`,
|
||||
title: 'Agent',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: ''
|
||||
})),
|
||||
totalCount: ptyIds.length,
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
function installRuntimeListResponses(
|
||||
...responses: (ReturnType<typeof runtimeListResult> | Error)[]
|
||||
): void {
|
||||
const queue = [...responses]
|
||||
mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => {
|
||||
const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args)
|
||||
if (compatible) {
|
||||
return Promise.resolve(compatible)
|
||||
}
|
||||
if (args.method === 'terminal.list') {
|
||||
const response = queue.shift() ?? runtimeListResult(['pty-1'])
|
||||
if (response instanceof Error) {
|
||||
return Promise.reject(response)
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'terminal-list',
|
||||
ok: true,
|
||||
result: response,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'default',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: Error) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetAgentHibernationCoordinatorForTests()
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
resetForegroundTerminalTabIdsForTests()
|
||||
resetAgentHibernationOutputActivityForTests()
|
||||
resetHibernationPaneAgeForTests()
|
||||
hydrateDrivers([])
|
||||
mockRuntimeEnvironmentCall.mockReset()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
afterEach(resetAgentHibernationCoordinatorFixture)
|
||||
|
||||
describe('agent sleep coordinator', () => {
|
||||
it('hibernates an eligible background worktree after two stable ticks', async () => {
|
||||
@@ -484,40 +315,50 @@ describe('agent sleep coordinator', () => {
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hibernates a runtime-backed candidate with fresh liveness and exact PTYs', async () => {
|
||||
vi.useFakeTimers()
|
||||
installRuntimeListResponses(
|
||||
runtimeListResult(['pty-1']),
|
||||
runtimeListResult(['pty-1']),
|
||||
runtimeListResult(['pty-1'])
|
||||
)
|
||||
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
|
||||
settings: {
|
||||
experimentalAgentHibernation: true,
|
||||
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS,
|
||||
activeRuntimeEnvironmentId: 'runtime-1'
|
||||
} as never,
|
||||
ptyIdsByTabId: { 'tab-1': [] }
|
||||
})
|
||||
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
|
||||
paneKey: `tab-1:${LEAF}`,
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF,
|
||||
ptyId: 'pty-1',
|
||||
expectedRuntimePtyId: 'pty-1'
|
||||
})
|
||||
expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'terminal.list',
|
||||
params: expect.objectContaining({ requireFreshPtyLiveness: true })
|
||||
it.each(['wt-bg', 'folder:folder-1'])(
|
||||
'hibernates a runtime-backed candidate in %s with fresh liveness and exact PTYs',
|
||||
async (worktreeId) => {
|
||||
vi.useFakeTimers()
|
||||
const result = runtimeListResult(['pty-1'])
|
||||
result.terminals[0].worktreeId = worktreeId
|
||||
installRuntimeListResponses(result, result, result)
|
||||
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
|
||||
settings: {
|
||||
experimentalAgentHibernation: true,
|
||||
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS,
|
||||
activeRuntimeEnvironmentId: 'runtime-1'
|
||||
} as never,
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-1',
|
||||
folderPath: tmpdir(),
|
||||
executionHostId: 'runtime:runtime-1'
|
||||
}
|
||||
] as never,
|
||||
tabsByWorktree: { [worktreeId]: [{ ...tab(), worktreeId }] },
|
||||
agentStatusByPaneKey: { [entry().paneKey]: { ...entry(), worktreeId } },
|
||||
ptyIdsByTabId: { 'tab-1': [] }
|
||||
})
|
||||
)
|
||||
})
|
||||
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(shutdown).toHaveBeenCalledWith(worktreeId, {
|
||||
paneKey: `tab-1:${LEAF}`,
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF,
|
||||
ptyId: 'pty-1',
|
||||
expectedRuntimePtyId: 'pty-1'
|
||||
})
|
||||
expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'terminal.list',
|
||||
params: expect.objectContaining({ requireFreshPtyLiveness: true })
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('requires fresh runtime liveness for confirmation and pre-shutdown recheck', async () => {
|
||||
vi.useFakeTimers()
|
||||
@@ -546,7 +387,7 @@ describe('agent sleep coordinator', () => {
|
||||
})
|
||||
|
||||
it('revalidates a confirmed pane without listing unrelated runtime worktrees', async () => {
|
||||
installRuntimeListResponses(...Array.from({ length: 5 }, () => runtimeListResult(['pty-1'])))
|
||||
installRuntimeListResponses(...Array.from({ length: 3 }, () => runtimeListResult(['pty-1'])))
|
||||
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
|
||||
settings: {
|
||||
experimentalAgentHibernation: true,
|
||||
@@ -583,15 +424,104 @@ describe('agent sleep coordinator', () => {
|
||||
const listCalls = mockRuntimeEnvironmentCall.mock.calls.filter(
|
||||
([args]) => args.method === 'terminal.list'
|
||||
)
|
||||
// Two global confirmation samples list both worktrees; the destructive
|
||||
// recheck lists only the candidate's owner: 2W + C, not 2W + C×W.
|
||||
expect(listCalls).toHaveLength(5)
|
||||
// Both confirmation samples and the destructive recheck query only the completed agent's owner.
|
||||
expect(listCalls).toHaveLength(3)
|
||||
expect(listCalls.at(-1)?.[0]).toMatchObject({
|
||||
selector: 'runtime-1',
|
||||
params: { worktree: expect.anything() }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not request runtime inventories for 100 workspaces without completed agents', async () => {
|
||||
installRuntimeListResponses()
|
||||
const tabs = Array.from({ length: 100 }, (_, index) => ({
|
||||
...tab(),
|
||||
id: `tab-${index}`,
|
||||
worktreeId: `wt-${index}`
|
||||
}))
|
||||
const shutdown = installEligibleState(vi.fn(), {
|
||||
worktreesByRepo: {
|
||||
'fixture-repo': tabs.map((t) => ({
|
||||
id: t.worktreeId,
|
||||
repoId: 'fixture-repo',
|
||||
hostId: 'runtime:runtime-1',
|
||||
runtimeOwnerEnvironmentId: 'runtime-1'
|
||||
}))
|
||||
} as never,
|
||||
tabsByWorktree: Object.fromEntries(tabs.map((t) => [t.worktreeId, [t]])),
|
||||
agentStatusByPaneKey: Object.fromEntries(
|
||||
tabs.map((t, index) => [
|
||||
`${t.id}:${LEAF}`,
|
||||
{
|
||||
...entry(),
|
||||
tabId: t.id,
|
||||
worktreeId: t.worktreeId,
|
||||
paneKey: `${t.id}:${LEAF}`,
|
||||
state: index % 2 === 0 ? 'working' : 'waiting'
|
||||
}
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
await runAgentHibernationTick()
|
||||
|
||||
expect(mockRuntimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires host evidence after a skipped workspace completes during another inventory request', async () => {
|
||||
const delayed = deferred<ReturnType<typeof runtimeListResult>>()
|
||||
installRuntimeListResponses()
|
||||
const respond = mockRuntimeEnvironmentCall.getMockImplementation()!
|
||||
mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) =>
|
||||
args.method === 'terminal.list'
|
||||
? delayed.promise.then((result) => ({ id: 'delayed', ok: true, result }))
|
||||
: respond(args)
|
||||
)
|
||||
const first = entry()
|
||||
const second = { ...entry(), tabId: 'tab-2', paneKey: `tab-2:${LEAF}`, worktreeId: 'wt-other' }
|
||||
const shutdown = installEligibleState(vi.fn(), {
|
||||
worktreesByRepo: {
|
||||
'fixture-repo': ['wt-bg', 'wt-other'].map((id) => ({
|
||||
id,
|
||||
repoId: 'fixture-repo',
|
||||
hostId: 'runtime:runtime-1',
|
||||
runtimeOwnerEnvironmentId: 'runtime-1'
|
||||
}))
|
||||
} as never,
|
||||
tabsByWorktree: {
|
||||
'wt-bg': [tab()],
|
||||
'wt-other': [{ ...tab(), id: 'tab-2', worktreeId: 'wt-other' }]
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[first.paneKey]: { ...first, state: 'working' },
|
||||
[second.paneKey]: second
|
||||
}
|
||||
})
|
||||
|
||||
const tick = runAgentHibernationTick()
|
||||
await vi.waitFor(() =>
|
||||
expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.list' })
|
||||
)
|
||||
)
|
||||
useAppStore.setState({ agentStatusByPaneKey: { [first.paneKey]: first } })
|
||||
delayed.resolve(runtimeListResult([]))
|
||||
await tick
|
||||
|
||||
installRuntimeListResponses()
|
||||
await runAgentHibernationTick()
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
await runAgentHibernationTick()
|
||||
expect(shutdown).toHaveBeenCalledTimes(1)
|
||||
expect(shutdown).toHaveBeenCalledWith(
|
||||
'wt-bg',
|
||||
expect.objectContaining({
|
||||
expectedRuntimePtyId: 'pty-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses fresh store state after awaiting runtime liveness before shutdown', async () => {
|
||||
vi.useFakeTimers()
|
||||
const delayed = deferred<ReturnType<typeof runtimeListResult>>()
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
RuntimeTerminalSummary
|
||||
} from '../../../shared/runtime-types'
|
||||
import { getWindowParkVisible, subscribeWindowParkVisibility } from './window-park-visibility'
|
||||
import { getEntryTabId } from './agent-hibernation-pane-eligibility'
|
||||
|
||||
export const AGENT_HIBERNATION_TICK_MS = 60 * 1000
|
||||
|
||||
@@ -79,7 +80,17 @@ function snapshotFromState(
|
||||
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
|
||||
ptyIdsByTabId: state.ptyIdsByTabId,
|
||||
runtimeLivePtyIdsByWorktreeId: runtimeLiveness.runtimeLivePtyIdsByWorktreeId,
|
||||
runtimeLivenessRequiredWorktreeIds: runtimeLiveness.runtimeLivenessRequiredWorktreeIds,
|
||||
// Why: a workspace can gain tabs or resolve its runtime owner while the inventory above
|
||||
// is in flight, and the plan is built from this later state. Union the fresh targets in
|
||||
// so such a workspace is required-but-absent and the planner skips it, rather than
|
||||
// answering for the execution host from client PTYs. Union, never replace: dropping a
|
||||
// pre-await target would narrow the fail-closed set instead of widening it.
|
||||
runtimeLivenessRequiredWorktreeIds: [
|
||||
...new Set([
|
||||
...runtimeLiveness.runtimeLivenessRequiredWorktreeIds,
|
||||
...getRuntimeLivenessTargetWorktrees(state, targetWorktreeId).keys()
|
||||
])
|
||||
],
|
||||
mobileLockedPtyIds: [...getAllDrivers()]
|
||||
.filter(([, driver]) => driver.kind === 'mobile')
|
||||
.map(([ptyId]) => ptyId),
|
||||
@@ -132,8 +143,23 @@ async function collectRuntimePtyLiveness(
|
||||
const targets = getRuntimeLivenessTargetWorktrees(state, targetWorktreeId)
|
||||
const runtimeLivePtyIdsByWorktreeId: Record<string, string[]> = {}
|
||||
const runtimeLivenessRequiredWorktreeIds = [...targets.keys()]
|
||||
if (targets.size === 0) {
|
||||
// Why: an all-local install has nothing to ask, so it must not pay the status scan below.
|
||||
return { runtimeLivePtyIdsByWorktreeId, runtimeLivenessRequiredWorktreeIds }
|
||||
}
|
||||
const completedTabIds = new Set<string>()
|
||||
for (const entry of Object.values(state.agentStatusByPaneKey)) {
|
||||
const tabId = entry?.state === 'done' ? getEntryTabId(entry) : null
|
||||
if (tabId) {
|
||||
completedTabIds.add(tabId)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
[...targets].map(async ([worktreeId, runtimeEnvironmentId]) => {
|
||||
if (!state.tabsByWorktree[worktreeId]?.some((tab) => completedTabIds.has(tab.id))) {
|
||||
// Skipped owners still require host evidence if an agent completes during this pass.
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await callRuntimeRpc<RuntimeTerminalListResult>(
|
||||
{ kind: 'environment', environmentId: runtimeEnvironmentId },
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { runAgentHibernationTick } from './agent-hibernation-coordinator'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../runtime/runtime-compatibility-test-fixture'
|
||||
import {
|
||||
deferred,
|
||||
entry,
|
||||
installEligibleState,
|
||||
layout,
|
||||
LEAF,
|
||||
mockRuntimeEnvironmentCall,
|
||||
resetAgentHibernationCoordinatorFixture,
|
||||
runtimeListResult,
|
||||
tab
|
||||
} from './agent-hibernation-coordinator-test-fixture'
|
||||
|
||||
afterEach(resetAgentHibernationCoordinatorFixture)
|
||||
|
||||
describe('agent sleep coordinator runtime-liveness races', () => {
|
||||
it('requires host evidence when a workspace becomes runtime-owned during an inventory request', async () => {
|
||||
const delayed = deferred<ReturnType<typeof runtimeListResult>>()
|
||||
const lateTab = { ...tab(), id: 'tab-late', worktreeId: 'wt-late' }
|
||||
const lateEntry = {
|
||||
...entry(),
|
||||
tabId: 'tab-late',
|
||||
worktreeId: 'wt-late',
|
||||
paneKey: `tab-late:${LEAF}`
|
||||
}
|
||||
const lateList = {
|
||||
...runtimeListResult(['pty-late']),
|
||||
terminals: runtimeListResult(['pty-late']).terminals.map((terminal) => ({
|
||||
...terminal,
|
||||
worktreeId: 'wt-late'
|
||||
}))
|
||||
}
|
||||
let firstListPending = true
|
||||
mockRuntimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args)
|
||||
if (compatible) {
|
||||
return Promise.resolve(compatible)
|
||||
}
|
||||
if (args.method !== 'terminal.list') {
|
||||
return Promise.resolve({ id: 'default', ok: true, result: {} })
|
||||
}
|
||||
const isLate = args.params?.worktree === 'id:wt-late'
|
||||
if (!isLate && firstListPending) {
|
||||
firstListPending = false
|
||||
return delayed.promise.then((result) => ({
|
||||
id: 'delayed',
|
||||
ok: true,
|
||||
result
|
||||
}))
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'terminal-list',
|
||||
ok: true,
|
||||
result: isLate ? lateList : runtimeListResult(['pty-1'])
|
||||
})
|
||||
})
|
||||
// Why: `wt-late` starts local-owned, so the pre-await target sample never lists it.
|
||||
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
|
||||
worktreesByRepo: {
|
||||
'fixture-repo': [
|
||||
{
|
||||
id: 'wt-bg',
|
||||
repoId: 'fixture-repo',
|
||||
hostId: 'runtime:runtime-1',
|
||||
runtimeOwnerEnvironmentId: 'runtime-1'
|
||||
},
|
||||
{ id: 'wt-late', repoId: 'fixture-repo', hostId: 'local' }
|
||||
]
|
||||
} as never,
|
||||
tabsByWorktree: { 'wt-bg': [tab()], 'wt-late': [lateTab] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': layout(),
|
||||
'tab-late': {
|
||||
root: { type: 'leaf', leafId: LEAF },
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: 'pty-late' }
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'], 'tab-late': ['pty-late'] },
|
||||
agentStatusByPaneKey: {
|
||||
[entry().paneKey]: entry(),
|
||||
[lateEntry.paneKey]: lateEntry
|
||||
}
|
||||
})
|
||||
|
||||
const tick = runAgentHibernationTick()
|
||||
await vi.waitFor(() =>
|
||||
expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.list' })
|
||||
)
|
||||
)
|
||||
// Runtime ownership resolves while the `wt-bg` inventory is still outstanding.
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
'fixture-repo': ['wt-bg', 'wt-late'].map((id) => ({
|
||||
id,
|
||||
repoId: 'fixture-repo',
|
||||
hostId: 'runtime:runtime-1',
|
||||
runtimeOwnerEnvironmentId: 'runtime-1'
|
||||
}))
|
||||
} as never
|
||||
})
|
||||
delayed.resolve(runtimeListResult(['pty-1']))
|
||||
await tick
|
||||
|
||||
// The racing pass has no host evidence for `wt-late`, so it must not count as one of
|
||||
// the two confirmations; hibernating on the next tick would rest on client PTYs alone.
|
||||
await runAgentHibernationTick()
|
||||
expect(shutdown).not.toHaveBeenCalledWith('wt-late', expect.anything())
|
||||
|
||||
await runAgentHibernationTick()
|
||||
expect(shutdown).toHaveBeenCalledWith(
|
||||
'wt-late',
|
||||
expect.objectContaining({
|
||||
ptyId: 'pty-late',
|
||||
expectedRuntimePtyId: 'pty-late'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user