mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
fix(launch-prompt): type main's line when the staging folder is unusable; one notice, no empty tab
ca1c4046ed 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.
This commit is contained in:
@@ -130,7 +130,6 @@ async function spawnAndPublishSession(
|
|||||||
launchFile: opts.launchFile,
|
launchFile: opts.launchFile,
|
||||||
command: opts.command,
|
command: opts.command,
|
||||||
env: opts.env,
|
env: opts.env,
|
||||||
orcaBuiltLine: opts.launchAgent !== undefined,
|
|
||||||
wslDistro,
|
wslDistro,
|
||||||
wslDirectory
|
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
|
// 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.
|
// 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)
|
deps.sessions.set(opts.sessionId, session)
|
||||||
await session.forceKillAndDisposeSubprocess()
|
await session.forceKillAndDisposeSubprocess()
|
||||||
removeLaunchFile(launchFile)
|
removeLaunchFile(launchFile)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import './mock-descendant-sweep'
|
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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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
|
// 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.
|
// 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 () => {
|
// A temp folder that looks writable and still fails the write is that race.
|
||||||
vi.stubEnv('TMPDIR', join(tempDir, 'missing'))
|
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')
|
const sub = mockSubprocess('/bin/zsh')
|
||||||
vi.mocked(sub.forceKill).mockImplementation(() => exitSubprocess?.(1))
|
vi.mocked(sub.forceKill).mockImplementation(() => exitSubprocess?.(1))
|
||||||
vi.mocked(sub.kill).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 () => {
|
it('prints a notice in the terminal when it types a line it could not stage', async () => {
|
||||||
vi.stubEnv('TMPDIR', join(tempDir, 'missing'))
|
vi.stubEnv('TMPDIR', join(tempDir, 'missing'))
|
||||||
const command = `claude '${'x'.repeat(600)}'`
|
const command = `claude '${'x'.repeat(600)}'`
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export async function spawnLocalPty(
|
|||||||
launchFile: args.launchFile,
|
launchFile: args.launchFile,
|
||||||
command: args.command,
|
command: args.command,
|
||||||
env: args.env,
|
env: args.env,
|
||||||
orcaBuiltLine: args.launchAgent !== undefined,
|
|
||||||
wslDistro,
|
wslDistro,
|
||||||
wslDirectory: wslLaunchDirectory
|
wslDirectory: wslLaunchDirectory
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
|
|||||||
import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation'
|
import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation'
|
||||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||||
import type { Repo } from '../../shared/repo-types'
|
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 { TuiAgent } from '../../shared/tui-agent'
|
||||||
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
|
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
|
||||||
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-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,
|
repo: Repo,
|
||||||
agent: TuiAgent,
|
agent: TuiAgent,
|
||||||
prompt: string | undefined,
|
prompt: string | undefined,
|
||||||
@@ -153,10 +155,20 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
|
|||||||
launchSource?: string
|
launchSource?: string
|
||||||
onPromptCarry?: (carried: boolean) => void
|
onPromptCarry?: (carried: boolean) => void
|
||||||
}
|
}
|
||||||
): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } {
|
): Promise<{
|
||||||
|
agent: TuiAgent
|
||||||
|
startup: WorktreeStartupLaunch
|
||||||
|
followup?: WorktreeStartupFollowup
|
||||||
|
}> {
|
||||||
if (!this.store) {
|
if (!this.store) {
|
||||||
throw new Error('runtime_unavailable')
|
throw new Error('runtime_unavailable')
|
||||||
}
|
}
|
||||||
|
await probeWslLaunchFolderBeforePlanning({
|
||||||
|
launchPlatform: this.getAgentLaunchPlatformForRepo(repo),
|
||||||
|
isRemote: repoIsRemote(repo),
|
||||||
|
workspacePath: repo.path,
|
||||||
|
prompt
|
||||||
|
})
|
||||||
return buildWorktreeStartupForAgent({
|
return buildWorktreeStartupForAgent({
|
||||||
repo,
|
repo,
|
||||||
agent,
|
agent,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
windowsDraftRefusal
|
windowsDraftRefusal
|
||||||
} from '../../shared/launch-prompt-carry'
|
} from '../../shared/launch-prompt-carry'
|
||||||
import { resolveStartupShell } from '../../shared/tui-agent-startup-shell'
|
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 { RuntimeTerminalCreate } from '../../shared/runtime-types'
|
||||||
import type {
|
import type {
|
||||||
AgentSessionCreateOperation,
|
AgentSessionCreateOperation,
|
||||||
@@ -184,11 +184,12 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
|
|||||||
const planned = planLaunchPrompt({
|
const planned = planLaunchPrompt({
|
||||||
...startupArgs,
|
...startupArgs,
|
||||||
prompt: request.prompt ?? '',
|
prompt: request.prompt ?? '',
|
||||||
host: thisOrcaLaunchHost({
|
host: await probedThisOrcaLaunchHost({
|
||||||
launchPlatform: startupArgs.platform,
|
launchPlatform: startupArgs.platform,
|
||||||
isRemote: Boolean(workspace.connectionId),
|
isRemote: Boolean(workspace.connectionId),
|
||||||
settings,
|
settings,
|
||||||
workspacePath: workspace.path
|
workspacePath: workspace.path,
|
||||||
|
prompt: request.prompt
|
||||||
}),
|
}),
|
||||||
// Why: this create returns before the agent is ready, so nothing pastes after it.
|
// Why: this create returns before the agent is ready, so nothing pastes after it.
|
||||||
paste: 'never'
|
paste: 'never'
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
|
|||||||
}
|
}
|
||||||
const agentStartup =
|
const agentStartup =
|
||||||
!args.startup && args.startupAgent
|
!args.startup && args.startupAgent
|
||||||
? this.buildStartupForAgent(
|
? await this.buildStartupForAgent(
|
||||||
repo,
|
repo,
|
||||||
args.startupAgent,
|
args.startupAgent,
|
||||||
args.startupPrompt,
|
args.startupPrompt,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { TuiAgent } from '../../shared/tui-agent'
|
|||||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||||
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
|
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
|
||||||
import { planLaunchPrompt } from '../../shared/tui-agent-startup'
|
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 { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry'
|
||||||
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
|
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
|
||||||
|
|
||||||
@@ -61,11 +61,12 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt
|
|||||||
isRemote
|
isRemote
|
||||||
}),
|
}),
|
||||||
prompt: opts.agentPrompt ?? '',
|
prompt: opts.agentPrompt ?? '',
|
||||||
host: thisOrcaLaunchHost({
|
host: await probedThisOrcaLaunchHost({
|
||||||
launchPlatform,
|
launchPlatform,
|
||||||
isRemote,
|
isRemote,
|
||||||
settings,
|
settings,
|
||||||
workspacePath: workspace.path
|
workspacePath: workspace.path,
|
||||||
|
prompt: opts.agentPrompt
|
||||||
}),
|
}),
|
||||||
// Why: a quick command has no paste after ready.
|
// Why: a quick command has no paste after ready.
|
||||||
paste: 'never'
|
paste: 'never'
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim
|
|||||||
import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
|
import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
|
||||||
import { planLaunchPrompt } from '../../shared/tui-agent-startup'
|
import { planLaunchPrompt } from '../../shared/tui-agent-startup'
|
||||||
import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry'
|
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 { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
|
||||||
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
|
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
|
||||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||||
@@ -271,12 +271,14 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
|
|||||||
...planInputs,
|
...planInputs,
|
||||||
prompt: opts.startupPrompt ?? '',
|
prompt: opts.startupPrompt ?? '',
|
||||||
...(opts.launchFile ? { launchFile: opts.launchFile } : {}),
|
...(opts.launchFile ? { launchFile: opts.launchFile } : {}),
|
||||||
host: thisOrcaLaunchHost({
|
host: await probedThisOrcaLaunchHost({
|
||||||
launchPlatform: platform,
|
launchPlatform: platform,
|
||||||
isRemote,
|
isRemote,
|
||||||
settings,
|
settings,
|
||||||
windowsShellOverride: opts.shellOverride,
|
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'
|
paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC
|
|||||||
if (!repo) {
|
if (!repo) {
|
||||||
throw new Error('Repository for the selected workspace is no longer available.')
|
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}`, {
|
return await this.createTerminal(`id:${worktree.id}`, {
|
||||||
command: startup.startup.command,
|
command: startup.startup.command,
|
||||||
env: startup.startup.env,
|
env: startup.startup.env,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { cachedPwshAvailability } from '../pwsh'
|
import { cachedPwshAvailability } from '../pwsh'
|
||||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||||
import { localLaunchArtifactsWritable } from '../providers/local-launch-artifact-directory'
|
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 {
|
import {
|
||||||
describeLaunchHost,
|
describeLaunchHost,
|
||||||
spawnedWindowsShell,
|
spawnedWindowsShell,
|
||||||
@@ -9,8 +13,7 @@ import {
|
|||||||
type WindowsShellSettings
|
type WindowsShellSettings
|
||||||
} from '../../shared/launch-host'
|
} from '../../shared/launch-host'
|
||||||
|
|
||||||
/** The host facts for a launch this Orca runs itself, locally or over its own SSH connection. */
|
type ThisOrcaLaunchHostArgs = {
|
||||||
export function thisOrcaLaunchHost(args: {
|
|
||||||
launchPlatform: NodeJS.Platform
|
launchPlatform: NodeJS.Platform
|
||||||
isRemote: boolean
|
isRemote: boolean
|
||||||
settings: WindowsShellSettings
|
settings: WindowsShellSettings
|
||||||
@@ -18,7 +21,10 @@ export function thisOrcaLaunchHost(args: {
|
|||||||
windowsShellOverride?: string | null
|
windowsShellOverride?: string | null
|
||||||
/** Where the pane opens; a WSL path names the distro its line and file are written into. */
|
/** Where the pane opens; a WSL path names the distro its line and file are written into. */
|
||||||
workspacePath?: string
|
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'
|
const local = !args.isRemote && args.launchPlatform === 'win32' && process.platform === 'win32'
|
||||||
return describeLaunchHost({
|
return describeLaunchHost({
|
||||||
launchPlatform: args.launchPlatform,
|
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<void> {
|
||||||
|
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<LaunchHost> {
|
||||||
|
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
|
/** 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. */
|
* the relay's, which this client cannot check. */
|
||||||
function writesLaunchArtifacts(args: {
|
function writesLaunchArtifacts(args: {
|
||||||
@@ -47,7 +89,7 @@ function writesLaunchArtifacts(args: {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32' && args.launchPlatform !== 'win32') {
|
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 !distro || !wslLaunchDirectoryKnownBroken(distro)
|
||||||
}
|
}
|
||||||
return localLaunchArtifactsWritable()
|
return localLaunchArtifactsWritable()
|
||||||
|
|||||||
@@ -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\<distro>\root\.cache\orca`, which only Windows can open.
|
||||||
|
vi.mock('../../shared/wsl-paths', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof WslPaths>()
|
||||||
|
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)])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,7 +15,7 @@ import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers'
|
|||||||
import { isSshSessionGoneError } from './pty-connection/pty-connect-limits'
|
import { isSshSessionGoneError } from './pty-connection/pty-connect-limits'
|
||||||
import { spawnIpcPty } from './ipc-pty-spawn-request'
|
import { spawnIpcPty } from './ipc-pty-spawn-request'
|
||||||
import { isLaunchFileUnavailableMessage } from '../../../../shared/launch-prompt-file'
|
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'
|
import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types'
|
||||||
|
|
||||||
const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection'
|
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.
|
// 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.
|
// 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
|
const refusedPrompt = launchFile?.content ?? launchPrompt
|
||||||
if (refusedPrompt && !options.command && isLaunchFileUnavailableMessage(message)) {
|
if (refusedPrompt && !options.command && isLaunchFileUnavailableMessage(message)) {
|
||||||
// The pane gets no shell; the notice holds the only copy of the prompt.
|
// The pane gets no shell; the notice holds the only copy of the prompt, and is the only error.
|
||||||
showAgentLaunchNotStartedNotice({ prompt: refusedPrompt })
|
settleRefusedAgentLaunch({ tabId, prompt: refusedPrompt })
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
if (connectionId && options.sessionId && message.includes(SSH_PTY_CONNECTION_MISMATCH_MARKER)) {
|
if (connectionId && options.sessionId && message.includes(SSH_PTY_CONNECTION_MISMATCH_MARKER)) {
|
||||||
// Why not `sessionExpired`: this string is minted by `toRelaySshPtyId`/`toAppSshPtyId` from a
|
// Why not `sessionExpired`: this string is minted by `toRelaySshPtyId`/`toAppSshPtyId` from a
|
||||||
|
|||||||
@@ -2,10 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness'
|
import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness'
|
||||||
import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file'
|
import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({ showNotStarted: vi.fn() }))
|
const mocks = vi.hoisted(() => {
|
||||||
|
const ptyIdsByTabId: Record<string, string[]> = {}
|
||||||
|
return { showNotStarted: vi.fn(), closeTerminalTab: vi.fn(), ptyIdsByTabId }
|
||||||
|
})
|
||||||
vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({
|
vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({
|
||||||
showAgentLaunchNotStartedNotice: mocks.showNotStarted
|
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', () => {
|
describe('a pane spawn the host refused for what carries its prompt', () => {
|
||||||
const originalWindow = globalThis.window
|
const originalWindow = globalThis.window
|
||||||
@@ -13,6 +22,7 @@ describe('a pane spawn the host refused for what carries its prompt', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
mocks.showNotStarted.mockReset()
|
mocks.showNotStarted.mockReset()
|
||||||
|
mocks.closeTerminalTab.mockReset()
|
||||||
installIpcPtyWindow(originalWindow, { data: () => {}, exit: () => {} })
|
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' })
|
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 () => {
|
it('stays quiet for any other spawn failure', async () => {
|
||||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||||
vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(new Error('spawn ENOENT'))
|
vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(new Error('spawn ENOENT'))
|
||||||
|
|||||||
@@ -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<string>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
* 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
|
* when the distro directory is unknown: a Windows path means nothing to an agent in the distro. A
|
||||||
* nothing to an agent in the distro, and a long line typed raw is cut or submitted early.
|
* 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: {
|
export function writeSpawnLaunchFile(args: {
|
||||||
launchFile?: LaunchFile
|
launchFile?: LaunchFile
|
||||||
command?: string
|
command?: string
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
orcaBuiltLine: boolean
|
|
||||||
wslDistro: string | null | undefined
|
wslDistro: string | null | undefined
|
||||||
wslDirectory: WslLaunchDirectory | undefined
|
wslDirectory: WslLaunchDirectory | undefined
|
||||||
}): WrittenLaunchFile | undefined {
|
}): WrittenLaunchFile | undefined {
|
||||||
const wslDirectory = args.wslDistro ? args.wslDirectory : undefined
|
const wslDirectory = args.wslDistro ? args.wslDirectory : undefined
|
||||||
if (
|
if (args.wslDistro && args.launchFile && wslDirectory?.distro !== args.wslDistro) {
|
||||||
args.wslDistro &&
|
throw new LaunchFileUnavailableError("the WSL distro's home directory could not be reached")
|
||||||
spawnNeedsWslLaunchDirectory(args) &&
|
|
||||||
wslDirectory?.distro !== args.wslDistro
|
|
||||||
) {
|
|
||||||
throw new LaunchFileUnavailableError(
|
|
||||||
"the WSL distro's home directory could not be reached",
|
|
||||||
args.launchFile ? 'file' : 'staged-line'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return args.launchFile
|
return args.launchFile
|
||||||
? writeLaunchFile({
|
? writeLaunchFile({
|
||||||
|
|||||||
@@ -8,7 +8,15 @@
|
|||||||
* by the host that owns the PTY, at the moment it accepts the spawn.
|
* by the host that owns the PTY, at the moment it accepts the spawn.
|
||||||
*/
|
*/
|
||||||
import { randomBytes } from 'node:crypto'
|
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 { tmpdir } from 'node:os'
|
||||||
import { join, posix } from 'node:path'
|
import { join, posix } from 'node:path'
|
||||||
import { quoteStartupArg } from './tui-agent-startup-shell'
|
import { quoteStartupArg } from './tui-agent-startup-shell'
|
||||||
@@ -31,6 +39,10 @@ export type StartupCommandStaging = {
|
|||||||
scriptPath?: string
|
scriptPath?: string
|
||||||
/** Why staging failed, for the host's log. */
|
/** Why staging failed, for the host's log. */
|
||||||
failure?: string
|
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
|
// 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 {
|
return {
|
||||||
command: args.command,
|
command: args.command,
|
||||||
delivery: 'typed-after-stage-failed',
|
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 {
|
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
|
* 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.
|
* leave the shell waiting for more input, and nothing else would tell the user why.
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ describe('a WSL launch file', () => {
|
|||||||
const written = writeSpawnLaunchFile({
|
const written = writeSpawnLaunchFile({
|
||||||
launchFile: file,
|
launchFile: file,
|
||||||
command: `claude '${buildLaunchFilePointer(file.placeholder)}'`,
|
command: `claude '${buildLaunchFilePointer(file.placeholder)}'`,
|
||||||
orcaBuiltLine: true,
|
|
||||||
wslDistro: 'Ubuntu',
|
wslDistro: 'Ubuntu',
|
||||||
wslDirectory: directory
|
wslDirectory: directory
|
||||||
})!
|
})!
|
||||||
@@ -48,7 +47,6 @@ describe('a WSL launch file', () => {
|
|||||||
writeSpawnLaunchFile({
|
writeSpawnLaunchFile({
|
||||||
launchFile: launchFile(),
|
launchFile: launchFile(),
|
||||||
command: 'claude',
|
command: 'claude',
|
||||||
orcaBuiltLine: true,
|
|
||||||
wslDistro: 'Ubuntu',
|
wslDistro: 'Ubuntu',
|
||||||
wslDirectory
|
wslDirectory
|
||||||
})
|
})
|
||||||
@@ -57,17 +55,13 @@ describe('a WSL launch file', () => {
|
|||||||
expect(readdirSync(join(windowsSide, '..'))).toEqual([])
|
expect(readdirSync(join(windowsSide, '..'))).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refuses a long agent line it would otherwise type raw, and types a short one', () => {
|
// Why: the plan read the same probe, so with no folder it chose main's delivery, typed as is.
|
||||||
const line = (command: string) => () =>
|
it('leaves a line of any length to be typed when the distro has no folder', () => {
|
||||||
writeSpawnLaunchFile({
|
for (const command of [`claude '${'x'.repeat(600)}'`, `claude 'one\ntwo'`, `claude 'fix it'`]) {
|
||||||
command,
|
expect(
|
||||||
orcaBuiltLine: true,
|
writeSpawnLaunchFile({ command, wslDistro: 'Ubuntu', wslDirectory: undefined })
|
||||||
wslDistro: 'Ubuntu',
|
).toBeUndefined()
|
||||||
wslDirectory: undefined
|
}
|
||||||
})
|
|
||||||
expect(line(`claude '${'x'.repeat(600)}'`)).toThrow(LaunchFileUnavailableError)
|
|
||||||
expect(line(`claude 'one\ntwo'`)).toThrow(LaunchFileUnavailableError)
|
|
||||||
expect(line(`claude 'fix it'`)()).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user