diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 4e421d05670..cd303bbf6f7 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -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 diff --git a/src/main/ipc/pty/pty-daemon-spawn-wsl-runtime.test.ts b/src/main/ipc/pty/pty-daemon-spawn-wsl-runtime.test.ts index a1496e06aca..a1b5ec3d2e0 100644 --- a/src/main/ipc/pty/pty-daemon-spawn-wsl-runtime.test.ts +++ b/src/main/ipc/pty/pty-daemon-spawn-wsl-runtime.test.ts @@ -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()), + readWslDistroRunningState: vi.fn(async () => 'running') +})) describe('registerPtyHandlers', () => { const { handlers, mainWindow } = setupPtyIpcSuite() diff --git a/src/main/ipc/pty/pty-launch-parity.test.ts b/src/main/ipc/pty/pty-launch-parity.test.ts index bdbb67e1ab3..11085fd64a8 100644 --- a/src/main/ipc/pty/pty-launch-parity.test.ts +++ b/src/main/ipc/pty/pty-launch-parity.test.ts @@ -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()), + readWslDistroRunningState: vi.fn(async () => 'running') +})) // Exercise real window-lane spawning through provider, telemetry and pane binding. describe('window lane: pty:spawn request -> provider', () => { diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index dc16e9c2c9d..45bdf77e244 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -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() diff --git a/src/main/ipc/pty/runtime/spawn-preflight-test-deps-fixture.ts b/src/main/ipc/pty/runtime/spawn-preflight-test-deps-fixture.ts new file mode 100644 index 00000000000..a22e2b932a5 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-preflight-test-deps-fixture.ts @@ -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 + } +} diff --git a/src/main/ipc/pty/runtime/spawn-preflight-wsl-background-hold.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-wsl-background-hold.test.ts new file mode 100644 index 00000000000..738631fbfec --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-preflight-wsl-background-hold.test.ts @@ -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()), + readWslDistroRunningState: mocks.readWslDistroRunningState +})) + +const WSL_CWD = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' +const hostPlatform = process.platform + +async function prepare(args: Partial): Promise { + 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() + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index c58dedd5a62..69031e67195 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -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 diff --git a/src/main/ipc/pty/spawn-startup-target.ts b/src/main/ipc/pty/spawn-startup-target.ts index cae78b6d8ac..eea1c28725e 100644 --- a/src/main/ipc/pty/spawn-startup-target.ts +++ b/src/main/ipc/pty/spawn-startup-target.ts @@ -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 + /** 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, diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index f4a46b0cb11..3803e6b6e97 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -109,7 +109,14 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG const reclaim: AgentSessionCreateOperation['reclaim'] = {} const operation = (async (): Promise => { // 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, diff --git a/src/main/runtime/rpc/methods/agent-session.test.ts b/src/main/runtime/rpc/methods/agent-session.test.ts index 1aba324134f..32e8f1232f2 100644 --- a/src/main/runtime/rpc/methods/agent-session.test.ts +++ b/src/main/runtime/rpc/methods/agent-session.test.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts index a1e221d5a7a..e9f8741e869 100644 --- a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts +++ b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 11135051432..da37dd06feb 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -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 ( diff --git a/src/main/runtime/runtime-folder-workspace-wsl-failed-start-pane.test.ts b/src/main/runtime/runtime-folder-workspace-wsl-failed-start-pane.test.ts new file mode 100644 index 00000000000..19d8ecc8ac6 --- /dev/null +++ b/src/main/runtime/runtime-folder-workspace-wsl-failed-start-pane.test.ts @@ -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()), + 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 { + 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() + }) +}) diff --git a/src/main/runtime/runtime-folder-workspace-wsl-launch-scope.test.ts b/src/main/runtime/runtime-folder-workspace-wsl-launch-scope.test.ts new file mode 100644 index 00000000000..6b5d01fd174 --- /dev/null +++ b/src/main/runtime/runtime-folder-workspace-wsl-launch-scope.test.ts @@ -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()), + 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 { + 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() + }) +}) diff --git a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts index d47771fddd9..d630a169e7e 100644 --- a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts +++ b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts @@ -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 || diff --git a/src/main/runtime/session-graph/session-graph-authority.ts b/src/main/runtime/session-graph/session-graph-authority.ts index 75bff5217f6..7e850e2ee50 100644 --- a/src/main/runtime/session-graph/session-graph-authority.ts +++ b/src/main/runtime/session-graph/session-graph-authority.ts @@ -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, diff --git a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts index b42edbbf71e..43b15a7f817 100644 --- a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts +++ b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts @@ -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 }) diff --git a/src/main/runtime/session-lifecycle/projection/resume-agent-session-in-workspace.ts b/src/main/runtime/session-lifecycle/projection/resume-agent-session-in-workspace.ts index 03efd433a91..9bb92eb4d99 100644 --- a/src/main/runtime/session-lifecycle/projection/resume-agent-session-in-workspace.ts +++ b/src/main/runtime/session-lifecycle/projection/resume-agent-session-in-workspace.ts @@ -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') } diff --git a/src/main/runtime/terminal/launch/failed-start-pane.ts b/src/main/runtime/terminal/launch/failed-start-pane.ts index 118b3ea8716..4dac063946a 100644 --- a/src/main/runtime/terminal/launch/failed-start-pane.ts +++ b/src/main/runtime/terminal/launch/failed-start-pane.ts @@ -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 { 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. diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index d6655924e74..e1429d360c0 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -53,7 +53,13 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext opts: OwnedMobileTerminalCreateOptions = {} ): Promise { 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) { diff --git a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts index 74757c94652..245b0e985ba 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts @@ -63,7 +63,10 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca ): Promise { 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) diff --git a/src/main/runtime/terminal/launch/terminal-create-deduplication.ts b/src/main/runtime/terminal/launch/terminal-create-deduplication.ts index 204f8a10371..9ce871eb14b 100644 --- a/src/main/runtime/terminal/launch/terminal-create-deduplication.ts +++ b/src/main/runtime/terminal/launch/terminal-create-deduplication.ts @@ -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 { + 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 + ) => Promise, + options: { wslUserAct?: boolean } = {} ): Promise { 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, diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index c311c24900f..2986bf6fec4 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -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 diff --git a/src/main/runtime/terminal/launch/terminal-split-in-main.ts b/src/main/runtime/terminal/launch/terminal-split-in-main.ts index 66ecc51997d..213c1520c21 100644 --- a/src/main/runtime/terminal/launch/terminal-split-in-main.ts +++ b/src/main/runtime/terminal/launch/terminal-split-in-main.ts @@ -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, { diff --git a/src/main/runtime/terminal/launch/terminal-split-surface.ts b/src/main/runtime/terminal/launch/terminal-split-surface.ts index 1a7ca6e6d60..be9e6dc9753 100644 --- a/src/main/runtime/terminal/launch/terminal-split-surface.ts +++ b/src/main/runtime/terminal/launch/terminal-split-surface.ts @@ -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, diff --git a/src/main/runtime/terminal/launch/terminal-workspace-scope.ts b/src/main/runtime/terminal/launch/terminal-workspace-scope.ts index bc5505ef747..7c880a4ea7f 100644 --- a/src/main/runtime/terminal/launch/terminal-workspace-scope.ts +++ b/src/main/runtime/terminal/launch/terminal-workspace-scope.ts @@ -120,14 +120,17 @@ export abstract class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktr async resolveTerminalWorkspaceLaunchScope( selector: string, - createdWorktree?: Worktree + createdWorktree?: Worktree, + options: { wslUserAct?: boolean } = {} ): Promise { - 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 { 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, diff --git a/tests/e2e/prepared-action-launch.test-fixture.ts b/tests/e2e/prepared-action-launch.test-fixture.ts index 1edf8f0649f..6ba697c3f98 100644 --- a/tests/e2e/prepared-action-launch.test-fixture.ts +++ b/tests/e2e/prepared-action-launch.test-fixture.ts @@ -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 }