diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx index 7a76434ce2f..17e74d7a925 100644 --- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx @@ -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()) + expect(mocks.gate).not.toHaveBeenCalled() + expect(mocks.resume).not.toHaveBeenCalled() + mocks.authority = 'live' + await act(async () => root?.render()) + expect(mocks.gate).toHaveBeenCalledTimes(1) + await act(async () => root?.render()) + 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()) + expect(mocks.resume).not.toHaveBeenCalled() + expect(mocks.gate).not.toHaveBeenCalled() + await act(async () => root?.render()) + 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()) + expect(mocks.gate).toHaveBeenCalledTimes(1) + await act(async () => root?.render()) + mocks.gate.mockResolvedValue('adopted') + await act(async () => root?.render()) + expect(mocks.gate.mock.calls.map(([id]) => id)).toEqual(['wt-1', 'wt-2', 'wt-1']) + expect(mocks.resume).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 3fae0571342..e840c3f9ba0 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -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()) 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 + ]) }