From d260805adf451b61bb0ca3c3b3ae9e5e4bec31bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:16:50 -0700 Subject: [PATCH] fix(launch-prompt): type main's line when the staging folder is unusable; one notice, no empty tab ca1c4046ed4 planned such a host for main's delivery, but the spawn then refused the line it chose: a WSL spawn with no distro folder refused any line too long to type, and the daemon refused any agent line whose staging write failed. On a Windows host nothing proves the agent is in front, so agent.launch and worker start keep main's typed line, and every one of them was refused (stack QA 2a, a 5-line prompt worse than main). The spawn now reads the same folder answer the plan read. With no WSL folder, or this machine's temp folder unwritable, the host types the line as is, which is main's delivery. Only a write that fails in a folder that looked usable (the WSL probe found it, or the temp folder passed its check) is refused. Main's planners probe the WSL folder before planning a prompt too long to type, so the first launch into a broken folder plans like later ones. A refused new agent tab showed its notice twice (the empty pane's recovery spawned it again) under a red error panel, in a tab left open. The notice now shows once per tab, without the error panel, and a tab left with no live terminal closes, as main's desktop route closes a refused tab. --- .../daemon/terminal-host-session-create.ts | 8 +- .../terminal-host-startup-staging.test.ts | 29 ++- src/main/providers/local-pty-spawn.ts | 1 - .../orca-runtime-activate-managed-worktree.ts | 16 +- .../orca-runtime-create-agent-session.ts | 7 +- .../orca-runtime-create-managed-worktree.ts | 2 +- ...resolve-mobile-session-terminal-command.ts | 7 +- ...runtime-resolve-worktree-removal-target.ts | 8 +- ...a-runtime-terminal-create-deduplication.ts | 2 +- src/main/runtime/this-orca-launch-host.ts | 52 ++++- .../runtime/wsl-broken-launch-folder.test.ts | 197 ++++++++++++++++++ .../terminal-pane/ipc-pty-connect.ts | 9 +- .../pty-transport-launch-refusal.test.ts | 61 +++++- .../terminal-pane/refused-agent-launch-tab.ts | 44 ++++ src/shared/launch-file-writing.ts | 17 +- src/shared/startup-command-staging.ts | 26 ++- src/shared/wsl-launch-directory.test.ts | 20 +- 17 files changed, 450 insertions(+), 56 deletions(-) create mode 100644 src/main/runtime/wsl-broken-launch-folder.test.ts create mode 100644 src/renderer/src/components/terminal-pane/refused-agent-launch-tab.ts diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index fd0a3b8c97d..5d499986ae4 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -130,7 +130,6 @@ async function spawnAndPublishSession( launchFile: opts.launchFile, command: opts.command, env: opts.env, - orcaBuiltLine: opts.launchAgent !== undefined, wslDistro, wslDirectory }) @@ -220,7 +219,12 @@ async function spawnAndPublishSession( }) // Why refuse: typed in full, a long agent line can leave the shell at a quote prompt with the // prompt lost while the launch reports success; the refusal hands the user the prompt instead. - if (staging.failure !== undefined && opts.launchAgent !== undefined) { + // A folder already unusable is not refused: the plan saw it too and chose main's typed line. + if ( + staging.failure !== undefined && + opts.launchAgent !== undefined && + staging.folderUnusable !== true + ) { deps.sessions.set(opts.sessionId, session) await session.forceKillAndDisposeSubprocess() removeLaunchFile(launchFile) diff --git a/src/main/daemon/terminal-host-startup-staging.test.ts b/src/main/daemon/terminal-host-startup-staging.test.ts index 80fda3d9c5a..cfb89c87717 100644 --- a/src/main/daemon/terminal-host-startup-staging.test.ts +++ b/src/main/daemon/terminal-host-startup-staging.test.ts @@ -1,5 +1,5 @@ import './mock-descendant-sweep' -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -101,8 +101,11 @@ describePosix('daemon startup command staging', () => { // Why (stack QA P8-2): typed in full, a long agent line left the shell at a quote prompt, the // prompt lost while agent.launch reported success; refusing hands the user the prompt instead. - it('refuses an agent launch it could not stage, typing nothing and keeping no session', async () => { - vi.stubEnv('TMPDIR', join(tempDir, 'missing')) + // A temp folder that looks writable and still fails the write is that race. + it('refuses an agent launch whose staging write failed in a usable-looking folder', async () => { + const notAFolder = join(tempDir, 'file') + writeFileSync(notAFolder, '') + vi.stubEnv('TMPDIR', notAFolder) const sub = mockSubprocess('/bin/zsh') vi.mocked(sub.forceKill).mockImplementation(() => exitSubprocess?.(1)) vi.mocked(sub.kill).mockImplementation(() => exitSubprocess?.(1)) @@ -124,6 +127,26 @@ describePosix('daemon startup command staging', () => { ) }) + // Why (stack QA 2a): the plan saw the same unusable folder and chose main's delivery, the line + // typed as is; refusing it made a 5-line prompt worse than main. + it('types an agent line as is when the temp folder is unusable', async () => { + vi.stubEnv('TMPDIR', join(tempDir, 'missing')) + const command = `claude 'one\ntwo'` + const sub = mockSubprocess('/bin/zsh') + const host = new TerminalHost({ spawnSubprocess: () => sub }) + const result = await host.createOrAttach({ + sessionId: 's-agent-folder-unusable', + cols: 80, + rows: 24, + command, + launchAgent: 'claude', + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + expect(result.isNew).toBe(true) + expect(sub.write).toHaveBeenCalledWith(`${command}\n`) + }) + it('prints a notice in the terminal when it types a line it could not stage', async () => { vi.stubEnv('TMPDIR', join(tempDir, 'missing')) const command = `claude '${'x'.repeat(600)}'` diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 180e08de8d3..8171bd803a0 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -62,7 +62,6 @@ export async function spawnLocalPty( launchFile: args.launchFile, command: args.command, env: args.env, - orcaBuiltLine: args.launchAgent !== undefined, wslDistro, wslDirectory: wslLaunchDirectory }) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index cbc916c975f..80e4a727fb7 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -4,6 +4,8 @@ import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' +import { repoIsRemote } from '../../shared/agent-launch-remote' +import { probeWslLaunchFolderBeforePlanning } from './this-orca-launch-host' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeTerminalWait } from '../../shared/runtime-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' @@ -143,7 +145,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM }) } - protected buildStartupForAgent( + protected async buildStartupForAgent( repo: Repo, agent: TuiAgent, prompt: string | undefined, @@ -153,10 +155,20 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM launchSource?: string onPromptCarry?: (carried: boolean) => void } - ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { + ): Promise<{ + agent: TuiAgent + startup: WorktreeStartupLaunch + followup?: WorktreeStartupFollowup + }> { if (!this.store) { throw new Error('runtime_unavailable') } + await probeWslLaunchFolderBeforePlanning({ + launchPlatform: this.getAgentLaunchPlatformForRepo(repo), + isRemote: repoIsRemote(repo), + workspacePath: repo.path, + prompt + }) return buildWorktreeStartupForAgent({ repo, agent, diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index d4358df9aad..25c92b96132 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -27,7 +27,7 @@ import { windowsDraftRefusal } from '../../shared/launch-prompt-carry' import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' -import { thisOrcaLaunchHost } from './this-orca-launch-host' +import { probedThisOrcaLaunchHost } from './this-orca-launch-host' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { AgentSessionCreateOperation, @@ -184,11 +184,12 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe const planned = planLaunchPrompt({ ...startupArgs, prompt: request.prompt ?? '', - host: thisOrcaLaunchHost({ + host: await probedThisOrcaLaunchHost({ launchPlatform: startupArgs.platform, isRemote: Boolean(workspace.connectionId), settings, - workspacePath: workspace.path + workspacePath: workspace.path, + prompt: request.prompt }), // Why: this create returns before the agent is ready, so nothing pastes after it. paste: 'never' diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 49eb69da7bc..cdc174d66a4 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -55,7 +55,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork } const agentStartup = !args.startup && args.startupAgent - ? this.buildStartupForAgent( + ? await this.buildStartupForAgent( repo, args.startupAgent, args.startupPrompt, diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index ac135bb79aa..74b692e8ee6 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -6,7 +6,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { planLaunchPrompt } from '../../shared/tui-agent-startup' -import { thisOrcaLaunchHost } from './this-orca-launch-host' +import { probedThisOrcaLaunchHost } from './this-orca-launch-host' import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' @@ -61,11 +61,12 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt isRemote }), prompt: opts.agentPrompt ?? '', - host: thisOrcaLaunchHost({ + host: await probedThisOrcaLaunchHost({ launchPlatform, isRemote, settings, - workspacePath: workspace.path + workspacePath: workspace.path, + prompt: opts.agentPrompt }), // Why: a quick command has no paste after ready. paste: 'never' diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 104c1ff012f..d60a9a443ac 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -21,7 +21,7 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' import { planLaunchPrompt } from '../../shared/tui-agent-startup' import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' -import { thisOrcaLaunchHost } from './this-orca-launch-host' +import { probedThisOrcaLaunchHost } from './this-orca-launch-host' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -271,12 +271,14 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ...planInputs, prompt: opts.startupPrompt ?? '', ...(opts.launchFile ? { launchFile: opts.launchFile } : {}), - host: thisOrcaLaunchHost({ + host: await probedThisOrcaLaunchHost({ launchPlatform: platform, isRemote, settings, windowsShellOverride: opts.shellOverride, - workspacePath: workspace.path + workspacePath: workspace.path, + // A caller's own launch file already carries the prompt, so no line needs staging. + ...(opts.launchFile ? {} : { prompt: opts.startupPrompt }) }), paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never' }) diff --git a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts index 59e13676e63..c16f6fc2462 100644 --- a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts +++ b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts @@ -156,7 +156,7 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC if (!repo) { throw new Error('Repository for the selected workspace is no longer available.') } - const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) + const startup = await this.buildStartupForAgent(repo, opts.agent, opts.prompt) return await this.createTerminal(`id:${worktree.id}`, { command: startup.startup.command, env: startup.startup.env, diff --git a/src/main/runtime/this-orca-launch-host.ts b/src/main/runtime/this-orca-launch-host.ts index c4f794c4b57..92f4e7a1f8e 100644 --- a/src/main/runtime/this-orca-launch-host.ts +++ b/src/main/runtime/this-orca-launch-host.ts @@ -1,7 +1,11 @@ import { cachedPwshAvailability } from '../pwsh' import { parseWslUncPath } from '../../shared/wsl-paths' import { localLaunchArtifactsWritable } from '../providers/local-launch-artifact-directory' -import { wslLaunchDirectoryKnownBroken } from '../providers/wsl-launch-directory-resolution' +import { + resolveWslLaunchDirectory, + wslLaunchDirectoryKnownBroken +} from '../providers/wsl-launch-directory-resolution' +import { typedStartupLineFits } from '../../shared/typed-startup-line' import { describeLaunchHost, spawnedWindowsShell, @@ -9,8 +13,7 @@ import { type WindowsShellSettings } from '../../shared/launch-host' -/** The host facts for a launch this Orca runs itself, locally or over its own SSH connection. */ -export function thisOrcaLaunchHost(args: { +type ThisOrcaLaunchHostArgs = { launchPlatform: NodeJS.Platform isRemote: boolean settings: WindowsShellSettings @@ -18,7 +21,10 @@ export function thisOrcaLaunchHost(args: { windowsShellOverride?: string | null /** Where the pane opens; a WSL path names the distro its line and file are written into. */ workspacePath?: string -}): LaunchHost { +} + +/** The host facts for a launch this Orca runs itself, locally or over its own SSH connection. */ +export function thisOrcaLaunchHost(args: ThisOrcaLaunchHostArgs): LaunchHost { const local = !args.isRemote && args.launchPlatform === 'win32' && process.platform === 'win32' return describeLaunchHost({ launchPlatform: args.launchPlatform, @@ -36,6 +42,42 @@ export function thisOrcaLaunchHost(args: { }) } +/** + * Probes the WSL distro a prompt too long to type would be staged into, before the launch is + * planned, so a first launch into an unusable folder is planned for main's delivery as later ones + * are. The spawn would await the same probe for such a line; a prompt short enough to type skips it. + */ +export async function probeWslLaunchFolderBeforePlanning(args: { + launchPlatform: NodeJS.Platform + isRemote: boolean + workspacePath?: string + prompt?: string +}): Promise { + const distro = wslLaunchDistro(args) + if (distro && args.prompt && !typedStartupLineFits(args.prompt.trim())) { + await resolveWslLaunchDirectory(distro) + } +} + +/** `thisOrcaLaunchHost` for a launch of `prompt`, once its WSL folder is probed when it may be. */ +export async function probedThisOrcaLaunchHost( + args: ThisOrcaLaunchHostArgs & { prompt?: string } +): Promise { + await probeWslLaunchFolderBeforePlanning(args) + return thisOrcaLaunchHost(args) +} + +function wslLaunchDistro(args: { + launchPlatform: NodeJS.Platform + isRemote: boolean + workspacePath?: string +}): string | undefined { + if (args.isRemote || process.platform !== 'win32' || args.launchPlatform === 'win32') { + return undefined + } + return args.workspacePath ? parseWslUncPath(args.workspacePath)?.distro : undefined +} + /** Whether this host can write the folder a staged line and a launch file go in. An SSH host's is * the relay's, which this client cannot check. */ function writesLaunchArtifacts(args: { @@ -47,7 +89,7 @@ function writesLaunchArtifacts(args: { return true } if (process.platform === 'win32' && args.launchPlatform !== 'win32') { - const distro = args.workspacePath ? parseWslUncPath(args.workspacePath)?.distro : undefined + const distro = wslLaunchDistro(args) return !distro || !wslLaunchDirectoryKnownBroken(distro) } return localLaunchArtifactsWritable() diff --git a/src/main/runtime/wsl-broken-launch-folder.test.ts b/src/main/runtime/wsl-broken-launch-folder.test.ts new file mode 100644 index 00000000000..3ac3aea20fa --- /dev/null +++ b/src/main/runtime/wsl-broken-launch-folder.test.ts @@ -0,0 +1,197 @@ +import '../daemon/mock-descendant-sweep' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { planLaunchPrompt } from '../../shared/tui-agent-startup' +import type { TuiAgent } from '../../shared/tui-agent' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' +import type * as WslPaths from '../../shared/wsl-paths' +import { TerminalHost } from '../daemon/terminal-host' +import type { SubprocessHandle } from '../daemon/session-subprocess-handle' +import { noteLaunchArtifactRefusal } from '../providers/local-launch-artifact-directory' +import { resolveSpawnWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution' +import { getAgentLaunchPlatformForRepo } from './runtime-agent-launch-resolution' +import { probedThisOrcaLaunchHost } from './this-orca-launch-host' + +const { runWslProcess, uncSide } = vi.hoisted(() => { + const side: { path: string | null } = { path: null } + return { runWslProcess: vi.fn(), uncSide: side } +}) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess })) +// Stands in for `\\wsl.localhost\\root\.cache\orca`, which only Windows can open. +vi.mock('../../shared/wsl-paths', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toWindowsWslUncPath: (linuxPath: string, distro: string) => + uncSide.path ?? actual.toWindowsWslUncPath(linuxPath, distro) + } +}) + +// The stack QA's prompts (2a): one long line and five short ones. +const P25K = 'QA-STACK P25K one line, inert. '.padEnd(25_600, 'x') +const ML5 = [1, 2, 3, 4, 5].map((i) => `QA-STACK ML5 line ${i}: inert text.`).join('\n') + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! +let scratch: string +let exitSubprocess: ((code: number) => void) | undefined + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + scratch = mkdtempSync(join(tmpdir(), 'orca-wsl-broken-folder-')) + exitSubprocess = undefined +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + rmSync(scratch, { recursive: true, force: true }) + uncSide.path = null + vi.clearAllMocks() +}) + +function probeAnswers(home: string | null) { + return { + environmentResolved: true, + code: home ? 0 : 1, + stdout: home ? `${home}\n/bin/bash\n` : '', + stderr: home ? '' : 'mkdir: cannot create directory: File exists', + timedOut: false + } +} + +function wslRepoPath(distro: string): string { + return `\\\\wsl.localhost\\${distro}\\root\\fixture` +} + +/** What `agent.launch` plans, with its paste for an uncarried prompt. */ +async function planAgentLaunch(distro: string, agent: TuiAgent, prompt: string) { + const repo = { connectionId: null, path: wslRepoPath(distro) } + const platform = getAgentLaunchPlatformForRepo(repo) + const settings = { terminalWindowsShell: 'powershell.exe' } + return planLaunchPrompt({ + ...resolveAgentStartupPlanInputs({ agent, settings, platform, isRemote: false }), + prompt, + host: await probedThisOrcaLaunchHost({ + launchPlatform: platform, + isRemote: false, + settings, + workspacePath: repo.path, + prompt + }), + paste: 'when-host-proves-agent' + }) +} + +function mockSubprocess(): SubprocessHandle { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handle stubs only what session creation calls. + return { + pid: 1, + shellPath: 'C:\\Windows\\System32\\wsl.exe', + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => exitSubprocess?.(1)), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => exitSubprocess?.(1)), + signal: vi.fn(), + onData: () => {}, + onExit: (callback: (code: number) => void) => { + exitSubprocess = callback + }, + dispose: vi.fn() + } as SubprocessHandle +} + +/** The spawn as main runs it: its probe, then the daemon's session create, noting a refusal. */ +async function spawnAgentLine(distro: string, agent: TuiAgent, command: string) { + const wslDistro = distro + const probe = resolveSpawnWslLaunchDirectory(wslDistro, { command, launchAgent: agent }) + const wslLaunchDirectory: WslLaunchDirectory | undefined = probe ? await probe : undefined + const sub = mockSubprocess() + const host = new TerminalHost({ spawnSubprocess: () => sub }) + try { + await host.createOrAttach({ + sessionId: `s-${distro}-${agent}-${command.length}`, + cols: 80, + rows: 24, + cwd: wslRepoPath(distro), + command, + launchAgent: agent, + ...(wslLaunchDirectory ? { wslLaunchDirectory } : {}), + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + } catch (error) { + noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error) + return { refused: error, typed: vi.mocked(sub.write).mock.calls.map(([data]) => data) } + } + return { refused: null, typed: vi.mocked(sub.write).mock.calls.map(([data]) => data) } +} + +async function launch(distro: string, agent: TuiAgent, prompt: string) { + const planned = await planAgentLaunch(distro, agent, prompt) + if (planned?.carry !== 'on-line') { + throw new Error(`expected the line, got ${planned?.carry}`) + } + return { + command: planned.plan.launchCommand, + ...(await spawnAgentLine(distro, agent, planned.plan.launchCommand)) + } +} + +// Why (stack QA 2a): with the distro's ~/.cache/orca unusable, main types the agent line as is (a +// 5-line prompt arrives exact); refusing it made that launch worse than main. +describe('agent.launch into a WSL workspace whose launch folder is unusable', () => { + it.each<[TuiAgent, string, string]>([ + ['claude', 'P25K', P25K], + ['claude', 'ML5', ML5], + ['grok', 'P25K', P25K] + ])( + 'types %s %s as main does when the folder was broken before first use', + async (agent, tag, prompt) => { + const distro = `qa-before-${agent}-${tag}` + runWslProcess.mockResolvedValue(probeAnswers(null)) + const { command, refused, typed } = await launch(distro, agent, prompt) + expect(command).toContain(prompt.split('\n')[0]) + expect(refused).toBeNull() + expect(typed).toEqual([expect.stringContaining(command)]) + } + ) + + // Why the probe before planning: past the line's ceiling the plan would otherwise pick a launch + // file, which a distro with no folder must refuse. + it("plans a prompt past the line ceiling for main's line on the first launch", async () => { + runWslProcess.mockResolvedValue(probeAnswers(null)) + const prompt = 'QA-STACK P120K one line, inert. '.padEnd(120_000, 'x') + const { command, refused, typed } = await launch('qa-before-p120k', 'claude', prompt) + expect(command).toContain(prompt) + expect(refused).toBeNull() + expect(typed).toEqual([expect.stringContaining(command)]) + }) + + it('refuses only the launch whose write failed after the probe found the folder, then types as main does', async () => { + const distro = 'qa-after-first-use' + const windowsSide = join(scratch, 'cache') + runWslProcess.mockResolvedValue(probeAnswers('/root')) + uncSide.path = windowsSide + const first = await launch(distro, 'claude', P25K) + expect(first.refused).toBeNull() + expect(first.typed).toEqual([expect.stringMatching(/^\. '\/root\/\.cache\/orca\/orca-launch-/)]) + + // The folder breaks after the probe found it: the probe said yes, so this write's failure is + // the race, refused with nothing typed. + rmSync(windowsSide, { recursive: true, force: true }) + writeFileSync(windowsSide, '') + const raced = await launch(distro, 'claude', P25K) + expect(String(raced.refused)).toMatch(/launch_file_unavailable/) + expect(raced.typed).toEqual([]) + + for (const prompt of [P25K, ML5]) { + const later = await launch(distro, 'claude', prompt) + expect(later.refused).toBeNull() + expect(later.typed).toEqual([expect.stringContaining(later.command)]) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index cad54176fba..32d40ae4522 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -15,7 +15,7 @@ import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers' import { isSshSessionGoneError } from './pty-connection/pty-connect-limits' import { spawnIpcPty } from './ipc-pty-spawn-request' import { isLaunchFileUnavailableMessage } from '../../../../shared/launch-prompt-file' -import { showAgentLaunchNotStartedNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' +import { settleRefusedAgentLaunch } from './refused-agent-launch-tab' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' @@ -194,11 +194,12 @@ function handleConnectError( } // Why the transport's prompt: the spawn carries it unless the connect call named its own command. // The host refuses a launch file, or a WSL line it cannot stage, with the same code. - const { launchFile, launchPrompt } = context.transportOptions + const { launchFile, launchPrompt, tabId } = context.transportOptions const refusedPrompt = launchFile?.content ?? launchPrompt if (refusedPrompt && !options.command && isLaunchFileUnavailableMessage(message)) { - // The pane gets no shell; the notice holds the only copy of the prompt. - showAgentLaunchNotStartedNotice({ prompt: refusedPrompt }) + // The pane gets no shell; the notice holds the only copy of the prompt, and is the only error. + settleRefusedAgentLaunch({ tabId, prompt: refusedPrompt }) + return undefined } if (connectionId && options.sessionId && message.includes(SSH_PTY_CONNECTION_MISMATCH_MARKER)) { // Why not `sessionExpired`: this string is minted by `toRelaySshPtyId`/`toAppSshPtyId` from a diff --git a/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts index 6bc07f350fa..a5682c10939 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts @@ -2,10 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file' -const mocks = vi.hoisted(() => ({ showNotStarted: vi.fn() })) +const mocks = vi.hoisted(() => { + const ptyIdsByTabId: Record = {} + return { showNotStarted: vi.fn(), closeTerminalTab: vi.fn(), ptyIdsByTabId } +}) vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({ showAgentLaunchNotStartedNotice: mocks.showNotStarted })) +vi.mock('@/components/terminal/terminal-tab-actions', () => ({ + closeTerminalTab: mocks.closeTerminalTab +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ ptyIdsByTabId: mocks.ptyIdsByTabId }) } +})) describe('a pane spawn the host refused for what carries its prompt', () => { const originalWindow = globalThis.window @@ -13,6 +22,7 @@ describe('a pane spawn the host refused for what carries its prompt', () => { beforeEach(() => { vi.resetModules() mocks.showNotStarted.mockReset() + mocks.closeTerminalTab.mockReset() installIpcPtyWindow(originalWindow, { data: () => {}, exit: () => {} }) }) @@ -35,6 +45,55 @@ describe('a pane spawn the host refused for what carries its prompt', () => { expect(mocks.showNotStarted).toHaveBeenCalledWith({ prompt: 'a long prompt' }) }) + // Why (stack QA, new agent tab with the temp folder read-only): the empty pane's recovery spawned + // it again, so the notice showed twice, under a red error panel, in a tab left open with nothing. + it('tells the user once, with no error panel, and closes the tab it left empty', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const refusal = new Error(describeLaunchFileUnavailable('EACCES', 'staged-line')) + vi.mocked(window.api.pty.spawn).mockRejectedValue(refusal) + const onError = vi.fn() + const connect = () => + createIpcPtyTransport({ + command: "claude 'a long prompt'", + launchPrompt: 'a long prompt', + tabId: 'tab-refused', + worktreeId: 'wt-1' + }).connect({ url: '', callbacks: { onError } }) + + await connect() + await connect() + + expect(mocks.showNotStarted).toHaveBeenCalledTimes(1) + expect(onError).not.toHaveBeenCalled() + await vi.waitFor(() => + expect(mocks.closeTerminalTab).toHaveBeenCalledWith('tab-refused', { + force: true, + skipRunningProcessConfirm: true, + captureRecentlyClosed: false + }) + ) + }) + + it('leaves a tab that still holds a live terminal', async () => { + mocks.ptyIdsByTabId['tab-with-shell'] = ['pty-live'] + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( + new Error(describeLaunchFileUnavailable('EACCES', 'staged-line')) + ) + + await createIpcPtyTransport({ + command: "claude 'a long prompt'", + launchPrompt: 'a long prompt', + tabId: 'tab-with-shell', + worktreeId: 'wt-1' + }).connect({ url: '', callbacks: { onError: vi.fn() } }) + + expect(mocks.showNotStarted).toHaveBeenCalledTimes(1) + // The close reads the store after a lazy import; let it settle before asserting it did not run. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(mocks.closeTerminalTab).not.toHaveBeenCalled() + }) + it('stays quiet for any other spawn failure', async () => { const { createIpcPtyTransport } = await import('./pty-transport') vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(new Error('spawn ENOENT')) diff --git a/src/renderer/src/components/terminal-pane/refused-agent-launch-tab.ts b/src/renderer/src/components/terminal-pane/refused-agent-launch-tab.ts new file mode 100644 index 00000000000..875e6d49f99 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/refused-agent-launch-tab.ts @@ -0,0 +1,44 @@ +import { showAgentLaunchNotStartedNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' + +/** Tabs whose refused agent launch the user was already told about. */ +const noticedTabIds = new Set() + +/** + * The host refused to start a pane's agent (`launch_file_unavailable`), so nothing ran in it. The + * notice holds the prompt, once per tab: the empty pane's recovery spawns it again, and is refused + * again. A tab left with no live terminal is closed, as main's desktop route closes a refused tab; + * one that still holds a live terminal is the user's, and stays. + */ +export function settleRefusedAgentLaunch(args: { + tabId: string | undefined + prompt: string +}): void { + const { tabId } = args + if (tabId !== undefined && noticedTabIds.has(tabId)) { + return + } + if (tabId !== undefined) { + noticedTabIds.add(tabId) + } + showAgentLaunchNotStartedNotice({ prompt: args.prompt }) + if (tabId !== undefined) { + void closeTabLeftEmpty(tabId).catch((error: unknown) => + console.error('Could not close a refused tab', error) + ) + } +} + +async function closeTabLeftEmpty(tabId: string): Promise { + // Why lazy: the store's terminal slice imports this transport, so a static import is a cycle. + const [{ useAppStore }, { closeTerminalTab }] = await Promise.all([ + import('@/store'), + import('@/components/terminal/terminal-tab-actions') + ]) + if ((useAppStore.getState().ptyIdsByTabId[tabId] ?? []).length === 0) { + closeTerminalTab(tabId, { + force: true, + skipRunningProcessConfirm: true, + captureRecentlyClosed: false + }) + } +} diff --git a/src/shared/launch-file-writing.ts b/src/shared/launch-file-writing.ts index 684ad53e5a3..4eceede9792 100644 --- a/src/shared/launch-file-writing.ts +++ b/src/shared/launch-file-writing.ts @@ -190,27 +190,20 @@ export function spawnNeedsWslLaunchDirectory(args: { /** * The write site's launch file for a spawn. A WSL session writes it into the distro, and refuses - * when the distro directory is unknown and a file or a staged line needs it: a Windows path means - * nothing to an agent in the distro, and a long line typed raw is cut or submitted early. + * when the distro directory is unknown: a Windows path means nothing to an agent in the distro. A + * line is not refused here: with no directory the host types it as is, which is the delivery the + * plan chose, from the same probe, for a distro whose folder is unusable. */ export function writeSpawnLaunchFile(args: { launchFile?: LaunchFile command?: string env?: Record - orcaBuiltLine: boolean wslDistro: string | null | undefined wslDirectory: WslLaunchDirectory | undefined }): WrittenLaunchFile | undefined { const wslDirectory = args.wslDistro ? args.wslDirectory : undefined - if ( - args.wslDistro && - spawnNeedsWslLaunchDirectory(args) && - wslDirectory?.distro !== args.wslDistro - ) { - throw new LaunchFileUnavailableError( - "the WSL distro's home directory could not be reached", - args.launchFile ? 'file' : 'staged-line' - ) + if (args.wslDistro && args.launchFile && wslDirectory?.distro !== args.wslDistro) { + throw new LaunchFileUnavailableError("the WSL distro's home directory could not be reached") } return args.launchFile ? writeLaunchFile({ diff --git a/src/shared/startup-command-staging.ts b/src/shared/startup-command-staging.ts index 5a37dca8292..76e2d65fa49 100644 --- a/src/shared/startup-command-staging.ts +++ b/src/shared/startup-command-staging.ts @@ -8,7 +8,15 @@ * by the host that owns the PTY, at the moment it accepts the spawn. */ import { randomBytes } from 'node:crypto' -import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { + accessSync, + constants, + mkdirSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join, posix } from 'node:path' import { quoteStartupArg } from './tui-agent-startup-shell' @@ -31,6 +39,10 @@ export type StartupCommandStaging = { scriptPath?: string /** Why staging failed, for the host's log. */ failure?: string + /** The failed write's folder was unusable before it (this host's temp folder, unwritable), which + * the launch's plan also saw, so it chose main's typed line; otherwise the folder looked usable + * (a WSL distro's, found by its probe) and the write still failed. */ + folderUnusable?: true } // Why only these type any short line as is: Orca's portable quoting is verified literal in these. Any @@ -137,7 +149,8 @@ export function stageStartupCommand(args: { return { command: args.command, delivery: 'typed-after-stage-failed', - failure: error instanceof Error ? error.message : String(error) + failure: error instanceof Error ? error.message : String(error), + ...(!wsl && !folderWritable(directory) ? { folderUnusable: true } : {}) } } return { @@ -147,6 +160,15 @@ export function stageStartupCommand(args: { } } +function folderWritable(directory: string): boolean { + try { + accessSync(directory, constants.W_OK) + return true + } catch { + return false + } +} + /** * The line the host prints to the terminal when staging failed: the full line it types instead may * leave the shell waiting for more input, and nothing else would tell the user why. diff --git a/src/shared/wsl-launch-directory.test.ts b/src/shared/wsl-launch-directory.test.ts index 6ae625b954e..3ab443697c0 100644 --- a/src/shared/wsl-launch-directory.test.ts +++ b/src/shared/wsl-launch-directory.test.ts @@ -31,7 +31,6 @@ describe('a WSL launch file', () => { const written = writeSpawnLaunchFile({ launchFile: file, command: `claude '${buildLaunchFilePointer(file.placeholder)}'`, - orcaBuiltLine: true, wslDistro: 'Ubuntu', wslDirectory: directory })! @@ -48,7 +47,6 @@ describe('a WSL launch file', () => { writeSpawnLaunchFile({ launchFile: launchFile(), command: 'claude', - orcaBuiltLine: true, wslDistro: 'Ubuntu', wslDirectory }) @@ -57,17 +55,13 @@ describe('a WSL launch file', () => { expect(readdirSync(join(windowsSide, '..'))).toEqual([]) }) - it('refuses a long agent line it would otherwise type raw, and types a short one', () => { - const line = (command: string) => () => - writeSpawnLaunchFile({ - command, - orcaBuiltLine: true, - wslDistro: 'Ubuntu', - wslDirectory: undefined - }) - expect(line(`claude '${'x'.repeat(600)}'`)).toThrow(LaunchFileUnavailableError) - expect(line(`claude 'one\ntwo'`)).toThrow(LaunchFileUnavailableError) - expect(line(`claude 'fix it'`)()).toBeUndefined() + // Why: the plan read the same probe, so with no folder it chose main's delivery, typed as is. + it('leaves a line of any length to be typed when the distro has no folder', () => { + for (const command of [`claude '${'x'.repeat(600)}'`, `claude 'one\ntwo'`, `claude 'fix it'`]) { + expect( + writeSpawnLaunchFile({ command, wslDistro: 'Ubuntu', wslDirectory: undefined }) + ).toBeUndefined() + } }) })