Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/main/ipc/preflight-wsl-agent-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
32 changes: 31 additions & 1 deletion src/main/ipc/preflight-wsl-agent-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "'\\''")}'`
}
Expand Down
40 changes: 39 additions & 1 deletion src/renderer/src/hooks/useDetectedAgents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -41,6 +42,7 @@ async function renderProbe(target: AgentDetectionTarget): Promise<Root> {

beforeEach(() => {
useAppStore.setState(initialAppState, true)
detectAgents.mockReset().mockResolvedValue([])
detectRemoteAgents.mockReset().mockResolvedValue([])
runtimeEnvironmentCall.mockReset().mockImplementation(({ method }: { method: string }) => {
const result =
Expand All @@ -64,7 +66,7 @@ beforeEach(() => {
})
})
globalThis.window.api = {
preflight: { detectRemoteAgents },
preflight: { detectAgents, detectRemoteAgents },
runtimeEnvironments: { call: runtimeEnvironmentCall }
} as unknown as Window['api']
})
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/renderer/src/hooks/useDetectedAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
Expand Down
29 changes: 29 additions & 0 deletions src/renderer/src/store/slices/detected-agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 17 additions & 3 deletions src/renderer/src/store/slices/detected-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
const context = getLocalAgentPreflightContext(get())
const contextKey = localPreflightContextKey(context)
const existing = get().detectedAgentIds
if (existing && detectedContextKey === contextKey) {
// Why: an empty result ([]) is truthy, so a prior "no agents found" detection
// (including WSL cold-start timeouts that soft-fail to []) must not be treated
// as cached — re-detect so a later install / PATH fix / warm distro is picked
// up without a context switch. Non-empty results still short-circuit.
// Mirrors ensureRemoteDetectedAgents / ensureRuntimeDetectedAgents (#6029).
if (existing?.length && detectedContextKey === contextKey) {
return Promise.resolve(existing)
}
if (detectPromise?.key === contextKey) {
Expand All @@ -96,6 +101,12 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
if (requestGeneration === localDetectionGeneration) {
set({ detectedAgentIds: typed, isDetectingAgents: false })
detectedContextKey = contextKey
// Why: empty detections must not leave a resolved detectPromise that
// short-circuits the next ensureDetectedAgents call — same non-sticky
// empty rule as the existing?.length guard above.
if (typed.length === 0) {
detectPromise = null
}
}
return typed
})
Expand Down Expand Up @@ -139,9 +150,12 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
pathFailureReason: result.pathFailureReason
})
// Why: once refresh has run, treat its result as the current detection
// snapshot so `ensureDetectedAgents` short-circuits.
// snapshot so `ensureDetectedAgents` short-circuits on non-empty hits.
// Empty stays non-sticky so a cold-start miss can recover without a
// manual clearLocalDetectedAgents.
detectedContextKey = contextKey
detectPromise = { key: contextKey, promise: Promise.resolve(typed) }
detectPromise =
typed.length > 0 ? { key: contextKey, promise: Promise.resolve(typed) } : null
}
return typed
})
Expand Down
Loading