mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user