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() + } }) })