Skip to content
Open
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
33 changes: 32 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,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, "'\\''")}'`
}
Expand Down
45 changes: 44 additions & 1 deletion src/renderer/src/hooks/useDetectedAgents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -50,6 +51,7 @@ beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
useAppStore.setState(initialAppState, true)
latestHookResult = null
detectAgents.mockReset().mockResolvedValue([])
detectRemoteAgents.mockReset().mockResolvedValue([])
refreshLocalAgents.mockReset().mockResolvedValue({
agents: [],
Expand Down Expand Up @@ -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']
})
Expand Down Expand Up @@ -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'))
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/src/hooks/useDetectedAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export function useDetectedAgents(
): UseDetectedAgentsResult {
const target = normalizeAgentDetectionTarget(connectionId)
const observedRemoteTargetKeysRef = useRef<Set<string>>(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<string | null>(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.
Expand Down Expand Up @@ -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()
}
}
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 @@ -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(
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 @@ -66,7 +66,12 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId)
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 @@ -85,6 +90,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 @@ -128,9 +139,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