mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(wsl): hold main's background WSL start before the cwd probe; let the user's runtime start check a WSL folder (#35971)
* fix(wsl): hold main's background WSL start in the shared preflight; folder launch scope honours the user's act Main's background start (no wslUserAct) is now held in resolvePtySpawnStartupTarget, before the startup cwd probe and any provider. A runtime start the user asked for (CLI, window, phone, split) checks a WSL folder workspace's path as their act, so it may start the distro; background work is refused with the stopped-distro message instead of folder_workspace_path_unavailable. * fix(wsl): agent-session create and user resume check a WSL folder as the user's act; background holds with Start * fix(wsl): agent-session ensure and a failed start's kept pane pass the user's act to the WSL folder check * test(e2e): a prepared terminal.create dedupes as the caller's act
This commit is contained in:
@@ -94,7 +94,8 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis
|
||||
resolveStartupCwd: ctx.deps.resolvePtySpawnStartupCwd,
|
||||
...(args.wslPassiveRestore === true
|
||||
? {
|
||||
passiveWslRestore: {
|
||||
wslStartHold: {
|
||||
wslPassiveRestore: true,
|
||||
sessionId: ctx.effectiveSessionId,
|
||||
isNewSession: ctx.ownsNewIncarnation,
|
||||
attachOnly: args.attachOnlyView === true
|
||||
|
||||
@@ -52,6 +52,11 @@ vi.mock('../../codex/codex-pane-account-registry', () =>
|
||||
vi.mock('../../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
// Fake-win32 runs have no wsl.exe: the distro runs, so main's background-start hold passes (E9).
|
||||
vi.mock('../../wsl-background-distro-gate', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
readWslDistroRunningState: vi.fn(async () => 'running')
|
||||
}))
|
||||
|
||||
describe('registerPtyHandlers', () => {
|
||||
const { handlers, mainWindow } = setupPtyIpcSuite()
|
||||
|
||||
@@ -130,6 +130,11 @@ vi.mock('../../codex/codex-state-db-backfill-recovery', () =>
|
||||
vi.mock('../../../shared/openssh-default-shell', () => ({
|
||||
readOpenSshDefaultShell: vi.fn(() => '')
|
||||
}))
|
||||
// Fake-win32 runs have no wsl.exe: the distro runs, so main's background-start hold passes (E9).
|
||||
vi.mock('../../wsl-background-distro-gate', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
readWslDistroRunningState: vi.fn(async () => 'running')
|
||||
}))
|
||||
|
||||
// Exercise real window-lane spawning through provider, telemetry and pane binding.
|
||||
describe('window lane: pty:spawn request -> provider', () => {
|
||||
|
||||
@@ -3,13 +3,12 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { finishPtyShutdown } from '../provider/liveness'
|
||||
import { prepareRuntimePtySpawn } from './spawn-preflight'
|
||||
import { buildRuntimePtySpawnOptions } from './spawn-options'
|
||||
import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state'
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
import { makeRuntimePtySpawnTestDeps } from './spawn-preflight-test-deps-fixture'
|
||||
import { ClaudeProfileRouter } from '../../../claude-accounts/claude-profile-router'
|
||||
import { installClaudeProfileRouter } from '../../../claude-accounts/claude-profile-installed-router'
|
||||
|
||||
@@ -42,41 +41,7 @@ const HOST_DEFAULT_SHELL = 'powershell.exe'
|
||||
const hostPlatform = process.platform
|
||||
|
||||
function makeDeps(): PtyRuntimeControllerDeps {
|
||||
const noCodexResumeLaunch: PtyRuntimeControllerDeps['noCodexResumeLaunch'] = (command) => ({
|
||||
codexResumeHome: null,
|
||||
command,
|
||||
notifyResumeUnavailable: false,
|
||||
droppedResumeArgv: false,
|
||||
providerSession: null
|
||||
})
|
||||
return {
|
||||
store: undefined,
|
||||
getSettings: () => ({
|
||||
...getDefaultSettings('/tmp'),
|
||||
terminalWindowsShell: HOST_DEFAULT_SHELL
|
||||
}),
|
||||
adoptStablePane: async () => null,
|
||||
getLocalPtyStartupPromise: () => undefined,
|
||||
getLocalPtyProviderStartupPromise: () => undefined,
|
||||
prepareCodexResumeHome: () => null,
|
||||
resolveCodexResumeLaunch: async (command) => noCodexResumeLaunch(command),
|
||||
noCodexResumeLaunch,
|
||||
reconcileSharedRuntimeResumeHome: async (resumeHome) => resumeHome.codexHomePath,
|
||||
stripSequencedStartupResumeArgv: (env) => env,
|
||||
assertWorkspaceAcceptsNewPty: () => undefined,
|
||||
resolvePtySpawnStartupCwd: (_worktreeId, cwd) => cwd,
|
||||
requestSerializedBuffer: async () => null,
|
||||
shutdownProviderAndDetectExit: async () => false,
|
||||
rememberSyntheticKillExit: () => {},
|
||||
rememberRetiredRejectedPty: () => {},
|
||||
sendPtyExitToRenderer: () => {},
|
||||
sendPtySpawnedToRenderer: () => {},
|
||||
finishPtyShutdown,
|
||||
trustedTerminalHandleEnv: new Set(),
|
||||
retiredRejectedPtyIds: new Map(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest.
|
||||
mainWindow: {} as BrowserWindow
|
||||
}
|
||||
return makeRuntimePtySpawnTestDeps(HOST_DEFAULT_SHELL)
|
||||
}
|
||||
|
||||
async function resolveSpawnOptions(
|
||||
@@ -216,7 +181,9 @@ describe('runtime pty spawn preflight: Claude account routing in a WSL pane', ()
|
||||
rows: 40,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\u',
|
||||
shellOverride: 'wsl.exe',
|
||||
env: { KEEP: '1' }
|
||||
env: { KEEP: '1' },
|
||||
// The user's start: a background one holds until the distro is proven running (E9).
|
||||
wslUserAct: true
|
||||
}
|
||||
const ctx = createRuntimePtySpawnState(makeDeps(), args)
|
||||
await expect(prepareRuntimePtySpawn(ctx)).resolves.toBeNull()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { finishPtyShutdown } from '../provider/liveness'
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
|
||||
/** Runtime spawn deps for preflight tests: no store, a host default shell, and inert side effects. */
|
||||
export function makeRuntimePtySpawnTestDeps(
|
||||
hostDefaultShell = 'powershell.exe'
|
||||
): PtyRuntimeControllerDeps {
|
||||
const noCodexResumeLaunch: PtyRuntimeControllerDeps['noCodexResumeLaunch'] = (command) => ({
|
||||
codexResumeHome: null,
|
||||
command,
|
||||
notifyResumeUnavailable: false,
|
||||
droppedResumeArgv: false,
|
||||
providerSession: null
|
||||
})
|
||||
return {
|
||||
store: undefined,
|
||||
getSettings: () => ({
|
||||
...getDefaultSettings('/tmp'),
|
||||
terminalWindowsShell: hostDefaultShell
|
||||
}),
|
||||
adoptStablePane: async () => null,
|
||||
getLocalPtyStartupPromise: () => undefined,
|
||||
getLocalPtyProviderStartupPromise: () => undefined,
|
||||
prepareCodexResumeHome: () => null,
|
||||
resolveCodexResumeLaunch: async (command) => noCodexResumeLaunch(command),
|
||||
noCodexResumeLaunch,
|
||||
reconcileSharedRuntimeResumeHome: async (resumeHome) => resumeHome.codexHomePath,
|
||||
stripSequencedStartupResumeArgv: (env) => env,
|
||||
assertWorkspaceAcceptsNewPty: () => undefined,
|
||||
resolvePtySpawnStartupCwd: (_worktreeId, cwd) => cwd,
|
||||
requestSerializedBuffer: async () => null,
|
||||
shutdownProviderAndDetectExit: async () => false,
|
||||
rememberSyntheticKillExit: () => {},
|
||||
rememberRetiredRejectedPty: () => {},
|
||||
sendPtyExitToRenderer: () => {},
|
||||
sendPtySpawnedToRenderer: () => {},
|
||||
finishPtyShutdown,
|
||||
trustedTerminalHandleEnv: new Set(),
|
||||
retiredRejectedPtyIds: new Map(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest.
|
||||
mainWindow: {} as BrowserWindow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as BackgroundDistroGateModule from '../../../wsl-background-distro-gate'
|
||||
import { wslDistroHoldMessage } from '../../../../shared/wsl-distro-stopped-hold'
|
||||
import { prepareRuntimePtySpawn } from './spawn-preflight'
|
||||
import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state'
|
||||
import { makeRuntimePtySpawnTestDeps } from './spawn-preflight-test-deps-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readWslDistroRunningState: vi.fn<() => Promise<'running' | 'stopped' | 'unverifiable'>>()
|
||||
}))
|
||||
vi.mock('../../../wsl-background-distro-gate', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof BackgroundDistroGateModule>()),
|
||||
readWslDistroRunningState: mocks.readWslDistroRunningState
|
||||
}))
|
||||
|
||||
const WSL_CWD = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
|
||||
const hostPlatform = process.platform
|
||||
|
||||
async function prepare(args: Partial<RuntimePtySpawnArgs>): Promise<void> {
|
||||
const ctx = createRuntimePtySpawnState(makeRuntimePtySpawnTestDeps(), {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: WSL_CWD,
|
||||
...args
|
||||
})
|
||||
try {
|
||||
await prepareRuntimePtySpawn(ctx)
|
||||
} finally {
|
||||
ctx.finishTerminalInstall()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mocks.readWslDistroRunningState.mockReset()
|
||||
mocks.readWslDistroRunningState.mockResolvedValue('stopped')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform })
|
||||
})
|
||||
|
||||
describe("main's background start in a stopped WSL distro (E9)", () => {
|
||||
it('holds in the shared preflight, whichever provider would run it', async () => {
|
||||
await expect(prepare({})).rejects.toThrow(wslDistroHoldMessage('Ubuntu', 'stopped'))
|
||||
expect(mocks.readWslDistroRunningState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('names an unanswered read as unverifiable', async () => {
|
||||
mocks.readWslDistroRunningState.mockResolvedValue('unverifiable')
|
||||
await expect(prepare({})).rejects.toThrow(wslDistroHoldMessage('Ubuntu', 'unverifiable'))
|
||||
})
|
||||
|
||||
it("never holds the user's start", async () => {
|
||||
await expect(prepare({ wslUserAct: true })).resolves.toBeUndefined()
|
||||
expect(mocks.readWslDistroRunningState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts once the distro runs', async () => {
|
||||
mocks.readWslDistroRunningState.mockResolvedValue('running')
|
||||
await expect(prepare({})).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -76,7 +76,11 @@ export async function prepareRuntimePtySpawn(
|
||||
projectRuntimeSource: { kind: 'store', store: ctx.deps.store },
|
||||
preAdopted: ctx.preAdoptedStablePane !== null,
|
||||
getSettings: ctx.deps.getSettings,
|
||||
resolveStartupCwd: ctx.deps.resolvePtySpawnStartupCwd
|
||||
resolveStartupCwd: ctx.deps.resolvePtySpawnStartupCwd,
|
||||
// Default-deny, as spawn-options' `wslBackgroundStart`: held before the cwd probe and any provider.
|
||||
...(args.wslUserAct === true
|
||||
? {}
|
||||
: { wslStartHold: { wslBackgroundStart: true, sessionId: ctx.sessionId } })
|
||||
})
|
||||
ctx.cwd = target.cwd
|
||||
ctx.requestedCwd = target.requestedCwd
|
||||
|
||||
@@ -55,8 +55,11 @@ type PtySpawnStartupArgs = {
|
||||
cwd: string | undefined,
|
||||
missingDirFallback?: TerminalStartupCwdMissingDirFallback
|
||||
) => string | undefined
|
||||
/** A restore the user did not ask for; held here, before any provider or cwd probe can boot WSL. */
|
||||
passiveWslRestore?: Pick<PtySpawnOptions, 'sessionId' | 'isNewSession' | 'attachOnly'>
|
||||
/** A restore or background start the user did not ask for; held here, before any provider or cwd probe can boot WSL. */
|
||||
wslStartHold?: Pick<
|
||||
PtySpawnOptions,
|
||||
'wslPassiveRestore' | 'wslBackgroundStart' | 'sessionId' | 'isNewSession' | 'attachOnly'
|
||||
>
|
||||
}
|
||||
|
||||
export async function resolvePtySpawnStartupTarget(
|
||||
@@ -73,10 +76,10 @@ export async function resolvePtySpawnStartupTarget(
|
||||
args.shellOverride,
|
||||
process.env.COMSPEC || 'powershell.exe'
|
||||
)
|
||||
if (args.passiveWslRestore && !args.preAdopted) {
|
||||
if (args.wslStartHold && !args.preAdopted) {
|
||||
// E9: whichever provider runs (daemon, degraded or off), and before the cwd probe's `wsl.exe`.
|
||||
await holdPassiveRestoreOfStoppedWslDistro(
|
||||
{ ...args.passiveWslRestore, wslPassiveRestore: true },
|
||||
args.wslStartHold,
|
||||
resolveWslSessionContext({
|
||||
cwd: args.cwd,
|
||||
sessionId: args.sessionId,
|
||||
|
||||
@@ -109,7 +109,14 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG
|
||||
const reclaim: AgentSessionCreateOperation['reclaim'] = {}
|
||||
const operation = (async (): Promise<RuntimeCreateAgentSessionResult> => {
|
||||
// Reserve before async preflight so racing retries cannot independently dispatch.
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree)
|
||||
// Only the agent-session RPC reaches here: the caller's own act, as its spawn below.
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(
|
||||
request.worktree,
|
||||
undefined,
|
||||
{
|
||||
wslUserAct: true
|
||||
}
|
||||
)
|
||||
if (
|
||||
!(await this.executionOwnerSupportsAgentSessionOperation(
|
||||
workspace,
|
||||
|
||||
@@ -228,7 +228,8 @@ describe('agent session RPC methods', () => {
|
||||
'id:worktree-1',
|
||||
undefined,
|
||||
false,
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
{ wslUserAct: true }
|
||||
)
|
||||
expect(createTerminal).toHaveBeenCalledWith('id:worktree-1', {
|
||||
wslUserAct: true,
|
||||
|
||||
@@ -46,7 +46,8 @@ describe('terminal.create RPC idempotency', () => {
|
||||
'id:worktree-1',
|
||||
'mutation-1',
|
||||
false,
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
{ wslUserAct: true }
|
||||
)
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
'id:worktree-1',
|
||||
|
||||
@@ -118,7 +118,8 @@ export const TERMINAL_LIFECYCLE_METHODS = [
|
||||
leafId: params.leafId,
|
||||
...(params.spawnIntent ? { spawnIntent: params.spawnIntent } : {}),
|
||||
...(preAllocatedHandle ? { preAllocatedHandle } : {})
|
||||
})
|
||||
}),
|
||||
{ wslUserAct: true }
|
||||
)
|
||||
// Switch point. A CLI start is work in the workspace; a window or phone tab is a person opening one.
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const statMock = vi.hoisted(() => vi.fn<(path: string) => Promise<{ isDirectory(): boolean }>>())
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof FsPromises>()),
|
||||
stat: statMock
|
||||
}))
|
||||
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures.
|
||||
const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec')
|
||||
await import('./orca-runtime-test-lifecycle.spec')
|
||||
const {
|
||||
createFolderWorkspaceRuntimeStore,
|
||||
makeFolderProjectGroup,
|
||||
makeFolderWorkspace,
|
||||
TEST_FOLDER_WORKSPACE_KEY
|
||||
} = await import('./orca-runtime-test-fixtures.spec')
|
||||
const { blockedWslDistroForPath } = await import('../wsl/wsl-unc-access-gate')
|
||||
const { isWslUserActFor } = await import('../wsl/wsl-user-act-scope')
|
||||
|
||||
const PARENT = '\\\\wsl.localhost\\Ubuntu\\home\\me'
|
||||
const FOLDER = `${PARENT}\\notes`
|
||||
const hostPlatform = process.platform
|
||||
const statsInUserAct: boolean[] = []
|
||||
|
||||
function failedStart(wslUserAct: boolean): Promise<unknown> {
|
||||
const runtime = new OrcaRuntimeService(
|
||||
createFolderWorkspaceRuntimeStore(
|
||||
makeFolderWorkspace({ folderPath: FOLDER }),
|
||||
makeFolderProjectGroup({ parentPath: PARENT })
|
||||
)
|
||||
)
|
||||
return runtime
|
||||
.createTerminal(`id:${TEST_FOLDER_WORKSPACE_KEY}`, {
|
||||
keepPaneOnStartFailure: true,
|
||||
startFailure: new Error('agent runtime needs repair'),
|
||||
...(wslUserAct ? { wslUserAct: true as const } : {})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
statsInUserAct.length = 0
|
||||
statMock.mockReset()
|
||||
statMock.mockImplementation(async () => {
|
||||
statsInUserAct.push(isWslUserActFor('Ubuntu'))
|
||||
return { isDirectory: () => true }
|
||||
})
|
||||
// Stopped, as the real gate reads it: only the user's act on the distro passes.
|
||||
vi.mocked(blockedWslDistroForPath).mockImplementation(async () =>
|
||||
isWslUserActFor('Ubuntu') ? null : { distro: 'Ubuntu', state: 'stopped' }
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform })
|
||||
vi.mocked(blockedWslDistroForPath).mockReset()
|
||||
vi.mocked(blockedWslDistroForPath).mockResolvedValue(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('the pane a failed start keeps, in a folder workspace on a stopped WSL distro (E9)', () => {
|
||||
it("checks the folder as the user's act when the user started it", async () => {
|
||||
await failedStart(true)
|
||||
expect(statsInUserAct).toEqual([true])
|
||||
})
|
||||
|
||||
it('never touches the folder for a background start', async () => {
|
||||
await failedStart(false)
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const statMock = vi.hoisted(() => vi.fn<(path: string) => Promise<{ isDirectory(): boolean }>>())
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof FsPromises>()),
|
||||
stat: statMock
|
||||
}))
|
||||
|
||||
// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures.
|
||||
const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec')
|
||||
await import('./orca-runtime-test-lifecycle.spec')
|
||||
const {
|
||||
createFolderWorkspaceRuntimeStore,
|
||||
makeFolderProjectGroup,
|
||||
makeFolderWorkspace,
|
||||
TEST_FOLDER_WORKSPACE_KEY
|
||||
} = await import('./orca-runtime-test-fixtures.spec')
|
||||
const { blockedWslDistroForPath } = await import('../wsl/wsl-unc-access-gate')
|
||||
const { isWslUserActFor } = await import('../wsl/wsl-user-act-scope')
|
||||
const { wslDistroHoldMessage } = await import('../../shared/wsl-distro-stopped-hold')
|
||||
const { LOCAL_EXECUTION_HOST_ID } = await import('../../shared/execution-host')
|
||||
|
||||
const PARENT = '\\\\wsl.localhost\\Ubuntu\\home\\me'
|
||||
const FOLDER = `${PARENT}\\notes`
|
||||
const hostPlatform = process.platform
|
||||
const statsInUserAct: boolean[] = []
|
||||
|
||||
function makeRuntime(): InstanceType<typeof OrcaRuntimeService> {
|
||||
return new OrcaRuntimeService(
|
||||
createFolderWorkspaceRuntimeStore(
|
||||
makeFolderWorkspace({ folderPath: FOLDER }),
|
||||
makeFolderProjectGroup({ parentPath: PARENT })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
statsInUserAct.length = 0
|
||||
statMock.mockReset()
|
||||
statMock.mockImplementation(async () => {
|
||||
statsInUserAct.push(isWslUserActFor('Ubuntu'))
|
||||
return { isDirectory: () => true }
|
||||
})
|
||||
// Stopped, as the real gate reads it: only the user's act on the distro passes.
|
||||
vi.mocked(blockedWslDistroForPath).mockImplementation(async () =>
|
||||
isWslUserActFor('Ubuntu') ? null : { distro: 'Ubuntu', state: 'stopped' }
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform })
|
||||
vi.mocked(blockedWslDistroForPath).mockReset()
|
||||
vi.mocked(blockedWslDistroForPath).mockResolvedValue(null)
|
||||
})
|
||||
|
||||
describe('a runtime start in a folder workspace on a stopped WSL distro (E9)', () => {
|
||||
it("checks the folder as the user's act, which may start the distro", async () => {
|
||||
await expect(
|
||||
makeRuntime().resolveTerminalWorkspaceLaunchScope(
|
||||
`id:${TEST_FOLDER_WORKSPACE_KEY}`,
|
||||
undefined,
|
||||
{
|
||||
wslUserAct: true
|
||||
}
|
||||
)
|
||||
).resolves.toMatchObject({ id: TEST_FOLDER_WORKSPACE_KEY, path: FOLDER })
|
||||
expect(statsInUserAct).toEqual([true])
|
||||
})
|
||||
|
||||
it('treats a deduplicated CLI create as the user act too', async () => {
|
||||
const run = vi.fn(async () => ({ handle: 'term-1' }))
|
||||
await makeRuntime().dedupeTerminalCreate(
|
||||
'local',
|
||||
`id:${TEST_FOLDER_WORKSPACE_KEY}`,
|
||||
'mutation-1',
|
||||
false,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dedupe returns `run`'s result untouched; only the launch scope is under test.
|
||||
run as never,
|
||||
{ wslUserAct: true }
|
||||
)
|
||||
expect(statsInUserAct).toEqual([true])
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('checks the folder as the user act for an agent-session create', async () => {
|
||||
await makeRuntime()
|
||||
.createAgentSession({
|
||||
clientOperationId: `${Date.now()}-0123456789abcdef0123456789abcdef`,
|
||||
worktree: `id:${TEST_FOLDER_WORKSPACE_KEY}`,
|
||||
agent: 'claude'
|
||||
})
|
||||
// Later launch steps are out of scope; only the folder check's act is under test.
|
||||
.catch(() => undefined)
|
||||
expect(statsInUserAct[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('checks the folder as the user act for a resume the user asked for', async () => {
|
||||
await makeRuntime().resumeAgentSessionInWorkspace(TEST_FOLDER_WORKSPACE_KEY, {
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'provider-session-1' },
|
||||
captureHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
mutationId: 'resume-1'
|
||||
})
|
||||
expect(statsInUserAct[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('checks the folder as the user act for an explicit agent-session ensure', async () => {
|
||||
await makeRuntime()
|
||||
.ensureAgentSession({
|
||||
kind: 'explicit',
|
||||
worktree: `id:${TEST_FOLDER_WORKSPACE_KEY}`,
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'provider-session-1' }
|
||||
})
|
||||
// Later launch steps are out of scope; only the folder check's act is under test.
|
||||
.catch(() => undefined)
|
||||
expect(statsInUserAct[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('holds background work with Start, naming the distro, without touching the folder', async () => {
|
||||
await expect(
|
||||
makeRuntime().resolveTerminalWorkspaceLaunchScope(`id:${TEST_FOLDER_WORKSPACE_KEY}`)
|
||||
).rejects.toThrow(wslDistroHoldMessage('Ubuntu', 'stopped'))
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+4
-1
@@ -106,7 +106,10 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree)
|
||||
// An explicit ensure is the caller's own act (automatic is refused above), as its spawn below.
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree, undefined, {
|
||||
wslUserAct: true
|
||||
})
|
||||
const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent)
|
||||
if (
|
||||
!namespace ||
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
getFolderWorkspacePathStatus,
|
||||
inferFolderWorkspacePathConnection
|
||||
} from '../../project-groups/folder-workspace-path-status'
|
||||
import {
|
||||
blockedWslDistroForPath,
|
||||
runAsWslUserAct,
|
||||
wslDistroForPath
|
||||
} from '../../wsl/wsl-unc-access-gate'
|
||||
import { wslDistroHoldMessage } from '../../../shared/wsl-distro-stopped-hold'
|
||||
import type { TerminalWorkspaceLaunchScope } from '../runtime-legacy-worker-terminal-recovery-types'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type { ResolvedWorktree } from '../runtime-worktree-path-identity'
|
||||
@@ -117,8 +123,10 @@ export abstract class OrcaRuntimeWithTransitionGraphReloadToTerminalState extend
|
||||
return connection.kind === 'ssh' ? connection.connectionId : null
|
||||
}
|
||||
|
||||
/** `wslUserAct`: the user's start, so its folder check may start a stopped WSL distro (E9). */
|
||||
protected async resolveFolderWorkspaceLaunchScope(
|
||||
selector: string
|
||||
selector: string,
|
||||
options: { wslUserAct?: boolean } = {}
|
||||
): Promise<(TerminalWorkspaceLaunchScope & { folderWorkspace: FolderWorkspace }) | null> {
|
||||
const workspace = this.resolveFolderWorkspaceSelector(selector)
|
||||
if (!workspace) {
|
||||
@@ -127,11 +135,24 @@ export abstract class OrcaRuntimeWithTransitionGraphReloadToTerminalState extend
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const status = await getFolderWorkspacePathStatus(this.store, {
|
||||
scope: 'folder-workspace',
|
||||
folderWorkspaceId: workspace.id
|
||||
})
|
||||
assertFolderWorkspacePathUsable(status)
|
||||
const store = this.store
|
||||
const readStatus = () =>
|
||||
getFolderWorkspacePathStatus(store, {
|
||||
scope: 'folder-workspace',
|
||||
folderWorkspaceId: workspace.id
|
||||
})
|
||||
if (options.wslUserAct) {
|
||||
assertFolderWorkspacePathUsable(
|
||||
await runAsWslUserAct(wslDistroForPath(workspace.folderPath), readStatus)
|
||||
)
|
||||
} else {
|
||||
// Background work holds with Start (a pane's card, an automation's hold), never "unavailable".
|
||||
const blocked = await blockedWslDistroForPath(workspace.folderPath)
|
||||
if (blocked) {
|
||||
throw new Error(wslDistroHoldMessage(blocked.distro, blocked.state))
|
||||
}
|
||||
assertFolderWorkspacePathUsable(await readStatus())
|
||||
}
|
||||
return {
|
||||
id: folderWorkspaceKey(workspace.id),
|
||||
path: workspace.folderPath,
|
||||
|
||||
@@ -225,7 +225,9 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu
|
||||
if (resumeStartup === undefined && tab.launchAgent) {
|
||||
let workspace
|
||||
try {
|
||||
workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
|
||||
workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`, undefined, {
|
||||
wslUserAct: wslUserAct === true
|
||||
})
|
||||
agentStartup = await this.resolveMobileSessionTerminalCommand(workspace, {
|
||||
agent: tab.launchAgent
|
||||
})
|
||||
|
||||
@@ -109,7 +109,10 @@ export abstract class OrcaRuntimeWithResumeAgentSessionInWorkspace extends OrcaR
|
||||
}
|
||||
let workspace
|
||||
try {
|
||||
workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
|
||||
// A resume the user asked for, as its create below.
|
||||
workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`, undefined, {
|
||||
wslUserAct: true
|
||||
})
|
||||
} catch {
|
||||
return refuse('no_resume_command')
|
||||
}
|
||||
|
||||
@@ -187,14 +187,17 @@ export abstract class OrcaRuntimeWithFailedStartPanes extends OrcaRuntimeWithCre
|
||||
worktreeSelector: string,
|
||||
created: dependencies.Worktree | undefined,
|
||||
pane: FailedStartPane,
|
||||
{ title }: dependencies.TerminalCreateOptions,
|
||||
{ title, wslUserAct }: dependencies.TerminalCreateOptions,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
if (error instanceof PaneOwnerContentionError) {
|
||||
// Another owner's terminal runs in the pane; the window attaches to it.
|
||||
return
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector, created)
|
||||
// The failed start's own act: a user's start may wake the distro; background stays held.
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector, created, {
|
||||
wslUserAct: wslUserAct === true
|
||||
})
|
||||
const create = { type: 'createTerminalTab' as const, workspace: workspace.id, ...pane }
|
||||
const reply = await this.applyLayoutCommand(title ? { ...create, title } : create)
|
||||
// `tab_exists`: the start wrote this pane before failing and kept it.
|
||||
|
||||
@@ -53,7 +53,13 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext
|
||||
opts: OwnedMobileTerminalCreateOptions = {}
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
this.pruneOwnedMobileDispatchRecipes()
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(
|
||||
`id:${worktreeId}`,
|
||||
undefined,
|
||||
{
|
||||
wslUserAct: opts.wslUserAct === true
|
||||
}
|
||||
)
|
||||
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
|
||||
// Why: an SSH sessionId means reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn.
|
||||
const stableSessionId =
|
||||
@@ -133,7 +139,8 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext
|
||||
`id:${worktreeId}`,
|
||||
opts.createMutation.id,
|
||||
true,
|
||||
(_selector, handle) => create(handle)
|
||||
(_selector, handle) => create(handle),
|
||||
{ wslUserAct: opts.wslUserAct === true }
|
||||
)
|
||||
: await create()
|
||||
if (terminal.ptyId) {
|
||||
|
||||
@@ -63,7 +63,10 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
const pairedCreate = Boolean(opts.clientNavigationId)
|
||||
const graphEpoch = this.captureReadyGraphEpoch()
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
// The paired client's own create: its user's act, as every start below.
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector, undefined, {
|
||||
wslUserAct: true
|
||||
})
|
||||
const worktreeId = workspace.id
|
||||
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
|
||||
assertMobileTerminalShellSupported(this.store, workspace, opts.shellOverride, cwd)
|
||||
|
||||
@@ -14,8 +14,21 @@ import {
|
||||
} from '../../../../shared/execution-host'
|
||||
import { resolveWorktreeLaunchHost } from '../../worktree-launch-host-repo'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types'
|
||||
|
||||
export abstract class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithCreateAgentSession {
|
||||
/** A create's scope: its `wslUserAct` lets the folder check start a stopped WSL distro (E9). */
|
||||
protected resolveTerminalCreateLaunchScope(
|
||||
selector: string,
|
||||
createdWorktree: Worktree | undefined,
|
||||
opts: { wslUserAct?: boolean }
|
||||
): Promise<TerminalWorkspaceLaunchScope> {
|
||||
return this.resolveTerminalWorkspaceLaunchScope(selector, createdWorktree, {
|
||||
wslUserAct: opts.wslUserAct === true
|
||||
})
|
||||
}
|
||||
|
||||
async dedupeTerminalCreate(
|
||||
clientIdentity: string,
|
||||
worktreeSelector: string | undefined,
|
||||
@@ -24,7 +37,8 @@ export abstract class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRun
|
||||
run: (
|
||||
canonicalWorktreeSelector: string | undefined,
|
||||
preAllocatedHandle: string | undefined
|
||||
) => Promise<RuntimeTerminalCreate>
|
||||
) => Promise<RuntimeTerminalCreate>,
|
||||
options: { wslUserAct?: boolean } = {}
|
||||
): Promise<RuntimeTerminalCreate> {
|
||||
if (!clientMutationId || !worktreeSelector) {
|
||||
if (reconcileExisting) {
|
||||
@@ -32,7 +46,11 @@ export abstract class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRun
|
||||
}
|
||||
return await run(worktreeSelector, undefined)
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(
|
||||
worktreeSelector,
|
||||
undefined,
|
||||
options
|
||||
)
|
||||
const canonicalWorktreeSelector = `id:${workspace.id}`
|
||||
const preAllocatedHandle = deriveRemoteRuntimeTerminalCreateHandle(
|
||||
clientIdentity,
|
||||
|
||||
@@ -40,7 +40,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi
|
||||
if (!ptyController?.spawn) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector, created)
|
||||
const workspace = await this.resolveTerminalCreateLaunchScope(worktreeSelector, created, opts)
|
||||
const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts)
|
||||
const launchTitleAt = { sequence: this.nextTitleObservationSequence(), epochMs: Date.now() }
|
||||
let cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path
|
||||
|
||||
@@ -98,7 +98,9 @@ export abstract class OrcaRuntimeWithSplitPaneInMain extends OrcaRuntimeWithSpli
|
||||
return this.keepSplitThatCannotStart(source, opts, new Error('runtime_unavailable'))
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(
|
||||
`id:${source.worktreeId}`
|
||||
`id:${source.worktreeId}`,
|
||||
undefined,
|
||||
{ wslUserAct: opts.wslUserAct === true }
|
||||
).catch((error: unknown) => this.keepSplitThatCannotStart(source, opts, error))
|
||||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||||
const result = await spawnInAdmittedPane(this.store, controller, {
|
||||
|
||||
@@ -47,7 +47,8 @@ export abstract class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeW
|
||||
const direction = opts.direction ?? 'horizontal'
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(
|
||||
`id:${pty.worktreeId}`,
|
||||
createdWorktree
|
||||
createdWorktree,
|
||||
{ wslUserAct: opts.wslUserAct === true }
|
||||
)
|
||||
const sourceAuthority = this.resolveTerminalSplitSourceAuthority(
|
||||
workspace.id,
|
||||
|
||||
@@ -120,14 +120,17 @@ export abstract class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktr
|
||||
|
||||
async resolveTerminalWorkspaceLaunchScope(
|
||||
selector: string,
|
||||
createdWorktree?: Worktree
|
||||
createdWorktree?: Worktree,
|
||||
options: { wslUserAct?: boolean } = {}
|
||||
): Promise<TerminalWorkspaceLaunchScope> {
|
||||
return (await this.resolveTerminalWorkspaceLaunchTarget(selector, createdWorktree)).scope
|
||||
return (await this.resolveTerminalWorkspaceLaunchTarget(selector, createdWorktree, options))
|
||||
.scope
|
||||
}
|
||||
|
||||
protected async resolveTerminalWorkspaceLaunchTarget(
|
||||
selector: string,
|
||||
createdWorktree?: Worktree
|
||||
createdWorktree?: Worktree,
|
||||
options: { wslUserAct?: boolean } = {}
|
||||
): Promise<ResolvedTerminalWorkspaceLaunchTarget> {
|
||||
if (isFloatingWorkspaceSelector(selector)) {
|
||||
// Why no managed worktree: the floating sentinel has no repo or worktree record behind it.
|
||||
@@ -143,7 +146,7 @@ export abstract class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktr
|
||||
}
|
||||
}
|
||||
|
||||
const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector)
|
||||
const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector, options)
|
||||
if (folderScope) {
|
||||
return {
|
||||
scope: folderScope,
|
||||
|
||||
@@ -117,7 +117,9 @@ export async function consumePreparedBackgroundCommand(
|
||||
'id:wt-1',
|
||||
undefined,
|
||||
false,
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
// terminal.create is the caller's own act, as its createTerminal (E9).
|
||||
{ wslUserAct: true }
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user