fix(wsl): background preflight and agent detection never boot a stopped WSL distro (E9) (#35411)

* fix(wsl): background preflight and agent detection never boot a stopped WSL distro (E9)

Integration checks (git/gh/glab --version and auth status) and agent detection ran inside the
focused workspace's WSL distro on every mount, poll and status-bar render, and wsl.exe starts a
stopped distro. They now take a settled running read first and refuse (WSL_DISTRO_STOPPED or
WSL_DISTRO_UNVERIFIABLE) when it is not running. Window focus/visibility, retry buttons, agent
refresh buttons, a runtime choice in Settings and first-run onboarding pass wakeWslDistro, which
runs the check as a user act on that distro only. Running distros behave exactly as before.

* fix(wsl): a focus wakes only a focused WSL workspace's distro; end-to-end preflight gate test

A focus with a native workspace active sends no wake. Ports the real running-list preflight case
from #35395 and adds user-act scoping cases.

* test(wsl): model the running gate in the preflight RPC suite and cover a stopped distro

Windows CI runs the real gate, so the WSL detection case must state that the distro runs.

* fix(wsl): an agent refresh click never joins a background refresh it would be refused with

Store the wake flag with each in-flight refresh, as preflight.ts does, so a status-bar or Agents
refresh click during a mount refresh reaches main as a user act. Note why the RPC project branch
stays ungated.

* fix(wsl): a refused background check waits for the distro, and an agent picker wakes it

A preflight or agent detection refused for a stopped WSL distro was final: once the distro came
up, nothing re-ran it, so the tab-bar + menu offered no WSL agents (real-WSL golden). Now a refusal
is deferred until the distro is next confirmed running (deferUntilWslDistroRunning), and main tells
renderers to re-ask. Opening an agent-launch picker (tab-bar +, composer, continuation dialog,
source-control action dialog) whose list was refused retries it as a wake, never joining a
background probe.

* fix(wsl): an open agent picker wakes a refused list once, and never retries real failures

The picker wake fired whenever the list read 'failed', so a woken probe that failed again
(pending -> failed) re-fired it: an endless wsl.exe loop while the picker stayed open, and repeated
probes for native failures too. It now fires at most once per open per target, and only when the
last probe was an E9 refusal (distro not running/unverifiable), matched by the gate's message.

* fix(wsl): forget agent-inventory refusals when the inventory or its project contexts are cleared
This commit is contained in:
Neil
2026-10-28 18:01:42 -07:00
committed by GitHub
parent 3a51382df5
commit 9babef2866
45 changed files with 978 additions and 78 deletions
@@ -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<unknown>) => act()
}))
// WSL detection uses runWslProcess, so child_process mocks cannot observe it.
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
@@ -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<unknown>) => act()
}))
// WSL detection uses runWslProcess, so child_process mocks cannot observe it.
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
@@ -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<unknown>) => 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 })
@@ -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<T>(run: () => Promise<T>): Promise<T> {
try {
return await run()
} catch (error) {
if (error instanceof WslDistroStoppedError || error instanceof WslDistroUnverifiableError) {
const { distro } = error
deferUntilWslDistroRunning(distro, RETRY_KEY, () => broadcastWslDistroRunning(distro))
}
throw error
}
}
@@ -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<string, (event: unknown, args?: unknown) => Promise<unknown>>(),
runWslProcess: vi.fn(),
readWslDistroRunningState: vi.fn(),
deferred: new Map<string, () => 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<unknown>) =>
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<typeof BackgroundDistroGate>()),
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<unknown> {
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()
})
})
+41 -8
View File
@@ -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<T>(
context: (PreflightRuntimeContext & PreflightWake) | undefined,
act: () => Promise<T>
): Promise<T> {
const wslTarget = context?.wakeWslDistro ? getPreflightWslTarget(context) : null
return deferRefusedPreflight(() =>
wslTarget ? runAsWslExecUserAct(wslTarget.distro, act) : act()
)
}
export function registerPreflightHandlers(store?: Pick<Store, 'getSettings'>): void {
const detectionOptions = (args?: AgentDetectionRequest) => {
@@ -29,23 +49,36 @@ export function registerPreflightHandlers(store?: Pick<Store, 'getSettings'>): v
'preflight:check',
async (
_event,
args?: PreflightRuntimeContext & { force?: boolean }
args?: PreflightRuntimeContext & PreflightWake & { force?: boolean }
): Promise<PreflightStatus> => {
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',
+5
View File
@@ -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<PreflightStatus> {
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') {
@@ -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<object>()),
runProcess: mocks.runProcess
}))
vi.mock('../wsl/wsl-runner', async (importOriginal) => ({
...(await importOriginal<object>()),
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()
})
})
@@ -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<unknown>) => act()
}))
vi.mock('./agent-detection', () => ({
detectInstalledAgentsWithShellPathHydration: detectInstalledMock,
refreshShellPathAndDetectAgents: refreshMock,
@@ -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<void> {
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 {
+46 -1
View File
@@ -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)
+16 -8
View File
@@ -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)
}
})
]
+45
View File
@@ -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<string | null> {
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<BlockedWslDistro | null> {
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<void> {
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<T>(
distro: string | null | undefined,
act: () => Promise<T>
): Promise<T> {
return runAsWslUserAct(await resolveWslExecDistro(distro), act)
}
@@ -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)
})
+10 -3
View File
@@ -43,13 +43,20 @@ export type PreflightRuntimeContext = {
}
export type PreflightApi = {
check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise<PreflightStatus>
check: (
args?: PreflightRuntimeContext & { force?: boolean; wakeWslDistro?: boolean }
) => Promise<PreflightStatus>
/** Complete by default; background callers opt into host-owned enabled preferences. */
detectAgents: (args?: PreflightRuntimeContext & { enabledOnly?: boolean }) => Promise<string[]>
detectAgents: (
args?: PreflightRuntimeContext & { enabledOnly?: boolean; wakeWslDistro?: boolean }
) => Promise<string[]>
/** 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<ZCodeInteractiveCapability>
/** `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<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{
+10 -3
View File
@@ -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<string[]> =>
ipcRenderer.invoke('preflight:detectAgents', args),
detectAgents: (
args?: PreflightRuntimeContext & { enabledOnly?: boolean; wakeWslDistro?: boolean }
): Promise<string[]> => 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<ZCodeInteractiveCapability> =>
ipcRenderer.invoke('preflight:zcodeInteractiveCapability'),
refreshAgents: (
args?: PreflightRuntimeContext & { enabledOnly?: boolean }
args?: PreflightRuntimeContext & { enabledOnly?: boolean; wakeWslDistro?: boolean }
): Promise<RefreshAgentsResult> => ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectRemoteAgents', args),
@@ -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
@@ -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 =
@@ -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()
@@ -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)
@@ -106,7 +106,7 @@ export function GitHubRow(props: { compact?: boolean } = {}): React.JSX.Element
<Button
variant="ghost"
size="sm"
onClick={() => void refreshPreflightStatus({ force: true })}
onClick={() => void refreshPreflightStatus({ force: true, wakeWslDistro: true })}
>
{translate('auto.components.onboarding.IntegrationsStep.80e3ce0bc9', 'Re-check')}
</Button>
@@ -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
}
@@ -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'
@@ -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', () => {
@@ -78,7 +78,7 @@ export function AgentsPane({
<AgentRuntimeSetting
settings={settings}
updateSettings={updateSettings}
refresh={refreshLocalAgents}
refresh={() => refreshLocalAgents(undefined, { wakeWslDistro: true })}
wslSupportedPlatform={wslSupportedPlatform}
wslAvailable={wslAvailable}
wslDistros={wslDistros}
@@ -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 })
})
})
@@ -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 })
}
/>
)}
</TaskSourceProviderCard>
@@ -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)
}
@@ -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)
@@ -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<unknown[]>
}
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<MockState>()((_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()
})
@@ -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])
}
@@ -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
@@ -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(
@@ -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)
@@ -0,0 +1,50 @@
import { beforeEach, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const state: {
preflightStatusError: string | null
refreshPreflightStatus: () => Promise<void>
ensureDetectedAgents: () => Promise<unknown[]>
} = {
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()
})
@@ -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()
})
)
}
@@ -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 })
})
})
+3 -2
View File
@@ -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(() => {
@@ -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<string, Promise<TuiAgent[]>>
refreshPromises: Map<string, Promise<TuiAgent[]>>
/** Refreshes sent as a user act; a background one may be refused for a stopped WSL distro. */
wakeRefreshes: WeakSet<Promise<TuiAgent[]>>
wakeDetects: WeakSet<Promise<TuiAgent[]>>
/** Contexts whose last probe the E9 gate refused (distro not running), not a real failure. */
refusedContextKeys: Set<string>
keptThroughFailure: WeakSet<TuiAgent[]>
failedDetectContextKeys: Set<string>
detectedContextKey: string | null
@@ -25,7 +32,8 @@ export function refreshLocalAgentInventory(
get: StoreApi<AppState>['getState'],
lifecycle: LocalAgentInventoryLifecycle,
worktreeId?: string | null,
includeDisabled = false
includeDisabled = false,
options: LocalAgentRefreshOptions = {}
): Promise<TuiAgent[]> {
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<LocalAgentInventoryLifecycle, 'refusedContextKeys'>,
contextKey: string,
error: unknown
): void {
if (isWslDistroRefusalError(error)) {
lifecycle.refusedContextKeys.add(contextKey)
} else {
lifecycle.refusedContextKeys.delete(contextKey)
}
}
@@ -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<string[]>((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 = () => {}
@@ -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<AppState, [], [], LocalDetectedAgentState>
@@ -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<TuiAgent[]> =>
actions.ensureDetectedAgents(worktreeId, false, true),
isLocalAgentInventoryRefused: (worktreeId?: string | null): boolean =>
lifecycle.refusedContextKeys.has(
localAgentInventoryContextKey(
getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId)
)
),
ensureDetectedAgentsForPicker: (worktreeId?: string | null): Promise<TuiAgent[]> =>
actions.ensureDetectedAgents(worktreeId, false, false, { wakeWslDistro: true }),
ensureDetectedAgents: (
worktreeId?: string | null,
nativeHost = false,
includeDisabled = false
includeDisabled = false,
options: LocalAgentRefreshOptions = {}
): Promise<TuiAgent[]> => {
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<TuiAgent[]> =>
actions.refreshDetectedAgents(worktreeId, true),
refreshDetectedAgents: (worktreeId?: string | null, includeDisabled = false) =>
refreshLocalAgentInventory(set, get, lifecycle, worktreeId, includeDisabled),
refreshAllDetectedAgents: (
worktreeId?: string | null,
options?: LocalAgentRefreshOptions
): Promise<TuiAgent[]> =>
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
@@ -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<TuiAgent[]>
refreshAllDetectedAgents: (worktreeId?: string | null) => Promise<TuiAgent[]>
refreshAllDetectedAgents: (
worktreeId?: string | null,
options?: LocalAgentRefreshOptions
) => Promise<TuiAgent[]>
ensureNativeDetectedAgents: () => Promise<TuiAgent[]>
ensureDetectedAgents: (worktreeId?: string | null) => Promise<TuiAgent[]>
refreshDetectedAgents: (worktreeId?: string | null) => Promise<TuiAgent[]>
/** 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<TuiAgent[]>
refreshDetectedAgents: (
worktreeId?: string | null,
options?: LocalAgentRefreshOptions
) => Promise<TuiAgent[]>
clearLocalDetectedAgentContextsForProjects: (projectIds: readonly string[]) => void
clearLocalDetectedAgents: () => void
}
@@ -101,6 +101,15 @@ function deferred<T>() {
return { promise, resolve, reject }
}
function focusWorktree(store: ReturnType<typeof createTestStore>, 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<PreflightStatus>()
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))
+19 -6
View File
@@ -17,12 +17,13 @@ export type PreflightSlice = {
preflightStatusLoading: boolean
preflightStatusError: string | null
refreshPreflightStatus: (options?: { force?: boolean }) => Promise<void>
/** `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<void>
invalidatePreflightStatus: () => void
}
let nonForcedPreflightRequest: { key: string; promise: Promise<void> } | null = null
let forcedPreflightRequest: { key: string; promise: Promise<void> } | null = null
let forcedPreflightRequest: { key: string; promise: Promise<void>; 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<AppState, [], [], PreflightSlice
refreshPreflightStatus: async (options) => {
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<AppState, [], [], PreflightSlice
if (!force && nonForcedPreflightRequest?.key === contextKey) {
return nonForcedPreflightRequest.promise
}
if (force && forcedPreflightRequest?.key === contextKey) {
// A background probe in flight may be refused for a stopped distro; a wake must not join it.
if (
force &&
forcedPreflightRequest?.key === contextKey &&
(forcedPreflightRequest.wake || !wake)
) {
return forcedPreflightRequest.promise
}
@@ -85,7 +98,7 @@ export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice
const contextChanged = get().preflightStatusContextKey !== contextKey
// Why: the host the context key names: the focused workspace's owner, else the default scope host.
const runtimeTarget = getActiveWorkspaceOwnerTarget(get()) ?? defaultScopeHost(get().settings)
const preflightArgs = buildPreflightArgs(force, context)
const preflightArgs = buildPreflightArgs(force, wake, context)
set({
preflightStatus: contextChanged ? null : get().preflightStatus,
preflightStatusChecked: contextChanged ? false : get().preflightStatusChecked,
@@ -136,7 +149,7 @@ export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice
if (!force) {
nonForcedPreflightRequest = { key: contextKey, promise: request }
} else {
forcedPreflightRequest = { key: contextKey, promise: request }
forcedPreflightRequest = { key: contextKey, promise: request, wake }
}
return request
+8
View File
@@ -0,0 +1,8 @@
/**
* True for the E9 gate's "distro not running / unverifiable" refusal. Over IPC only the message
* survives, so this matches WslDistroStoppedError and WslDistroUnverifiableError by text.
*/
export function isWslDistroRefusalError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /WSL distro '.+' is not running\.|WSL didn't answer whether '.+' is running/.test(message)
}