diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c56dd9941e8..fd1ab704e4a 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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", diff --git a/src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts b/src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts new file mode 100644 index 00000000000..837a8f3a59d --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts @@ -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 + +export function installEligibleState( + shutdownCompletedAgentPaneForHibernation: HibernationShutdownStub = vi.fn(), + overrides: Partial = {} +): 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 | 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(): { + promise: Promise + resolve: (value: T) => void + reject: (error: Error) => void +} { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +export function resetAgentHibernationCoordinatorFixture(): void { + resetAgentHibernationCoordinatorForTests() + clearRuntimeCompatibilityCacheForTests() + resetForegroundTerminalTabIdsForTests() + resetAgentHibernationOutputActivityForTests() + resetHibernationPaneAgeForTests() + hydrateDrivers([]) + mockRuntimeEnvironmentCall.mockReset() + vi.useRealTimers() +} diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 75d253980a6..e73beb50d10 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -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 = {} -): 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 | 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(): { - promise: Promise - resolve: (value: T) => void - reject: (error: Error) => void -} { - let resolve!: (value: T) => void - let reject!: (error: Error) => void - const promise = new Promise((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>() + 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>() diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index 5f17e1e4834..c7287aed26c 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -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 = {} 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() + 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( { kind: 'environment', environmentId: runtimeEnvironmentId }, diff --git a/src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts b/src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts new file mode 100644 index 00000000000..0c226ef0e6a --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts @@ -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>() + 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' + }) + ) + }) +})