fix: gate startup agent recovery on host inventory

This commit is contained in:
Neil
2026-09-14 06:45:09 -07:00
parent 93c3702463
commit 30756cb71d
2 changed files with 102 additions and 14 deletions
@@ -3,15 +3,16 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects'
import type { TerminalColdActivationController } from '../terminal-cold-activation'
const mocks = vi.hoisted(() => ({
gate: vi.fn(),
resume: vi.fn(),
authority: 'none',
launchStatus: vi.fn((_worktreeId: string, _provider: string): string => 'idle'),
createTab: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(() => 'none', {
useAppStore: Object.assign(() => mocks.authority, {
getState: () => ({ activeWorktreeId: 'wt-1' })
})
}))
@@ -22,7 +23,7 @@ vi.mock('@/lib/structured-agent-session-launch', () => ({
getStructuredAgentLaunchStatus: mocks.launchStatus
}))
vi.mock('@/lib/resume-sleeping-agent-session', () => ({
resumeSleepingAgentSessionsForWorktree: vi.fn()
resumeSleepingAgentSessionsForWorktree: mocks.resume
}))
vi.mock('@/lib/workspace-terminal-host-authority', () => ({
createWorkspaceTerminalHostAuthoritySelector: () => () => 'none'
@@ -34,23 +35,48 @@ vi.mock('../terminal-pane/terminal-parked-tab-watchers', () => ({
disposeAllParkedTerminalWatchers: vi.fn()
}))
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
let root: Root | undefined
afterEach(async () => {
await act(async () => root?.unmount())
vi.clearAllMocks()
mocks.authority = 'none'
})
function Watcher(): null {
function Watcher({ restored = true, hydrated = false, worktreeId = 'wt-1' } = {}): null {
useTerminalWatcherEffects({
activeWorktreeId: 'wt-1',
activeWorktreeId: worktreeId,
workspaceSessionReady: true,
terminalStartupRestorationReady: true,
terminalStartupRestorationReady: restored,
hydrationSucceeded: hydrated,
workspaceSurfaceIds: [],
tabsByWorktree: {},
createTab: mocks.createTab,
reconcileWorktreeTabModel: () => ({ renderableTabCount: 0 })
} as unknown as TerminalColdActivationController)
reconcileWorktreeTabModel: () => ({
renderableTabCount: 0,
activeRenderableTabId: null
}),
activationDeferredMountTabIdsByWorktreeRef: { current: new Map() },
activeTabId: null,
activeTabIdByWorktree: {},
activeView: 'terminal',
activityTerminalPortals: [],
anyMountedWorktreeHasLayout: false,
backgroundMountRevision: 0,
effectiveParkedTerminalWorktreeIds: new Set(),
evictionExemptTerminalTabIds: new Set(),
getEffectiveLayoutForWorktree: () => undefined,
groupsByWorktree: {},
measurableBackgroundWorktreeIdsRef: { current: new Set() },
mountedWorktreeIdsRef: { current: new Set() },
pairedRuntimeParkingEnvironmentIds: new Set(),
pendingStartupByTabId: {},
renderedActiveWorktreeId: worktreeId,
terminalParkingEnabled: false,
terminalProviderSnapshotCapabilityRevision: 0,
terminalSshParkingEnabled: false,
terminalTitleSnapshotAuthorityEnabled: false
})
return null
}
@@ -81,3 +107,48 @@ describe('passive terminal seeding during native chat creation', () => {
expect(mocks.createTab).toHaveBeenCalledTimes(expectedTabs)
})
})
describe('startup agent recovery host inventory', () => {
it('keeps recovery available until the execution host answers', async () => {
mocks.authority = 'unverifiable'
mocks.gate.mockResolvedValue('adopted')
root = createRoot(document.createElement('div'))
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).not.toHaveBeenCalled()
expect(mocks.resume).not.toHaveBeenCalled()
mocks.authority = 'live'
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).toHaveBeenCalledTimes(1)
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).toHaveBeenCalledTimes(1)
})
it('waits for terminal restoration, then uses the activation gate', async () => {
mocks.authority = 'live'
mocks.gate.mockResolvedValue('adopted')
root = createRoot(document.createElement('div'))
await act(async () => root?.render(<Watcher hydrated restored={false} />))
expect(mocks.resume).not.toHaveBeenCalled()
expect(mocks.gate).not.toHaveBeenCalled()
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).toHaveBeenCalledWith('wt-1')
expect(mocks.resume).not.toHaveBeenCalled()
})
it.each(['blocked', 'rejected'])('retries a %s startup after leaving and returning', async (outcome) => {
mocks.authority = 'live'
if (outcome === 'rejected') {
mocks.gate.mockRejectedValue(new Error('host unavailable'))
} else {
mocks.gate.mockResolvedValue('blocked')
}
root = createRoot(document.createElement('div'))
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).toHaveBeenCalledTimes(1)
await act(async () => root?.render(<Watcher hydrated worktreeId="wt-2" />))
mocks.gate.mockResolvedValue('adopted')
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate.mock.calls.map(([id]) => id)).toEqual(['wt-1', 'wt-2', 'wt-1'])
expect(mocks.resume).not.toHaveBeenCalled()
})
})
@@ -11,7 +11,6 @@ import {
} from './terminal-pane/terminal-parked-tab-watchers'
import { useAppStore } from '@/store'
import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate'
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-terminal-host-authority'
import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle'
@@ -251,7 +250,12 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
const startupResumeWorktreeIdsRef = useRef(new Set<string>())
useEffect(() => {
if (!workspaceSessionReady || !hydrationSucceeded || !activeWorktreeId) {
if (
!workspaceSessionReady ||
!terminalStartupRestorationReady ||
!hydrationSucceeded ||
!activeWorktreeId
) {
return
}
if (startupResumeWorktreeIdsRef.current.has(activeWorktreeId)) {
@@ -263,7 +267,20 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
return
}
startupResumeWorktreeIdsRef.current.add(activeWorktreeId)
// Why: startup hydration restores the worktree without activateAndRevealWorktree, so orphaned live/quit records need a terminal-surface pass after cold restore.
resumeSleepingAgentSessionsForWorktree(activeWorktreeId)
}, [activeWorktreeId, activeWorktreeHostAuthority, hydrationSucceeded, workspaceSessionReady])
// Startup recovery needs the same host census and in-flight gate as explicit activation.
void gateWorktreeAgentActivation(activeWorktreeId).then(
(outcome) => {
if (outcome === 'blocked') {
startupResumeWorktreeIdsRef.current.delete(activeWorktreeId)
}
},
() => startupResumeWorktreeIdsRef.current.delete(activeWorktreeId)
)
}, [
activeWorktreeId,
activeWorktreeHostAuthority,
hydrationSucceeded,
terminalStartupRestorationReady,
workspaceSessionReady
])
}