From 211bbbb6d181cc5ff5a28d46e22524b699407db6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:54:50 -0700 Subject: [PATCH] fix(wsl): main's background terminal starts never start a stopped WSL distro (#35865) * fix(wsl): main's background terminal starts never start a stopped WSL distro Every start main makes through the runtime controller is background work unless its caller names the user's act (CLI and window creates and splits, paired-client creates and tab opens, agent launches and sessions, conversation resumes). #35813's hold now also holds those fresh and restored starts while the distro is stopped or unverifiable. * fix(wsl): headless Run now starts as the user's act; update create expectations A headless automation's Run now names the user's act through launchAgentTerminal, so it may start a stopped WSL distro; a scheduled run stays background and is held. The paired-client background create expectation now includes wslUserAct. * fix(wsl): main's background start checks a folder workspace's path passively With #35899's folder stat gate, the runtime preflight passes passiveWslRestore = !wslUserAct, so a background start never stats a stopped distro's folder as a user act before the daemon hold. --- .../headless-automation-agent-launch.test.ts | 26 +++++++- .../headless-automation-agent-launch.ts | 6 +- .../runtime-automation-service.test.ts | 4 +- ...y-adapter-wsl-passive-restore-hold.test.ts | 65 +++++++++++++++++++ .../daemon/daemon-wsl-passive-restore-hold.ts | 23 +++---- src/main/ipc/pty/runtime/spawn-options.ts | 4 ++ .../spawn-preflight-requested-shell.test.ts | 24 +++++++ src/main/ipc/pty/runtime/spawn-preflight.ts | 4 +- src/main/ipc/pty/runtime/spawn-state.ts | 2 + src/main/providers/pty-provider-contract.ts | 2 + .../orca-runtime-create-agent-session.ts | 2 + ...ca-runtime-create-terminal-dependencies.ts | 14 ++-- .../agent-launch-floating-workspace.test.ts | 1 + .../rpc/methods/agent-launch-surfaces.ts | 2 + .../runtime/rpc/methods/agent-launch.test.ts | 2 + .../runtime/rpc/methods/agent-session.test.ts | 1 + .../terminal/terminal-lifecycle-methods.ts | 8 ++- .../runtime-pty-controller-contract.ts | 2 + .../runtime/runtime-terminal-contracts.ts | 2 + .../get-agent-session-execution-namespace.ts | 2 + ...form-mobile-session-pty-records-refresh.ts | 8 ++- .../projection/restored-pane-materialize.ts | 4 +- .../resume-agent-session-in-workspace.ts | 2 + .../terminal/launch/failed-start-pane.ts | 4 +- .../mobile-terminal-chat-fork-create.ts | 4 +- .../terminal/launch/mobile-terminal-create.ts | 27 +++++++- .../launch/mobile-terminal-owned-create.ts | 1 + .../launch/mobile-terminal-renderer-create.ts | 31 ++------- .../launch/terminal-create-deduplication.ts | 5 +- .../terminal/launch/terminal-split-in-main.ts | 3 + .../terminal/launch/terminal-split-surface.ts | 3 + .../runtime/terminal/launch/terminal-split.ts | 5 +- .../terminal/recovery/restored-pane-start.ts | 2 + ...ent-background-session-remote.unit.test.ts | 2 + 34 files changed, 239 insertions(+), 58 deletions(-) diff --git a/src/main/automations/headless-automation-agent-launch.test.ts b/src/main/automations/headless-automation-agent-launch.test.ts index 2776dfa0292..10d26db0f98 100644 --- a/src/main/automations/headless-automation-agent-launch.test.ts +++ b/src/main/automations/headless-automation-agent-launch.test.ts @@ -68,7 +68,12 @@ function automation(overrides: Record) { } } -const RUN = { id: 'run-1', title: 'Nightly', scheduledFor: Date.UTC(2026, 9, 9, 3) } +const RUN = { + id: 'run-1', + title: 'Nightly', + scheduledFor: Date.UTC(2026, 9, 9, 3), + trigger: 'scheduled' as const +} const TARGET = { ok: true, repo: { id: 'repo-1', path: '/repo', displayName: 'repo' } } function previousRun(overrides: Partial = {}): AutomationRun { @@ -100,7 +105,8 @@ function previousRun(overrides: Partial = {}): AutomationRun { function launch( runtime: ReturnType, overrides: Record, - runs: AutomationRun[] = [] + runs: AutomationRun[] = [], + run: typeof RUN | (Omit & { trigger: 'manual' }) = RUN ) { return launchHeadlessAutomationAgent( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only the members faked in harness(). @@ -108,7 +114,7 @@ function launch( { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only these automation fields are read. automation: automation(overrides) as never, - run: RUN, + run, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the repo is read. target: TARGET as never, runs @@ -117,6 +123,20 @@ function launch( } describe('headless automation agent launch', () => { + // E9: the runtime holds a background start on a stopped WSL distro; only Run now names the user. + it("starts Run now as the user's act, and a scheduled run as background work", async () => { + const scheduled = harness() + await launch(scheduled, {}) + expect(scheduled.launchAgentTerminal.mock.calls[0]?.[1]).not.toHaveProperty('wslUserAct') + + const manual = harness() + await launch(manual, {}, [], { ...RUN, trigger: 'manual' }) + expect(manual.launchAgentTerminal).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ wslUserAct: true }) + ) + }) + it('starts a post-start agent bare and delivers its prompt once, after the terminal is recorded', async () => { const runtime = harness() let finishDelivery: (delivered: boolean) => void = () => {} diff --git a/src/main/automations/headless-automation-agent-launch.ts b/src/main/automations/headless-automation-agent-launch.ts index ac006a38ff8..9ece51a5f00 100644 --- a/src/main/automations/headless-automation-agent-launch.ts +++ b/src/main/automations/headless-automation-agent-launch.ts @@ -30,7 +30,7 @@ export async function launchHeadlessAutomationAgent( runtime: OrcaRuntimeService, request: { automation: Automation - run: Pick + run: Pick target: Extract runs: readonly AutomationRun[] } @@ -103,7 +103,9 @@ export async function launchHeadlessAutomationAgent( prompt: startupPrompt ?? '', title: run.title, suppressStartupUpdatePrompt: true, - ...(automation.extraAgentArgs ? { extraAgentArgs: automation.extraAgentArgs } : {}) + ...(automation.extraAgentArgs ? { extraAgentArgs: automation.extraAgentArgs } : {}), + // Run now is the user's act and may start a stopped WSL distro; a scheduled run never does. + ...(run.trigger === 'scheduled' ? {} : { wslUserAct: true as const }) }) terminal = launched const shown = await runtime.showManagedWorktree(`id:${launched.worktreeId}`) diff --git a/src/main/automations/runtime-automation-service.test.ts b/src/main/automations/runtime-automation-service.test.ts index cc249a5d25c..c5ba99297b9 100644 --- a/src/main/automations/runtime-automation-service.test.ts +++ b/src/main/automations/runtime-automation-service.test.ts @@ -70,8 +70,8 @@ describe('headless automation dispatch', () => { workspaceId: 'wt-1', agentId: 'goose' } as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the title is read. - run: { title: 'Nightly' } as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the title and trigger are read. + run: { title: 'Nightly', trigger: 'scheduled' } as never, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: unused for an existing workspace. target: {} as never }) diff --git a/src/main/daemon/daemon-pty-adapter-wsl-passive-restore-hold.test.ts b/src/main/daemon/daemon-pty-adapter-wsl-passive-restore-hold.test.ts index 813957e68a2..0140c315708 100644 --- a/src/main/daemon/daemon-pty-adapter-wsl-passive-restore-hold.test.ts +++ b/src/main/daemon/daemon-pty-adapter-wsl-passive-restore-hold.test.ts @@ -104,4 +104,69 @@ describe('a passive restore of a WSL bridge pane (E9)', () => { expect(spawns).toBe(1) expect(readState).not.toHaveBeenCalled() }) + + describe('a background start from main (orchestration, automations, recovery)', () => { + const fresh = () => + adapter.spawn({ + cols: 80, + rows: 24, + cwd: WSL_CWD, + isNewSession: true, + wslBackgroundStart: true + }) + + it('holds a new terminal too, starting nothing, while the distro is stopped', async () => { + const error = await fresh().catch((caught: unknown) => caught) + expect(error instanceof Error ? parseWslDistroHold(error.message) : null).toEqual({ + distro: 'Ubuntu', + state: 'stopped' + }) + expect(spawns).toBe(0) + }) + + it('holds it as unverifiable when the read cannot tell', async () => { + readState.mockResolvedValue('unverifiable') + const error = await fresh().catch((caught: unknown) => caught) + expect(error instanceof Error ? parseWslDistroHold(error.message) : null).toEqual({ + distro: 'Ubuntu', + state: 'unverifiable' + }) + expect(spawns).toBe(0) + }) + + it('holds its restore of a saved pane', async () => { + const held = await adapter + .spawn({ + cols: 80, + rows: 24, + cwd: WSL_CWD, + sessionId: SESSION_ID, + wslBackgroundStart: true + }) + .catch((caught: unknown) => caught) + expect(held instanceof Error ? parseWslDistroHold(held.message) : null).toEqual({ + distro: 'Ubuntu', + state: 'stopped' + }) + expect(spawns).toBe(0) + }) + + it('starts once a settled read proves the distro running', async () => { + readState.mockResolvedValue('running') + await fresh() + expect(spawns).toBe(1) + }) + + it('leaves a non-WSL start alone', async () => { + await adapter.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\repo', + isNewSession: true, + wslBackgroundStart: true + }) + expect(spawns).toBe(1) + expect(readState).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/daemon/daemon-wsl-passive-restore-hold.ts b/src/main/daemon/daemon-wsl-passive-restore-hold.ts index 8a67361c1ca..39295d10995 100644 --- a/src/main/daemon/daemon-wsl-passive-restore-hold.ts +++ b/src/main/daemon/daemon-wsl-passive-restore-hold.ts @@ -3,21 +3,22 @@ import { wslDistroHoldMessage } from '../../shared/wsl-distro-stopped-hold' import type { PtySpawnOptions } from '../providers/pty-provider-contract' /** - * Launch restore, a reload or a remount the user did not ask for would start a stopped distro's - * `wsl.exe` (E9). It starts only when a settled read proves the distro running; a stopped or - * unanswered read holds the pane until the user's Start. + * Launch restore, a reload, a remount or main's background start (orchestration, automations, + * recovery) the user did not ask for would start a stopped distro's `wsl.exe` (E9). It starts + * only when a settled read proves the distro running; a stopped or unanswered read holds it + * until the user's Start. */ export async function holdPassiveRestoreOfStoppedWslDistro( - opts: Pick, + opts: Pick< + PtySpawnOptions, + 'wslPassiveRestore' | 'wslBackgroundStart' | 'sessionId' | 'isNewSession' | 'attachOnly' + >, wslDistro: string | undefined ): Promise { - if ( - opts.wslPassiveRestore !== true || - !wslDistro || - !opts.sessionId || - opts.isNewSession === true || - opts.attachOnly === true - ) { + const passiveRestore = + opts.wslPassiveRestore === true && Boolean(opts.sessionId) && opts.isNewSession !== true + // Background work holds fresh starts too: a new session's `wsl.exe` starts the distro as well. + if (!wslDistro || opts.attachOnly === true || !(passiveRestore || opts.wslBackgroundStart)) { return } const state = await readWslDistroRunningState(wslDistro, { retryUnverifiable: true }) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 10d3cad8b9e..75f7dbfe4c9 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -124,6 +124,10 @@ export async function buildRuntimePtySpawnOptions( if (args.signal) { ctx.spawnOptions.signal = args.signal } + // Default-deny (E9): a main-side start is background work unless its caller names the user's act. + if (!ctx.preAdoptedStablePane && args.wslUserAct !== true) { + ctx.spawnOptions.wslBackgroundStart = true + } if ( args.onPtySpawnCommitted && (ctx.provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(ctx.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 29eb57adbc1..dc16e9c2c9d 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 @@ -104,6 +104,30 @@ describe('runtime pty spawn options: declared spawn intent', () => { }) }) +describe("runtime pty spawn options: the user's act on a WSL distro (E9)", () => { + it("checks a folder workspace's path passively unless the start is the user's act", async () => { + const admit = vi.fn(() => undefined) + for (const args of [{}, { wslUserAct: true as const }]) { + const ctx = createRuntimePtySpawnState( + { ...makeDeps(), assertWorkspaceAcceptsNewPty: admit }, + { cols: 120, rows: 40, worktreeId: 'wt-1', ...args } + ) + await prepareRuntimePtySpawn(ctx) + ctx.finishTerminalInstall() + } + expect(admit.mock.calls.map((call) => call[3])).toEqual([true, false]) + }) + + it('marks every main-side start as background work unless its caller names the user', async () => { + await expect(resolveSpawnOptions({ cols: 120, rows: 40 })).resolves.toMatchObject({ + wslBackgroundStart: true + }) + await expect( + resolveSpawnOptions({ cols: 120, rows: 40, wslUserAct: true }) + ).resolves.not.toHaveProperty('wslBackgroundStart') + }) +}) + /** Runtime --shell must choose the provider shell, not type its name into the host default shell. */ describe('runtime pty spawn preflight: requested shell on a local Windows host', () => { afterEach(() => { diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 69e20775bbd..c58dedd5a62 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -33,7 +33,9 @@ export async function prepareRuntimePtySpawn( const admission = ctx.deps.assertWorkspaceAcceptsNewPty( args.worktreeId, args.sessionId, - localPtySpawnCwd(args) + localPtySpawnCwd(args), + // Main's background start must not stat a stopped distro's folder as a user act (E9). + args.wslUserAct !== true ) if (admission) { await admission diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 2cde4027ec8..d123c915189 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -129,6 +129,8 @@ export type RuntimePtySpawnArgs = { resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ resumeOrigin?: 'saved' + /** The user's own act (a CLI command, a paired client's create, a launch); without it a stopped WSL distro holds the start. */ + wslUserAct?: true connectionId?: string | null worktreeId?: string preAllocatedHandle?: string diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 1130c1e7e76..9df9433f049 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -97,6 +97,8 @@ export type PtySpawnOptions = { spawnIntent?: SpawnIntent /** A restore no user act asked for: a WSL bridge pane whose distro is stopped is held, not started. */ wslPassiveRestore?: true + /** A main-side start no user act asked for (orchestration, automations, recovery): held like a passive restore. */ + wslBackgroundStart?: true agentSessionEnsure?: { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 07b9129e4c6..f4a46b0cb11 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -168,6 +168,8 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG } try { terminal = await this.createTerminal(`id:${workspace.id}`, { + // Only the agent-session RPC reaches here: the caller's own act. + wslUserAct: true, // No requested surface: the host-planned launch uses unknown. startupAgent: request.agent, ...(request.prompt ? { startupPrompt: request.prompt } : {}), diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 3511fcadcb5..93a4a48ee20 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -51,14 +51,20 @@ export const spawnShellInputs = ({ ...(launchRuntime ? { launchRuntime } : {}) }) -/** Abort disconnected callers, refuse sleeping workspaces, and pass fork intent for host validation. */ +/** Abort disconnected callers, refuse sleeping workspaces, and pass fork intent and the user's act for host validation. */ export function callerSpawnScope( - opts: Pick -): { signal?: AbortSignal; refuseSleptWorktree?: true; spawnIntent?: SpawnIntent } { + opts: Pick +): { + signal?: AbortSignal + refuseSleptWorktree?: true + spawnIntent?: SpawnIntent + wslUserAct?: true +} { return { ...(opts.signal ? { signal: opts.signal } : {}), ...(opts.refuseSleptWorktree ? { refuseSleptWorktree: true } : {}), - ...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}) + ...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}), + ...(opts.wslUserAct ? { wslUserAct: true } : {}) } } diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index 8393c729cf6..2fbdba74d96 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -128,6 +128,7 @@ describe('agent.launch with the real floating workspace resolver', () => { expect(createTerminal).toHaveBeenCalledExactlyOnceWith( `id:${FLOATING_TERMINAL_WORKTREE_ID}`, { + wslUserAct: true, startupAgent: 'codex', terminalImageViewerSupported: false, // Derived on the host by the window's rule, from the same chat-view setting. diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 39a67f57c55..ac9b71ec7e7 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -140,6 +140,8 @@ export function agentLaunchSurfaceFactory( terminalSpawn.rethrow(new AgentLaunchTabClosedError()) } const created = context.runtime.createTerminal(`id:${worktreeId}`, { + // An agent launch the caller asked for (CLI, window or paired client) is the user's act. + wslUserAct: true, // Let the runtime build the configured launcher: agent ids are not commands (cursor uses cursor-agent). startupAgent: agent, terminalImageViewerSupported: terminalImageViewerSupported(context), diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index d5abaeff03e..3afbc025c46 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -546,6 +546,7 @@ describe('the terminal factory', () => { const result = await launch(CREATE_LAUNCH, runtime) expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', { + wslUserAct: true, startupAgent: 'claude', terminalImageViewerSupported: false, viewMode: 'terminal', @@ -571,6 +572,7 @@ describe('the terminal factory', () => { expect(runtime.showManagedTerminalWorkspace).not.toHaveBeenCalled() // Normalize selectors to ids before lower layers add their own id: prefix. expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { + wslUserAct: true, startupAgent: 'gemini', terminalImageViewerSupported: false, viewMode: 'terminal', diff --git a/src/main/runtime/rpc/methods/agent-session.test.ts b/src/main/runtime/rpc/methods/agent-session.test.ts index f25f74d6713..1aba324134f 100644 --- a/src/main/runtime/rpc/methods/agent-session.test.ts +++ b/src/main/runtime/rpc/methods/agent-session.test.ts @@ -231,6 +231,7 @@ describe('agent session RPC methods', () => { expect.any(Function) ) expect(createTerminal).toHaveBeenCalledWith('id:worktree-1', { + wslUserAct: true, command: 'codex resume provider-session-1', startupCommandDelivery: undefined, env: undefined, 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 d8c0773a7ff..11135051432 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -86,6 +86,8 @@ export const TERMINAL_LIFECYCLE_METHODS = [ params.reconcileExisting === true, (canonicalWorktreeSelector, preAllocatedHandle) => runtime.createTerminal(canonicalWorktreeSelector, { + // A CLI command (typed or an agent's) or a window's create is the user's act. + wslUserAct: true, command: params.command, terminalImageViewerSupported: terminalImageViewerSupported(context), ...(params.shell ? { shellOverride: params.shell } : {}), @@ -149,7 +151,8 @@ export const TERMINAL_LIFECYCLE_METHODS = [ command: params.command, env: params.env, // The window's split shows why its start failed, as its own spawn did. - keepPaneOnStartFailure: true + keepPaneOnStartFailure: true, + wslUserAct: true }) : // The schema requires a handle when no pane is named. await runtime.splitTerminal(params.terminal ?? '', { @@ -157,7 +160,8 @@ export const TERMINAL_LIFECYCLE_METHODS = [ command: params.command, env: params.env, telemetrySource: params.telemetrySource, - retrySplit: params.retrySplit + retrySplit: params.retrySplit, + wslUserAct: true }) }) }), diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index b54afa32c6c..e67c6edbf23 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -88,6 +88,8 @@ export type RuntimePtyController = { sessionId?: string /** Windows shell to spawn AS this PTY, instead of the host default. */ shellOverride?: string + /** The user's own act (a CLI command, a paired client's create, a launch); without it a stopped WSL distro holds the start. */ + wslUserAct?: true /** The agent launch's runtime fact; when present the PTY shell comes only from it. */ launchRuntime?: AgentLaunchRuntime isNewSession?: boolean diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 61b11617708..e6cdede25e0 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -103,6 +103,8 @@ export type TerminalCreateOptions = { agentSessionCreateOperationId?: string /** The paired client's declared intent; the host's fork gate validates and may defer it. */ spawnIntent?: SpawnIntent + /** The user's own act (a CLI command, a paired client's create, a launch); without it a stopped WSL distro holds the start. */ + wslUserAct?: true signal?: AbortSignal /** Refuse (`worktree_terminals_sleeping`) instead of waking a worktree the host has put to sleep. */ refuseSleptWorktree?: boolean 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 2304e26eb0f..d47771fddd9 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 @@ -156,6 +156,8 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O throw new Error('client_disconnected') } const terminal = await this.createTerminal(`id:${workspace.id}`, { + // Only the agent-session RPC reaches here: the caller's own act. + wslUserAct: true, command: startup.launchCommand, env: withAgentSessionAccountEnv(startup.env, request.accountEnv), launchConfig: startup.launchConfig, diff --git a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts index a98a9783e49..59c6f03f1c6 100644 --- a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts +++ b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts @@ -183,7 +183,13 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten const targetGroupId = snapshot?.tabGroups?.find((group) => group.tabOrder.includes(tab.parentTabId) )?.id - const trigger = { automatic, targetsHost, targetGroupId } + // An explicit tab open is the user's act; an automatic probe never starts a stopped distro. + const trigger = { + automatic, + targetsHost, + targetGroupId, + ...(automatic ? {} : { wslUserAct: true as const }) + } let startupCwdFallback: RuntimeMobileSessionTabActivationResult['startupCwdFallback'] try { if (ORCA_LAYOUT_OWNER_IS_MAIN) { 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 292225c247b..2ef003c5adc 100644 --- a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts +++ b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts @@ -199,7 +199,8 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu automatic = false, targetsHost = false, targetGroupId, - sessionId + sessionId, + wslUserAct }: RestoredPaneTrigger & { sessionId?: string } ): Promise { // Recover undelivered startup commands (#7587) or recorded agent sessions (#27147, #27777); live-session attach skips startup commands, preventing double launches. @@ -262,6 +263,7 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu : {}), launchRuntime: agentStartup.launchRuntime, targetGroupId, + ...(wslUserAct ? { wslUserAct } : {}), onPtySpawnDispatched: () => { dispatched = true }, 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 780812a2240..03efd433a91 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 @@ -170,6 +170,8 @@ export abstract class OrcaRuntimeWithResumeAgentSessionInWorkspace extends OrcaR false, undefined, { + // A resume the user asked for (a vault or external-conversation reopen). + wslUserAct: true, command: resume.command, env: resume.env, startupCommandDelivery: resume.startupCommandDelivery, diff --git a/src/main/runtime/terminal/launch/failed-start-pane.ts b/src/main/runtime/terminal/launch/failed-start-pane.ts index 700e3e783b9..118b3ea8716 100644 --- a/src/main/runtime/terminal/launch/failed-start-pane.ts +++ b/src/main/runtime/terminal/launch/failed-start-pane.ts @@ -170,7 +170,9 @@ export abstract class OrcaRuntimeWithFailedStartPanes extends OrcaRuntimeWithCre ...opts, ...pane, presentation: 'background', - keepPaneOnStartFailure: true + keepPaneOnStartFailure: true, + // The window's own new tab: a user act, as New Terminal is for a window spawn. + wslUserAct: true }) } diff --git a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts index bb8464200a5..3df9d8ceab6 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts @@ -1,8 +1,8 @@ import { OrcaRuntimeWithCreateMobileSessionTerminal, + type MobileTerminalCreateOptions, type OwnedMobileTerminalCreateOptions } from './mobile-terminal-create' -import type { MobileTerminalCreateOptions } from './mobile-terminal-renderer-create' import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types' import { @@ -157,6 +157,8 @@ export abstract class OrcaRuntimeWithMobileChatForkCreate extends OrcaRuntimeWit opts.activate !== false, phoneAnchor, { + // The phone user's own fork. + wslUserAct: true, ...startup, identity: { tabId: deterministicAgentSessionUuid(`mobile-chat-fork-tab\0${seed}`), diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-create.ts index 952353dbc8b..985b898c9d8 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-create.ts @@ -1,4 +1,3 @@ -import type { MobileTerminalCreateOptions } from './mobile-terminal-renderer-create' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-session-contracts' import { OrcaRuntimeWithFailedStartPanes } from './failed-start-pane' import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' @@ -19,7 +18,33 @@ import { navigationTargetsHost } from '../../../../shared/runtime-navigation' import { MOBILE_TERMINAL_CREATE_RESULT_TTL_MS } from '../../orca-runtime-core' import { seedResumeLaunchIdentity } from '../../../ipc/pty/pane/resume-launch-identity' +/** A paired client's terminal create request. */ +export type MobileTerminalCreateOptions = { + terminalImageViewerSupported?: boolean + afterTabId?: string + sourceChatFork?: { sessionId: string } + targetGroupId?: string + command?: string + shellOverride?: string + cwd?: string + env?: Record + envToDelete?: string[] + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + agent?: TuiAgent + agentPrompt?: string + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + activate?: boolean + clientNavigationId?: string + clientMutationId?: string + supportsSplitGroupPlacement?: boolean + signal?: AbortSignal +} + export type OwnedMobileTerminalCreateOptions = { + /** A paired client's own create or tab open; without it a stopped WSL distro holds the start. */ + wslUserAct?: true command?: string shellOverride?: string cwd?: string 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 0d85e78d7bf..d6655924e74 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -103,6 +103,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext ? { resumeProviderSession: opts.resumeProviderSession } : {}), ...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}), + ...(opts.wslUserAct ? { wslUserAct: true } : {}), ...(opts.resumeOrigin ? { resumeOrigin: opts.resumeOrigin } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}), 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 35f60a749ac..74757c94652 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts @@ -3,9 +3,6 @@ import type { MobileTerminalCommandOptions, MobileTerminalCommand } from './mobi import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' import { OrcaRuntimeWithMobileChatForkCreate } from './mobile-terminal-chat-fork-create' -import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types' import { randomUUID } from 'node:crypto' import { getRuntimeDesktopSurface } from '../../runtime-desktop-surface' @@ -19,29 +16,7 @@ import { rendererPublicationThrottle } from '../../../window/renderer-publicatio import { deterministicAgentSessionUuid } from '../../runtime-agent-launch-resolution' import { assertMobileTerminalShellSupported } from './mobile-terminal-shell-refusal' import { anchorTabOfCreate, mainPlacedAfter } from './mobile-terminal-create-anchor' - -export type MobileTerminalCreateOptions = { - terminalImageViewerSupported?: boolean - afterTabId?: string - sourceChatFork?: { sessionId: string } - targetGroupId?: string - command?: string - shellOverride?: string - cwd?: string - env?: Record - envToDelete?: string[] - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - agent?: TuiAgent - agentPrompt?: string - launchConfig?: SleepingAgentLaunchConfig - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - activate?: boolean - clientNavigationId?: string - clientMutationId?: string - supportsSplitGroupPlacement?: boolean - signal?: AbortSignal -} +import type { MobileTerminalCreateOptions } from './mobile-terminal-create' export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWithMobileChatForkCreate { protected abstract resolveMobileSessionTerminalCommand( @@ -130,6 +105,8 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca opts.activate !== false, afterTabId, { + // The paired client's own create: its user's act. + wslUserAct: true, ...startupCommand, cwd, identity: { @@ -151,6 +128,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca opts.activate !== false, afterTabId, { + wslUserAct: true, command: startupCommand.command, ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), cwd, @@ -277,6 +255,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca opts.activate !== false, afterTabId, { + wslUserAct: true, command: startupCommand.command, ...(opts.shellOverride ? { shellOverride: 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 53a84789966..204f8a10371 100644 --- a/src/main/runtime/terminal/launch/terminal-create-deduplication.ts +++ b/src/main/runtime/terminal/launch/terminal-create-deduplication.ts @@ -156,6 +156,8 @@ export abstract class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRun title?: string extraAgentArgs?: string suppressStartupUpdatePrompt?: boolean + /** Run now is the user's act; a scheduled run is background and a stopped WSL distro holds it. */ + wslUserAct?: true } ): Promise { const worktree = await this.resolveGitWorktreeSelector(worktreeSelector) @@ -184,7 +186,8 @@ export abstract class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRun launchRuntime: startup.launchRuntime, startupCommandDelivery: startup.startup.startupCommandDelivery, telemetry: startup.startup.telemetry, - title: opts.title + title: opts.title, + ...(opts.wslUserAct ? { wslUserAct: true } : {}) }) // Why: agents that read the prompt after they start get it typed in, not on argv. if (startup.followup) { 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 b57b0d7f9df..66ecc51997d 100644 --- a/src/main/runtime/terminal/launch/terminal-split-in-main.ts +++ b/src/main/runtime/terminal/launch/terminal-split-in-main.ts @@ -27,6 +27,8 @@ export type SplitPaneInMainOptions = { activate?: boolean /** The window's own split keeps a pane whose start failed, showing why, as its spawn did. */ keepPaneOnStartFailure?: boolean + /** The user's own act (a CLI or window split); without it a stopped WSL distro holds the start. */ + wslUserAct?: true } export abstract class OrcaRuntimeWithSplitPaneInMain extends OrcaRuntimeWithSplitPtyBackedTerminal { @@ -109,6 +111,7 @@ export abstract class OrcaRuntimeWithSplitPaneInMain extends OrcaRuntimeWithSpli ...opts.env }), envToDelete: opts.envToDelete, + ...(opts.wslUserAct ? { wslUserAct: true } : {}), connectionId: workspace.connectionId, worktreeId: workspace.id, preAllocatedHandle, diff --git a/src/main/runtime/terminal/launch/terminal-split-surface.ts b/src/main/runtime/terminal/launch/terminal-split-surface.ts index 48d271d7113..1a7ca6e6d60 100644 --- a/src/main/runtime/terminal/launch/terminal-split-surface.ts +++ b/src/main/runtime/terminal/launch/terminal-split-surface.ts @@ -22,6 +22,8 @@ export type TerminalSplitOptions = { // workspace, for splits the user never asked to see. surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource + /** The user's own act (a CLI or window split); without it a stopped WSL distro holds the start. */ + wslUserAct?: true } export abstract class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitTerminal { @@ -78,6 +80,7 @@ export abstract class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeW envToDelete: opts.envToDelete, connectionId: workspace.connectionId, worktreeId: workspace.id, + ...(opts.wslUserAct ? { wslUserAct: true } : {}), preAllocatedHandle, tabId: parentTabId, leafId, diff --git a/src/main/runtime/terminal/launch/terminal-split.ts b/src/main/runtime/terminal/launch/terminal-split.ts index f1cb22218e3..1c2a887e546 100644 --- a/src/main/runtime/terminal/launch/terminal-split.ts +++ b/src/main/runtime/terminal/launch/terminal-split.ts @@ -45,6 +45,8 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx telemetrySource?: TerminalPaneSplitSource /** The leaf id a timed-out split reported: wait for that pane instead of splitting again. */ retrySplit?: string + /** The user's own act (a CLI or window split); without it a stopped WSL distro holds the start. */ + wslUserAct?: true } = {}, // Internal creation evidence; RPC and preload callers never supply it. createdWorktree?: Worktree @@ -76,7 +78,8 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx command: opts.command, env: opts.env, envToDelete: opts.envToDelete, - activate: opts.activate + activate: opts.activate, + ...(opts.wslUserAct ? { wslUserAct: true } : {}) } ) } diff --git a/src/main/runtime/terminal/recovery/restored-pane-start.ts b/src/main/runtime/terminal/recovery/restored-pane-start.ts index f24663db007..801fa5c311d 100644 --- a/src/main/runtime/terminal/recovery/restored-pane-start.ts +++ b/src/main/runtime/terminal/recovery/restored-pane-start.ts @@ -15,6 +15,8 @@ export type RestoredPaneStartPlan = { /** Who asked, which the start keeps: an automatic probe is refused for a slept worktree. */ export type RestoredPaneTrigger = { automatic?: boolean + /** A user's own tab open or Start; without it a stopped WSL distro holds the start. */ + wslUserAct?: true targetsHost?: boolean targetGroupId?: string } diff --git a/tests/e2e/launch-agent-background-session-remote.unit.test.ts b/tests/e2e/launch-agent-background-session-remote.unit.test.ts index 1bcb2ae3cf9..2f0b84ea964 100644 --- a/tests/e2e/launch-agent-background-session-remote.unit.test.ts +++ b/tests/e2e/launch-agent-background-session-remote.unit.test.ts @@ -140,6 +140,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', terminal: { handle: 'prepared-background', worktreeId: 'wt-1', title: null } }) expect(spawn).toHaveBeenCalledExactlyOnceWith('id:wt-1', { + // A paired client's terminal.create is the user's act (E9). + wslUserAct: true, command: plan.launchCommand, env: { ACTION_PROFILE: 'captured' }, envToDelete: undefined,