Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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