From 8663d454cd4c9a990618bfc9faaf3a4ec76e101e Mon Sep 17 00:00:00 2001 From: bbingz Date: Sun, 12 Jul 2026 19:39:51 +0800 Subject: [PATCH 1/2] fix(preflight): do not pin empty WSL/local agent detection (#8366) Empty [] is truthy, so a cold-start soft-fail permanently hid agents from the launcher until a context switch. Match remote/runtime non-sticky empty caching, clear empty detectPromise snapshots, and retry one WSL ETIMEDOUT. --- .../ipc/preflight-wsl-agent-detection.test.ts | 40 +++++++++++++++++++ src/main/ipc/preflight-wsl-agent-detection.ts | 32 ++++++++++++++- .../src/store/slices/detected-agents.test.ts | 29 ++++++++++++++ .../src/store/slices/detected-agents.ts | 20 ++++++++-- 4 files changed, 117 insertions(+), 4 deletions(-) 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..1b5afe867da 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -43,13 +43,43 @@ 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: Node's execFile timeout sets code=ETIMEDOUT; our Promise.race wrapper + // does the same. A killed child may surface as 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + // rarely, but the kill-signal path commonly uses code null with killed=true. + 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/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 0d9575eeea7..3d5af8f5a9d 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -394,6 +394,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 4c6cdf3725b..e6dc7333607 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -77,7 +77,12 @@ export const createDetectedAgentsSlice: StateCreator 0 ? { key: contextKey, promise: Promise.resolve(typed) } : null } return typed }) From cf6e400d98d988737f96e7580f9ad6ea8efa2684 Mon Sep 17 00:00:00 2001 From: bbingz Date: Sun, 12 Jul 2026 20:04:24 +0800 Subject: [PATCH 2/2] fix(preflight): retry empty local agent detect on launcher remount Store non-sticky empty was not enough: useDetectedAgents only called ensureLocal when detectedIds was null, so TabBar/QuickLaunch never re-probed after a WSL cold-start []. Mirror SSH/runtime one-shot empty remount retry for local surfaces (#8366 / #8391). --- .../src/hooks/useDetectedAgents.test.tsx | 40 ++++++++++++++++++- src/renderer/src/hooks/useDetectedAgents.ts | 9 ++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/hooks/useDetectedAgents.test.tsx b/src/renderer/src/hooks/useDetectedAgents.test.tsx index 7e9febda32a..af060a9a204 100644 --- a/src/renderer/src/hooks/useDetectedAgents.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.test.tsx @@ -10,6 +10,7 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' +const detectAgents = vi.fn() const detectRemoteAgents = vi.fn() const runtimeEnvironmentCall = vi.fn() const initialAppState = useAppStore.getInitialState() @@ -41,6 +42,7 @@ async function renderProbe(target: AgentDetectionTarget): Promise { beforeEach(() => { useAppStore.setState(initialAppState, true) + detectAgents.mockReset().mockResolvedValue([]) detectRemoteAgents.mockReset().mockResolvedValue([]) runtimeEnvironmentCall.mockReset().mockImplementation(({ method }: { method: string }) => { const result = @@ -64,7 +66,7 @@ beforeEach(() => { }) }) globalThis.window.api = { - preflight: { detectRemoteAgents }, + preflight: { detectAgents, detectRemoteAgents }, runtimeEnvironments: { call: runtimeEnvironmentCall } } as unknown as Window['api'] }) @@ -115,6 +117,42 @@ describe('useDetectedAgents (ssh call site)', () => { }) }) +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() + + 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('retries a cached empty runtime result when the launch surface is reopened', async () => { let detectCalls = 0 diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index d4be36d0e64..c348d2763a8 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -102,7 +102,7 @@ export function useDetectedAgents( ? `ssh:${targetId}` : targetKind === 'runtime' && targetId ? `runtime:${targetId}` - : null + : 'local' if (targetKind === 'ssh' && targetId) { if (detectedIds === null) { retriedEmptyTargetRef.current = emptyRetryKey @@ -124,7 +124,14 @@ export function useDetectedAgents( void ensureRuntime(targetId) } } else { + // Why: local/WSL cold-start soft-fails to [] (see #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 []. if (detectedIds === null) { + retriedEmptyTargetRef.current = emptyRetryKey + void ensureLocal() + } else if (detectedIds.length === 0 && retriedEmptyTargetRef.current !== emptyRetryKey) { + retriedEmptyTargetRef.current = emptyRetryKey void ensureLocal() } }