fix(launch-prompt): plan a host that cannot write its staging folder for main's delivery

d8cfcf397a refused an agent launch the daemon could not stage, which also
refused worker briefs (and AI-button prompts) that main pasted. The staged
script and the launch file share one folder per host, so "can write it" now
folds into takesLaunchFile, derived before planning in main: this machine's
temp folder (checked when planned, a refused write remembered for 30 s) or the
WSL distro's folder (the probe's failure, and a refused write, are remembered
for the same TTL). A host that cannot write it is planned for main's delivery
up front: the paste where main pasted, the line within the typed budget where
main typed it. Only a write that fails after the plan said yes is refused.
This commit is contained in:
Brennan Benson
2026-10-02 10:19:42 -07:00
parent d8cfcf397a
commit ca1c4046ed
15 changed files with 272 additions and 56 deletions
+3 -5
View File
@@ -18,10 +18,8 @@ import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segment
import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { resolveWslSessionContext } from './wsl-session-context'
import {
forgetWslLaunchDirectoryAfterRefusal,
resolveSpawnWslLaunchDirectory
} from '../providers/wsl-launch-directory-resolution'
import { noteLaunchArtifactRefusal } from '../providers/local-launch-artifact-directory'
import { resolveSpawnWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { resolveUnixShellPath } from '../providers/local-pty-utils'
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
@@ -265,7 +263,7 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
try {
result = await this.createOrAttachSpawn(context, context.historySeedSegments)
} catch (error) {
forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error)
noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error)
throw error
}
if (result.isNew && !attachOnly) {
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
localLaunchArtifactsWritable,
noteLaunchArtifactRefusal
} from './local-launch-artifact-directory'
import { WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS } from './wsl-launch-directory-resolution'
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: vi.fn() }))
const REFUSAL = new Error(
'Orca could not write it (ENOSPC), so the agent was not started. [launch_file_unavailable]'
)
// Why: a host that cannot write its temp folder neither stages a line nor writes a launch file, so
// the next launch is planned without either rather than refused again.
describe('whether this machine can write its staging folder', () => {
afterEach(() => {
vi.useRealTimers()
})
it('leaves this machine’s folder alone when the refused write was into a WSL distro', () => {
noteLaunchArtifactRefusal({ wslDistro: 'Ubuntu', wslLaunchDirectory: undefined }, REFUSAL)
expect(localLaunchArtifactsWritable()).toBe(true)
})
it('is yes until a write there is refused, then again after the failure TTL', () => {
vi.useFakeTimers()
expect(localLaunchArtifactsWritable()).toBe(true)
noteLaunchArtifactRefusal({ wslDistro: null, wslLaunchDirectory: undefined }, new Error('EIO'))
expect(localLaunchArtifactsWritable()).toBe(true)
noteLaunchArtifactRefusal({ wslDistro: null, wslLaunchDirectory: undefined }, REFUSAL)
expect(localLaunchArtifactsWritable()).toBe(false)
vi.advanceTimersByTime(WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS)
expect(localLaunchArtifactsWritable()).toBe(true)
})
})
@@ -0,0 +1,40 @@
import { accessSync, constants } from 'node:fs'
import { tmpdir } from 'node:os'
import { isLaunchFileRefusal } from '../../shared/launch-prompt-file'
import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory'
import {
noteWslLaunchDirectoryRefusal,
WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS
} from './wsl-launch-directory-resolution'
let refusedAt: number | null = null
/** Whether this machine can write its temp folder, where staged lines and launch files go. A write
* refused there counts for the failure TTL, then the folder is checked again. */
export function localLaunchArtifactsWritable(now = Date.now()): boolean {
if (refusedAt !== null && now - refusedAt < WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS) {
return false
}
try {
accessSync(tmpdir(), constants.W_OK)
return true
} catch {
return false
}
}
/** A spawn refused for its staged line or launch file: the folder it wrote to counts as broken,
* so the next launch is planned without writing there. */
export function noteLaunchArtifactRefusal(
spawn: {
wslDistro: string | null | undefined
wslLaunchDirectory: WslLaunchDirectory | undefined
},
error: unknown
): void {
if (spawn.wslDistro) {
noteWslLaunchDirectoryRefusal(spawn.wslLaunchDirectory, error)
} else if (isLaunchFileRefusal(error)) {
refusedAt = Date.now()
}
}
+4 -6
View File
@@ -30,10 +30,8 @@ import {
writeSpawnLaunchFile,
type WrittenLaunchFile
} from '../../shared/launch-file-writing'
import {
forgetWslLaunchDirectoryAfterRefusal,
resolveSpawnWslLaunchDirectory
} from './wsl-launch-directory-resolution'
import { resolveSpawnWslLaunchDirectory } from './wsl-launch-directory-resolution'
import { noteLaunchArtifactRefusal } from './local-launch-artifact-directory'
export async function spawnLocalPty(
args: PtySpawnOptions,
@@ -69,7 +67,7 @@ export async function spawnLocalPty(
wslDirectory: wslLaunchDirectory
})
} catch (error) {
forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error)
noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error)
throw error
}
args = {
@@ -84,7 +82,7 @@ export async function spawnLocalPty(
return await spawnFreshLocalPty(args, getOptions, reattachId, launchFile)
} catch (error) {
removeLaunchFile(launchFile)
forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error)
noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error)
throw error
}
}
@@ -3,7 +3,8 @@ import { createLocalPtyLaunchPlan, resolveLocalPtyWslDistro } from './local-pty-
import type { LocalPtyProviderOptions } from './local-pty-provider-types'
import type { PtySpawnOptions } from './types'
import {
forgetWslLaunchDirectoryAfterRefusal,
noteWslLaunchDirectoryRefusal,
wslLaunchDirectoryKnownBroken,
resolveSpawnWslLaunchDirectory,
resolveWslLaunchDirectory,
WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS
@@ -82,21 +83,38 @@ describe('resolveWslLaunchDirectory', () => {
}
})
// Why (stack QA P8-2): a folder found once and broken later must not be written into forever.
it('asks the distro again after a write into the found folder was refused', async () => {
runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n'))
const found = await resolveWslLaunchDirectory('Void')
forgetWslLaunchDirectoryAfterRefusal(found, new Error('EACCES'))
await resolveWslLaunchDirectory('Void')
expect(runWslProcess).toHaveBeenCalledTimes(1)
forgetWslLaunchDirectoryAfterRefusal(
found,
new Error(
'Orca could not write it (EEXIST), so the agent was not started. [launch_file_unavailable]'
// Why (stack QA P8-2): a folder found once and broken later must not be written into forever,
// and the next launch is planned without it until the distro is asked again.
it('counts a folder broken after a refused write, then asks the distro again', async () => {
vi.useFakeTimers()
try {
runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n'))
const found = await resolveWslLaunchDirectory('Void')
noteWslLaunchDirectoryRefusal(found, new Error('EACCES'))
expect(wslLaunchDirectoryKnownBroken('Void')).toBe(false)
noteWslLaunchDirectoryRefusal(
found,
new Error(
'Orca could not write it (EEXIST), so the agent was not started. [launch_file_unavailable]'
)
)
)
await resolveWslLaunchDirectory('Void')
expect(runWslProcess).toHaveBeenCalledTimes(2)
expect(wslLaunchDirectoryKnownBroken('Void')).toBe(true)
await expect(resolveWslLaunchDirectory('Void')).resolves.toBeUndefined()
expect(runWslProcess).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS)
expect(wslLaunchDirectoryKnownBroken('Void')).toBe(false)
await expect(resolveWslLaunchDirectory('Void')).resolves.toMatchObject({ distro: 'Void' })
expect(runWslProcess).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('counts a distro whose probe failed broken, for planning', async () => {
runWslProcess.mockResolvedValue(answers('', 1))
await resolveWslLaunchDirectory('Mint')
await Promise.resolve()
expect(wslLaunchDirectoryKnownBroken('Mint')).toBe(true)
})
it('probes only for a spawn that needs the directory', async () => {
@@ -11,6 +11,8 @@ import { runWslProcess } from '../wsl/wsl-runner'
const ORCA_CACHE_RELATIVE = '.cache/orca'
const probes = new Map<string, Promise<WslLaunchDirectory | undefined>>()
/** Distros whose folder a probe or a write found broken, until the failure TTL lets them retry. */
const failedDistros = new Set<string>()
/** A failed probe is answered from memory this long, so a broken distro does not cost every spawn
* a `wsl.exe` round trip, yet a distro that comes back is found again. */
@@ -41,8 +43,8 @@ export function resolveSpawnWslLaunchDirectory(
* The distro directory a WSL spawn's staged line and launch file are written to, and the login
* shell the pane runs, which decides how a staged line is sourced; undefined when the distro cannot
* be asked, and the write site then refuses a launch that needs one. Probed once per distro; a
* success is kept until a write into it fails (`forgetWslLaunchDirectoryAfterRefusal`), a failure
* for `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`.
* success is kept until a write into it fails (`noteWslLaunchDirectoryRefusal`), a failure for
* `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`.
*/
export async function resolveWslLaunchDirectory(
distro: string | null | undefined
@@ -56,26 +58,41 @@ export async function resolveWslLaunchDirectory(
probes.set(distro, probe)
const pending = probe
void pending.then((found) => {
if (!found) {
setTimeout(() => {
if (probes.get(distro) === pending) {
probes.delete(distro)
}
}, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS).unref?.()
if (found) {
failedDistros.delete(distro)
} else {
rememberFailure(distro, pending)
}
})
}
return await probe
}
/** A spawn into this directory was refused: forget it, so the next launch asks the distro again
* rather than writing into a folder that stopped working. */
export function forgetWslLaunchDirectoryAfterRefusal(
function rememberFailure(distro: string, pending: Promise<WslLaunchDirectory | undefined>): void {
failedDistros.add(distro)
setTimeout(() => {
if (probes.get(distro) === pending) {
probes.delete(distro)
failedDistros.delete(distro)
}
}, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS).unref?.()
}
/** Whether the distro's folder is known broken, so a launch is planned without writing there. */
export function wslLaunchDirectoryKnownBroken(distro: string): boolean {
return failedDistros.has(distro)
}
/** A spawn into this directory was refused: count it broken until the failure TTL, so the next
* launch is planned without it and a later one asks the distro again. */
export function noteWslLaunchDirectoryRefusal(
directory: WslLaunchDirectory | undefined,
error: unknown
): void {
if (directory && isLaunchFileRefusal(error)) {
probes.delete(directory.distro)
const pending = Promise.resolve(undefined)
probes.set(directory.distro, pending)
rememberFailure(directory.distro, pending)
}
}
@@ -187,7 +187,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
host: thisOrcaLaunchHost({
launchPlatform: startupArgs.platform,
isRemote: Boolean(workspace.connectionId),
settings
settings,
workspacePath: workspace.path
}),
// Why: this create returns before the agent is ready, so nothing pastes after it.
paste: 'never'
@@ -61,7 +61,12 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt
isRemote
}),
prompt: opts.agentPrompt ?? '',
host: thisOrcaLaunchHost({ launchPlatform, isRemote, settings }),
host: thisOrcaLaunchHost({
launchPlatform,
isRemote,
settings,
workspacePath: workspace.path
}),
// Why: a quick command has no paste after ready.
paste: 'never'
})
@@ -275,7 +275,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
launchPlatform: platform,
isRemote,
settings,
windowsShellOverride: opts.shellOverride
windowsShellOverride: opts.shellOverride,
workspacePath: workspace.path
}),
paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never'
})
@@ -161,7 +161,8 @@ export function buildWorktreeStartupForAgent(
host: thisOrcaLaunchHost({
isRemote: repoIsRemote(repo),
launchPlatform: environment.getLaunchPlatform(),
settings
settings,
workspacePath: repo.path
}),
// The caller, or else the host's follow-up, pastes what the line leaves once the agent runs.
paste: 'when-host-proves-agent'
@@ -1,8 +1,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cachedPwshAvailability } from '../pwsh'
import { localLaunchArtifactsWritable } from '../providers/local-launch-artifact-directory'
import { wslLaunchDirectoryKnownBroken } from '../providers/wsl-launch-directory-resolution'
import { thisOrcaLaunchHost } from './this-orca-launch-host'
vi.mock('../pwsh', () => ({ cachedPwshAvailability: vi.fn(() => null) }))
vi.mock('../providers/local-launch-artifact-directory', () => ({
localLaunchArtifactsWritable: vi.fn(() => true)
}))
vi.mock('../providers/wsl-launch-directory-resolution', () => ({
wslLaunchDirectoryKnownBroken: vi.fn(() => false)
}))
const realPlatform = process.platform
@@ -51,3 +59,44 @@ describe('the PowerShell a launch this Orca runs lands in', () => {
).toBeNull()
})
})
// Why: a host that cannot write its staging folder can neither stage a line nor write a launch
// file there, so the launch is planned for main's delivery up front instead of refused.
describe('whether a launch this Orca runs can write its staging folder', () => {
afterEach(() => {
onPlatform(realPlatform)
vi.mocked(localLaunchArtifactsWritable).mockReturnValue(true)
vi.mocked(wslLaunchDirectoryKnownBroken).mockReturnValue(false)
})
it('follows this machine’s temp folder for a local launch', () => {
const host = () =>
thisOrcaLaunchHost({ launchPlatform: 'darwin', isRemote: false, settings: null })
onPlatform('darwin')
expect(host().takesLaunchFile).toBe(true)
vi.mocked(localLaunchArtifactsWritable).mockReturnValue(false)
expect(host().takesLaunchFile).toBe(false)
})
it('follows the distro’s folder for a WSL workspace', () => {
onPlatform('win32')
vi.mocked(wslLaunchDirectoryKnownBroken).mockImplementation((distro) => distro === 'qasfwin')
const host = (workspacePath: string) =>
thisOrcaLaunchHost({
launchPlatform: 'linux',
isRemote: false,
settings: null,
workspacePath
})
expect(host('\\\\wsl.localhost\\qasfwin\\root\\fixture').takesLaunchFile).toBe(false)
expect(host('\\\\wsl.localhost\\Ubuntu\\home\\ada').takesLaunchFile).toBe(true)
})
it('cannot check an SSH host’s folder from here', () => {
vi.mocked(localLaunchArtifactsWritable).mockReturnValue(false)
expect(
thisOrcaLaunchHost({ launchPlatform: 'linux', isRemote: true, settings: null })
.takesLaunchFile
).toBe(true)
})
})
+24 -1
View File
@@ -1,4 +1,7 @@
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 {
describeLaunchHost,
spawnedWindowsShell,
@@ -13,6 +16,8 @@ export function thisOrcaLaunchHost(args: {
settings: WindowsShellSettings
/** The shell this launch asked for (`--shell`), which outranks the setting. */
windowsShellOverride?: string | null
/** Where the pane opens; a WSL path names the distro its line and file are written into. */
workspacePath?: string
}): LaunchHost {
const local = !args.isRemote && args.launchPlatform === 'win32' && process.platform === 'win32'
return describeLaunchHost({
@@ -26,6 +31,24 @@ export function thisOrcaLaunchHost(args: {
windowsShellOverride: args.windowsShellOverride,
pwshAvailable: cachedPwshAvailability()
})
: null
: null,
writesLaunchArtifacts: writesLaunchArtifacts(args)
})
}
/** 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: {
launchPlatform: NodeJS.Platform
isRemote: boolean
workspacePath?: string
}): boolean {
if (args.isRemote) {
return true
}
if (process.platform === 'win32' && args.launchPlatform !== 'win32') {
const distro = args.workspacePath ? parseWslUncPath(args.workspacePath)?.distro : undefined
return !distro || !wslLaunchDirectoryKnownBroken(distro)
}
return localLaunchArtifactsWritable()
}
+10 -4
View File
@@ -21,9 +21,10 @@ export type LaunchHost = {
/** Whether the host can prove the launched agent holds its terminal before a paste
* (`launched-agent-foreground`). A Windows host cannot, so #24257's guarded paste is refused. */
provesAgentInFront: boolean
/** Whether the host writes a launch file the agent can read. A paired Orca may be older, and an
* SSH Windows host's relay may run its panes in WSL (its OpenSSH default shell, which this client
* cannot see), where it writes none. Such a host gets the line or the paste instead. */
/** Whether the host writes a launch file the agent can read, and stages a long line, both in one
* folder. A paired Orca may be older, an SSH Windows host's relay may run its panes in WSL (its
* OpenSSH default shell, which this client cannot see), and a host may find that folder
* unwritable. Such a host gets the line within its typed budget, or the paste, instead. */
takesLaunchFile: boolean
/** The shell a local Windows pane is spawned as, which the line is judged and quoted by: a WSL
* pane runs the distro's POSIX shell whatever the Windows setting, and the PowerShell decides
@@ -80,12 +81,17 @@ export function describeLaunchHost(args: {
paired: boolean
/** `spawnedWindowsShell` on this machine, which a launch elsewhere does not use. */
windowsPaneShell?: WindowsPaneShell | null
/** Whether the host can write the folder its staged lines and launch files go in. */
writesLaunchArtifacts?: boolean
}): LaunchHost {
const runsElsewhere = args.isRemote || args.paired
return {
paired: args.paired,
provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32',
takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32'),
takesLaunchFile:
!args.paired &&
!(args.isRemote && args.launchPlatform === 'win32') &&
args.writesLaunchArtifacts !== false,
windowsPaneShell:
runsElsewhere || args.launchPlatform !== 'win32' ? null : (args.windowsPaneShell ?? null)
}
+22
View File
@@ -218,6 +218,28 @@ describe('a host that types the line raw', () => {
expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line')
})
// Why: a host that cannot write its staging folder types a long line raw, so the plan picks
// main's delivery up front: the paste where main pasted, the line where main typed it.
it('plans a host that cannot stage for main’s delivery, not a staged line or a file', () => {
const extra = {
platform: 'linux' as const,
host: {
paired: false,
provesAgentInFront: true,
takesLaunchFile: false,
windowsPaneShell: null
}
}
const long = 'y'.repeat(20_000)
expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line')
expect(plan('claude', long, { ...extra, paste: 'once-agent-runs' })?.carry).toBe(
'paste-after-ready'
)
expect(plan('claude', long, extra)?.carry).toBe('paste-after-ready')
expect(plan('codex', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready')
expect(plan('claude', long, { ...extra, paste: 'never' })?.carry).toBe('on-line')
})
it('pastes on a paired host what its line cannot carry typed, even for Claude', () => {
const extra = {
platform: 'linux' as const,
+10 -9
View File
@@ -104,10 +104,11 @@ const utf8 = new TextEncoder()
* stages it when it is long or multi-line, and a Windows host types it when its shell was measured
* carrying such a line exactly (`windowsLaunchLineVerdict`); an unmeasured one does what main does
* on the caller's path. When the line cannot (past the argv ceiling, measured damaged by a Windows
* shell, past a paired host's typed budget), a caller whose paste main used gets that paste, so the
* agent receives the user's text. Otherwise it rides a launch file, which goes only to an agent measured reading
* one, on a host that writes it. Failing a file it is pasted once the agent is ready, unless the
* caller's paste cannot reach the agent on this host: then the line carries it, as main typed it.
* shell, past the typed budget of a paired host or one that cannot write its staging folder), a
* caller whose paste main used gets that paste, so the agent receives the user's text. Otherwise it
* rides a launch file, which goes only to an agent measured reading one, on a host that writes it.
* Failing a file it is pasted once the agent is ready, unless the caller's paste cannot reach the
* agent on this host: then the line carries it, as main typed it.
*/
export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchCommand: string }>(
args: A,
@@ -180,11 +181,11 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
paneShell === 'powershell.exe' || paneShell === 'pwsh.exe' ? paneShell : null
)
: 'exact'
// Why #24257's typed budget: an older paired host may type the line raw, truncated past it.
if (
windowsLine === 'damaged' ||
(args.host.paired && !typedStartupLineFits(plan.launchCommand))
) {
// Why #24257's typed budget: an older paired host, or a host that cannot write its staging
// folder, types the line raw, truncated past it.
const stagesOnHost = args.platform !== 'win32' || paneShell === 'wsl.exe'
const typesRaw = args.host.paired || (!args.host.takesLaunchFile && stagesOnHost)
if (windowsLine === 'damaged' || (typesRaw && !typedStartupLineFits(plan.launchCommand))) {
return viaLaunchFile()
}
// Why: unmeasured, so this path does what main does: its paste, or the line it typed.