diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index b64dbde80af..dafc1f8d251 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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.", 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..985a2567469 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts @@ -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 = {} +): 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 | 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(): { + 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 bf6758c0d32..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 () => { diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index 6497bd6fd77..c7287aed26c 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -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), 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..6f338161a96 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts @@ -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>() + 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' + }) + ) + }) +})