diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index 15b7e74fa60..ba23059e76c 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -34,6 +34,12 @@ const { })) const runWslProcessMock = vi.hoisted(() => vi.fn()) +// These cases cover a running distro; the E9 stopped-distro gate has its own suite. +vi.mock('../wsl/wsl-background-exec-gate', () => ({ + assertWslDistroRunningForExec: async () => {}, + runAsWslExecUserAct: async (_distro: unknown, act: () => Promise) => act() +})) + // WSL detection uses runWslProcess, so child_process mocks cannot observe it. vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) diff --git a/src/main/ipc/preflight-agent-refresh.test.ts b/src/main/ipc/preflight-agent-refresh.test.ts index e739e1f88c0..ed84b271bcf 100644 --- a/src/main/ipc/preflight-agent-refresh.test.ts +++ b/src/main/ipc/preflight-agent-refresh.test.ts @@ -35,6 +35,12 @@ const { })) const runWslProcessMock = vi.hoisted(() => vi.fn()) +// These cases cover a running distro; the E9 stopped-distro gate has its own suite. +vi.mock('../wsl/wsl-background-exec-gate', () => ({ + assertWslDistroRunningForExec: async () => {}, + runAsWslExecUserAct: async (_distro: unknown, act: () => Promise) => act() +})) + // WSL detection uses runWslProcess, so child_process mocks cannot observe it. vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index af226ac6bf9..3edd6136672 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -36,6 +36,12 @@ const { mergePersistedWindowsPathMock: vi.fn() })) +// These cases cover a running distro; the E9 stopped-distro gate has its own suite. +vi.mock('../wsl/wsl-background-exec-gate', () => ({ + assertWslDistroRunningForExec: async () => {}, + runAsWslExecUserAct: async (_distro: unknown, act: () => Promise) => act() +})) + vi.mock('electron', () => ({ ipcMain: { handle: handleMock @@ -514,6 +520,8 @@ describe('preflight', () => { }) const stalePending = runPreflightCheck(false, { wslDistro: 'Ubuntu' }) + // The running-distro gate resolves first; hold the stale git/gh/glab probes before refreshing. + await expect.poll(() => runWslProcessMock.mock.calls.length).toBe(3) phase = 'fresh' const fresh = await runPreflightCheck(true, { wslDistro: 'Ubuntu' }) expect(fresh.git).toEqual({ installed: true }) diff --git a/src/main/ipc/preflight-wsl-refusal-retry.ts b/src/main/ipc/preflight-wsl-refusal-retry.ts new file mode 100644 index 00000000000..61625541aa0 --- /dev/null +++ b/src/main/ipc/preflight-wsl-refusal-retry.ts @@ -0,0 +1,32 @@ +import { BrowserWindow } from 'electron' +import { deferUntilWslDistroRunning } from '../wsl-background-distro-gate' +import { WslDistroStoppedError, WslDistroUnverifiableError } from '../wsl/wsl-unc-access-gate' + +export const PREFLIGHT_WSL_DISTRO_RUNNING_CHANNEL = 'preflight:wslDistroRunning' +const RETRY_KEY = 'preflight-refused' + +function broadcastWslDistroRunning(distro: string): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send(PREFLIGHT_WSL_DISTRO_RUNNING_CHANNEL, distro) + } catch { + // A renderer can disappear between isDestroyed() and send(). + } + } +} + +/** A refused background check is deferred, not done: renderers re-ask once the distro runs (E9). */ +export async function deferRefusedPreflight(run: () => Promise): Promise { + try { + return await run() + } catch (error) { + if (error instanceof WslDistroStoppedError || error instanceof WslDistroUnverifiableError) { + const { distro } = error + deferUntilWslDistroRunning(distro, RETRY_KEY, () => broadcastWslDistroRunning(distro)) + } + throw error + } +} diff --git a/src/main/ipc/preflight-wsl-stopped-distro.test.ts b/src/main/ipc/preflight-wsl-stopped-distro.test.ts new file mode 100644 index 00000000000..2f0064c80e1 --- /dev/null +++ b/src/main/ipc/preflight-wsl-stopped-distro.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BackgroundDistroGate from '../wsl-background-distro-gate' + +const mocks = vi.hoisted(() => ({ + handlers: new Map Promise>(), + runWslProcess: vi.fn(), + readWslDistroRunningState: vi.fn(), + deferred: new Map unknown>(), + sent: vi.fn(), + listWslDistrosAsync: vi.fn() +})) + +vi.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [{ isDestroyed: () => false, webContents: { send: mocks.sent } }] + }, + ipcMain: { + handle: (channel: string, handler: (event: unknown, args?: unknown) => Promise) => + mocks.handlers.set(channel, handler) + } +})) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.runWslProcess })) +vi.mock('../wsl', () => ({ listWslDistrosAsync: mocks.listWslDistrosAsync })) +vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: mocks.readWslDistroRunningState, + deferUntilWslDistroRunning: (distro: string, key: string, task: () => unknown) => + mocks.deferred.set(`${distro}:${key}`, task) +})) +vi.mock('../bitbucket/client', () => ({ + getBitbucketAuthStatus: async () => ({ configured: false, authenticated: false, account: null }) +})) +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsAuthStatus: async () => ({ + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + }) +})) +vi.mock('../gitea/client', () => ({ + getGiteaAuthStatus: async () => ({ + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + }) +})) + +import { registerPreflightHandlers } from './preflight' +import { _resetPreflightCache } from '../preflight/agent-detection' +import { getDefaultSettings } from '../../shared/constants' + +const UBUNTU = { wslDistro: 'Ubuntu' } + +function invoke(channel: string, args?: unknown): Promise { + const handler = mocks.handlers.get(channel) + if (!handler) { + throw new Error(`no handler for ${channel}`) + } + return handler(null, args) +} + +describe('preflight never starts a stopped WSL distro in the background (E9)', () => { + const originalPlatform = process.platform + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mocks.handlers.clear() + mocks.deferred.clear() + mocks.sent.mockReset() + _resetPreflightCache() + mocks.readWslDistroRunningState.mockReset().mockResolvedValue('stopped') + mocks.listWslDistrosAsync.mockReset().mockResolvedValue(['Ubuntu', 'Debian']) + mocks.runWslProcess.mockReset().mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + registerPreflightHandlers({ getSettings: () => getDefaultSettings('/tmp') }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + }) + + it('refuses a mount or poll check of a stopped distro without spawning wsl.exe', async () => { + await expect(invoke('preflight:check', { ...UBUNTU, force: true })).rejects.toMatchObject({ + code: 'WSL_DISTRO_STOPPED', + distro: 'Ubuntu' + }) + expect(mocks.runWslProcess).not.toHaveBeenCalled() + }) + + it('refuses when WSL cannot say whether the distro runs', async () => { + mocks.readWslDistroRunningState.mockResolvedValue('unverifiable') + await expect(invoke('preflight:check', UBUNTU)).rejects.toMatchObject({ + code: 'WSL_DISTRO_UNVERIFIABLE' + }) + expect(mocks.runWslProcess).not.toHaveBeenCalled() + }) + + it('checks the default distro when the workspace names none', async () => { + await expect(invoke('preflight:check', { wslDefault: true })).rejects.toMatchObject({ + distro: 'Ubuntu' + }) + expect(mocks.runWslProcess).not.toHaveBeenCalled() + }) + + it('lets a window-focus or retry wake start the focused workspace distro', async () => { + await invoke('preflight:check', { ...UBUNTU, force: true, wakeWslDistro: true }) + expect(mocks.runWslProcess).toHaveBeenCalled() + for (const [spec] of mocks.runWslProcess.mock.calls) { + expect(spec).toMatchObject({ distro: 'Ubuntu' }) + } + }) + + it('probes a running distro exactly as before', async () => { + mocks.readWslDistroRunningState.mockResolvedValue('running') + await invoke('preflight:check', UBUNTU) + expect(mocks.runWslProcess).toHaveBeenCalled() + }) + + it('refuses mount-time agent detection in a stopped distro', async () => { + await expect( + invoke('preflight:detectAgents', { ...UBUNTU, enabledOnly: true }) + ).rejects.toMatchObject({ code: 'WSL_DISTRO_STOPPED' }) + expect(mocks.runWslProcess).not.toHaveBeenCalled() + }) + + it('refuses a background agent refresh but lets a refresh click start the distro', async () => { + await expect(invoke('preflight:refreshAgents', UBUNTU)).rejects.toMatchObject({ + code: 'WSL_DISTRO_STOPPED' + }) + expect(mocks.runWslProcess).not.toHaveBeenCalled() + + await invoke('preflight:refreshAgents', { ...UBUNTU, wakeWslDistro: true }) + expect(mocks.runWslProcess).toHaveBeenCalled() + }) + + it('defers a refused check until the distro runs, then tells renderers to re-ask', async () => { + await expect(invoke('preflight:detectAgents', UBUNTU)).rejects.toMatchObject({ + code: 'WSL_DISTRO_STOPPED' + }) + expect(mocks.sent).not.toHaveBeenCalled() + const retry = mocks.deferred.get('Ubuntu:preflight-refused') + expect(retry).toBeDefined() + retry?.() + expect(mocks.sent).toHaveBeenCalledWith('preflight:wslDistroRunning', 'Ubuntu') + }) + + it('lets an agent picker opening start the distro to list its agents', async () => { + await invoke('preflight:detectAgents', { ...UBUNTU, enabledOnly: true, wakeWslDistro: true }) + expect(mocks.runWslProcess).toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index d38229b0185..c4bb55c35de 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -4,7 +4,12 @@ import { detectRemoteWindowsTerminalCapabilities, runPreflightCheck } from '../preflight/agent-detection' -import { detectAgentsOnHost, refreshAgentsOnHost } from '../preflight/workspace-agent-detection' +import { + assertAgentDetectionHostRunning, + detectAgentsOnHost, + refreshAgentsOnHost, + type AgentDetectionHost +} from '../preflight/workspace-agent-detection' import type { PreflightRuntimeContext, PreflightStatus, @@ -12,8 +17,23 @@ import type { } from '../preflight/agent-detection' import { readZCodeInteractiveCapability } from '../zcode/interactive-capability' +import { getPreflightWslTarget } from './preflight-runtime-target' +import { runAsWslExecUserAct } from '../wsl/wsl-background-exec-gate' +import { deferRefusedPreflight } from './preflight-wsl-refusal-retry' type AgentDetectionRequest = PreflightRuntimeContext & { enabledOnly?: boolean } +/** Set by a user act (window focus, a click, opening a picker): it may start its WSL distro. */ +type PreflightWake = { wakeWslDistro?: boolean } + +function runPreflightAct( + context: (PreflightRuntimeContext & PreflightWake) | undefined, + act: () => Promise +): Promise { + const wslTarget = context?.wakeWslDistro ? getPreflightWslTarget(context) : null + return deferRefusedPreflight(() => + wslTarget ? runAsWslExecUserAct(wslTarget.distro, act) : act() + ) +} export function registerPreflightHandlers(store?: Pick): void { const detectionOptions = (args?: AgentDetectionRequest) => { @@ -29,23 +49,36 @@ export function registerPreflightHandlers(store?: Pick): v 'preflight:check', async ( _event, - args?: PreflightRuntimeContext & { force?: boolean } + args?: PreflightRuntimeContext & PreflightWake & { force?: boolean } ): Promise => { - return runPreflightCheck(args?.force, args) + return runPreflightAct(args, () => runPreflightCheck(args?.force, args)) } ) - ipcMain.handle('preflight:detectAgents', async (_event, args?: AgentDetectionRequest) => - detectAgentsOnHost({ kind: 'local', context: args }, detectionOptions(args)) + // Mount-time detection must not start a stopped distro; opening an agent picker wakes it. + ipcMain.handle( + 'preflight:detectAgents', + async (_event, args?: AgentDetectionRequest & PreflightWake) => + runPreflightAct(args, async () => { + const host: AgentDetectionHost = { kind: 'local', context: args } + await assertAgentDetectionHostRunning(host) + return detectAgentsOnHost(host, detectionOptions(args)) + }) ) ipcMain.handle('preflight:zcodeInteractiveCapability', async () => readZCodeInteractiveCapability() ) - ipcMain.handle('preflight:refreshAgents', async (_event, args?: AgentDetectionRequest) => { - return refreshAgentsOnHost({ kind: 'local', context: args }, detectionOptions(args)) - }) + ipcMain.handle( + 'preflight:refreshAgents', + async (_event, args?: AgentDetectionRequest & PreflightWake) => + runPreflightAct(args, async () => { + const host: AgentDetectionHost = { kind: 'local', context: args } + await assertAgentDetectionHostRunning(host) + return refreshAgentsOnHost(host, detectionOptions(args)) + }) + ) ipcMain.handle( 'preflight:detectRemoteAgents', diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index ed3b02b3fe5..66348d9fe16 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -30,6 +30,7 @@ import { } from '../ipc/preflight-remote-windows-terminal-capabilities' import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { prunePreflightWslCache } from '../preflight-wsl-cache' +import { assertWslDistroRunningForExec } from '../wsl/wsl-background-exec-gate' import { detectInstalledAgents, type InstalledAgentDetectionOptions @@ -255,6 +256,10 @@ async function executePreflightCheck( context: PreflightRuntimeContext | undefined, wslTarget: WslPreflightTarget | null ): Promise { + if (wslTarget) { + // Mounts and polls must not start a stopped distro (E9); window focus and retry clicks wake it. + await assertWslDistroRunningForExec(wslTarget.distro) + } if (process.platform === 'win32' && !wslTarget) { await mergePersistedWindowsPathAsync(process.env, { forceRefresh: force }) } else if (process.platform !== 'win32') { diff --git a/src/main/preflight/preflight-wsl-stopped-distro.test.ts b/src/main/preflight/preflight-wsl-stopped-distro.test.ts new file mode 100644 index 00000000000..3f3e633eabd --- /dev/null +++ b/src/main/preflight/preflight-wsl-stopped-distro.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult } from '@orca/process-host/process-spec' + +// End to end through the real `wsl --list --running` parse; only process spawns are faked. +const mocks = vi.hoisted(() => ({ runProcess: vi.fn(), runWslProcess: vi.fn() })) +vi.mock('@orca/process-host', async (importOriginal) => ({ + ...(await importOriginal()), + runProcess: mocks.runProcess +})) +vi.mock('../wsl/wsl-runner', async (importOriginal) => ({ + ...(await importOriginal()), + runWslProcess: mocks.runWslProcess +})) + +import { _resetWslCachesForTests } from '../wsl' +import { _resetWslBackgroundDistroGateForTests } from '../wsl-background-distro-gate' +import { runAsWslExecUserAct } from '../wsl/wsl-background-exec-gate' +import { _resetPreflightCache, runPreflightCheck } from './agent-detection' + +function runningList(...distros: string[]): ProcessResult { + return { + code: 0, + signal: null, + stdout: distros.map((distro) => `${distro}\n`).join(''), + stderr: '', + timedOut: false + } +} + +let originalPlatform: NodeJS.Platform + +beforeEach(() => { + originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mocks.runProcess.mockReset().mockResolvedValue(runningList()) + mocks.runWslProcess.mockReset().mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + _resetPreflightCache() +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + _resetWslCachesForTests() + _resetWslBackgroundDistroGateForTests() +}) + +describe('runPreflightCheck against the real running list (E9)', () => { + it('refuses a forced check of a distro the running list omits, running nothing in it', async () => { + await expect(runPreflightCheck(true, { wslDistro: 'Ubuntu' })).rejects.toThrow( + "WSL distro 'Ubuntu' is not running" + ) + expect(mocks.runWslProcess).not.toHaveBeenCalled() + }) + + it('runs inside a user act on that distro', async () => { + await runAsWslExecUserAct('Ubuntu', () => runPreflightCheck(true, { wslDistro: 'Ubuntu' })) + expect(mocks.runWslProcess).toHaveBeenCalled() + }) + + it('a user act on another distro does not admit this one', async () => { + await expect( + runAsWslExecUserAct('Debian', () => runPreflightCheck(true, { wslDistro: 'Ubuntu' })) + ).rejects.toThrow("WSL distro 'Ubuntu' is not running") + expect(mocks.runWslProcess).not.toHaveBeenCalled() + }) + + it('probes a listed running distro', async () => { + mocks.runProcess.mockResolvedValue(runningList('Ubuntu')) + await runPreflightCheck(true, { wslDistro: 'Ubuntu' }) + expect(mocks.runWslProcess).toHaveBeenCalled() + }) +}) diff --git a/src/main/preflight/workspace-agent-detection.test.ts b/src/main/preflight/workspace-agent-detection.test.ts index 5a153a0d463..aab44bef2ba 100644 --- a/src/main/preflight/workspace-agent-detection.test.ts +++ b/src/main/preflight/workspace-agent-detection.test.ts @@ -27,6 +27,12 @@ const { detectInstalledMock, refreshMock } = vi.hoisted(() => ({ refreshMock: vi.fn() })) +// These cases cover a running distro; the E9 stopped-distro gate has its own suite. +vi.mock('../wsl/wsl-background-exec-gate', () => ({ + assertWslDistroRunningForExec: async () => {}, + runAsWslExecUserAct: async (_distro: unknown, act: () => Promise) => act() +})) + vi.mock('./agent-detection', () => ({ detectInstalledAgentsWithShellPathHydration: detectInstalledMock, refreshShellPathAndDetectAgents: refreshMock, diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index 73a0b14d36e..06bcbc5403e 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -33,6 +33,7 @@ import { type RefreshAgentsResult } from './agent-detection' import type { InstalledAgentDetectionOptions } from './installed-agent-detection' +import { assertWslDistroRunningForExec } from '../wsl/wsl-background-exec-gate' export type AgentDetectionHost = | { kind: 'local'; context?: PreflightRuntimeContext } @@ -142,6 +143,14 @@ export async function detectAgentsOnHost( return detectInstalledAgentsWithShellPathHydration(host.context, options) } +/** Background detection in a WSL runtime refuses a stopped distro (E9); a user act for it passes. */ +export async function assertAgentDetectionHostRunning(host: AgentDetectionHost): Promise { + const wslTarget = host.kind === 'local' ? getPreflightWslTarget(host.context) : null + if (wslTarget) { + await assertWslDistroRunningForExec(wslTarget.distro) + } +} + const agentRefreshListeners = new Set<() => void>() export function onAgentsRefreshedOnHost(listener: () => void): () => void { diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index 41e33aeb48a..43293163d0a 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -10,8 +10,10 @@ const { detectInstalledAgentsWithShellPathHydrationMock, detectRemoteWindowsTerminalCapabilitiesMock, refreshShellPathAndDetectAgentsMock, - runPreflightCheckMock + runPreflightCheckMock, + assertWslDistroRunningForExecMock } = vi.hoisted(() => ({ + assertWslDistroRunningForExecMock: vi.fn(async (_distro?: string | null) => {}), detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteWindowsTerminalCapabilitiesMock: vi.fn(), refreshShellPathAndDetectAgentsMock: vi.fn(), @@ -25,6 +27,11 @@ vi.mock('../../../preflight/agent-detection', () => ({ runPreflightCheck: runPreflightCheckMock })) +// The running gate is real on Windows CI; each case states whether the distro runs. +vi.mock('../../../wsl/wsl-background-exec-gate', () => ({ + assertWslDistroRunningForExec: assertWslDistroRunningForExecMock +})) + function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } } @@ -124,6 +131,44 @@ describe('preflight RPC methods', () => { expect(ssh).toMatchObject({ ok: false, error: { message: SSH_HOST_NOT_SERVED_MESSAGE } }) }) + it("refuses a paired client's detection in a stopped WSL distro without probing it (E9)", async () => { + const projectRuntime = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'p1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'p1:wsl:Ubuntu' + } + } as const + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + assertWslDistroRunningForExecMock.mockRejectedValueOnce( + new Error("WSL distro 'Ubuntu' is not running.") + ) + detectInstalledAgentsWithShellPathHydrationMock.mockClear() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these methods read only resolveAgentDetectionHost and getRuntimeId from the runtime. + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveAgentDetectionHost: () => ({ kind: 'local', context: { projectRuntime } }) as const + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + try { + const response = await dispatcher.dispatch( + makeRequest('preflight.detectAgents', { + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\w' + }) + ) + expect(response).toMatchObject({ ok: false }) + expect(assertWslDistroRunningForExecMock).toHaveBeenLastCalledWith('Ubuntu') + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('forwards a repo target before workspace creation and rejects malformed ids', async () => { detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex']) const resolveAgentDetectionHost = vi.fn(() => ({ kind: 'local' }) as const) diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 5d4b8ced7ff..358e794ca47 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -4,6 +4,7 @@ import { runPreflightCheck } from '../../../preflight/agent-detection' import { + assertAgentDetectionHostRunning, detectAgentsOnHost, refreshAgentsOnHost } from '../../../preflight/workspace-agent-detection' @@ -27,12 +28,16 @@ export const PREFLIGHT_METHODS = [ permission: 'workspace', params: PreflightProjectAgentDetection, // Only the host knows the workspace's actual project runtime, including WSL. - handler: async (params, { runtime }) => - params.project - ? runtime.detectProjectLaunchContext(params.project.repo) - : detectAgentsOnHost( - await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId) - ) + handler: async (params, { runtime }) => { + if (params.project) { + // Ungated: it serves a workspace create in that project, which boots the distro anyway. + return runtime.detectProjectLaunchContext(params.project.repo) + } + const host = await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId) + // A client's inventory read is background work: it must not start a stopped WSL distro (E9). + await assertAgentDetectionHostRunning(host) + return detectAgentsOnHost(host) + } }), defineMethod({ name: 'preflight.detectRemoteAgents', @@ -51,7 +56,10 @@ export const PREFLIGHT_METHODS = [ name: 'preflight.refreshAgents', permission: 'workspace', params: PreflightAgentDetection, - handler: async (params, { runtime }) => - refreshAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId)) + handler: async (params, { runtime }) => { + const host = await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId) + await assertAgentDetectionHostRunning(host) + return refreshAgentsOnHost(host) + } }) ] diff --git a/src/main/wsl/wsl-background-exec-gate.ts b/src/main/wsl/wsl-background-exec-gate.ts new file mode 100644 index 00000000000..7e80157662d --- /dev/null +++ b/src/main/wsl/wsl-background-exec-gate.ts @@ -0,0 +1,45 @@ +import { listWslDistrosAsync } from '../wsl' +import { + assertWslUncPathRunning, + blockedWslDistroForPath, + runAsWslUserAct, + type BlockedWslDistro +} from './wsl-unc-access-gate' + +// `wsl.exe` with no `-d` runs the default distro, which `--list --quiet` names first. +async function resolveWslExecDistro(distro: string | null | undefined): Promise { + if (process.platform !== 'win32') { + return null + } + return distro?.trim() || ((await listWslDistrosAsync())[0] ?? null) +} + +/** + * Why a background `wsl.exe` run in `distro` (null: the default) must not start now, or null when + * it may. Any `-d`/`--exec` boots a stopped distro (E9); a user act for that distro passes. + */ +export async function blockedWslDistroForExec( + distro: string | null | undefined +): Promise { + const target = await resolveWslExecDistro(distro) + // No distro named and none listed: wsl.exe has nothing to start. + return target ? blockedWslDistroForPath('', target) : null +} + +/** Throws WslDistroStoppedError/WslDistroUnverifiableError when `blockedWslDistroForExec` refuses. */ +export async function assertWslDistroRunningForExec( + distro: string | null | undefined +): Promise { + const target = await resolveWslExecDistro(distro) + if (target) { + await assertWslUncPathRunning('', target) + } +} + +/** Runs `act` as a user act on `distro` (null: the default), so its `wsl.exe` runs may start it. */ +export async function runAsWslExecUserAct( + distro: string | null | undefined, + act: () => Promise +): Promise { + return runAsWslUserAct(await resolveWslExecDistro(distro), act) +} diff --git a/src/main/wsl/wsl-distro-refusal-message.test.ts b/src/main/wsl/wsl-distro-refusal-message.test.ts new file mode 100644 index 00000000000..5da419c7752 --- /dev/null +++ b/src/main/wsl/wsl-distro-refusal-message.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'vitest' +import { isWslDistroRefusalError } from '../../shared/wsl-distro-refusal' +import { WslDistroStoppedError, WslDistroUnverifiableError } from './wsl-unc-access-gate' + +it('recognizes both gate refusals as they arrive over IPC, and nothing else', () => { + const overIpc = (error: Error): Error => + new Error( + `Error invoking remote method 'preflight:detectAgents': ${error.name}: ${error.message}` + ) + expect(isWslDistroRefusalError(overIpc(new WslDistroStoppedError('Ubuntu')))).toBe(true) + expect(isWslDistroRefusalError(overIpc(new WslDistroUnverifiableError('Ubuntu')))).toBe(true) + expect(isWslDistroRefusalError(new Error('wsl.exe timed out'))).toBe(false) +}) diff --git a/src/preload/api/preflight-api.ts b/src/preload/api/preflight-api.ts index 8d5c6460059..5129537184d 100644 --- a/src/preload/api/preflight-api.ts +++ b/src/preload/api/preflight-api.ts @@ -43,13 +43,20 @@ export type PreflightRuntimeContext = { } export type PreflightApi = { - check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise + check: ( + args?: PreflightRuntimeContext & { force?: boolean; wakeWslDistro?: boolean } + ) => Promise /** Complete by default; background callers opt into host-owned enabled preferences. */ - detectAgents: (args?: PreflightRuntimeContext & { enabledOnly?: boolean }) => Promise + detectAgents: ( + args?: PreflightRuntimeContext & { enabledOnly?: boolean; wakeWslDistro?: boolean } + ) => Promise + /** A WSL distro a background check was refused for is running now; re-ask. Desktop only. */ + onWslDistroRunning?: (callback: (distro: string) => void) => () => void /** Cached in main for the process lifetime. */ zcodeInteractiveCapability: () => Promise + /** `wakeWslDistro`: a user act that may start a stopped WSL distro; background refreshes omit it. */ refreshAgents: ( - args?: PreflightRuntimeContext & { enabledOnly?: boolean } + args?: PreflightRuntimeContext & { enabledOnly?: boolean; wakeWslDistro?: boolean } ) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{ diff --git a/src/preload/api/preflight-bridge.ts b/src/preload/api/preflight-bridge.ts index ab4e5c3f76f..ca0f030c155 100644 --- a/src/preload/api/preflight-bridge.ts +++ b/src/preload/api/preflight-bridge.ts @@ -5,6 +5,7 @@ import type { ZCodeInteractiveCapability } from '../../shared/zcode-missing-tui' export const preflightApi = { check: (args?: { force?: boolean + wakeWslDistro?: boolean }): Promise<{ git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } @@ -26,12 +27,18 @@ export const preflightApi = { } linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (args?: PreflightRuntimeContext & { enabledOnly?: boolean }): Promise => - ipcRenderer.invoke('preflight:detectAgents', args), + detectAgents: ( + args?: PreflightRuntimeContext & { enabledOnly?: boolean; wakeWslDistro?: boolean } + ): Promise => ipcRenderer.invoke('preflight:detectAgents', args), + onWslDistroRunning: (callback: (distro: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, distro: string): void => callback(distro) + ipcRenderer.on('preflight:wslDistroRunning', listener) + return () => ipcRenderer.removeListener('preflight:wslDistroRunning', listener) + }, zcodeInteractiveCapability: (): Promise => ipcRenderer.invoke('preflight:zcodeInteractiveCapability'), refreshAgents: ( - args?: PreflightRuntimeContext & { enabledOnly?: boolean } + args?: PreflightRuntimeContext & { enabledOnly?: boolean; wakeWslDistro?: boolean } ): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => ipcRenderer.invoke('preflight:detectRemoteAgents', args), diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 4546cc10343..4b2fa137451 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -203,7 +203,8 @@ function QuickTabBody({ const observation = useAgentMenuObservation( cardProps.agentRows?.target ?? null, active && Boolean(cardProps.agentRows), - cardProps.agentRows?.detectionTarget + cardProps.agentRows?.detectionTarget, + { wakeOnOpen: true } ) const launchSelection = observation.selectionFor(quickAgent, quickAgentSurface) const createDisabled = formDisabled || !launchSelection.ready diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx index 6f791c84e22..cf6844bd967 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx @@ -83,7 +83,8 @@ export function AgentSessionContinuationDialog({ const observation = useAgentMenuObservation( workspace ? { workspace, launchCwd: request?.initialCwd } : null, open && Boolean(request), - detectionTarget + detectionTarget, + { wakeOnOpen: true } ) const { detectedIds, detectionFailed } = observation.detection const detecting = diff --git a/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts b/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts index d3fb692c1fc..def0400a854 100644 --- a/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts +++ b/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts @@ -130,7 +130,7 @@ describe('landing preflight runtime boundary', () => { // Reveal force-refreshes through the sibling visibility handler; polling only re-arms afterward. act(() => setDocumentVisibility('visible')) expect(refresh).toHaveBeenCalledTimes(1) - expect(refresh).toHaveBeenCalledWith({ force: true }) + expect(refresh).toHaveBeenCalledWith({ force: true, wakeWslDistro: true }) expect(vi.getTimerCount()).toBe(1) view.unmount() diff --git a/src/renderer/src/components/landing-preflight-runtime.ts b/src/renderer/src/components/landing-preflight-runtime.ts index 453776d59ea..b0fba3fe505 100644 --- a/src/renderer/src/components/landing-preflight-runtime.ts +++ b/src/renderer/src/components/landing-preflight-runtime.ts @@ -51,7 +51,8 @@ export function useLandingPreflightRuntime(): { preflightIssues: PreflightIssue[ void refreshPreflightStatus() const handleWindowActive = (): void => { if (document.visibilityState === 'visible') { - void refreshPreflightStatus({ force: true }) + // Focus is a user act on the focused workspace, the only one preflight checks. + void refreshPreflightStatus({ force: true, wakeWslDistro: true }) } } document.addEventListener('visibilitychange', handleWindowActive) diff --git a/src/renderer/src/components/onboarding/IntegrationsStep.tsx b/src/renderer/src/components/onboarding/IntegrationsStep.tsx index 05995f8aa29..88b81bcf196 100644 --- a/src/renderer/src/components/onboarding/IntegrationsStep.tsx +++ b/src/renderer/src/components/onboarding/IntegrationsStep.tsx @@ -106,7 +106,7 @@ export function GitHubRow(props: { compact?: boolean } = {}): React.JSX.Element diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index 29ccbc4983f..58d61e50782 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -235,7 +235,8 @@ export function useOnboardingFlow( } didAutoSelectRef.current = true // Refresh PATH on mount; pre-hydration session-cache entries can falsely report no agents. - void refreshDetectedAgents().then((ids) => { + // First-run setup is a user act: it may start the default WSL distro to find its agents. + void refreshDetectedAgents(undefined, { wakeWslDistro: true }).then((ids) => { if (selectedAgentRef.current !== null) { return } diff --git a/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts b/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts index 32e045b4051..0429e1ab390 100644 --- a/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts +++ b/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts @@ -56,7 +56,9 @@ export function useSourceControlActionAgentChoice(args: { : NO_PROJECT_ROWS, [args.worktreeId, args.repoId, args.repo, worktreeDetectionTarget] ) - const observation = useAgentMenuObservation(rows.target, args.open, rows.detectionTarget) + const observation = useAgentMenuObservation(rows.target, args.open, rows.detectionTarget, { + wakeOnOpen: true + }) const { detectedIds, detectionFailed, needsServerUpdate } = observation.detection const detectedAgents = observation.model.state === 'settled' diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index abfbb9d5c2e..8271da4c02c 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -249,19 +249,28 @@ describe('AgentsPane', () => { expect(markup).not.toContain('Couldn’t detect installed agents') }) - it('keeps Windows runtime changes scoped to the local agent refresh', () => { - renderPane( - { - ...getDefaultSettings('/tmp'), - activeRuntimeEnvironmentId: 'env-1' - }, - { wslSupportedPlatform: true, wslAvailable: true, wslDistros: ['Ubuntu'] } - ) + it('keeps Windows runtime changes scoped to the local agent refresh', async () => { + // SSR reads Zustand’s initial snapshot, so patch/restore its local refresh action. + const initialState = useAppStore.getInitialState() + const priorRefresh = initialState.refreshDetectedAgents + const refreshLocal = vi.fn(async () => []) + initialState.refreshDetectedAgents = refreshLocal + try { + renderPane( + { + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'env-1' + }, + { wslSupportedPlatform: true, wslAvailable: true, wslDistros: ['Ubuntu'] } + ) + await agentRuntimeSettingMock.lastRefresh?.() + } finally { + initialState.refreshDetectedAgents = priorRefresh + } - expect(agentRuntimeSettingMock.lastRefresh).toBe( - useAppStore.getInitialState().refreshDetectedAgents - ) - expect(agentRuntimeSettingMock.lastRefresh).not.toBe(detectedAgentsMock.refresh) + // Choosing a runtime is a user act: its refresh may start that WSL distro. + expect(refreshLocal).toHaveBeenCalledWith(undefined, { wakeWslDistro: true }) + expect(detectedAgentsMock.refresh).not.toHaveBeenCalled() }) it('renders the keep-awake modes from settings', () => { diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 8fbe16bb4eb..7117fcbf4be 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -78,7 +78,7 @@ export function AgentsPane({ refreshLocalAgents(undefined, { wakeWslDistro: true })} wslSupportedPlatform={wslSupportedPlatform} wslAvailable={wslAvailable} wslDistros={wslDistros} diff --git a/src/renderer/src/components/settings/TasksPane.test.tsx b/src/renderer/src/components/settings/TasksPane.test.tsx index 869efac550e..f323e86e545 100644 --- a/src/renderer/src/components/settings/TasksPane.test.tsx +++ b/src/renderer/src/components/settings/TasksPane.test.tsx @@ -382,6 +382,6 @@ describe('TasksPane', () => { await act(async () => { retry?.click() }) - expect(mocks.refreshPreflightStatus).toHaveBeenCalledWith({ force: true }) + expect(mocks.refreshPreflightStatus).toHaveBeenCalledWith({ force: true, wakeWslDistro: true }) }) }) diff --git a/src/renderer/src/components/settings/TasksPane.tsx b/src/renderer/src/components/settings/TasksPane.tsx index ee468f34c55..eff4680efa7 100644 --- a/src/renderer/src/components/settings/TasksPane.tsx +++ b/src/renderer/src/components/settings/TasksPane.tsx @@ -232,7 +232,9 @@ export function TasksPane({ settings, updateSettings }: TasksPaneProps): React.J canHide={canHide} onToggleVisible={() => toggleProvider(provider)} onOpenIntegrations={() => openIntegrations()} - onRetryConnection={() => void refreshPreflightStatus({ force: true })} + onRetryConnection={() => + void refreshPreflightStatus({ force: true, wakeWslDistro: true }) + } /> )} diff --git a/src/renderer/src/components/settings/source-control-preflight-card-status.ts b/src/renderer/src/components/settings/source-control-preflight-card-status.ts index b4b3935b535..f88eb9a3850 100644 --- a/src/renderer/src/components/settings/source-control-preflight-card-status.ts +++ b/src/renderer/src/components/settings/source-control-preflight-card-status.ts @@ -80,7 +80,7 @@ export function usePreflightCardStatuses( const refresh = (): void => { setRefreshing(true) - void refreshPreflightStatus({ force: true }).finally(() => { + void refreshPreflightStatus({ force: true, wakeWslDistro: true }).finally(() => { if (mountedRef.current) { setRefreshing(false) } diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 969c07de431..a2b4734bb41 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -91,7 +91,10 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { setIsRefreshing(true) try { // Why: re-run PATH detection so a freshly-installed/removed CLI's bar appears/hides without restarting Orca. - await Promise.all([(refreshRemote ?? refreshRateLimits)(), refreshDetectedAgents()]) + await Promise.all([ + (refreshRemote ?? refreshRateLimits)(), + refreshDetectedAgents(undefined, { wakeWslDistro: true }) + ]) } finally { if (mountedRef.current) { setIsRefreshing(false) diff --git a/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.test.tsx b/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.test.tsx new file mode 100644 index 00000000000..314a9fcbd23 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { create } from 'zustand' + +type InventoryState = 'pending' | 'successful' | 'failed' +type MockState = { + inventory: InventoryState + refused: boolean + readLocalAgentInventory: () => { ids: null; state: InventoryState } + isLocalAgentInventoryRefused: () => boolean + ensureDetectedAgentsForPicker: (worktreeId?: string | null) => Promise +} + +const mocks = vi.hoisted(() => ({ ensure: vi.fn(async (_worktreeId?: string | null) => []) })) +const store = vi.hoisted(() => { + const holder: { current: unknown } = { current: null } + return holder +}) +vi.mock('@/store', () => ({ + get useAppStore() { + return store.current + } +})) + +import { useAgentPickerRefusedWake } from './use-agent-picker-refused-wake' + +function makeStore(refused: boolean) { + const useStore = create()((_set, get) => ({ + inventory: 'failed', + refused, + readLocalAgentInventory: () => ({ ids: null, state: get().inventory }), + isLocalAgentInventoryRefused: () => get().refused, + ensureDetectedAgentsForPicker: mocks.ensure + })) + store.current = useStore + return useStore +} + +const LOCAL = { kind: 'local', worktreeId: 'wt-1' } as const + +beforeEach(() => { + mocks.ensure.mockClear() +}) + +it('wakes a refused WSL list once per open, even when the woken probe fails again (E9)', () => { + const useStore = makeStore(true) + const { rerender } = renderHook(({ open }) => useAgentPickerRefusedWake(open, LOCAL), { + initialProps: { open: true } + }) + expect(mocks.ensure).toHaveBeenCalledTimes(1) + expect(mocks.ensure).toHaveBeenCalledWith('wt-1') + // The wake probe runs (pending) and fails again while the picker stays open. + act(() => useStore.setState({ inventory: 'pending' })) + act(() => useStore.setState({ inventory: 'failed' })) + act(() => useStore.setState({ inventory: 'pending' })) + act(() => useStore.setState({ inventory: 'failed' })) + expect(mocks.ensure).toHaveBeenCalledTimes(1) + + // Closing and reopening is a new user act. + rerender({ open: false }) + rerender({ open: true }) + expect(mocks.ensure).toHaveBeenCalledTimes(2) +}) + +it('never retries a native or non-refusal failure while the picker is open', () => { + const useStore = makeStore(false) + renderHook(() => useAgentPickerRefusedWake(true, LOCAL)) + act(() => useStore.setState({ inventory: 'pending' })) + act(() => useStore.setState({ inventory: 'failed' })) + expect(mocks.ensure).not.toHaveBeenCalled() +}) + +it('wakes nothing while closed or for a remote host', () => { + makeStore(true) + renderHook(() => useAgentPickerRefusedWake(false, LOCAL)) + renderHook(() => useAgentPickerRefusedWake(true, { kind: 'ssh', connectionId: 'ssh-1' })) + expect(mocks.ensure).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.ts b/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.ts new file mode 100644 index 00000000000..47e5153b1cc --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef } from 'react' +import { useAppStore } from '@/store' +import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' + +/** + * A local agent list refused while its WSL distro was stopped stays failed. The user opening a + * launch picker is a user act, so it retries that list once as a wake (E9). Any other failure, + * native or WSL, is left to the normal retry so an open picker never loops probes. + */ +export function useAgentPickerRefusedWake( + userOpened: boolean, + detectionTarget: AgentDetectionTarget | undefined +): void { + const worktreeId = + userOpened && detectionTarget?.kind === 'local' + ? (detectionTarget.worktreeId ?? null) + : undefined + // The target this open already woke; cleared on close so the next open may wake again. + const wokenFor = useRef<{ worktreeId: string | null } | null>(null) + const failed = useAppStore( + (state) => + worktreeId !== undefined && state.readLocalAgentInventory(worktreeId).state === 'failed' + ) + useEffect(() => { + if (worktreeId === undefined) { + wokenFor.current = null + return + } + if (!failed || wokenFor.current?.worktreeId === worktreeId) { + return + } + const state = useAppStore.getState() + if (!state.isLocalAgentInventoryRefused(worktreeId)) { + return + } + wokenFor.current = { worktreeId } + void state.ensureDetectedAgentsForPicker(worktreeId) + }, [failed, worktreeId]) +} diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts index 92284eb81b8..f1d3b9ff3f5 100644 --- a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts @@ -10,6 +10,7 @@ import { runtimeHostContactForEntry } from '../../../../shared/runtime-host-cont import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { useAppStore } from '@/store' +import { useAgentPickerRefusedWake } from './use-agent-picker-refused-wake' import { buildAgentLaunchRouteInput, workspaceKindForWorktreeId, @@ -81,15 +82,20 @@ export type AgentMenuRowsTarget = { export function useNewTabAgentMenuRows( target: string | AgentMenuRowsTarget, open: boolean, - detectionTarget: AgentDetectionTarget | undefined + detectionTarget: AgentDetectionTarget | undefined, + options?: AgentMenuObservationOptions ): NewTabAgentMenuModel { - return useAgentMenuObservation(target, open, detectionTarget).model + return useAgentMenuObservation(target, open, detectionTarget, options).model } +/** `wakeOnOpen`: `open` is the user opening this picker, a user act that may start its WSL distro. */ +export type AgentMenuObservationOptions = { wakeOnOpen?: boolean } + export function useAgentMenuObservation( target: string | AgentMenuRowsTarget | null, open: boolean, - detectionTarget: AgentDetectionTarget | undefined + detectionTarget: AgentDetectionTarget | undefined, + options?: AgentMenuObservationOptions ) { const worktreeId = typeof target === 'string' ? target : target?.workspace.worktreeId const launchCwd = typeof target === 'string' ? undefined : target?.launchCwd @@ -100,6 +106,7 @@ export function useAgentMenuObservation( ) // Why: acquiring while the workspace is shown means the first open already knows its agents. const acquired = useDetectedAgents(open || worktreeShown ? detectionTarget : undefined) + useAgentPickerRefusedWake(options?.wakeOnOpen === true && open, detectionTarget) // Closing the menu or leaving the workspace must not erase the known inventory. const storedIds = useAppStore((state) => detectionTarget ? readDetectedAgentsForTarget(state, detectionTarget) : null diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 6fcb2b14006..7de770fa13a 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -89,7 +89,8 @@ export function useTabBarCreateMenuController({ const agentMenu = useNewTabAgentMenuRows( worktreeId, newTabMenuOpen && showAgentLaunchItems, - agentDetectionTarget + agentDetectionTarget, + { wakeOnOpen: true } ) const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) const agentLaunchOptions = buildTabAgentLaunchOptionsFromRows( diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 52c89bd823d..15e789930fd 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -20,6 +20,7 @@ import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge' import { registerOrcaProfileAuthIpcBridge } from './orca-profile-auth-ipc-bridge' +import { registerPreflightWslRetryIpcBridge } from './preflight-wsl-retry-ipc-bridge' import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge' import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge' import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge' @@ -94,6 +95,7 @@ export function installAppLifetimeIpcEvents(): () => void { registerSettingsAndSidebarIpcBridge(unsubs) registerQuitAndStopAllSessionsIpcBridge(unsubs) registerOrcaProfileAuthIpcBridge(unsubs) + registerPreflightWslRetryIpcBridge(unsubs) registerWorkspaceShortcutIpcBridge(unsubs) registerOsMarkdownFileOpenBridge(unsubs) registerWslAccountNoticeIpcBridge(unsubs) diff --git a/src/renderer/src/hooks/ipc-events/preflight-wsl-retry-ipc-bridge.test.ts b/src/renderer/src/hooks/ipc-events/preflight-wsl-retry-ipc-bridge.test.ts new file mode 100644 index 00000000000..e851ed8d4dd --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/preflight-wsl-retry-ipc-bridge.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const state: { + preflightStatusError: string | null + refreshPreflightStatus: () => Promise + ensureDetectedAgents: () => Promise + } = { + preflightStatusError: null, + refreshPreflightStatus: vi.fn(async () => {}), + ensureDetectedAgents: vi.fn(async () => []) + } + return { state } +}) +vi.mock('../../store', () => ({ useAppStore: { getState: () => mocks.state } })) + +import { registerPreflightWslRetryIpcBridge } from './preflight-wsl-retry-ipc-bridge' + +let emit: (distro: string) => void = () => {} + +beforeEach(() => { + mocks.state.preflightStatusError = null + vi.mocked(mocks.state.refreshPreflightStatus).mockClear() + vi.mocked(mocks.state.ensureDetectedAgents).mockClear() + vi.stubGlobal('window', { + api: { + preflight: { + onWslDistroRunning: (callback: (distro: string) => void) => { + emit = callback + return () => {} + } + } + } + }) +}) + +it('re-asks refused integration checks and agent detection once the distro runs (E9)', () => { + mocks.state.preflightStatusError = "WSL distro 'Ubuntu' is not running." + registerPreflightWslRetryIpcBridge([]) + emit('Ubuntu') + expect(mocks.state.refreshPreflightStatus).toHaveBeenCalledOnce() + expect(mocks.state.ensureDetectedAgents).toHaveBeenCalledOnce() +}) + +it('leaves a healthy integration status alone', () => { + registerPreflightWslRetryIpcBridge([]) + emit('Ubuntu') + expect(mocks.state.refreshPreflightStatus).not.toHaveBeenCalled() + expect(mocks.state.ensureDetectedAgents).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/hooks/ipc-events/preflight-wsl-retry-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/preflight-wsl-retry-ipc-bridge.ts new file mode 100644 index 00000000000..1af63a3cb7f --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/preflight-wsl-retry-ipc-bridge.ts @@ -0,0 +1,19 @@ +import { useAppStore } from '../../store' + +/** Main saw a WSL distro running that a background check was refused for: re-ask what it refused. */ +export function registerPreflightWslRetryIpcBridge(unsubs: (() => void)[]): void { + const onWslDistroRunning = window.api.preflight?.onWslDistroRunning + if (typeof onWslDistroRunning !== 'function') { + return + } + unsubs.push( + onWslDistroRunning(() => { + const state = useAppStore.getState() + if (state.preflightStatusError !== null) { + void state.refreshPreflightStatus() + } + // A refused inventory is marked failed, so this re-detects it; a known list is reused. + void state.ensureDetectedAgents() + }) + ) +} diff --git a/src/renderer/src/hooks/useDetectedAgents.test.tsx b/src/renderer/src/hooks/useDetectedAgents.test.tsx index 593d6054a2a..b71d450d089 100644 --- a/src/renderer/src/hooks/useDetectedAgents.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.test.tsx @@ -220,7 +220,7 @@ describe('Floating Workspace authority', () => { expect(activeResult.current?.detectedIds).toEqual(['claude']) expect(floatingResult.current?.detectedIds).toEqual(['codex']) expect(useAppStore.getState().detectedAgentIds).toEqual(['claude']) - expect(refreshLocalAgents).toHaveBeenLastCalledWith({ enabledOnly: true }) + expect(refreshLocalAgents).toHaveBeenLastCalledWith({ enabledOnly: true, wakeWslDistro: true }) }) }) diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index ce8e1febda3..4cc2ed2cf4f 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -179,9 +179,10 @@ export function useDetectedAgents( if (targetKind === 'ssh' && targetId) { return state.refreshRemoteDetectedAgents(targetId) } + // Only the refresh control calls this: a user act that may start a stopped WSL distro. return includeDisabled - ? state.refreshAllDetectedAgents(localWorktreeId) - : state.refreshDetectedAgents(localWorktreeId) + ? state.refreshAllDetectedAgents(localWorktreeId, { wakeWslDistro: true }) + : state.refreshDetectedAgents(localWorktreeId, { wakeWslDistro: true }) }, [includeDisabled, isUnknown, localWorktreeId, runtimeWorktreeId, targetKind, targetId]) useEffect(() => { diff --git a/src/renderer/src/store/slices/local-agent-inventory-refresh.ts b/src/renderer/src/store/slices/local-agent-inventory-refresh.ts index 9f0f27d7097..215baf86e62 100644 --- a/src/renderer/src/store/slices/local-agent-inventory-refresh.ts +++ b/src/renderer/src/store/slices/local-agent-inventory-refresh.ts @@ -8,10 +8,17 @@ import { getLocalAgentPreflightContext } from '@/lib/local-preflight-context' import { localAgentInventoryContextKey } from '@/lib/local-preflight-context-key' import * as contextEviction from './local-agent-context-eviction' import { getLegacyLoadingPatch, getSupersededDetectPatch } from './local-agent-legacy-loading' +import type { LocalAgentRefreshOptions } from './local-detected-agent-store-state' +import { isWslDistroRefusalError } from '../../../../shared/wsl-distro-refusal' export type LocalAgentInventoryLifecycle = { detectPromises: Map> refreshPromises: Map> + /** Refreshes sent as a user act; a background one may be refused for a stopped WSL distro. */ + wakeRefreshes: WeakSet> + wakeDetects: WeakSet> + /** Contexts whose last probe the E9 gate refused (distro not running), not a real failure. */ + refusedContextKeys: Set keptThroughFailure: WeakSet failedDetectContextKeys: Set detectedContextKey: string | null @@ -25,7 +32,8 @@ export function refreshLocalAgentInventory( get: StoreApi['getState'], lifecycle: LocalAgentInventoryLifecycle, worktreeId?: string | null, - includeDisabled = false + includeDisabled = false, + options: LocalAgentRefreshOptions = {} ): Promise { const isolatedInventory = worktreeId === FLOATING_TERMINAL_WORKTREE_ID const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) @@ -56,7 +64,8 @@ export function refreshLocalAgentInventory( } } const inflight = lifecycle.refreshPromises.get(contextKey) - if (inflight) { + // A refresh click must reach main as a wake, not ride a background refresh it would refuse. + if (inflight && (!options.wakeWslDistro || lifecycle.wakeRefreshes.has(inflight))) { exposeInflightToLegacy() return inflight } @@ -89,8 +98,9 @@ export function refreshLocalAgentInventory( [publishedContextKey]: true } })) + const refreshArgs = includeDisabled ? context : { ...context, enabledOnly: true } const pending = window.api.preflight - .refreshAgents(includeDisabled ? context : { ...context, enabledOnly: true }) + .refreshAgents(options.wakeWslDistro ? { ...refreshArgs, wakeWslDistro: true } : refreshArgs) .then((result) => { const typed = result.agents.filter(isTuiAgent) const published = includeDisabled @@ -101,6 +111,7 @@ export function refreshLocalAgentInventory( lifecycle.refreshPromises.get(contextKey) === pending ) { lifecycle.failedDetectContextKeys.delete(contextKey) + lifecycle.refusedContextKeys.delete(contextKey) lifecycle.failedDetectContextKeys.delete(publishedContextKey) const exposeToLegacy = lifecycle.legacyRefreshContextKey !== null && @@ -131,12 +142,13 @@ export function refreshLocalAgentInventory( } return typed }) - .catch(() => { + .catch((error: unknown) => { const fallback = get().localDetectedAgentIdsByContext[contextKey] ?? [] if ( requestGeneration === lifecycle.localDetectionGeneration && lifecycle.refreshPromises.get(contextKey) === pending ) { + markLocalAgentRefusal(lifecycle, contextKey, error) lifecycle.keptThroughFailure.add(fallback) const publishedFallback = get().localDetectedAgentIdsByContext[publishedContextKey] ?? [] lifecycle.keptThroughFailure.add(publishedFallback) @@ -186,6 +198,23 @@ export function refreshLocalAgentInventory( } }) } + if (options.wakeWslDistro) { + lifecycle.wakeRefreshes.add(pending) + lifecycle.wakeRefreshes.add(publishedPending) + } lifecycle.refreshPromises.set(contextKey, pending) return pending } + +/** Records whether a context's failed probe was an E9 refusal, which a picker opening may retry. */ +export function markLocalAgentRefusal( + lifecycle: Pick, + contextKey: string, + error: unknown +): void { + if (isWslDistroRefusalError(error)) { + lifecycle.refusedContextKeys.add(contextKey) + } else { + lifecycle.refusedContextKeys.delete(contextKey) + } +} diff --git a/src/renderer/src/store/slices/local-detected-agent-state.test.ts b/src/renderer/src/store/slices/local-detected-agent-state.test.ts index d7b387388ff..6129f10d9da 100644 --- a/src/renderer/src/store/slices/local-detected-agent-state.test.ts +++ b/src/renderer/src/store/slices/local-detected-agent-state.test.ts @@ -101,6 +101,84 @@ describe('local detected agent context lifecycle', () => { expect(store.getState().detectedAgentIds).toEqual(['codex']) }) + it('sends a refresh click as a wake instead of joining a background refresh', async () => { + const store = createTestStore([makeRepo('repo-1')]) + let finishBackground: (value: unknown) => void = () => {} + refreshAgents.mockReturnValueOnce( + new Promise((resolve) => { + finishBackground = resolve + }) + ) + + const background = store.getState().refreshDetectedAgents() + // The background call may be refused for a stopped WSL distro; the click must reach main itself. + const click = store.getState().refreshDetectedAgents(undefined, { wakeWslDistro: true }) + const again = store.getState().refreshDetectedAgents() + + expect(refreshAgents).toHaveBeenCalledTimes(2) + expect(refreshAgents).toHaveBeenLastCalledWith(expect.objectContaining({ wakeWslDistro: true })) + finishBackground({ agents: ['claude'], pathSource: 'process_env', pathFailureReason: 'none' }) + await Promise.all([background, click, again]) + }) + + it('retries a refused list as a wake when an agent picker opens, without joining a background probe', async () => { + const store = createTestStore([makeRepo('repo-1')]) + detectAgents.mockRejectedValueOnce(new Error("WSL distro 'Ubuntu' is not running.")) + await store.getState().ensureDetectedAgents() + expect(store.getState().readLocalAgentInventory().state).toBe('failed') + + let finishBackground: (ids: string[]) => void = () => {} + detectAgents + .mockReturnValueOnce( + new Promise((resolve) => { + finishBackground = resolve + }) + ) + .mockResolvedValueOnce(['codex']) + const background = store.getState().ensureDetectedAgents() + const picker = store.getState().ensureDetectedAgentsForPicker() + + expect(detectAgents).toHaveBeenCalledTimes(3) + expect(detectAgents).toHaveBeenLastCalledWith(expect.objectContaining({ wakeWslDistro: true })) + await expect(picker).resolves.toEqual(['codex']) + // The background probe settles later but no longer owns the context. + finishBackground([]) + await background + expect(store.getState().readLocalAgentInventory()).toEqual({ + ids: ['codex'], + state: 'successful' + }) + // A known list is reused: opening the picker again probes nothing. + await store.getState().ensureDetectedAgentsForPicker() + expect(detectAgents).toHaveBeenCalledTimes(3) + }) + + it('marks only an E9 refusal as refused, so a picker never retries real failures', async () => { + const store = createTestStore([makeRepo('repo-1')]) + detectAgents.mockRejectedValueOnce( + new Error( + "Error invoking remote method 'preflight:detectAgents': WslDistroStoppedError: WSL distro 'Ubuntu' is not running. Retry, or open a terminal in it, to start it." + ) + ) + await store.getState().ensureDetectedAgents() + expect(store.getState().isLocalAgentInventoryRefused()).toBe(true) + + detectAgents.mockRejectedValueOnce(new Error('wsl.exe timed out')) + await store.getState().ensureDetectedAgents() + expect(store.getState().readLocalAgentInventory().state).toBe('failed') + expect(store.getState().isLocalAgentInventoryRefused()).toBe(false) + }) + + it('forgets a refusal when the local inventory is cleared', async () => { + const store = createTestStore([makeRepo('repo-1')]) + detectAgents.mockRejectedValueOnce(new Error("WSL distro 'Ubuntu' is not running.")) + await store.getState().ensureDetectedAgents() + expect(store.getState().isLocalAgentInventoryRefused()).toBe(true) + + store.getState().clearLocalDetectedAgents() + expect(store.getState().isLocalAgentInventoryRefused()).toBe(false) + }) + it('re-detects re-enabled agents and prevents pre-change requests from restoring caches', async () => { const store = createTestStore([]) let finishOld: (ids: string[]) => void = () => {} diff --git a/src/renderer/src/store/slices/local-detected-agent-state.ts b/src/renderer/src/store/slices/local-detected-agent-state.ts index ccd5abe8392..b203e2f9d55 100644 --- a/src/renderer/src/store/slices/local-detected-agent-state.ts +++ b/src/renderer/src/store/slices/local-detected-agent-state.ts @@ -9,12 +9,16 @@ import { localAgentInventoryContextKey } from '@/lib/local-preflight-context-key import * as contextEviction from './local-agent-context-eviction' import { getLegacyLoadingPatch } from './local-agent-legacy-loading' import { + markLocalAgentRefusal, refreshLocalAgentInventory, type LocalAgentInventoryLifecycle } from './local-agent-inventory-refresh' -import { createEmptyLocalDetectedAgentState } from './local-detected-agent-store-state' +import { + createEmptyLocalDetectedAgentState, + type LocalAgentRefreshOptions, + type LocalDetectedAgentState +} from './local-detected-agent-store-state' import { readLocalAgentInventoryOutcome } from './local-agent-inventory-outcome' -import type { LocalDetectedAgentState } from './local-detected-agent-store-state' type LocalDetectedAgentStateCreator = StateCreator @@ -23,6 +27,9 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se const lifecycle: LocalAgentInventoryLifecycle = { detectPromises: new Map(), refreshPromises: new Map(), + wakeRefreshes: new WeakSet(), + wakeDetects: new WeakSet(), + refusedContextKeys: new Set(), keptThroughFailure: new WeakSet(), failedDetectContextKeys: new Set(), detectedContextKey: null, @@ -47,11 +54,21 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se }, ensureAllDetectedAgents: (worktreeId?: string | null): Promise => actions.ensureDetectedAgents(worktreeId, false, true), + isLocalAgentInventoryRefused: (worktreeId?: string | null): boolean => + lifecycle.refusedContextKeys.has( + localAgentInventoryContextKey( + getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) + ) + ), + ensureDetectedAgentsForPicker: (worktreeId?: string | null): Promise => + actions.ensureDetectedAgents(worktreeId, false, false, { wakeWslDistro: true }), ensureDetectedAgents: ( worktreeId?: string | null, nativeHost = false, - includeDisabled = false + includeDisabled = false, + options: LocalAgentRefreshOptions = {} ): Promise => { + const wake = options.wakeWslDistro === true const isolatedInventory = includeDisabled || nativeHost || worktreeId === FLOATING_TERMINAL_WORKTREE_ID const context = nativeHost @@ -60,7 +77,8 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se const contextKey = localAgentInventoryContextKey(context, includeDisabled) const existing = get().localDetectedAgentIdsByContext[contextKey] const inflightRefresh = lifecycle.refreshPromises.get(contextKey) - if (inflightRefresh) { + // A picker's wake must not ride a background probe that a stopped WSL distro refuses. + if (inflightRefresh && (!wake || lifecycle.wakeRefreshes.has(inflightRefresh))) { if (!isolatedInventory) { lifecycle.legacyRefreshContextKey = contextKey } @@ -95,7 +113,7 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se } } const inflight = lifecycle.detectPromises.get(contextKey) - if (inflight) { + if (inflight && (!wake || lifecycle.wakeDetects.has(inflight))) { exposeInflightToLegacy() return inflight } @@ -116,14 +134,16 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se [contextKey]: true } })) + const detectArgs = includeDisabled ? context : { ...context, enabledOnly: true } const pending = window.api.preflight - .detectAgents(includeDisabled ? context : { ...context, enabledOnly: true }) + .detectAgents(wake ? { ...detectArgs, wakeWslDistro: true } : detectArgs) .then((ids) => { const typed = ids.filter(isTuiAgent) if ( requestGeneration === lifecycle.localDetectionGeneration && lifecycle.detectPromises.get(contextKey) === pending ) { + lifecycle.refusedContextKeys.delete(contextKey) lifecycle.failedDetectContextKeys.delete(contextKey) const exposeToLegacy = lifecycle.legacyDetectContextKey === contextKey if (exposeToLegacy) { @@ -144,12 +164,13 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se } return typed }) - .catch((): TuiAgent[] => { + .catch((error: unknown): TuiAgent[] => { if ( requestGeneration === lifecycle.localDetectionGeneration && lifecycle.detectPromises.get(contextKey) === pending ) { lifecycle.failedDetectContextKeys.add(contextKey) + markLocalAgentRefusal(lifecycle, contextKey, error) const exposeToLegacy = lifecycle.legacyDetectContextKey === contextKey if (exposeToLegacy) { lifecycle.legacyDetectContextKey = null @@ -173,14 +194,20 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se lifecycle.detectPromises.delete(contextKey) } }) + if (wake) { + lifecycle.wakeDetects.add(pending) + } lifecycle.detectPromises.set(contextKey, pending) return pending }, - refreshAllDetectedAgents: (worktreeId?: string | null): Promise => - actions.refreshDetectedAgents(worktreeId, true), - refreshDetectedAgents: (worktreeId?: string | null, includeDisabled = false) => - refreshLocalAgentInventory(set, get, lifecycle, worktreeId, includeDisabled), + refreshAllDetectedAgents: ( + worktreeId?: string | null, + options?: LocalAgentRefreshOptions + ): Promise => + refreshLocalAgentInventory(set, get, lifecycle, worktreeId, true, options), + refreshDetectedAgents: (worktreeId?: string | null, options?: LocalAgentRefreshOptions) => + refreshLocalAgentInventory(set, get, lifecycle, worktreeId, false, options), clearLocalDetectedAgentContextsForProjects: (projectIds: readonly string[]) => { const eviction = contextEviction.getLocalAgentContextEviction({ @@ -189,7 +216,8 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se internalContextKeys: [ ...lifecycle.detectPromises.keys(), ...lifecycle.refreshPromises.keys(), - ...lifecycle.failedDetectContextKeys + ...lifecycle.failedDetectContextKeys, + ...lifecycle.refusedContextKeys ], detectedContextKey: lifecycle.detectedContextKey, legacyDetectContextKey: lifecycle.legacyDetectContextKey, @@ -202,6 +230,7 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se lifecycle.detectPromises.delete(contextKey) lifecycle.refreshPromises.delete(contextKey) lifecycle.failedDetectContextKeys.delete(contextKey) + lifecycle.refusedContextKeys.delete(contextKey) } lifecycle.detectedContextKey = eviction.detectedContextKey lifecycle.legacyDetectContextKey = eviction.legacyDetectContextKey @@ -215,6 +244,7 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se lifecycle.detectPromises.clear() lifecycle.refreshPromises.clear() lifecycle.failedDetectContextKeys.clear() + lifecycle.refusedContextKeys.clear() lifecycle.detectedContextKey = null lifecycle.legacyDetectContextKey = null lifecycle.legacyRefreshContextKey = null diff --git a/src/renderer/src/store/slices/local-detected-agent-store-state.ts b/src/renderer/src/store/slices/local-detected-agent-store-state.ts index 1f0a1ecb53d..5451d024e00 100644 --- a/src/renderer/src/store/slices/local-detected-agent-store-state.ts +++ b/src/renderer/src/store/slices/local-detected-agent-store-state.ts @@ -5,6 +5,9 @@ import type { } from '../../../../shared/shell-path-hydration-types' import type { TuiAgent } from '../../../../shared/tui-agent' +/** `wakeWslDistro`: a user act (a click, a runtime choice) that may start a stopped WSL distro. */ +export type LocalAgentRefreshOptions = { wakeWslDistro?: boolean } + export type LocalDetectedAgentState = { readLocalAgentInventory: (worktreeId?: string | null) => { ids: TuiAgent[] | null @@ -19,10 +22,20 @@ export type LocalDetectedAgentState = { pathSource: PathSource | null pathFailureReason: ShellHydrationFailureReason | null ensureAllDetectedAgents: (worktreeId?: string | null) => Promise - refreshAllDetectedAgents: (worktreeId?: string | null) => Promise + refreshAllDetectedAgents: ( + worktreeId?: string | null, + options?: LocalAgentRefreshOptions + ) => Promise ensureNativeDetectedAgents: () => Promise ensureDetectedAgents: (worktreeId?: string | null) => Promise - refreshDetectedAgents: (worktreeId?: string | null) => Promise + /** The focused (or given) workspace's last agent probe was refused because its WSL distro was not running. */ + isLocalAgentInventoryRefused: (worktreeId?: string | null) => boolean + /** Opening an agent-launch picker is a user act: a list a stopped WSL distro refused may wake it. */ + ensureDetectedAgentsForPicker: (worktreeId?: string | null) => Promise + refreshDetectedAgents: ( + worktreeId?: string | null, + options?: LocalAgentRefreshOptions + ) => Promise clearLocalDetectedAgentContextsForProjects: (projectIds: readonly string[]) => void clearLocalDetectedAgents: () => void } diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index 354ca49f635..65e6b580c99 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -101,6 +101,15 @@ function deferred() { return { promise, resolve, reject } } +function focusWorktree(store: ReturnType, path: string): void { + store.setState({ + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + worktreesByRepo: { 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path })] }, + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1' + }) +} + describe('createPreflightSlice', () => { it('dedupes concurrent non-forced checks', async () => { resetPreflightMocks() @@ -173,6 +182,40 @@ describe('createPreflightSlice', () => { expect(store.getState().preflightStatus?.glab?.installed).toBe(true) }) + it('sends a focus wake instead of joining a background forced poll', async () => { + resetPreflightMocks() + const poll = deferred() + preflightCheck.mockReturnValueOnce(poll.promise).mockResolvedValueOnce(makeStatus(true)) + const store = createTestStore() + focusWorktree(store, '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo') + + const background = store.getState().refreshPreflightStatus({ force: true }) + // The poll may be refused for a stopped WSL distro; the user's focus must still reach main. + const focus = store.getState().refreshPreflightStatus({ force: true, wakeWslDistro: true }) + const again = store.getState().refreshPreflightStatus({ force: true }) + + expect(preflightCheck).toHaveBeenCalledTimes(2) + expect(preflightCheck).toHaveBeenLastCalledWith({ + force: true, + wakeWslDistro: true, + wslDistro: 'Ubuntu' + }) + poll.resolve(makeStatus(false)) + await Promise.all([background, focus, again]) + expect(store.getState().preflightStatus?.glab?.installed).toBe(true) + }) + + it('a focus with a native workspace active wakes no WSL distro', async () => { + resetPreflightMocks() + preflightCheck.mockResolvedValue(makeStatus(true)) + const store = createTestStore() + focusWorktree(store, '/home/alice/repo') + + await store.getState().refreshPreflightStatus({ force: true, wakeWslDistro: true }) + + expect(preflightCheck).toHaveBeenCalledWith({ force: true }) + }) + it('checks integrations inside the active WSL worktree distro', async () => { resetPreflightMocks() preflightCheck.mockResolvedValueOnce(makeStatus(true)) diff --git a/src/renderer/src/store/slices/preflight.ts b/src/renderer/src/store/slices/preflight.ts index a0cff01eb1f..aaeb03c83e6 100644 --- a/src/renderer/src/store/slices/preflight.ts +++ b/src/renderer/src/store/slices/preflight.ts @@ -17,12 +17,13 @@ export type PreflightSlice = { preflightStatusLoading: boolean preflightStatusError: string | null - refreshPreflightStatus: (options?: { force?: boolean }) => Promise + /** `wakeWslDistro`: a user act (window focus, a click) that may start the focused workspace's stopped WSL distro. */ + refreshPreflightStatus: (options?: { force?: boolean; wakeWslDistro?: boolean }) => Promise invalidatePreflightStatus: () => void } let nonForcedPreflightRequest: { key: string; promise: Promise } | null = null -let forcedPreflightRequest: { key: string; promise: Promise } | null = null +let forcedPreflightRequest: { key: string; promise: Promise; wake: boolean } | null = null let latestPreflightRequestId = 0 function getErrorMessage(error: unknown): string { @@ -31,16 +32,22 @@ function getErrorMessage(error: unknown): string { function buildPreflightArgs( force: boolean, + wake: boolean, context: LocalPreflightContext -): (PreflightRuntimeContext & { force?: boolean }) | undefined { +): (PreflightRuntimeContext & { force?: boolean; wakeWslDistro?: boolean }) | undefined { const wslDistro = context?.wslDistro const wslDefault = context?.wslDefault === true const projectRuntime = context?.projectRuntime if (!force && !wslDistro && !wslDefault && !projectRuntime) { return undefined } + // Only a focused WSL workspace's own distro may wake; a native workspace wakes nothing. + const wslWorkspace = + Boolean(wslDistro) || + (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') return { ...(force ? { force: true } : {}), + ...(wake && wslWorkspace ? { wakeWslDistro: true } : {}), ...(projectRuntime ? { projectRuntime } : {}), ...(wslDistro ? { wslDistro } : {}), ...(wslDefault ? { wslDefault: true } : {}) @@ -69,6 +76,7 @@ export const createPreflightSlice: StateCreator { const force = options?.force === true + const wake = options?.wakeWslDistro === true const context = getLocalPreflightContext(get()) const contextKey = localPreflightContextKey(context) if (!force && forcedPreflightRequest?.key === contextKey) { @@ -77,7 +85,12 @@ export const createPreflightSlice: StateCreator