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
+ ])
}