diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 1853540d8db..50d284a89e0 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -93,6 +93,46 @@ describe('detectWslCommandsOnPath', () => { const found = await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude']) expect(found).toEqual(new Set()) + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('retries once on ETIMEDOUT cold-start and returns the second probe result', async () => { + const timeoutError = Object.assign(new Error('Timed out running wsl.exe'), { + code: 'ETIMEDOUT' + }) + execFileAsyncMock.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce({ + stdout: '__ORCA_AGENT_PATH__grok\t/home/user/.local/bin/grok\n', + stderr: '' + }) + + const found = await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['grok']) + + expect(found).toEqual(new Set(['grok'])) + expect(execFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('retries once when the child is killed by the timeout path', async () => { + const killedError = Object.assign(new Error('spawn wsl.exe ETIMEDOUT'), { + killed: true + }) + execFileAsyncMock.mockRejectedValueOnce(killedError).mockResolvedValueOnce({ + stdout: '__ORCA_AGENT_PATH__claude\t/usr/bin/claude\n', + stderr: '' + }) + + const found = await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude']) + + expect(found).toEqual(new Set(['claude'])) + expect(execFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not retry non-timeout probe failures', async () => { + execFileAsyncMock.mockRejectedValue(new Error("zsh:1: parse error near `done'")) + + const found = await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude']) + + expect(found).toEqual(new Set()) + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) }) it('skips the probe entirely when no commands are requested', async () => { diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index b5eff064325..52f4626ccdb 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -43,13 +43,44 @@ export async function detectWslCommandsOnPath( // Why: WSL cold-start plus many parallel wsl.exe probes can timeout and // cache an empty result. One probe through the distro user's login shell // matches zsh/bash PATH customizations from their normal terminals. - const { stdout } = await execWslAgentDetectionCommand(wslTarget, script) + // One ETIMEDOUT/killed retry absorbs a single cold-start miss without + // turning every hard shell failure into a multi-second hang. + const { stdout } = await execWslAgentDetectionCommandWithColdStartRetry(wslTarget, script) return parseWslDetectedCommands(stdout) } catch { return new Set() } } +function isWslAgentDetectionTimeoutError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const code = (error as { code?: unknown }).code + // Why: the two cold-start timeout paths surface differently — the Promise.race + // wrapper rejects with code=ETIMEDOUT, while Node's execFile `timeout` kills the + // child (code=null, killed=true). Retry on both; a real shell failure (non-zero + // exit / ENOENT) matches neither and propagates without a wasted retry. + if (code === 'ETIMEDOUT') { + return true + } + return (error as { killed?: unknown }).killed === true +} + +async function execWslAgentDetectionCommandWithColdStartRetry( + target: WslPreflightTarget, + command: string +): Promise<{ stdout: string; stderr: string }> { + try { + return await execWslAgentDetectionCommand(target, command) + } catch (error) { + if (!isWslAgentDetectionTimeoutError(error)) { + throw error + } + return await execWslAgentDetectionCommand(target, command) + } +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } diff --git a/src/renderer/src/hooks/useDetectedAgents.test.tsx b/src/renderer/src/hooks/useDetectedAgents.test.tsx index a4f0d7f3f96..186c8856681 100644 --- a/src/renderer/src/hooks/useDetectedAgents.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.test.tsx @@ -15,6 +15,7 @@ import { } from '../../../shared/protocol-version' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +const detectAgents = vi.fn() const detectRemoteAgents = vi.fn() const refreshLocalAgents = vi.fn() const runtimeEnvironmentCall = vi.fn() @@ -50,6 +51,7 @@ beforeEach(() => { clearRuntimeCompatibilityCacheForTests() useAppStore.setState(initialAppState, true) latestHookResult = null + detectAgents.mockReset().mockResolvedValue([]) detectRemoteAgents.mockReset().mockResolvedValue([]) refreshLocalAgents.mockReset().mockResolvedValue({ agents: [], @@ -80,7 +82,11 @@ beforeEach(() => { }) }) globalThis.window.api = { - preflight: { detectRemoteAgents, refreshAgents: refreshLocalAgents }, + preflight: { + detectAgents, + detectRemoteAgents, + refreshAgents: refreshLocalAgents + }, runtimeEnvironments: { call: runtimeEnvironmentCall } } as unknown as Window['api'] }) @@ -145,6 +151,43 @@ describe('useDetectedAgents (unresolved target)', () => { }) }) +describe('useDetectedAgents (local call site)', () => { + it('fires local detection once on mount and does not thrash after an empty result', async () => { + const root = await renderProbe({ kind: 'local' }) + + expect(detectAgents).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().detectedAgentIds).toEqual([]) + + await act(async () => { + root.render(createElement(HookProbe, { target: { kind: 'local' } })) + }) + await flushEffects() + + // Same mounted surface: empty remount-retry marks once; re-render must not thrash. + expect(detectAgents).toHaveBeenCalledTimes(1) + }) + + it('retries a cached empty local result when the launch surface is reopened', async () => { + // Why: WSL/local cold-start soft-fails to [] (#8366). Reopening TabBar / + // QuickLaunch must re-enter ensureDetectedAgents without a project switch. + const firstRoot = await renderProbe({ kind: 'local' }) + + expect(detectAgents).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().detectedAgentIds).toEqual([]) + + await act(async () => { + firstRoot.unmount() + }) + roots.splice(roots.indexOf(firstRoot), 1) + detectAgents.mockResolvedValueOnce(['grok']) + + await renderProbe({ kind: 'local' }) + + expect(detectAgents).toHaveBeenCalledTimes(2) + expect(useAppStore.getState().detectedAgentIds).toEqual(['grok']) + }) +}) + describe('useDetectedAgents (runtime call site)', () => { it('distinguishes an initial remote failure from the pre-effect loading state', async () => { runtimeEnvironmentCall.mockRejectedValue(new Error('runtime disconnected')) diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index 2776f2b69da..f81b8703cdf 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -54,6 +54,10 @@ export function useDetectedAgents( ): UseDetectedAgentsResult { const target = normalizeAgentDetectionTarget(connectionId) const observedRemoteTargetKeysRef = useRef>(new Set()) + // Why: remounted local launch surfaces (TabBar/QuickLaunch) must re-probe after + // a cold-start empty [] without requiring a project/context switch (#8366). + // null = this mount has not yet claimed the local empty-retry slot. + const localEmptyRetryKeyRef = useRef(null) // Why: undefined means "store not yet hydrated" — we don't know if the // worktree is local or remote yet. This prevents flashing local agents for // remote worktrees during hydration. @@ -158,7 +162,15 @@ export function useDetectedAgents( void state.ensureRuntimeDetectedAgents(targetId) } } else { + // Why: local/WSL cold-start soft-fails to [] (#8366). Store non-sticky empty + // is not enough — TabBar/QuickLaunch only re-enter via this hook, so mirror + // SSH/runtime: one fresh probe when a launch surface remounts on []. + const emptyRetryKey = 'local' if (detectedIds === null) { + localEmptyRetryKeyRef.current = emptyRetryKey + void state.ensureDetectedAgents() + } else if (detectedIds.length === 0 && localEmptyRetryKeyRef.current !== emptyRetryKey) { + localEmptyRetryKeyRef.current = emptyRetryKey void state.ensureDetectedAgents() } } diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 1755f355072..db57d563eeb 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -395,6 +395,35 @@ describe('createDetectedAgentsSlice WSL context', () => { expect(detectAgents).toHaveBeenCalledTimes(2) }) + it('re-runs local detection after an empty result instead of pinning it', async () => { + const store = createTestStore({ + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + // Why: detectPromise / detectedContextKey are module-scoped and can leak + // from earlier cases with the same local preflight context. + store.getState().clearLocalDetectedAgents() + // An empty [] is truthy, so a prior "no agents found" (including WSL + // cold-start soft-fails) must not short-circuit later probes. + detectAgents + .mockReset() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(['grok']) + .mockResolvedValue(['claude']) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual([]) + expect(store.getState().detectedAgentIds).toEqual([]) + expect(detectAgents).toHaveBeenCalledTimes(1) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['grok']) + expect(detectAgents).toHaveBeenCalledTimes(2) + expect(store.getState().detectedAgentIds).toEqual(['grok']) + // Why: module-scoped detectPromise would otherwise short-circuit the next + // case that shares the same local preflight context. + store.getState().clearLocalDetectedAgents() + }) + it('ignores in-flight local detection results after a project runtime switch', async () => { let resolveDetection: (agents: string[]) => void = () => {} detectAgents.mockReturnValueOnce( diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index e3a4f4947a0..ddc73823921 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -66,7 +66,12 @@ export const createDetectedAgentsSlice: StateCreator 0 ? { key: contextKey, promise: Promise.resolve(typed) } : null } return typed })