mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
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.
This commit is contained in:
committed by
github-actions[bot]
parent
e1ba7ff6e2
commit
c240da2c45
@@ -100,9 +100,9 @@
|
||||
"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. Folder and git workspaces hibernate the exact host PTY; rejected/truncated inventories and intervening input/output or state changes block shutdown.",
|
||||
"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"
|
||||
"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",
|
||||
@@ -111,7 +111,8 @@
|
||||
"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/foreground-terminal-tabs.test.ts",
|
||||
"src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -123,6 +124,12 @@
|
||||
"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": [
|
||||
@@ -130,10 +137,10 @@
|
||||
"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",
|
||||
"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": 32.19,
|
||||
"summary": "92 tests passed across seven coordinator, planner, confirmation, age, activity and visibility files."
|
||||
"durationSeconds": 48.18,
|
||||
"summary": "93 tests passed across eight coordinator, liveness-race, planner, confirmation, age, activity and visibility files."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -146,11 +153,11 @@
|
||||
},
|
||||
"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."
|
||||
"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. No new timer, concurrency, subprocess or retry; only active when experimental automatic hibernation is enabled."
|
||||
"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.",
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { 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 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 } 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 const mockRuntimeEnvironmentCall = 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 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
|
||||
}
|
||||
|
||||
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: { 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' }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -80,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),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { runAgentHibernationTick } from './agent-hibernation-coordinator'
|
||||
import { createCompatibleRuntimeStatusResponseIfNeeded } 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: { method: string; params?: { worktree?: string } }) => {
|
||||
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