fix(orchestration): offer the worker brief to the one launch carry rule

Round-1 review fixes. The worker brief no longer forces its own sensitive
launch file past the host checks. Worker start offers the brief as an ordinary
startup prompt with main's paste (`once-agent-runs`): it rides the agent's
launch line where the line carries it exactly, and is otherwise pasted once the
agent is ready, as main pasted it, so a host that cannot take a launch file no
longer fails the start. With no sensitive file, the agent's hook reports the
brief itself, which status readers compact to its task as on main.

- Created worktrees are agent-first again, as on main: the startup terminal
  keeps the handle the brief names, setup and default tabs follow it, and a
  wait-for-setup sequence still holds the agent until setup succeeds.
- The brief names the CLI command the spawn's own WSL decision will give the
  terminal.
- A launch the shell reports finished, with no agent in front, fails and tears
  down the start. Where hooks are off or never reach the pane, the launch is
  judged by main's evidence: a title turn-start edge, or the agent holding its
  terminal.
This commit is contained in:
Brennan Benson
2026-10-02 16:24:17 -07:00
parent 2bd886311c
commit 0440dab920
42 changed files with 1231 additions and 706 deletions
@@ -7,9 +7,6 @@ const AGENT_PROMPT_EFFECT_POLL_MS = 50
const HOOK_OBSERVED_TURN_START_AGENTS = new Set<TuiAgent>(['antigravity', 'codex', 'kimi'])
/** Same vocabulary as a worker's turn start: `unobserved` is unverifiable, never "not delivered". */
export type LaunchTurnStartVerdict = 'observed' | 'permission' | 'unsupported' | 'unobserved'
/** The prompt bytes are written before verification, so this only ever means "not observed". */
export const AGENT_PROMPT_STALLED_ERROR = 'agent_prompt_stalled'
@@ -117,6 +117,23 @@ describe('a terminal create that is handed a launch prompt', () => {
expect(spawnedCommand(spawn)).not.toContain('x'.repeat(100))
})
// Why: orchestration pasted worker briefs on every host; such a caller keeps that paste.
it('leaves a prompt the line cannot carry to a caller whose paste is main’s', async () => {
const { runtime, spawn } = runtimeWithAgentLaunch()
const onStartupPromptCarry = vi.fn()
await runtime.createTerminal('id:wt-1', {
startupAgent: 'claude',
startupPrompt: 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1),
startupPromptPaste: 'once-agent-runs',
onStartupPromptCarry
})
expect(onStartupPromptCarry).toHaveBeenCalledExactlyOnceWith(false)
expect(spawn.mock.calls[0]?.[0]?.launchFile).toBeUndefined()
expect(spawnedCommand(spawn)).not.toContain('x'.repeat(100))
})
it('refuses such a prompt when the caller cannot paste it, rather than dropping it', async () => {
const { runtime, spawn } = runtimeWithAgentLaunch()
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
LAUNCH_HOOK_SILENCE_MS,
observeLaunchTurnStart,
type LaunchTurnStartProbe
} from './launch-turn-start-observation'
/** A hook proof that never arrives, as when an agent's turn carried no prompt the hook saw. */
function pendingHookTurn(signal: AbortSignal): Promise<'unobserved'> {
return new Promise((resolve) => signal.addEventListener('abort', () => resolve('unobserved')))
}
function probe(overrides: Partial<LaunchTurnStartProbe>): LaunchTurnStartProbe {
return {
observeHookTurn: pendingHookTurn,
hookReachedPane: () => false,
readWorkingSequence: () => 0,
launchRecorded: () => true,
readForeground: async () => 'agent',
...overrides
}
}
describe('observeLaunchTurnStart', () => {
it('keeps waiting on the hook while hook events reach the pane', async () => {
await expect(
observeLaunchTurnStart(probe({ hookReachedPane: () => true }), {
launchStartedAt: Date.now() - LAUNCH_HOOK_SILENCE_MS,
timeoutMs: 600
})
).resolves.toBe('unobserved')
})
// Why: hooks not installed on a host leave the pane silent; the launch is judged as main judged it.
it('judges a launch whose hooks stayed silent by the agent holding its terminal', async () => {
await expect(
observeLaunchTurnStart(probe({}), {
launchStartedAt: Date.now() - LAUNCH_HOOK_SILENCE_MS,
timeoutMs: 600
})
).resolves.toBe('unsupported')
})
it('does not judge a launch by its own evidence before its hooks had time to report', async () => {
await expect(
observeLaunchTurnStart(probe({}), { launchStartedAt: Date.now(), timeoutMs: 600 })
).resolves.toBe('unobserved')
})
it('takes the hook proof over the launch evidence', async () => {
await expect(
observeLaunchTurnStart(probe({ observeHookTurn: async () => 'observed' }), {
launchStartedAt: Date.now(),
timeoutMs: 600
})
).resolves.toBe('observed')
})
it('reports an exit only for a launch it saw recorded and then finished', async () => {
let recorded = true
setTimeout(() => {
recorded = false
}, 100)
await expect(
observeLaunchTurnStart(
probe({ launchRecorded: () => recorded, readForeground: async () => 'shell' }),
{ launchStartedAt: Date.now(), timeoutMs: 600 }
)
).resolves.toBe('exited')
await expect(
observeLaunchTurnStart(
probe({ launchRecorded: () => false, readForeground: async () => 'shell' }),
{ launchStartedAt: Date.now(), timeoutMs: 600 }
)
).resolves.toBe('unobserved')
})
})
@@ -0,0 +1,130 @@
import type { LaunchedAgentForeground } from './launched-agent-foreground'
/**
* Same vocabulary as a worker's turn start: `unobserved` is unverifiable, never "not delivered";
* `unsupported` is a launch proven only by its agent holding the terminal; `exited` is a launch the
* host proved ended before any turn.
*/
export type LaunchTurnStartVerdict =
| 'observed'
| 'permission'
| 'unsupported'
| 'unobserved'
| 'exited'
/** How long a launch may go with no hook event at all before its hooks are taken as not reaching it. */
export const LAUNCH_HOOK_SILENCE_MS = 10_000
const LAUNCH_EVIDENCE_POLL_MS = 250
/** A foreground read is a process scan, so it is taken at most this often. */
const LAUNCH_FOREGROUND_READ_MS = 1_000
export type LaunchTurnStartProbe = {
/** The agent's own hook proof of a prompt-carrying turn; absent where hooks cannot prove one. */
observeHookTurn?: (signal: AbortSignal) => Promise<'observed' | 'permission' | 'unobserved'>
/** Any hook event has reached the pane since the launch. */
hookReachedPane: () => boolean
/** The pane's title/lifecycle turn-start count: the evidence main accepted. */
readWorkingSequence: () => number
/** Whether the launch command is still recorded on its PTY; the shell's command-finished retires it. */
launchRecorded: () => boolean
readForeground: () => Promise<LaunchedAgentForeground>
}
/**
* Whether a prompt that rode an agent's launch command started a turn.
*
* The agent's hook is the proof wherever it can give one. A launch whose hooks never reach the pane
* (turned off, or not installed on that host) is judged as main judged it: a title turn-start edge,
* or else the agent holding its terminal, which carries the prompt on its command line. A launch
* the shell reports finished, with no agent in front, exited at startup.
*/
export async function observeLaunchTurnStart(
probe: LaunchTurnStartProbe,
args: { launchStartedAt: number; timeoutMs: number; signal?: AbortSignal }
): Promise<LaunchTurnStartVerdict> {
const stop = new AbortController()
const abort = (): void => stop.abort()
args.signal?.addEventListener('abort', abort, { once: true })
const deadline = Date.now() + args.timeoutMs
// Why: the window ends every watch, so a hook proof that never resolves cannot hold the start.
const expiry = setTimeout(abort, args.timeoutMs)
try {
const hook = probe
.observeHookTurn?.(stop.signal)
.then((verdict) => (verdict === 'unobserved' ? null : verdict))
.catch(() => null)
const launch = watchLaunchEvidence(probe, args.launchStartedAt, deadline, stop.signal)
return (await firstNonNull(hook ? [hook, launch] : [launch])) ?? 'unobserved'
} finally {
clearTimeout(expiry)
stop.abort()
args.signal?.removeEventListener('abort', abort)
}
}
async function watchLaunchEvidence(
probe: LaunchTurnStartProbe,
launchStartedAt: number,
deadline: number,
signal: AbortSignal
): Promise<LaunchTurnStartVerdict | null> {
try {
const baseline = probe.readWorkingSequence()
let launchSeen = false
let nextForegroundReadAt = 0
while (!signal.aborted && Date.now() < deadline) {
if (probe.launchRecorded()) {
launchSeen = true
} else if (launchSeen && (await probe.readForeground()) !== 'agent') {
return 'exited'
}
const hooksSilent =
!probe.observeHookTurn ||
(Date.now() - launchStartedAt >= LAUNCH_HOOK_SILENCE_MS && !probe.hookReachedPane())
if (hooksSilent) {
if (probe.readWorkingSequence() > baseline) {
return 'observed'
}
if (Date.now() >= nextForegroundReadAt) {
nextForegroundReadAt = Date.now() + LAUNCH_FOREGROUND_READ_MS
if ((await probe.readForeground()) === 'agent') {
return 'unsupported'
}
}
}
await abortableDelay(LAUNCH_EVIDENCE_POLL_MS, signal)
}
} catch {
// A replaced or closed PTY leaves the launch unproven, not failed.
}
return null
}
/** The first result that is not null, or null once every promise has settled without one. */
function firstNonNull<T>(promises: Promise<T | null>[]): Promise<T | null> {
return new Promise((resolve) => {
let pending = promises.length
for (const promise of promises) {
void promise.then((value) => {
pending--
if (value !== null) {
resolve(value)
} else if (pending === 0) {
resolve(null)
}
})
}
})
}
async function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
await new Promise<void>((resolve) => {
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal.removeEventListener('abort', done)
resolve()
}
signal.addEventListener('abort', done, { once: true })
})
}
@@ -1,5 +1,6 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithListManagedWorktrees } from './orca-runtime-list-managed-worktrees'
import type { LaunchPromptPaste } from '../../shared/launch-prompt-carry'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation'
import { getRepoExecutionHostId } from '../../shared/execution-host'
@@ -156,6 +157,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
agentArgs?: string | null
launchSource?: string
onPromptCarry?: (carried: boolean) => void
promptPaste?: LaunchPromptPaste
}
): Promise<{
agent: TuiAgent
@@ -179,6 +181,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
...(launchInputs?.agentArgs !== undefined ? { agentArgs: launchInputs.agentArgs } : {}),
...(launchInputs?.launchSource ? { launchSource: launchInputs.launchSource } : {}),
...(launchInputs?.onPromptCarry ? { onPromptCarry: launchInputs.onPromptCarry } : {}),
...(launchInputs?.promptPaste ? { promptPaste: launchInputs.promptPaste } : {}),
settings: this.store.getSettings(),
getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo),
toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences)
@@ -4,8 +4,7 @@ import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-term
import type { TerminalHandleRecord } from './runtime-terminal-contracts'
import type {
AgentPromptTurnStartEvidence,
AgentPromptWaitTextCache,
LaunchTurnStartVerdict
AgentPromptWaitTextCache
} from './agent-prompt-submission-verification'
import {
isTerminalSendSettlementAgent,
@@ -13,6 +12,11 @@ import {
} from './agent-prompt-submission-verification'
import type { TuiAgent } from '../../shared/tui-agent'
import { AgentPromptRequestCorrelation } from './agent-prompt-request-correlation'
import type { LaunchedAgentForeground } from './launched-agent-foreground'
import {
observeLaunchTurnStart,
type LaunchTurnStartVerdict
} from './launch-turn-start-observation'
export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWithSerializeAgentPromptSubmission {
private readonly agentPromptCorrelation = new AgentPromptRequestCorrelation()
@@ -24,6 +28,10 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit
record: TerminalHandleRecord
leaf: RuntimeLeafRecord
}
declare readLaunchedAgentForeground: (
ptyId: string,
agent: TuiAgent
) => Promise<LaunchedAgentForeground>
getTerminalPromptRequestBinding(handle: string): {
ptyId: string
@@ -102,9 +110,10 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit
}
/**
* Whether a prompt that rode an agent's launch command line started a turn. Only a hook event
* that carried an explicit prompt after `launchStartedAt` counts: a spinner title, a prompt-less
* SessionStart or output bytes prove nothing about the prompt.
* Whether a prompt that rode an agent's launch command line started a turn. The hook proof counts
* only an event that carried an explicit prompt after `launchStartedAt`: a spinner title, a
* prompt-less SessionStart or output bytes prove nothing about the prompt. Where hooks cannot give
* that proof, or never reach the pane, the launch's own evidence decides (`observeLaunchTurnStart`).
*/
async observeTerminalLaunchTurnStart(
handle: string,
@@ -112,15 +121,41 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit
timeoutMs: number,
signal?: AbortSignal
): Promise<LaunchTurnStartVerdict> {
if (!isTerminalSendSettlementAgent(launch.agent)) {
return 'unsupported'
}
const { ptyId } = this.getTerminalPromptRequestBinding(handle)
const agent = launch.agent
const hooksProveTurn =
isTerminalSendSettlementAgent(agent) &&
this.store?.getSettings().agentStatusHooksEnabled !== false
return observeLaunchTurnStart(
{
...(hooksProveTurn
? {
observeHookTurn: (stop) =>
this.observeLaunchHookTurn(handle, ptyId, launch.launchStartedAt, timeoutMs, stop)
}
: {}),
hookReachedPane: () => this.getFreshExplicitAgentStatusForPty(handle, ptyId) !== null,
readWorkingSequence: () => this.getAgentPromptActivity(handle, ptyId).workingSequence,
launchRecorded: () => Boolean(this.ptysById.get(ptyId)?.launchAgent),
readForeground: async () =>
agent ? await this.readLaunchedAgentForeground(ptyId, agent) : 'unknown'
},
{ launchStartedAt: launch.launchStartedAt, timeoutMs, ...(signal ? { signal } : {}) }
)
}
private async observeLaunchHookTurn(
handle: string,
ptyId: string,
launchStartedAt: number,
timeoutMs: number,
signal: AbortSignal
): Promise<'observed' | 'permission' | 'unobserved'> {
try {
await verifyAgentPromptSubmission({
baseline: {
...this.getAgentPromptActivity(handle, ptyId),
explicitPromptStartedAt: launch.launchStartedAt
explicitPromptStartedAt: launchStartedAt
},
readActivity: () => this.getAgentPromptActivity(handle, ptyId),
explicitPromptOnly: true,
@@ -129,15 +164,9 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit
})
return 'observed'
} catch (error) {
const message = error instanceof Error ? error.message : ''
if (message === 'agent_prompt_blocked') {
return 'permission'
}
// Why: a replaced PTY leaves the launch unproven, not failed.
if (message === 'agent_prompt_stalled' || message === 'terminal_handle_stale') {
return 'unobserved'
}
throw error
return error instanceof Error && error.message === 'agent_prompt_blocked'
? 'permission'
: 'unobserved'
}
}
@@ -63,7 +63,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
{
...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}),
...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}),
...(args.onStartupPromptCarry ? { onPromptCarry: args.onStartupPromptCarry } : {})
...(args.onStartupPromptCarry ? { onPromptCarry: args.onStartupPromptCarry } : {}),
...(args.startupPromptPaste ? { promptPaste: args.startupPromptPaste } : {})
}
)
: null
@@ -12,7 +12,11 @@ import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer'
import { appendRecentPtyPathCandidates } from './terminal-output-path-candidates'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
import {
resolveLocalProjectRuntimeForRepo,
resolveLocalProjectRuntimeForWorktreeId
} from '../local-project-runtime-resolution'
import { predictSpawnOrchestrationCliCommand } from './orchestration/spawn-cli-command-prediction'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import {
resolveTerminalOrchestrationCliCommand,
@@ -270,6 +274,32 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim
: undefined
})
}
/** `getTerminalOrchestrationCliCommand` for a terminal not yet spawned in this worktree, or in a
* worktree not yet created for this repo (which starts on the repo's host and distro). */
async predictOrchestrationCliCommandForSpawn(
target: { worktreeId: string } | { repoSelector: string }
): Promise<OrchestrationCliCommand> {
const settings = this.store?.getSettings() ?? {}
if ('worktreeId' in target) {
const scope = await this.showTerminalWorkspaceLaunchScope(`id:${target.worktreeId}`)
return predictSpawnOrchestrationCliCommand({
connectionId: scope.connectionId,
cwd: scope.path,
projectRuntime: this.resolveProjectRuntimeForWorktree(target.worktreeId),
settings
})
}
const repo = await this.resolveRepoSelector(target.repoSelector)
return predictSpawnOrchestrationCliCommand({
connectionId: repo.connectionId ?? null,
cwd: repo.path,
projectRuntime: this.store
? resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
: undefined,
settings
})
}
/**
* Recover a live terminal handle for a worker whose durable handle stopped resolving
* (renderer graph epoch bump / handle invalidation) while its PTY is still tracked. Fences on
@@ -19,16 +19,21 @@ vi.mock('../git/worktree', () => ({
listWorktreesStrict: vi.fn().mockResolvedValue([WORKTREE])
}))
async function launchedCodex(rows: () => AgentStatusIpcPayload[]) {
async function launchedCodex(
rows: () => AgentStatusIpcPayload[],
options: { hooksEnabled?: boolean; foreground?: () => string | null } = {}
) {
const store = makeStore()
const settings = { ...store.getSettings(), agentStatusHooksEnabled: options.hooksEnabled ?? true }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared store fixture implements only what a launch reads.
const runtime = new OrcaRuntimeService(makeStore() as never, undefined, {
getAgentStatusSnapshot: rows
})
const launchStore = { ...store, getSettings: () => settings } as never
const runtime = new OrcaRuntimeService(launchStore, undefined, { getAgentStatusSnapshot: rows })
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-launch' }),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
getForegroundProcess: async () => null,
confirmForegroundProcess: async () => options.foreground?.() ?? null
})
const { handle } = await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, {
launchAgent: 'codex'
@@ -87,15 +92,65 @@ describe('observeTerminalLaunchTurnStart', () => {
).resolves.toBe('observed')
})
it('reports an agent with no settled turn-start signal as unsupported', async () => {
const { runtime, handle } = await launchedCodex(() => [])
// Why: with hooks off, main's evidence decides, so a worker start is no slower than main's.
it('takes a title turn-start edge as the turn when hooks are turned off', async () => {
const { runtime, handle } = await launchedCodex(() => [], { hooksEnabled: false })
const observed = runtime.observeTerminalLaunchTurnStart(
handle,
{ launchStartedAt: Date.now(), agent: 'codex' },
2_000
)
await new Promise((resolve) => setTimeout(resolve, 50))
runtime.onPtyData('pty-launch', '\x1b]0;Codex working\x07', Date.now())
await expect(
runtime.observeTerminalLaunchTurnStart(
handle,
{ launchStartedAt: Date.now(), agent: 'aider' },
300
)
).resolves.toBe('unsupported')
await expect(observed).resolves.toBe('observed')
})
// A Windows host cannot prove an agent in front; its shell alone is all it reads.
it.skipIf(process.platform === 'win32')(
'takes an agent proven in front as the launch when its hooks give no proof',
async () => {
const { runtime, handle } = await launchedCodex(() => [], { foreground: () => 'aider' })
await expect(
runtime.observeTerminalLaunchTurnStart(
handle,
{ launchStartedAt: Date.now(), agent: 'aider' },
2_000
)
).resolves.toBe('unsupported')
}
)
it.skipIf(process.platform === 'win32')(
'reports a launch the shell finished, with the shell back in front, as exited',
async () => {
const { runtime, handle } = await launchedCodex(() => [], { foreground: () => 'zsh' })
const observed = runtime.observeTerminalLaunchTurnStart(
handle,
{ launchStartedAt: Date.now(), agent: 'codex' },
2_000
)
await new Promise((resolve) => setTimeout(resolve, 300))
runtime.emitDaemonPtyTransientFact('pty-launch', { kind: 'command-finished', exitCode: 1 })
await expect(observed).resolves.toBe('exited')
}
)
it.skipIf(process.platform === 'win32')(
'does not read a finished command as an exit while the agent still holds the terminal',
async () => {
const { runtime, handle } = await launchedCodex(() => [], { foreground: () => 'codex' })
const observed = runtime.observeTerminalLaunchTurnStart(
handle,
{ launchStartedAt: Date.now(), agent: 'codex' },
800
)
await new Promise((resolve) => setTimeout(resolve, 300))
runtime.emitDaemonPtyTransientFact('pty-launch', { kind: 'command-finished', exitCode: 0 })
await expect(observed).resolves.toBe('unobserved')
}
)
})
@@ -280,7 +280,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
// 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.startupPromptPaste ?? (opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never')
})
if (!planned) {
// Why: an explicit agent that yields no plan would otherwise spawn a bare
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { predictSpawnOrchestrationCliCommand } from './spawn-cli-command-prediction'
// A dev build names `orca-dev` everywhere; these cases are about the host and its WSL decision.
vi.mock('./cli-command', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
runtimeOrchestrationCliCommand: () => undefined
}))
const realPlatform = process.platform
function onPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform })
}
describe('predictSpawnOrchestrationCliCommand', () => {
afterEach(() => onPlatform(realPlatform))
it('tells a terminal on an SSH host to run the bare bridge', () => {
onPlatform('win32')
expect(
predictSpawnOrchestrationCliCommand({
connectionId: 'ssh-1',
cwd: 'C:\\repo',
projectRuntime: undefined,
settings: { terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' }
})
).toBe('orca')
})
// Why: the spawn turns a Windows-path repo into a WSL session when the user's shell is WSL, and a
// worker told `orca` there cannot report; the live terminal answers `orca-ide`.
it('tells a Windows terminal whose shell is WSL to run the scoped launcher', () => {
onPlatform('win32')
expect(
predictSpawnOrchestrationCliCommand({
connectionId: null,
cwd: 'C:\\repo',
projectRuntime: undefined,
settings: { terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' }
})
).toBe('orca-ide')
})
it('tells a Windows terminal in PowerShell to run orca', () => {
onPlatform('win32')
expect(
predictSpawnOrchestrationCliCommand({
connectionId: null,
cwd: 'C:\\repo',
projectRuntime: undefined,
settings: { terminalWindowsShell: 'powershell.exe' }
})
).toBe('orca')
})
it('tells a POSIX terminal to run orca', () => {
onPlatform('darwin')
expect(
predictSpawnOrchestrationCliCommand({
connectionId: null,
cwd: '/repo',
projectRuntime: undefined,
settings: {}
})
).toBe('orca')
})
})
@@ -0,0 +1,46 @@
import { resolveWslSessionContext } from '../../daemon/wsl-session-context'
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { resolveLocalWindowsTerminalRuntimeOptions } from '../../../shared/local-windows-terminal-runtime'
import {
resolveTerminalOrchestrationCliCommand,
runtimeOrchestrationCliCommand,
type OrchestrationCliCommand
} from './cli-command'
/**
* The command a terminal not yet spawned will be told to run: what `getTerminalOrchestrationCliCommand`
* answers once it exists, from the WSL decision its spawn makes (spawn-preflight).
*/
export function predictSpawnOrchestrationCliCommand(args: {
connectionId: string | null
/** The directory the terminal starts in; a WSL UNC path puts it in that distro. */
cwd: string
projectRuntime: ProjectExecutionRuntimeResolution | undefined
settings: Partial<Pick<GlobalSettings, 'terminalWindowsShell' | 'terminalWindowsWslDistro'>>
}): OrchestrationCliCommand {
let isWsl = false
if (!args.connectionId && process.platform === 'win32') {
const options = resolveLocalWindowsTerminalRuntimeOptions({
requestedShellOverride: undefined,
settings: args.settings,
projectRuntime: args.projectRuntime,
fallbackHostShell: process.env.COMSPEC || 'powershell.exe'
})
isWsl = Boolean(
resolveWslSessionContext({
cwd: args.cwd,
shellOverride: options.shellOverride,
terminalWindowsWslDistro: options.terminalWindowsWslDistro
})
)
}
return resolveTerminalOrchestrationCliCommand({
connectionId: args.connectionId,
isWsl,
// Unread: `isWsl` is always decided here, so the worktree-path fallback never runs.
worktreeId: '',
projectRuntime: args.projectRuntime,
runtimeCliCommand: runtimeOrchestrationCliCommand()
})
}
@@ -33,7 +33,6 @@ vi.mock('./orchestration/worker/worker-start-validation', () => ({
})
}))
vi.mock('./orchestration/worker/worker-setup-gate', () => ({
createSetupBeforeAgentGate: () => async () => {},
persistGatedSetupSpawnFailure: () => false,
persistWorkerReadinessStage: () => {},
persistWorkerSetupWaitOutcome: () => {},
@@ -29,7 +29,7 @@ const TERMINAL_HANDLE = 'term_worker'
const STRUCTURED_ORCA_SESSION_ID = 'orca_session_id:sess_worker'
const structuredPreambles: string[] = []
/** A terminal worker's brief rides its launch line, in the launch file the host writes. */
/** A terminal worker's brief, offered to its launch line; the carry rule takes it on a POSIX host. */
const terminalBriefs: string[] = []
// The session host the code under test reads; a structural fake, so no host type is claimed.
@@ -46,10 +46,11 @@ vi.mock('./orchestration/worker/worker-topology', async (importOriginal) => ({
return { identity: { handle: STRUCTURED_HANDLE, sessionId: 'sess_worker' }, host: {} }
},
createExistingWorktreeWorkerTerminal: async (args: {
launchBrief?: { launchFile: { content: string } } | null
launchBrief?: { text: string; carried: boolean } | null
}) => {
if (args.launchBrief) {
terminalBriefs.push(args.launchBrief.launchFile.content)
args.launchBrief.carried = true
terminalBriefs.push(args.launchBrief.text)
}
return { handle: TERMINAL_HANDLE }
}
@@ -159,17 +160,11 @@ describe('a worker cannot tell which mode it is running in', () => {
status: 'running',
exitCode: null
})
// What an unpackaged build resolves, which the launch brief also resolves for itself.
// What an unpackaged build resolves, for the live terminal and the one the brief is built for.
vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca-dev')
// The worker's brief rides its launch line, so its handle is minted before the spawn.
vi.spyOn(runtime, 'predictOrchestrationCliCommandForSpawn').mockResolvedValue('orca-dev')
// The brief names the worker's handle, so it is minted before the spawn.
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue(TERMINAL_HANDLE)
vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope').mockResolvedValue({
id: WORKTREE,
path: '/repo/worktree',
connectionId: null,
repo: null,
folderWorkspace: null
})
vi.spyOn(runtime, 'observeTerminalLaunchTurnStart').mockResolvedValue('observed')
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({
handle: TERMINAL_HANDLE,
@@ -75,16 +75,9 @@ describe('orchestration worker release incarnation fallback', () => {
exitCode: null
})
vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca')
// The worker's brief rides its launch line, so its handle is minted before the spawn.
// The brief names the worker's handle and CLI command, so both are settled before the spawn.
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue('term_worker')
vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope').mockResolvedValue({
id: 'repo::worktree',
path: '/repo/worktree',
connectionId: null,
repo: null,
folderWorkspace: null
})
vi.spyOn(runtime, 'observeTerminalLaunchTurnStart').mockResolvedValue('observed')
vi.spyOn(runtime, 'predictOrchestrationCliCommandForSpawn').mockResolvedValue('orca')
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({
handle: 'term_worker',
accepted: true,
@@ -86,15 +86,9 @@ describe('worker-start honours the settings default', () => {
exitCode: null
})
vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca')
// The worker's brief rides its launch line, so its handle is minted before the spawn.
// The brief names the worker's handle and CLI command, so both are settled before the spawn.
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue(TERMINAL_HANDLE)
vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope').mockResolvedValue({
id: 'repo::wt',
path: '/repo/worktree',
connectionId: null,
repo: null,
folderWorkspace: null
})
vi.spyOn(runtime, 'predictOrchestrationCliCommandForSpawn').mockResolvedValue('orca')
vi.spyOn(runtime, 'observeTerminalLaunchTurnStart').mockResolvedValue('observed')
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({
handle: TERMINAL_HANDLE,
@@ -243,7 +237,7 @@ describe('worker-start honours the settings default', () => {
expect(createExistingWorktreeWorkerTerminal).not.toHaveBeenCalled()
})
it('creates the new worktree, then its terminal worker with the brief on the launch line', async () => {
it('still creates the new worktree agent-first when the default is a terminal worker', async () => {
const created = mockWorktreeCreation()
const result = await startWorker(
@@ -252,9 +246,7 @@ describe('worker-start honours the settings default', () => {
)
expect(result).toMatchObject({ state: 'ready', mode: { mode: 'terminal' } })
// Why: the brief needs the created worktree's setup gate first, so no agent-first terminal.
expect(created).toHaveBeenCalledWith(expect.not.objectContaining({ startupAgent: 'claude' }))
expect(createExistingWorktreeWorkerTerminal).toHaveBeenCalledTimes(1)
expect(created).toHaveBeenCalledWith(expect.objectContaining({ startupAgent: 'claude' }))
expect(createStructuredWorkerSessionForWorktree).not.toHaveBeenCalled()
})
@@ -18,6 +18,8 @@ describe('Antigravity orchestration worker lifecycle', () => {
it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => {
h.setup()
// The paste path: a host whose line cannot carry the brief leaves it for after readiness.
h.leaveBriefForPaste()
const readiness = h.deferred<RuntimeTerminalWait>()
vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise)
@@ -43,20 +43,14 @@ describe('orchestration RPC methods', () => {
vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({
id: 'repo::worktree'
} as never)
vi.spyOn(runtime, 'createTerminal').mockResolvedValue({
handle: 'term_worker',
worktreeId: 'repo::worktree',
title: 'worker'
// The carry rule's answer on a POSIX host: the brief rides the worker's launch line.
vi.spyOn(runtime, 'createTerminal').mockImplementation(async (_selector, options) => {
options?.onStartupPromptCarry?.(true)
return { handle: 'term_worker', worktreeId: 'repo::worktree', title: 'worker' }
})
// A created worker's brief rides its launch line, so its handle is minted before the spawn.
// The brief names the worker's handle and CLI command, so both are settled before the spawn.
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue('term_worker')
vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope').mockResolvedValue({
id: 'repo::worktree',
path: '/repo/worktree',
connectionId: null,
repo: null,
folderWorkspace: null
})
vi.spyOn(runtime, 'predictOrchestrationCliCommandForSpawn').mockResolvedValue('orca')
vi.spyOn(runtime, 'observeTerminalLaunchTurnStart').mockResolvedValue('observed')
vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({
handle: 'term_worker',
@@ -162,19 +156,16 @@ describe('orchestration RPC methods', () => {
expect(db.getWorkerDispatch(result.dispatchId)?.state).toBe('ready')
// Why: dispatching a worker is background work — surfaceOwner:false adopts
// the tab without scrolling the sidebar to the worker's workspace.
// The brief rides a launch file; argv carries only a pointer to it.
// The brief is offered to the launch line through the one carry rule, as main's paste caller.
expect(runtime.createTerminal).toHaveBeenCalledWith('id:repo::worktree', {
startupAgent: 'codex',
launchSource: 'orchestration',
title: `worker-${task.id}`,
surfaceOwner: false,
preAllocatedHandle: 'term_worker',
startupPrompt: expect.not.stringContaining('implement worker start'),
launchFile: {
placeholder: expect.stringMatching(/^orca-launch-file-/),
content: expect.stringContaining('implement worker start'),
sensitive: true
}
startupPrompt: expect.stringContaining('implement worker start'),
startupPromptPaste: 'once-agent-runs',
onStartupPromptCarry: expect.any(Function)
})
expect(runtime.observeTerminalLaunchTurnStart).toHaveBeenCalledWith(
'term_worker',
@@ -322,7 +313,7 @@ describe('orchestration RPC methods', () => {
warning: 'Terminal term_worker is running but could not be revealed.'
})
)
expect(runtime.observeTerminalLaunchTurnStart).toHaveBeenCalled()
expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalled()
})
it('starts in an exact existing worktree from a floating coordinator', async () => {
@@ -538,8 +529,7 @@ describe('orchestration RPC methods', () => {
}
)
// Why: agent-first creation types its own launch line, so a brief-carrying agent comes after.
it('creates a child worktree, then its brief-carrying agent, with setup run by default', async () => {
it('creates a child worktree agent-first with setup run by default', async () => {
setup()
mockCurrentWorkerStart()
vi.mocked(runtime.showManagedWorktree).mockResolvedValue({
@@ -552,6 +542,7 @@ describe('orchestration RPC methods', () => {
} as never)
const create = vi.spyOn(runtime, 'createManagedWorktree').mockResolvedValue({
worktree: { id: 'repo::child', repoId: 'repo' },
startupTerminal: { spawned: true, handle: 'term_worker' },
setupReceipt: {
requested: 'run',
hookFound: true,
@@ -597,11 +588,13 @@ describe('orchestration RPC methods', () => {
name: 'child-worker',
runHooks: false,
setupDecision: 'run',
startupAgent: 'codex',
startupTerminalHandle: 'term_worker',
startupPrompt: expect.stringContaining('child worker'),
activate: false,
lineage: expect.objectContaining({ parentWorktree: 'repo::parent', noParent: false })
})
)
expect(create).toHaveBeenCalledWith(expect.not.objectContaining({ startupAgent: 'codex' }))
expect(result.effects).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: 'agent', action: 'created' }),
@@ -609,15 +602,7 @@ describe('orchestration RPC methods', () => {
expect.objectContaining({ role: 'configured_tab', action: 'created' })
])
)
expect(runtime.createTerminal).toHaveBeenCalledWith(
'id:repo::child',
expect.objectContaining({
startupAgent: 'codex',
preAllocatedHandle: 'term_worker',
launchFile: expect.objectContaining({ sensitive: true })
})
)
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
expect(runtime.createTerminal).not.toHaveBeenCalled()
})
})
})
@@ -13,11 +13,10 @@ import {
type WorkerStartModeReceipt
} from '../../orchestration-worker-start-mode'
import { EXISTING_WORKTREE_SETUP, placeWorkerAgent } from './worker-start-agent-placement'
import { awaitStructuredWorkerSetupGate } from './worker-start-structured-setup-gate'
import { awaitWorkerSetupGate } from './worker-start-setup-gate'
import { assertOrchestrationWorktreeCreationSupported } from './folder-worktree-placement'
import type { WorkerStartInput } from './worker-start-schema'
import {
createSetupBeforeAgentGate,
persistGatedSetupSpawnFailure,
persistWorkerReadinessStage,
persistWorkerSetupWaitOutcome,
@@ -28,11 +27,7 @@ import { parseTaskDeps } from './task-deps-argument'
import { assertExplicitWorkerTerminalUsable } from './explicit-worker-terminal-validation'
import { recordCreatedWorkerTerminalCustody } from './created-worker-terminal-custody'
import { tearDownFailedWorkerStart } from './failed-worker-start-teardown'
import {
requireWorkerAuthority,
type WorkerEffect,
type WorkerSetupReceipt
} from './worker-topology'
import { requireWorkerAuthority, type WorkerEffect } from './worker-topology'
import { prepareLocalWorkerStart } from './worker-start-validation'
import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement'
import { waitForWorkerStartComposer } from '../../../../launched-agent-composer-readiness'
@@ -146,22 +141,6 @@ export async function startLocalWorker(args: {
let placed: Awaited<ReturnType<typeof placeWorkerAgent>> | undefined
let failedStage = 'terminal_create'
const timeoutMs = params.timeoutMs ?? 60_000
let gatedSetupReceipt: WorkerSetupReceipt | undefined
let setupGateStartedAt: number | undefined
const awaitSetupBeforeAgent = createSetupBeforeAgentGate({
runtime,
db,
dispatchId: started.dispatch.id,
effects,
timeoutMs,
onStage: (stage) => {
failedStage = stage
},
onSetupReceipt: (setup) => {
gatedSetupReceipt = setup
setupGateStartedAt = Date.now()
}
})
try {
placed = await placeWorkerAgent({
runtime,
@@ -179,7 +158,6 @@ export async function startLocalWorker(args: {
onStage: (stage) => {
failedStage = stage
},
awaitSetupBeforeAgent,
...(params.terminal
? {}
: {
@@ -219,15 +197,13 @@ export async function startLocalWorker(args: {
persistWorkerReadinessStage(setupStage)
failedStage = 'agent_readiness'
// A structured session is ready the moment its attach returns ok: there is no boot-to-idle
// gap and no terminal title to read an idle edge from. Only the repo's wait-for-setup policy
// still holds it back, and that gate has to be waited on explicitly here.
// A brief on the launch line needs no idle agent to paste into; its blocking dialogs are
// watched during turn observation instead.
const wait = placed.launchBrief
? null
: structuredSession
? await awaitStructuredWorkerSetupGate({
const launchBrief = placed.launchBrief?.carried ? placed.launchBrief : null
// A structured session is ready the moment its attach returns ok, and a brief that rode the
// launch line needs no idle agent to paste into (its dialogs are watched while its turn is
// observed). Only the repo's wait-for-setup policy still holds either back, waited on here.
const wait =
structuredSession || launchBrief
? await awaitWorkerSetupGate({
runtime,
setup: setupReceipt,
effects,
@@ -251,7 +227,9 @@ export async function startLocalWorker(args: {
? `Agent startup blocked: ${describeTerminalWaitBlockedReason(wait.blockedReason)}`
: structuredSession
? `Setup did not finish before the structured worker started (${wait.status}).`
: `Agent did not become ready (${wait.status}).`
: launchBrief
? `Setup did not finish before the worker's agent started (${wait.status}).`
: `Agent did not become ready (${wait.status}).`
)
}
}
@@ -285,8 +263,10 @@ export async function startLocalWorker(args: {
timeoutMs: params.timeoutMs ?? 60_000,
effects,
terminalRevealWarning: placed.warning,
launchBrief: placed.launchBrief,
launchObservationTimeoutMs: remainingLaunchObservationMs(timeoutMs, setupGateStartedAt),
launchBrief,
launchObservationTimeoutMs: launchBrief
? remainingLaunchObservationMs(timeoutMs, launchBrief.launchStartedAt)
: timeoutMs,
onStage: (stage) => {
failedStage = stage
}
@@ -304,7 +284,7 @@ export async function startLocalWorker(args: {
dispatchId: started.dispatch.id,
failedStage,
error,
setup: placed?.setupReceipt ?? gatedSetupReceipt ?? EXISTING_WORKTREE_SETUP,
setup: placed?.setupReceipt ?? EXISTING_WORKTREE_SETUP,
launch: launch.receipt,
mode
})
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { LaunchFile } from '../../../../../../shared/launch-prompt-file'
import { normalizePromptField } from '../../../../../../shared/agent-status-field-normalization'
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
describe('worker-start with the brief on the launch command line', () => {
@@ -7,7 +7,7 @@ describe('worker-start with the brief on the launch command line', () => {
afterEach(() => h.cleanup())
it('puts the brief in a sensitive launch file on the spawn and pastes nothing', async () => {
it("offers the brief to the carry rule as main's paste caller, and pastes nothing it carried", async () => {
h.setup()
const { dispatchId } = await h.startWorker({ agent: 'codex' })
@@ -16,30 +16,55 @@ describe('worker-start with the brief on the launch command line', () => {
expect.objectContaining({
startupAgent: 'codex',
preAllocatedHandle: 'term_worker',
startupPrompt: expect.stringMatching(/^The full task is in the file `orca-launch-file-/),
launchFile: expect.objectContaining({ sensitive: true })
startupPromptPaste: 'once-agent-runs',
onStartupPromptCarry: expect.any(Function)
})
)
const launchFile = vi.mocked(h.runtime.createTerminal).mock.calls[0][1]?.launchFile
expect(launchFile?.content).toContain('release fixture task')
expect(launchFile?.content).toContain(dispatchId)
// Why: the brief rides only in the file, never on a command line or in history.
expect(vi.mocked(h.runtime.createTerminal).mock.calls[0][1]?.startupPrompt).not.toContain(
'release fixture task'
)
const options = vi.mocked(h.runtime.createTerminal).mock.calls[0][1]
expect(options).not.toHaveProperty('launchFile')
expect(options?.startupPrompt).toContain('release fixture task')
expect(options?.startupPrompt).toContain(dispatchId)
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
expect(h.runtime.waitForTerminal).toHaveBeenCalledWith(
'term_worker',
expect.objectContaining({ condition: 'tui-idle', signal: expect.any(AbortSignal) })
)
expect(h.runtime.observeTerminalLaunchTurnStart).toHaveBeenCalled()
})
// Why: every status reader finds a worker by its preamble; the brief is the prompt the agent's
// hook reports, so it publishes the compact form main published for the pasted brief.
it("publishes the brief as the worker's status prompt, compacted to its task as main did", async () => {
h.setup()
await h.startWorker({ agent: 'claude' })
const prompt = vi.mocked(h.runtime.createTerminal).mock.calls[0][1]?.startupPrompt ?? ''
const status = normalizePromptField(prompt)
expect(status).toMatch(/^You are working inside Orca, a multi-agent IDE\./)
expect(status).toContain('=== TASK === release fixture task')
expect(status).not.toContain('orca-launch-file')
})
// Why: a host whose line cannot carry the brief whole (a Windows shell, an SSH host with a WSL
// shell) gets main's paste after readiness instead of a refused launch file.
it('pastes the brief once the agent is ready where the carry rule leaves it', async () => {
h.setup()
vi.mocked(h.runtime.createTerminal).mockImplementation(async (_selector, options) => {
options?.onStartupPromptCarry?.(false)
return { handle: 'term_worker', worktreeId: 'repo::worktree', title: 'worker' }
})
const { dispatchId } = await h.startWorker({ agent: 'codex' })
expect(h.runtime.observeTerminalLaunchTurnStart).not.toHaveBeenCalled()
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
expect(vi.mocked(h.runtime.sendTerminalAgentPrompt).mock.calls[0][1]).toContain(dispatchId)
expect(h.db.getWorkerDispatch(dispatchId)?.state).toBe('ready')
})
it('binds the dispatch to the handle the brief names, and only after the spawn', async () => {
h.setup()
let boundAtSpawn: unknown = 'unset'
let launchFile: LaunchFile | undefined
let brief: string | undefined
vi.mocked(h.runtime.createTerminal).mockImplementation(async (_selector, options) => {
launchFile = options?.launchFile
brief = options?.startupPrompt
options?.onStartupPromptCarry?.(true)
boundAtSpawn = h.db.db
.prepare('SELECT id FROM dispatch_contexts WHERE assignee_handle = ?')
.get('term_worker')
@@ -48,7 +73,7 @@ describe('worker-start with the brief on the launch command line', () => {
const { dispatchId } = await h.startWorker({ agent: 'codex' })
expect(launchFile?.content).toContain('--from term_worker')
expect(brief).toContain('--from term_worker')
expect(boundAtSpawn).toBeUndefined()
expect(
h.db.db
@@ -57,6 +82,37 @@ describe('worker-start with the brief on the launch command line', () => {
).toEqual({ id: dispatchId })
})
// Why: the terminal is live with the brief on its line, so a retry must not run the task twice.
it('fails a start whose terminal lost the handle its brief names, closing that terminal', async () => {
h.setup()
vi.mocked(h.runtime.createTerminal).mockImplementation(async (_selector, options) => {
options?.onStartupPromptCarry?.(true)
return { handle: 'term_adopted', worktreeId: 'repo::worktree', title: 'worker' }
})
const task = h.db.createTask({ spec: 'adopted pane', runId: h.activeRunId })
await expect(
h.call('orchestration.workerStart', { task: task.id, from: 'term_coord', agent: 'codex' })
).resolves.toMatchObject({
state: 'failed',
lastError: 'Worker terminal did not keep its pre-allocated handle.'
})
expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_adopted')
})
it("names the CLI command predicted for the worker's own terminal", async () => {
h.setup()
vi.mocked(h.runtime.predictOrchestrationCliCommandForSpawn).mockResolvedValue('orca-ide')
await h.startWorker({ agent: 'codex' })
expect(h.runtime.predictOrchestrationCliCommandForSpawn).toHaveBeenCalledWith({
worktreeId: 'repo::worktree'
})
const brief = vi.mocked(h.runtime.createTerminal).mock.calls[0][1]?.startupPrompt
expect(brief).toContain('orca-ide orchestration send')
})
it('reads a turn the launch observation did not see as unknown, not ready', async () => {
h.setup()
vi.mocked(h.runtime.observeTerminalLaunchTurnStart).mockResolvedValue('unobserved')
@@ -75,19 +131,9 @@ describe('worker-start with the brief on the launch command line', () => {
expect(receipt).not.toMatchObject({ lastError: expect.stringMatching(/composer|up to 30s/) })
})
// No agent measured reading its launch file lacks a prompt hook today, so the verdict is mocked;
// this keeps the start truthful if one is added.
describe('a launch-file agent with no prompt hook', () => {
const blockedWait = {
handle: 'term_worker',
condition: 'tui-idle' as const,
satisfied: false,
status: 'running' as const,
exitCode: null,
blockedReason: 'agent-trust-workspace' as const
}
function startUnsupported(spec: string) {
// Why: a launch whose hooks give no proof is judged on its own evidence, as main judged it.
describe('a launch proven only by the agent holding its terminal', () => {
function startProvenByForeground(spec: string) {
vi.mocked(h.runtime.observeTerminalLaunchTurnStart).mockResolvedValue('unsupported')
const task = h.db.createTask({ spec, runId: h.activeRunId })
return h.call('orchestration.workerStart', {
@@ -97,40 +143,49 @@ describe('worker-start with the brief on the launch command line', () => {
})
}
it('is ready only once its launch readiness wait says so', async () => {
it('is ready, saying no turn start was observable', async () => {
h.setup()
await expect(startUnsupported('ready worker')).resolves.toMatchObject({
await expect(startProvenByForeground('ready worker')).resolves.toMatchObject({
state: 'ready',
turnStart: 'unsupported'
})
expect(h.runtime.waitForTerminal).toHaveBeenCalledWith(
'term_worker',
expect.objectContaining({ condition: 'tui-idle', launchReadiness: true })
)
})
it('is not ready while a trust dialog holds it', async () => {
h.setup()
vi.mocked(h.runtime.waitForTerminal).mockResolvedValue(blockedWait)
await expect(startUnsupported('trust-blocked worker')).resolves.toMatchObject({
vi.mocked(h.runtime.waitForTerminal).mockResolvedValue({
handle: 'term_worker',
condition: 'tui-idle',
satisfied: false,
status: 'running',
exitCode: null,
blockedReason: 'agent-trust-workspace'
})
await expect(startProvenByForeground('trust-blocked worker')).resolves.toMatchObject({
state: 'outcome_unknown',
stage: 'turn_start_blocked',
lastError: expect.stringContaining('Agent startup blocked: agent-trust-workspace')
})
})
it('stays unknown when it never shows readiness', async () => {
h.setup()
vi.mocked(h.runtime.waitForTerminal).mockRejectedValue(new Error('timeout'))
await expect(startUnsupported('silent worker')).resolves.toMatchObject({
state: 'outcome_unknown',
turnStart: 'unobserved',
lastError: expect.stringContaining('reports no turn start')
})
})
})
it('pastes the brief, with no launch file, for an agent not measured reading one', async () => {
// Why: main's readiness wait failed a start whose agent died at launch; the host can prove it.
it('fails and tears down a start whose agent the host proves exited at launch', async () => {
h.setup()
vi.mocked(h.runtime.observeTerminalLaunchTurnStart).mockResolvedValue('exited')
const task = h.db.createTask({ spec: 'crashing worker', runId: h.activeRunId })
await expect(
h.call('orchestration.workerStart', { task: task.id, from: 'term_coord', agent: 'codex' })
).resolves.toMatchObject({
state: 'failed',
failedStage: 'turn_observation',
lastError: expect.stringContaining('Agent exited before its first turn started')
})
expect(h.db.getTask(task.id)?.status).toBe('failed')
})
it('offers the brief to an agent that does not read a launch file too', async () => {
h.setup()
const task = h.db.createTask({ spec: 'gemini brief', runId: h.activeRunId })
await h.call('orchestration.workerStart', {
@@ -140,10 +195,13 @@ describe('worker-start with the brief on the launch command line', () => {
})
expect(h.runtime.createTerminal).toHaveBeenCalledWith(
expect.any(String),
expect.not.objectContaining({ launchFile: expect.anything() })
expect.objectContaining({
startupPrompt: expect.stringContaining('gemini brief'),
startupPromptPaste: 'once-agent-runs'
})
)
expect(h.runtime.observeTerminalLaunchTurnStart).not.toHaveBeenCalled()
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalled()
expect(h.runtime.observeTerminalLaunchTurnStart).toHaveBeenCalled()
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
it('still reports a dialog that paints after the agent first looked ready', async () => {
@@ -260,7 +318,7 @@ describe('worker-start with the brief on the launch command line', () => {
})
await h.startWorker({ agent: 'zcode' })
expect(vi.mocked(h.runtime.createTerminal).mock.calls[0][1]).not.toHaveProperty('launchFile')
expect(vi.mocked(h.runtime.createTerminal).mock.calls[0][1]).not.toHaveProperty('startupPrompt')
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1)
})
})
@@ -1,32 +1,35 @@
/**
* A worker's dispatch brief delivered on its agent's launch command line, in a sensitive launch
* file, instead of pasted into the agent's screen once it looks ready.
* A worker's dispatch brief offered to its agent's launch command, through the one carry rule
* (`carryLaunchPrompt`) every launch uses, as main's paste caller (`once-agent-runs`): the brief
* rides the line where the line carries it exactly, and is otherwise left for worker start's paste
* once the agent is ready, as main pasted it.
*
* Why: a paste races the agent's startup; a fresh Codex lost or truncated worker briefs, and Enter
* could land on a startup dialog (#23745). The brief names the worker's handle, so it is
* allocated before the spawn.
* could land on a startup dialog (#23745). The brief names the worker's handle and the CLI command
* its terminal will run, so both are settled before the spawn.
*/
import { getAppEnvironment } from '../../../../../../shared/app-environment'
import { carryInLaunchFile, type LaunchFile } from '../../../../../../shared/launch-prompt-file'
import { agentReadsLaunchFile } from '../../../../../../shared/launch-prompt-carry'
import type { TuiAgent } from '../../../../../../shared/tui-agent'
import { agentPromptRidesLaunchCommand } from '../../../../../../shared/tui-agent-startup'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import type { OrchestrationDb } from '../../../../orchestration/db'
import { orcaSessionIdOrHandle } from '../../../../orchestration/orchestration-party'
import { resolveTerminalOrchestrationCliCommand } from '../../../../orchestration/cli-command'
import { buildDispatchPreamble } from '../../../../orchestration/preamble'
import type { TerminalCreateOptions } from '../../../../runtime-terminal-contracts'
export type WorkerLaunchBrief = {
/** Pre-allocated: the brief names the worker's handle before its terminal exists. */
handle: string
startupPrompt: string
launchFile: LaunchFile
text: string
/** Taken before the spawn: only a prompt-carrying hook turn after it proves the brief landed. */
launchStartedAt: number
/** Set by the spawn's carry rule; false leaves the brief for the paste once the agent is ready. */
carried: boolean
}
export type WorkerLaunchBriefFactory = (worktreeId: string) => Promise<WorkerLaunchBrief>
/** Where the worker's terminal will spawn: an existing worktree, or one created for a repo. */
export type WorkerLaunchTarget = { worktreeId: string } | { repoSelector: string }
export type WorkerLaunchBriefFactory = (target: WorkerLaunchTarget) => Promise<WorkerLaunchBrief>
export function createWorkerLaunchBriefFactory(args: {
runtime: OrcaRuntimeService
@@ -40,21 +43,13 @@ export function createWorkerLaunchBriefFactory(args: {
devMode: boolean | undefined
}): WorkerLaunchBriefFactory | undefined {
const { runtime, agent } = args
// Why: an agent that takes its text only after start has no launch command to carry it, and one
// not measured reading its launch file would stop on an approval; both keep the paste.
if (!agent || !agentPromptRidesLaunchCommand(agent) || !agentReadsLaunchFile(agent)) {
// Why: an agent that takes its text only after start has no launch command to carry it.
if (!agent || !agentPromptRidesLaunchCommand(agent)) {
return undefined
}
return async (worktreeId) => {
const scope = await runtime.showTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
const target = {
connectionId: scope.connectionId,
isWsl: undefined,
worktreeId,
projectRuntime: runtime.resolveProjectRuntimeForWorktree(worktreeId)
}
return async (target) => {
const handle = runtime.createPreAllocatedTerminalHandle()
const brief = buildDispatchPreamble({
const text = buildDispatchPreamble({
canDispatchSubWorkers: args.dispatchDepth < runtime.getNestedWorkerMaxDepth(),
taskId: args.taskId,
dispatchId: args.dispatchId,
@@ -62,14 +57,22 @@ export function createWorkerLaunchBriefFactory(args: {
coordinatorHandle: orcaSessionIdOrHandle(args.coordinatorHandle, args.db),
workerHandle: handle,
devMode: args.devMode,
cliCommand: resolveTerminalOrchestrationCliCommand({
...target,
runtimeCliCommand: getAppEnvironment().isPackaged() ? undefined : 'orca-dev'
})
cliCommand: await runtime.predictOrchestrationCliCommandForSpawn(target)
})
// Why sensitive: Orca's brief, not the user's words, is never shown as the worker's prompt or
// used to name its worktree.
const { prompt, launchFile } = carryInLaunchFile(brief, true)
return { handle, startupPrompt: prompt, launchFile, launchStartedAt: Date.now() }
return { handle, text, launchStartedAt: Date.now(), carried: false }
}
}
/** The create options that offer `brief` to the carry rule; its outcome lands on `brief.carried`. */
export function workerLaunchBriefCreateOptions(
brief: WorkerLaunchBrief
): Pick<TerminalCreateOptions, 'startupPrompt' | 'startupPromptPaste' | 'onStartupPromptCarry'> {
return {
startupPrompt: brief.text,
// Main pasted worker briefs once the agent was ready, on every host.
startupPromptPaste: 'once-agent-runs',
onStartupPromptCarry: (carried) => {
brief.carried = carried
}
}
}
@@ -56,15 +56,9 @@ describe('orchestration worker release recovery', () => {
worktreeId: 'repo::worktree',
title: 'worker'
})
// The brief names the worker's handle and CLI command, so both are settled before the spawn.
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue('term_worker')
vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope').mockResolvedValue({
id: 'repo::worktree',
path: '/repo/worktree',
connectionId: null,
repo: null,
folderWorkspace: null
})
vi.spyOn(runtime, 'observeTerminalLaunchTurnStart').mockResolvedValue('observed')
vi.spyOn(runtime, 'predictOrchestrationCliCommandForSpawn').mockResolvedValue('orca')
vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({
handle: 'term_worker',
condition: 'tui-idle',
@@ -37,6 +37,8 @@ export type OrchestrationWorkerReleaseHarness = {
options?: WorkerStartOptions
) => Promise<{ taskId: string; dispatchId: string }>
deferred: typeof deferred
/** The carry rule's answer on a host whose line cannot carry the brief (a Windows shell). */
leaveBriefForPaste: () => void
coordinatorPaneKey: string
workerPaneKey: string
readonly db: OrchestrationDb
@@ -94,20 +96,11 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe
vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({
id: 'repo::worktree'
} as never)
vi.spyOn(runtime, 'createTerminal').mockResolvedValue({
handle: 'term_worker',
worktreeId: 'repo::worktree',
title: 'worker'
})
// The worker's brief rides its launch line, so its handle is minted before the spawn.
// The carry rule's answer on a POSIX host: the brief rides the worker's launch line.
carryBrief(true)
// The brief names the worker's handle and CLI command, so both are settled before the spawn.
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue('term_worker')
vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope').mockResolvedValue({
id: 'repo::worktree',
path: '/repo/worktree',
connectionId: null,
repo: null,
folderWorkspace: null
})
vi.spyOn(runtime, 'predictOrchestrationCliCommandForSpawn').mockResolvedValue('orca')
vi.spyOn(runtime, 'observeTerminalLaunchTurnStart').mockResolvedValue('observed')
vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({
handle: 'term_worker',
@@ -202,6 +195,13 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe
return worker
}
function carryBrief(carried: boolean): void {
vi.spyOn(runtime, 'createTerminal').mockImplementation(async (_selector, options) => {
options?.onStartupPromptCarry?.(carried)
return { handle: 'term_worker', worktreeId: 'repo::worktree', title: 'worker' }
})
}
return {
setup,
cleanup,
@@ -210,6 +210,7 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe
settle,
startSettledWorker,
deferred,
leaveBriefForPaste: () => carryBrief(false),
coordinatorPaneKey,
workerPaneKey,
get db() {
@@ -1,7 +1,5 @@
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../../../../../shared/orchestration-timing-budgets'
import type { OrchestrationDb } from '../../../../orchestration/db'
import { awaitStructuredWorkerSetupGate } from './worker-start-structured-setup-gate'
import {
applyWaitForSetupOutcome,
type WorkerEffect,
@@ -20,8 +18,7 @@ type WorkerSetupStageArgs = {
db: OrchestrationDb
dispatchId: string
worktreeId: string
/** Absent while a created worktree's agent terminal waits on setup. */
terminalHandle?: string
terminalHandle: string
setup: WorkerSetupReceipt
effects: WorkerEffect[]
}
@@ -73,59 +70,10 @@ export function persistWorkerSetupWaitOutcome(
}
/**
* Holds a created worktree's agent terminal until its wait-for-setup gate settles. Agent-first
* creation sequences the launch line behind setup in the shell; a worker whose brief rides that
* line creates its terminal afterwards, so it has to wait here instead.
* What a launched brief's turn start may still wait: the agent's launch, any setup it waits behind
* and its turn share one start budget, as setup and boot did when the brief was pasted. The floor
* keeps the turn the window a pasted brief always had.
*/
export function createSetupBeforeAgentGate(args: {
runtime: Pick<OrcaRuntimeService, 'waitForSetupTerminalCompletion'>
db: OrchestrationDb
dispatchId: string
effects: WorkerEffect[]
timeoutMs: number
onStage: (stage: string) => void
/** A failed gate throws before placement returns, so the failure receipt needs this copy. */
onSetupReceipt: (setup: WorkerSetupReceipt) => void
}): (worktreeId: string, setup: WorkerSetupReceipt) => Promise<void> {
return async (worktreeId, setup) => {
args.onSetupReceipt(setup)
const setupStage = {
db: args.db,
dispatchId: args.dispatchId,
worktreeId,
setup,
effects: args.effects
}
if (persistGatedSetupSpawnFailure(setupStage)) {
args.onStage('setup_start')
throw new Error('Setup terminal failed to start before the gated agent launch.')
}
args.onStage('setup_wait')
const wait = await awaitStructuredWorkerSetupGate({
runtime: args.runtime,
setup,
effects: args.effects,
timeoutMs: args.timeoutMs
})
if (wait) {
persistWorkerSetupWaitOutcome({ ...setupStage, wait })
if (!wait.satisfied) {
throw new Error(`Setup did not finish before the worker's agent started (${wait.status}).`)
}
}
}
}
/**
* What a launched brief's turn start may still wait once the setup gate has run: setup and turn
* share one start budget, as setup and agent boot did when the brief was pasted. The floor keeps
* the turn the window a pasted brief always had.
*/
export function remainingLaunchObservationMs(
timeoutMs: number,
setupGateStartedAt: number | undefined
): number {
return setupGateStartedAt === undefined
? timeoutMs
: Math.max(setupGateStartedAt + timeoutMs - Date.now(), AGENT_PROMPT_EFFECT_TIMEOUT_MS)
export function remainingLaunchObservationMs(timeoutMs: number, launchStartedAt: number): number {
return Math.max(launchStartedAt + timeoutMs - Date.now(), AGENT_PROMPT_EFFECT_TIMEOUT_MS)
}
@@ -11,9 +11,6 @@
* That reorder is also why the host verdict lands here: `agentSession.createSupport` can only be
* asked about a workspace that exists, so for a created worktree it cannot run before the start.
* A refusal becomes a terminal agent in the worktree that was just created, never a failed start.
*
* A terminal worker whose brief rides its launch line takes the same order: agent-first creation
* types its own launch line, so the terminal is created after setup's gate instead.
*/
import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority'
@@ -28,6 +25,7 @@ import type { WorkerStartInput } from './worker-start-schema'
import {
createExistingWorktreeWorkerTerminal,
createStructuredWorkerSessionForWorktree,
refuseWorkerTerminalHandleMismatch,
type WorkerEffect,
type WorkerSetupReceipt
} from './worker-topology'
@@ -46,7 +44,7 @@ export type WorkerAgentPlacement = {
terminalHandle: string
structuredSession: WorkerStructuredSession | null
setupReceipt: WorkerSetupReceipt
/** Present when the dispatch brief rode the agent's launch command instead of a paste. */
/** The brief offered to the created terminal's launch line; `carried` says whether it rode it. */
launchBrief: WorkerLaunchBrief | null
warning?: string
}
@@ -68,10 +66,8 @@ type WorkerAgentPlacementArgs = {
effects: WorkerEffect[]
/** Attributes a throw to the step that was running, the way the caller's own stages do. */
onStage: (stage: string) => void
/** Builds the brief a created terminal's launch line carries; absent keeps the paste. */
/** Builds the brief offered to a created terminal's launch line; absent keeps the paste. */
launchBrief?: WorkerLaunchBriefFactory
/** Holds a created worktree's agent terminal until its wait-for-setup gate settles. */
awaitSetupBeforeAgent: (worktreeId: string, setup: WorkerSetupReceipt) => Promise<void>
}
/** The setup receipt for a placement that creates no worktree, and the one a start reports if it
@@ -121,8 +117,13 @@ async function placeInCreatedWorktree(
coordinatorWorktree: PlacedWorktree
): Promise<WorkerAgentPlacement> {
args.onStage('worktree_create')
// Why: agent-first creation types the launch line itself, so a brief must create the terminal after.
const agentFirst = args.mode.mode !== 'structured' && !args.launchBrief
const terminalWorker = args.mode.mode !== 'structured'
// Built before the worktree exists: agent-first creation spawns the terminal its line rides.
const launchBrief = terminalWorker
? ((await args.launchBrief?.({
repoSelector: args.params.repo ?? coordinatorWorktree.repoId
})) ?? null)
: null
const created = await createWorkerWorktree({
runtime: args.runtime,
db: args.db,
@@ -131,30 +132,28 @@ async function placeInCreatedWorktree(
coordinatorWorktree,
params: args.params,
agent: args.agent as TuiAgent,
withAgentTerminal: agentFirst,
withAgentTerminal: terminalWorker,
...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}),
launchBrief,
effects: args.effects
})
const worktree = requireWorktree(created.worktree)
if (agentFirst) {
if (terminalWorker) {
const terminalHandle = requireTerminal(created.terminalHandle)
if (launchBrief && terminalHandle !== launchBrief.handle) {
await refuseWorkerTerminalHandleMismatch(args.runtime, terminalHandle)
}
return {
mode: args.mode,
worktree,
terminalHandle: requireTerminal(created.terminalHandle),
terminalHandle,
structuredSession: null,
setupReceipt: created.setupReceipt,
launchBrief: null
launchBrief
}
}
args.onStage('terminal_create')
const mode =
args.mode.mode === 'structured'
? await resolveWorkerStartModeOnHost(args.runtime, args.mode, worktree.id, args.agent)
: args.mode
if (mode.mode !== 'structured') {
await args.awaitSetupBeforeAgent(worktree.id, created.setupReceipt)
args.onStage('terminal_create')
}
const mode = await resolveWorkerStartModeOnHost(args.runtime, args.mode, worktree.id, args.agent)
return {
mode,
worktree,
@@ -192,7 +191,7 @@ async function createWorkerAgentSurface(
launchBrief: null
}
}
const launchBrief = (await args.launchBrief?.(worktreeId)) ?? null
const launchBrief = (await args.launchBrief?.({ worktreeId })) ?? null
const terminal = await createExistingWorktreeWorkerTerminal({
runtime: args.runtime,
worktreeId,
@@ -1,11 +1,14 @@
/**
* The `wait-for-setup` gate for a structured worker on a worktree this start created.
* The `wait-for-setup` gate for a worker on a worktree this start created, when no readiness wait
* reads its outcome: a structured worker, or a PTY worker whose brief rode its launch line.
*
* A PTY worker gets the gate for free: agent-first creation sequences the agent's startup command
* behind the setup runner, so `tui-idle` cannot arrive until setup exits, and the worker start
* reads the gate's outcome off that wait. A structured session has no startup command to sequence,
* so without this the worker would take its dispatch preamble while `install` was still running,
* and the repo's wait-for-setup policy would record no evidence at all.
* A PTY worker whose brief is pasted gets the gate for free: agent-first creation sequences the
* agent's startup command behind the setup runner, so `tui-idle` cannot arrive until setup exits,
* and the worker start reads the gate's outcome off that wait. A brief that rode the line is
* sequenced the same way (the runner skips the agent when setup fails) but waits on no readiness,
* and a structured session has no startup command to sequence at all; without this, either would
* report a start while `install` was still running, and the repo's wait-for-setup policy would
* record no evidence at all.
*
* Bounded by the start's own timeout, and deliberately forgiving: a wait that cannot be taken —
* an in-process hook with no setup terminal, or a setup pty already gone — yields no verdict
@@ -20,19 +23,19 @@
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import type { WorkerEffect, WorkerSetupReceipt } from './worker-topology'
export type StructuredWorkerSetupGate = {
export type WorkerSetupGate = {
satisfied: boolean
status: string
/** A setup gate has no agent prompt to block on; declared so the wait union stays property-typed. */
blockedReason?: undefined
}
export async function awaitStructuredWorkerSetupGate(args: {
export async function awaitWorkerSetupGate(args: {
runtime: Pick<OrcaRuntimeService, 'waitForSetupTerminalCompletion'>
setup: WorkerSetupReceipt
effects: WorkerEffect[]
timeoutMs: number
}): Promise<StructuredWorkerSetupGate | null> {
}): Promise<WorkerSetupGate | null> {
if (args.setup.startupPolicy !== 'wait-for-setup' || args.setup.state !== 'running') {
return null
}
@@ -47,7 +50,7 @@ export async function awaitStructuredWorkerSetupGate(args: {
satisfied: completion.exitCode === 0,
status: 'exited'
})),
new Promise<StructuredWorkerSetupGate>((resolve) => {
new Promise<WorkerSetupGate>((resolve) => {
timer = setTimeout(() => resolve({ satisfied: false, status: 'timeout' }), args.timeoutMs)
})
])
@@ -1,13 +1,12 @@
import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../../../../../shared/orchestration-timing-budgets'
import type {
RuntimeTerminalPromptDelivery,
RuntimeTerminalWait,
RuntimeTerminalWaitBlockedReason
} from '../../../../../../shared/runtime-terminal-contracts'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import type { TuiAgent } from '../../../../../../shared/tui-agent'
import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias'
import { waitForWorkerStartComposer } from '../../../../launched-agent-composer-readiness'
import type { LaunchTurnStartVerdict } from '../../../../launch-turn-start-observation'
/**
* Turn-start verdict for a dispatched worker prompt, in the execution-boundary vocabulary:
@@ -98,12 +97,16 @@ export function describeUnobservedWorkerTurnStart(agent: string | null): string
/** Pause before re-arming a dialog watch that found the agent idle; an idle screen re-reads instantly. */
const STARTUP_DIALOG_REWATCH_MS = 500
/** Past the 1.5 s quiet a dialog needs to read as one, so a dialog already painting is caught. */
const STARTUP_DIALOG_SETTLE_MS = 2_000
/**
* Turn-start verdict for a worker whose brief rode its launch command line. Only a hook turn that
* carried an explicit prompt after the spawn counts. A startup dialog that blocks the agent is
* reported as blocked but not failed: the brief is already on the agent's command line, so the
* agent runs it once the dialog is answered, and its report then settles the dispatch.
* Turn-start verdict for a worker whose brief rode its launch command line. The agent's hook turn
* that carried an explicit prompt after the spawn is the proof; where hooks give none, the launch's
* own evidence (`observeLaunchTurnStart`). A startup dialog that blocks the agent is reported as
* blocked but not failed: the brief is already on the agent's command line, so the agent runs it
* once the dialog is answered, and its report then settles the dispatch. An agent the host proves
* exited at startup fails the start, as a pasted brief's readiness wait did.
*/
export async function observeWorkerLaunchTurnStart(args: {
runtime: OrcaRuntimeService
@@ -123,7 +126,7 @@ export async function observeWorkerLaunchTurnStart(args: {
timeoutMs,
controller.signal
)
.catch((): WorkerTurnStartVerdict => 'unobserved')
.catch((): LaunchTurnStartVerdict => 'unobserved')
const dialog = watchForStartupDialog(runtime, terminalHandle, deadline, controller.signal)
const first = await Promise.race([
observed.then((verdict) => ({ verdict, blockedReason: null })),
@@ -133,12 +136,16 @@ export async function observeWorkerLaunchTurnStart(args: {
return blockedLaunchObservation(first.blockedReason)
}
const verdict = first.verdict ?? (await observed)
if (verdict === 'exited') {
throw new Error('Agent exited before its first turn started; the shell is back in front.')
}
if (verdict === 'observed') {
return { verdict }
}
if (verdict === 'unsupported') {
controller.abort()
return await awaitUnsupportedLaunchReadiness(runtime, terminalHandle, args.agent, deadline)
// Why: an agent proven in front can still be held by a dialog that has not settled on screen.
const blockedReason = await settledWithin(dialog, STARTUP_DIALOG_SETTLE_MS)
return blockedReason ? blockedLaunchObservation(blockedReason) : { verdict }
}
// Why: a dialog already on screen outranks any verdict short of an observed turn.
const blockedReason = await settledOrNull(dialog)
@@ -183,39 +190,6 @@ async function watchForStartupDialog(
return null
}
/**
* An agent with no prompt hook proves nothing about its turn, so its launch readiness is the
* evidence, as it was before a paste: ready or blocked. No readiness in the window stays unknown,
* since the agent may already be working on the brief it was launched with.
*/
async function awaitUnsupportedLaunchReadiness(
runtime: OrcaRuntimeService,
terminalHandle: string,
agent: TuiAgent | null,
deadline: number
): Promise<WorkerTurnStartObservation> {
const timeoutMs = Math.max(1, deadline - Date.now())
let wait: RuntimeTerminalWait | undefined
try {
wait = agent
? await waitForWorkerStartComposer(runtime, terminalHandle, agent, timeoutMs)
: await runtime.waitForTerminal(terminalHandle, {
condition: 'tui-idle',
timeoutMs,
launchReadiness: true
})
} catch {
return { verdict: 'unobserved', reason: describeUnreadyWorkerLaunch(agent, timeoutMs) }
}
if (wait?.blockedReason) {
return blockedLaunchObservation(wait.blockedReason)
}
if (wait && !wait.satisfied) {
throw new Error(`Agent did not become ready (${wait.status}).`)
}
return { verdict: 'unsupported' }
}
function blockedLaunchObservation(
blockedReason: RuntimeTerminalWaitBlockedReason
): WorkerTurnStartObservation {
@@ -240,15 +214,6 @@ function describeUnobservedWorkerLaunch(agent: string | null, windowMs: number):
)
}
function describeUnreadyWorkerLaunch(agent: string | null, windowMs: number): string {
return (
`The task rode ${agent ?? 'the agent'}'s launch command line. This agent reports no turn ` +
`start, and it showed neither readiness nor a startup dialog within ` +
`${Math.round(windowMs / 1000)}s. This is unverifiable, not proof the worker is dead: it may ` +
'already be working on the task. If the worker reports, this Dispatch settles normally.'
)
}
async function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
await new Promise<void>((resolve) => {
const timer = setTimeout(done, ms)
@@ -264,3 +229,17 @@ async function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
async function settledOrNull<T>(promise: Promise<T | null>): Promise<T | null> {
return await Promise.race([promise, Promise.resolve(null)])
}
async function settledWithin<T>(promise: Promise<T | null>, ms: number): Promise<T | null> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), ms)
})
])
} finally {
clearTimeout(timer)
}
}
@@ -4,7 +4,7 @@ import type { TuiAgent } from '../../../../../../shared/tui-agent'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import type { OrchestrationDb } from '../../../../orchestration/db'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import type { WorkerLaunchBrief } from './worker-launch-brief'
import { workerLaunchBriefCreateOptions, type WorkerLaunchBrief } from './worker-launch-brief'
import { createStructuredWorkerSession } from '../../orchestration-structured-worker-session'
export type WorkerEffect = {
@@ -78,18 +78,8 @@ export async function createExistingWorktreeWorkerTerminal(args: {
// Why: dispatching a worker is background work; it must not pull the sidebar
// to the worker's workspace while the user is reading somewhere else.
surfaceOwner: false,
...(brief
? {
preAllocatedHandle: brief.handle,
startupPrompt: brief.startupPrompt,
launchFile: brief.launchFile
}
: {})
...(brief ? { preAllocatedHandle: brief.handle, ...workerLaunchBriefCreateOptions(brief) } : {})
})
if (brief && terminal.handle !== brief.handle) {
// Why: the brief names this handle; a different one would teach the worker the wrong identity.
throw new Error('Worker terminal did not keep its pre-allocated handle.')
}
args.effects.push({
kind: 'terminal',
role: 'agent',
@@ -98,9 +88,30 @@ export async function createExistingWorktreeWorkerTerminal(args: {
surface: terminal.surface,
warning: terminal.warning
})
if (brief && terminal.handle !== brief.handle) {
await refuseWorkerTerminalHandleMismatch(args.runtime, terminal.handle)
}
return { handle: terminal.handle, warning: terminal.warning }
}
/**
* Fails a start whose terminal came back under another handle than the one its brief names. The
* brief would teach the worker the wrong identity, and it may already be on that terminal's line,
* so the terminal is closed first: a retry must not run the task a second time.
*/
export async function refuseWorkerTerminalHandleMismatch(
runtime: Pick<OrcaRuntimeService, 'closeTerminal'>,
handle: string
): Promise<never> {
const closed = await runtime.closeTerminal(handle).then(
() => true,
() => false
)
throw new Error(
`Worker terminal did not keep its pre-allocated handle${closed ? '' : `; close ${handle} by hand`}.`
)
}
/**
* A worker that IS a structured chat session, in the same shape the terminal path returns.
*
@@ -2,9 +2,10 @@
* Creating the worktree a `--worktree new-child` / `new-top-level` dispatch asks for.
*
* `withAgentTerminal` is the whole difference between the two worker modes: a PTY worker's
* worktree is created agent-first, so the startup terminal IS the worker, while a structured
* worker's worktree is created with no agent at all and its session is created for the worktree
* afterwards. Setup, default tabs and lineage are identical either way.
* worktree is created agent-first, so the startup terminal IS the worker and its launch line is
* offered the brief, while a structured worker's worktree is created with no agent at all and its
* session is created for the worktree afterwards. Setup, default tabs and lineage are identical
* either way.
*/
import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority'
@@ -12,6 +13,7 @@ import type { TuiAgent } from '../../../../../../shared/tui-agent'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import type { OrchestrationDb } from '../../../../orchestration/db'
import type { WorkerEffect, WorkerSetupReceipt } from './worker-topology'
import { workerLaunchBriefCreateOptions, type WorkerLaunchBrief } from './worker-launch-brief'
export async function createWorkerWorktree(args: {
runtime: OrcaRuntimeService
@@ -36,6 +38,8 @@ export async function createWorkerWorktree(args: {
* bracketed paste. The renderer's own structured worktree create skips both the same way. */
withAgentTerminal: boolean
launchPreferences?: AgentLaunchPreferences
/** Offered to the startup terminal's launch line; it names that terminal's handle. */
launchBrief?: WorkerLaunchBrief | null
effects: WorkerEffect[]
}): Promise<{
worktree: Awaited<ReturnType<OrcaRuntimeService['showManagedWorktree']>>
@@ -62,7 +66,13 @@ export async function createWorkerWorktree(args: {
? {
startupAgent: args.agent,
startupLaunchSource: 'orchestration',
...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {})
...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}),
...(args.launchBrief
? {
startupTerminalHandle: args.launchBrief.handle,
...workerLaunchBriefCreateOptions(args.launchBrief)
}
: {})
}
: {}),
activate: false,
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import type { OrchestrationDb } from '../../../../orchestration/db'
import {
createNewWorktreeWorkerFixture,
type NewWorktreeWorkerFixture
} from './workers-new-worktree.test-support'
describe('a new-worktree worker with its brief offered to the agent-first launch line', () => {
const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
let db: OrchestrationDb
let runtime: OrcaRuntimeService
let startWorker: NewWorktreeWorkerFixture['startWorker']
beforeEach(() => {
;({ db, runtime, startWorker } = createNewWorktreeWorkerFixture(coordinatorPaneKey))
})
afterEach(() => {
db.close()
})
/** Agent-first creation, whose carry rule reports whether the brief rode the launch command. */
function mockBriefCarryingWorktree(
carried: boolean,
options?: {
startupPolicy?: 'start-immediately' | 'wait-for-setup'
setupTerminalHandle?: string
}
) {
vi.spyOn(runtime, 'createManagedWorktree').mockImplementation(async (args) => {
args.onStartupPromptCarry?.(carried)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the fields worker-start reads are returned.
return {
worktree: { id: 'repo::created', repoId: 'repo' },
startupTerminal: { spawned: true, handle: 'term_worker' },
setupReceipt: {
requested: 'run',
hookFound: true,
startupPolicy: options?.startupPolicy ?? 'start-immediately',
state: 'running',
terminalHandle: options?.setupTerminalHandle
}
} as never
})
}
// Why: main spawns the agent first and provisions setup and tabs after it, with no extra shell.
it('spawns the agent first with its brief on the line, and pastes nothing', async () => {
mockBriefCarryingWorktree(true)
const { result } = await startWorker({ worktree: 'new-top-level' })
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
startupAgent: 'codex',
startupLaunchSource: 'orchestration',
startupTerminalHandle: 'term_worker',
startupPromptPaste: 'once-agent-runs',
startupPrompt: expect.stringContaining('--from term_worker')
})
)
expect(vi.mocked(runtime.createManagedWorktree).mock.calls[0]?.[0].startupPrompt).toContain(
'new-worktree task'
)
expect(runtime.createTerminal).not.toHaveBeenCalled()
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
expect(runtime.observeTerminalLaunchTurnStart).toHaveBeenCalledWith(
'term_worker',
expect.objectContaining({ agent: 'codex', launchStartedAt: expect.any(Number) }),
expect.any(Number),
expect.any(AbortSignal)
)
expect(result).toMatchObject({ state: 'ready', turnStart: 'observed' })
})
// Why: a host the line cannot reach whole (a Windows shell, a relay WSL shell) gets main's paste.
it('pastes the brief once the agent is ready where the line cannot carry it', async () => {
mockBriefCarryingWorktree(false)
const { result } = await startWorker()
expect(runtime.observeTerminalLaunchTurnStart).not.toHaveBeenCalled()
expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
expect(result).toMatchObject({ state: 'ready' })
})
it('closes a startup terminal that came back under another handle than its brief names', async () => {
vi.spyOn(runtime, 'createManagedWorktree').mockImplementation(async (args) => {
args.onStartupPromptCarry?.(true)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the fields worker-start reads are returned.
return {
worktree: { id: 'repo::created', repoId: 'repo' },
startupTerminal: { spawned: true, handle: 'term_adopted' }
} as never
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the start reads no field of a close result.
const close = vi.spyOn(runtime, 'closeTerminal').mockResolvedValue({} as never)
const { result } = await startWorker()
expect(result).toMatchObject({
state: 'failed',
lastError: 'Worker terminal did not keep its pre-allocated handle.'
})
expect(close).toHaveBeenCalledWith('term_adopted')
})
it("names the CLI command the new worktree's terminal will run", async () => {
mockBriefCarryingWorktree(true)
vi.mocked(runtime.predictOrchestrationCliCommandForSpawn).mockResolvedValue('orca-ide')
await startWorker()
expect(runtime.predictOrchestrationCliCommandForSpawn).toHaveBeenCalledWith({
repoSelector: 'repo'
})
const prompt = vi.mocked(runtime.createManagedWorktree).mock.calls[0]?.[0].startupPrompt
expect(prompt).toContain('orca-ide orchestration send')
expect(prompt).not.toMatch(/(^|\s)orca orchestration send/)
})
it('fails the start at the setup gate when wait-for-setup fails behind the brief', async () => {
mockBriefCarryingWorktree(true, {
startupPolicy: 'wait-for-setup',
setupTerminalHandle: 'term_setup'
})
vi.mocked(runtime.waitForSetupTerminalCompletion).mockResolvedValue({ exitCode: 1 })
const { result } = await startWorker()
expect(result).toMatchObject({ state: 'failed', failedStage: 'setup_wait' })
expect(runtime.observeTerminalLaunchTurnStart).not.toHaveBeenCalled()
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
// Why: setup and the launched brief's turn start share the start's one budget, never T + T.
it.each([
[20_000, 40_000],
[50_000, 30_000]
])(
'after %i ms of setup, observes the launched turn for what is left (%i ms, never under 30 s)',
async (setupMs, observationMs) => {
mockBriefCarryingWorktree(true, {
startupPolicy: 'wait-for-setup',
setupTerminalHandle: 'term_setup'
})
let now = 1_000_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
vi.mocked(runtime.waitForSetupTerminalCompletion).mockImplementation(async () => {
now += setupMs
return { exitCode: 0 }
})
await startWorker({ timeoutMs: 60_000 })
expect(runtime.observeTerminalLaunchTurnStart).toHaveBeenCalledWith(
'term_worker',
expect.anything(),
observationMs,
expect.any(AbortSignal)
)
}
)
})
@@ -0,0 +1,96 @@
import { vi } from 'vitest'
import { OrcaRuntimeService } from '../../../../orca-runtime'
import { OrchestrationDb } from '../../../../orchestration/db'
import { ORCHESTRATION_METHODS } from '../../orchestration'
export type NewWorktreeWorkerFixture = {
db: OrchestrationDb
runtime: OrcaRuntimeService
runId: string
startWorker: (
overrides?: Record<string, unknown>
) => Promise<{ result: unknown; task: ReturnType<OrchestrationDb['createTask']> }>
}
/** A worker-start runtime whose coordinator sits in `repo::parent` and asks for a new worktree. */
export function createNewWorktreeWorkerFixture(
coordinatorPaneKey: string
): NewWorktreeWorkerFixture {
const db = new OrchestrationDb(':memory:')
const runtime = new OrcaRuntimeService()
runtime.setOrchestrationDb(db)
const runId = db.createRun({
objective: 'Test new-worktree workers',
coordinatorHandle: 'term_coord',
coordinatorPaneKey
}).id
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
handle === 'term_coord'
? coordinatorPaneKey
: handle === 'term_worker'
? 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
: null
)
vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) =>
handle === 'term_worker' ? 'runtime_test:term_worker:1' : null
)
vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {})
vi.spyOn(runtime, 'showTerminal').mockResolvedValue({
handle: 'term_coord',
worktreeId: 'repo::parent',
status: 'running'
} as never)
vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({
id: 'repo::parent',
repoId: 'repo'
} as never)
vi.spyOn(runtime, 'showRepo').mockResolvedValue({
id: 'repo',
kind: 'git'
} as never)
vi.spyOn(runtime, 'createTerminal')
vi.spyOn(runtime, 'listTerminals').mockResolvedValue({
terminals: [{ handle: 'term_worker', title: 'Codex' }],
totalCount: 1,
truncated: false
} as never)
vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({
handle: 'term_worker',
condition: 'tui-idle',
satisfied: true,
status: 'running',
exitCode: null
})
vi.spyOn(runtime, 'waitForSetupTerminalCompletion').mockReturnValue(new Promise(() => undefined))
vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca')
// The brief is built before the spawn; these answer for the terminal it is offered to.
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue('term_worker')
vi.spyOn(runtime, 'predictOrchestrationCliCommandForSpawn').mockResolvedValue('orca')
vi.spyOn(runtime, 'observeTerminalLaunchTurnStart').mockResolvedValue('observed')
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({
handle: 'term_worker',
accepted: true,
bytesWritten: 1
})
async function startWorker(overrides: Record<string, unknown> = {}) {
const task = db.createTask({ spec: 'new-worktree task', runId })
const method = ORCHESTRATION_METHODS.find(
(candidate) => candidate.name === 'orchestration.workerStart'
)
if (!method) {
throw new Error('workerStart method is not registered')
}
const params = method.params!.parse({
task: task.id,
from: 'term_coord',
worktree: 'new-child',
name: 'new-worker',
agent: 'codex',
...overrides
})
const result = await method.handler(params, { runtime })
return { result, task }
}
return { db, runtime, runId, startWorker }
}
@@ -3,12 +3,15 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/protocol-version'
import { getAppEnvironment } from '../../../../../../shared/app-environment'
import { OrcaRuntimeService } from '../../../../orca-runtime'
import { OrchestrationDb } from '../../../../orchestration/db'
import { RpcDispatcher } from '../../../dispatcher'
import type { RpcRequest } from '../../../core'
import { ORCHESTRATION_METHODS } from '../../orchestration'
import {
createNewWorktreeWorkerFixture,
type NewWorktreeWorkerFixture
} from './workers-new-worktree.test-support'
describe('orchestration new-worktree workers', () => {
type CreateWorktreeResult = Awaited<ReturnType<OrcaRuntimeService['createManagedWorktree']>>
@@ -16,78 +19,11 @@ describe('orchestration new-worktree workers', () => {
let db: OrchestrationDb
let runtime: OrcaRuntimeService
let runId: string
let startWorker: NewWorktreeWorkerFixture['startWorker']
const paths: string[] = []
beforeEach(() => {
db = new OrchestrationDb(':memory:')
runtime = new OrcaRuntimeService()
runtime.setOrchestrationDb(db)
runId = db.createRun({
objective: 'Test new-worktree workers',
coordinatorHandle: 'term_coord',
coordinatorPaneKey
}).id
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
handle === 'term_coord'
? coordinatorPaneKey
: handle === 'term_worker'
? 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
: null
)
vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) =>
handle === 'term_worker' ? 'runtime_test:term_worker:1' : null
)
vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {})
vi.spyOn(runtime, 'showTerminal').mockResolvedValue({
handle: 'term_coord',
worktreeId: 'repo::parent',
status: 'running'
} as never)
vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({
id: 'repo::parent',
repoId: 'repo'
} as never)
vi.spyOn(runtime, 'showRepo').mockResolvedValue({
id: 'repo',
kind: 'git'
} as never)
vi.spyOn(runtime, 'createTerminal').mockResolvedValue({
handle: 'term_worker',
worktreeId: 'repo::created',
title: 'worker'
})
// A created worker's brief rides its launch line, so its handle is minted before the spawn.
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue('term_worker')
vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope').mockResolvedValue({
id: 'repo::created',
path: '/repo/created',
connectionId: null,
repo: null,
folderWorkspace: null
})
vi.spyOn(runtime, 'observeTerminalLaunchTurnStart').mockResolvedValue('observed')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the fields worker-start reads are listed.
vi.spyOn(runtime, 'listTerminals').mockResolvedValue({
terminals: [],
totalCount: 0,
truncated: false
} as never)
vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({
handle: 'term_worker',
condition: 'tui-idle',
satisfied: true,
status: 'running',
exitCode: null
})
vi.spyOn(runtime, 'waitForSetupTerminalCompletion').mockReturnValue(
new Promise(() => undefined)
)
vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca')
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({
handle: 'term_worker',
accepted: true,
bytesWritten: 1
})
;({ db, runtime, runId, startWorker } = createNewWorktreeWorkerFixture(coordinatorPaneKey))
})
afterEach(() => {
@@ -97,26 +33,6 @@ describe('orchestration new-worktree workers', () => {
}
})
async function startWorker(overrides: Record<string, unknown> = {}) {
const task = db.createTask({ spec: 'new-worktree task', runId })
const method = ORCHESTRATION_METHODS.find(
(candidate) => candidate.name === 'orchestration.workerStart'
)
if (!method) {
throw new Error('workerStart method is not registered')
}
const params = method.params!.parse({
task: task.id,
from: 'term_coord',
worktree: 'new-child',
name: 'new-worker',
agent: 'codex',
...overrides
})
const result = await method.handler(params, { runtime })
return { result, task }
}
function mockCreatedWorktree(options?: {
hookFound?: boolean
startupPolicy?: 'start-immediately' | 'wait-for-setup'
@@ -126,46 +42,37 @@ describe('orchestration new-worktree workers', () => {
}) {
const hookFound = options?.hookFound ?? true
const state = options?.state ?? (hookFound ? 'running' : 'not_configured')
vi.spyOn(runtime, 'createManagedWorktree').mockImplementation(async (args) => {
// Only agent-first creation spawns, and so lists, the agent's own terminal.
const terminals = (options?.terminals ?? [{ handle: 'term_worker', title: 'Codex' }]).filter(
(terminal) => args.startupAgent || terminal.handle !== 'term_worker'
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the fields worker-start reads are listed.
vi.spyOn(runtime, 'createManagedWorktree').mockResolvedValue({
worktree: { id: 'repo::created', repoId: 'repo' },
startupTerminal: { spawned: true, handle: 'term_worker' },
setupReceipt: {
requested: state === 'skipped' ? 'skip' : 'run',
hookFound,
startupPolicy: options?.startupPolicy ?? 'start-immediately',
state,
terminalHandle:
options?.setupTerminalHandle ??
options?.terminals?.find((terminal) => terminal.title === 'Setup')?.handle
}
} as never)
if (options?.terminals) {
vi.mocked(runtime.listTerminals).mockResolvedValue({
terminals,
totalCount: terminals.length,
terminals: options.terminals,
totalCount: options.terminals.length,
truncated: false
} as never)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the fields worker-start reads are returned.
return {
worktree: { id: 'repo::created', repoId: 'repo' },
...(args.startupAgent ? { startupTerminal: { spawned: true, handle: 'term_worker' } } : {}),
setupReceipt: {
requested: state === 'skipped' ? 'skip' : 'run',
hookFound,
startupPolicy: options?.startupPolicy ?? 'start-immediately',
state,
terminalHandle:
options?.setupTerminalHandle ??
options?.terminals?.find((terminal) => terminal.title === 'Setup')?.handle
}
} as never
})
}
}
// Why: agent-first creation types its own launch line, so a brief-carrying agent comes after.
it('creates an independent top-level worktree, then its brief-carrying agent', async () => {
it('creates an independent top-level worktree and reuses its agent terminal', async () => {
mockCreatedWorktree()
const { result } = await startWorker({ worktree: 'new-top-level' })
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.not.objectContaining({ startupAgent: expect.anything() })
)
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
createdWithAgent: 'codex',
startupAgent: 'codex',
startupLaunchSource: 'orchestration',
awaitTerminalProvisioning: true,
observeSetupCompletion: true,
lineage: expect.objectContaining({ noParent: true, parentWorktree: undefined })
@@ -188,35 +95,10 @@ describe('orchestration new-worktree workers', () => {
})
])
)
expect(runtime.createTerminal).toHaveBeenCalledWith(
'id:repo::created',
expect.objectContaining({
startupAgent: 'codex',
launchSource: 'orchestration',
preAllocatedHandle: 'term_worker',
launchFile: expect.objectContaining({
content: expect.stringContaining('new-worktree task'),
sensitive: true
})
})
)
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
it('still creates a paste-only agent worktree agent-first and reuses its terminal', async () => {
mockCreatedWorktree()
const { result } = await startWorker({ worktree: 'new-top-level', agent: 'aider' })
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({ startupAgent: 'aider', startupLaunchSource: 'orchestration' })
)
expect(result).toMatchObject({ state: 'ready' })
expect(runtime.createTerminal).not.toHaveBeenCalled()
expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
})
it('passes launch preferences to the brief-carrying agent terminal', async () => {
it('passes launch preferences into agent-first worktree creation', async () => {
mockCreatedWorktree()
const { result } = await startWorker({
@@ -224,11 +106,10 @@ describe('orchestration new-worktree workers', () => {
effort: 'high'
})
expect(runtime.createTerminal).toHaveBeenCalledWith(
'id:repo::created',
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
startupAgent: 'codex',
launchPreferences: { model: 'custom-codex-model', effort: 'high' }
startupLaunchPreferences: { model: 'custom-codex-model', effort: 'high' }
})
)
expect(result).toMatchObject({
@@ -278,30 +159,10 @@ describe('orchestration new-worktree workers', () => {
it('injects the execution host CLI command without a Dispatch capability', async () => {
mockCreatedWorktree()
vi.mocked(runtime.getTerminalOrchestrationCliCommand).mockReturnValue('orca-ide')
// The host writes a WSL agent's launch file into the distro, so its brief rides one too.
vi.spyOn(runtime, 'resolveProjectRuntimeForWorktree').mockReturnValue({
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'repo',
distro: 'Ubuntu',
reason: 'global-default',
cacheKey: 'wsl:Ubuntu'
}
})
// A dev build names `orca-dev` everywhere; the host's own command shows only when packaged.
const packaged = vi.spyOn(getAppEnvironment(), 'isPackaged').mockReturnValue(true)
try {
await startWorker({ worktree: 'new-top-level' })
} finally {
packaged.mockRestore()
}
await startWorker({ worktree: 'new-top-level' })
const createOptions = vi.mocked(runtime.createTerminal).mock.calls[0]?.[1]
const prompt = createOptions?.launchFile?.content ?? ''
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
const prompt = vi.mocked(runtime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? ''
expect(prompt).toContain('orca-ide orchestration send')
expect(prompt).not.toMatch(/(^|\s)orca orchestration send/)
})
@@ -406,8 +267,7 @@ describe('orchestration new-worktree workers', () => {
expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' })
])
)
expect(runtime.observeTerminalLaunchTurnStart).toHaveBeenCalledOnce()
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
expect(db.getInbox(10).filter((message) => message.run_id === runId)).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'status', priority: 'high' })])
)
@@ -438,43 +298,8 @@ describe('orchestration new-worktree workers', () => {
})
})
// Why: setup and the launched brief's turn start share the start's one budget, never T + T.
it.each([
[20_000, 40_000],
[50_000, 30_000]
])(
'after %i ms of setup, observes the launched turn for what is left (%i ms, never under 30 s)',
async (setupMs, observationMs) => {
mockCreatedWorktree({
startupPolicy: 'wait-for-setup',
state: 'running',
setupTerminalHandle: 'term_setup'
})
let now = 1_000_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
vi.mocked(runtime.waitForSetupTerminalCompletion).mockImplementation(async () => {
now += setupMs
return { exitCode: 0 }
})
await startWorker({ timeoutMs: 60_000 })
expect(runtime.observeTerminalLaunchTurnStart).toHaveBeenCalledWith(
'term_worker',
expect.anything(),
observationMs,
expect.any(AbortSignal)
)
}
)
it('records wait-for-setup success before the agent terminal is created', async () => {
mockCreatedWorktree({
startupPolicy: 'wait-for-setup',
state: 'running',
setupTerminalHandle: 'term_setup'
})
vi.mocked(runtime.waitForSetupTerminalCompletion).mockResolvedValue({ exitCode: 0 })
it('records wait-for-setup success before task input is accepted', async () => {
mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' })
const { result } = await startWorker()
const dispatchId = (result as { dispatchId: string }).dispatchId
@@ -487,10 +312,9 @@ describe('orchestration new-worktree workers', () => {
expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' })
])
})
expect(runtime.waitForSetupTerminalCompletion).toHaveBeenCalledWith('term_setup')
expect(
vi.mocked(runtime.waitForSetupTerminalCompletion).mock.invocationCallOrder[0]
).toBeLessThan(vi.mocked(runtime.createTerminal).mock.invocationCallOrder[0]!)
expect(vi.mocked(runtime.waitForTerminal).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(runtime.sendTerminalAgentPrompt).mock.invocationCallOrder[0]!
)
expect(JSON.parse(db.getWorkerDispatch(dispatchId)?.effects ?? '[]')).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' })
@@ -499,13 +323,12 @@ describe('orchestration new-worktree workers', () => {
})
// Why: a first dispatch the composer signal settles must record setup exactly as one the idle
// wait settles; the composer lane once returned nothing and skipped this record. Paste-only:
// a brief-carrying agent waits on no readiness before its turn.
// wait settles; the composer lane once returned nothing and skipped this record.
it('settles a fresh worker start on main’s idle wait, not the launch paste’s signal', async () => {
mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' })
const composerSignal = vi.spyOn(runtime, 'waitForFreshWorkerComposer')
const { result } = await startWorker({ agent: 'aider' })
const { result } = await startWorker()
expect(composerSignal).not.toHaveBeenCalled()
expect(runtime.waitForTerminal).toHaveBeenCalledWith(
@@ -518,8 +341,15 @@ describe('orchestration new-worktree workers', () => {
})
})
it('does not start the agent when the gated setup terminal fails to start', async () => {
it('does not inject task input when the gated setup terminal fails to start', async () => {
mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'spawn_failed' })
vi.mocked(runtime.waitForTerminal).mockResolvedValue({
handle: 'term_worker',
condition: 'tui-idle',
satisfied: false,
status: 'exited',
exitCode: 1
})
const { result, task } = await startWorker()
@@ -532,16 +362,18 @@ describe('orchestration new-worktree workers', () => {
])
})
expect(db.getTask(task.id)?.status).toBe('failed')
expect(runtime.createTerminal).not.toHaveBeenCalled()
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
it('does not start the agent when the gated setup script fails', async () => {
mockCreatedWorktree({
startupPolicy: 'wait-for-setup',
state: 'running',
setupTerminalHandle: 'term_setup'
it('does not inject task input when the gated setup script fails', async () => {
mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' })
vi.mocked(runtime.waitForTerminal).mockResolvedValue({
handle: 'term_worker',
condition: 'tui-idle',
satisfied: false,
status: 'exited',
exitCode: 1
})
vi.mocked(runtime.waitForSetupTerminalCompletion).mockResolvedValue({ exitCode: 1 })
const { result } = await startWorker()
@@ -551,59 +383,29 @@ describe('orchestration new-worktree workers', () => {
setup: { state: 'failed' },
effects: expect.arrayContaining([expect.objectContaining({ kind: 'setup', state: 'failed' })])
})
expect(runtime.createTerminal).not.toHaveBeenCalled()
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
it('does not mislabel a wait-for-setup timeout as setup failure', async () => {
mockCreatedWorktree({
startupPolicy: 'wait-for-setup',
state: 'running',
setupTerminalHandle: 'term_setup'
mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' })
vi.mocked(runtime.waitForTerminal).mockResolvedValue({
handle: 'term_worker',
condition: 'tui-idle',
satisfied: false,
status: 'running',
exitCode: null
})
const { result } = await startWorker({ timeoutMs: 1 })
const { result } = await startWorker()
expect(result).toMatchObject({
state: 'failed',
failedStage: 'setup_wait',
lastError: expect.stringContaining('(timeout)'),
setup: { state: 'running' },
effects: expect.arrayContaining([
expect.objectContaining({ kind: 'setup', state: 'running' })
])
failedStage: 'agent_readiness',
setup: { state: 'running' }
})
expect(runtime.createTerminal).not.toHaveBeenCalled()
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
// A paste-only agent is still created agent-first, so its readiness wait carries setup's gate.
it.each([
['running', true, 'exited', 'ready', undefined, 'succeeded'],
['spawn_failed', false, 'exited', 'failed', 'setup_start', 'spawn_failed'],
['running', false, 'exited', 'failed', 'setup_wait', 'failed'],
['running', false, 'running', 'failed', 'agent_readiness', 'running']
] as const)(
'gates an agent-first %s setup (idle=%s, %s) off the paste worker readiness wait',
async (setupState, satisfied, status, state, failedStage, finalSetupState) => {
mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: setupState })
vi.mocked(runtime.waitForTerminal).mockResolvedValue({
handle: 'term_worker',
condition: 'tui-idle',
satisfied,
status,
exitCode: null
})
const { result } = await startWorker({ agent: 'aider' })
expect(result).toMatchObject({
state,
...(failedStage ? { failedStage } : {}),
setup: { startupPolicy: 'wait-for-setup', state: finalSetupState }
})
expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(state === 'ready' ? 1 : 0)
}
)
it('distinguishes no-effect failure, unknown acceptance, and durable residual effects', async () => {
vi.spyOn(runtime, 'createManagedWorktree').mockRejectedValueOnce(
new Error('repository validation failed before creation')
@@ -630,20 +432,17 @@ describe('orchestration new-worktree workers', () => {
})
mockCreatedWorktree()
// A startup dialog holds the launch turn: unknown, since answering it runs the brief.
vi.mocked(runtime.observeTerminalLaunchTurnStart).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(runtime.waitForTerminal).mockResolvedValueOnce({
handle: 'term_worker',
condition: 'tui-idle',
satisfied: false,
status: 'running',
exitCode: null,
blockedReason: 'agent-trust-workspace'
status: 'exited',
exitCode: 1
})
const durableEffect = await startWorker({ name: 'durable-effect' })
expect(durableEffect.result).toMatchObject({
state: 'outcome_unknown',
stage: 'turn_start_blocked',
state: 'failed',
failedStage: 'agent_readiness',
effects: expect.arrayContaining([
expect.objectContaining({ kind: 'worktree', id: 'repo::created' }),
expect.objectContaining({ kind: 'terminal', id: 'term_worker' })
@@ -718,6 +517,7 @@ describe('orchestration new-worktree workers', () => {
finishCreate?.({
worktree: { id: 'repo::created', repoId: 'repo' },
startupTerminal: { spawned: true, handle: 'term_worker' },
setupReceipt: {
requested: 'run',
hookFound: false,
@@ -762,8 +562,7 @@ describe('orchestration new-worktree workers', () => {
from: 'term_coord',
worktree: 'new-child',
name: 'recover-input-worker',
// Only a paste can fail at dispatch input; aider takes its brief after start.
agent: 'aider'
agent: 'codex'
}
}
@@ -832,20 +631,22 @@ describe('orchestration new-worktree workers', () => {
it('persists pre-effect, post-effect, and post-input stages in order', async () => {
mockCreatedWorktree({ hookFound: false })
let finishTerminal:
| ((value: Awaited<ReturnType<OrcaRuntimeService['createTerminal']>>) => void)
let finishWait:
| ((value: Awaited<ReturnType<OrcaRuntimeService['waitForTerminal']>>) => void)
| undefined
let finishTurn: ((value: 'observed') => void) | undefined
vi.mocked(runtime.createTerminal).mockImplementationOnce(
let finishPrompt:
| ((value: Awaited<ReturnType<OrcaRuntimeService['sendTerminalAgentPrompt']>>) => void)
| undefined
vi.mocked(runtime.waitForTerminal).mockImplementationOnce(
async () =>
await new Promise((resolve) => {
finishTerminal = resolve
finishWait = resolve
})
)
vi.mocked(runtime.observeTerminalLaunchTurnStart).mockImplementationOnce(
vi.mocked(runtime.sendTerminalAgentPrompt).mockImplementationOnce(
async () =>
await new Promise((resolve) => {
finishTurn = resolve
finishPrompt = resolve
})
)
@@ -855,8 +656,9 @@ describe('orchestration new-worktree workers', () => {
const dispatch = task ? db.getDispatchContext(task.id) : undefined
expect(dispatch && db.getWorkerDispatch(dispatch.id)).toMatchObject({
state: 'starting',
stage: 'terminal_creating',
worktree_id: 'repo::created'
stage: 'terminal_readying',
worktree_id: 'repo::created',
agent_terminal_handle: 'term_worker'
})
})
const dispatch = db.getDispatchContext(db.listTasks()[0]!.id)!
@@ -864,16 +666,21 @@ describe('orchestration new-worktree workers', () => {
expect.arrayContaining([expect.objectContaining({ kind: 'worktree', id: 'repo::created' })])
)
finishTerminal?.({ handle: 'term_worker', worktreeId: 'repo::created', title: 'worker' })
finishWait?.({
handle: 'term_worker',
condition: 'tui-idle',
satisfied: true,
status: 'running',
exitCode: null
})
await vi.waitFor(() =>
expect(db.getWorkerDispatch(dispatch.id)).toMatchObject({
state: 'starting',
stage: 'authority_attached',
agent_terminal_handle: 'term_worker'
stage: 'authority_attached'
})
)
finishTurn?.('observed')
finishPrompt?.({ handle: 'term_worker', accepted: true, bytesWritten: 1 })
await expect(pending).resolves.toMatchObject({
result: { state: 'ready', stage: 'input_accepted' }
})
@@ -12,6 +12,8 @@ describe('composer-marker first dispatch readiness', () => {
'waits for %s’s new composer before delivering exactly one dispatch',
async (agent) => {
h.setup()
// The paste path: a host whose line cannot carry the brief leaves it for after readiness.
h.leaveBriefForPaste()
const gate = h.deferred<RuntimeTerminalWait>()
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise)
const pending = h.startWorker({ agent })
@@ -53,13 +53,14 @@ function createPorts() {
const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'
async function startupTerminalOptions(startupPaneKey?: string) {
async function startupTerminalOptions(startupPaneKey?: string, startupTerminalHandle?: string) {
const { createTerminal, ports } = createPorts()
await startRuntimeLocalWorktreeTerminals({
request: {
repoSelector: `id:${repo.id}`,
name: worktree.displayName,
...(startupPaneKey ? { startupPaneKey } : {})
...(startupPaneKey ? { startupPaneKey } : {}),
...(startupTerminalHandle ? { startupTerminalHandle } : {})
},
repo,
worktree,
@@ -84,6 +85,14 @@ describe('startRuntimeLocalWorktreeTerminals reserved startup pane', () => {
const options = await startupTerminalOptions()
expect(options).not.toHaveProperty('tabId')
expect(options).not.toHaveProperty('leafId')
expect(options).not.toHaveProperty('preAllocatedHandle')
})
// Why: a worker's brief names its terminal's handle before the startup terminal exists.
it('creates the startup terminal under the handle the caller minted', async () => {
expect(await startupTerminalOptions(undefined, 'term_worker')).toMatchObject({
preAllocatedHandle: 'term_worker'
})
})
})
@@ -97,6 +97,9 @@ export async function startRuntimeLocalWorktreeTerminals(args: {
command: sequencedStartup.command,
...(request.startupCwd ? { cwd: request.startupCwd } : {}),
...paneIdentity(request.startupPaneKey),
...(request.startupTerminalHandle
? { preAllocatedHandle: request.startupTerminalHandle }
: {}),
...(setup && startup ? { claudeAgentTeamsSourceCommand: startup.command } : {}),
env: sequencedStartup.env,
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
@@ -8,6 +8,7 @@ import type {
Worktree
} from '../../shared/worktree/types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { LaunchPromptPaste } from '../../shared/launch-prompt-carry'
import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../shared/workspace-source'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { TaskSourceContext } from '../../shared/task-source-context'
@@ -54,6 +55,9 @@ export type RuntimeManagedWorktreeCreateArgs = {
/** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself (one
* `carryLaunchPrompt` leaves for the paste); reports whether the prompt rode the command. */
onStartupPromptCarry?: (carried: boolean) => void
/** Main-internal: the caller's paste for an uncarried `startupPrompt` (`startupPromptPaste` on a
* terminal create). */
startupPromptPaste?: LaunchPromptPaste
/** Per-launch inputs used when `startupAgent` is the created terminal surface. */
startupAgentArgs?: string | null
startupCwd?: string
@@ -61,6 +65,8 @@ export type RuntimeManagedWorktreeCreateArgs = {
startupLaunchSource?: string
/** A caller-minted `tabId:leafId` for the startup terminal's pane. */
startupPaneKey?: string
/** A caller-minted handle for the startup terminal, when the startup prompt names it. */
startupTerminalHandle?: string
pendingFirstAgentMessageRename?: boolean
automationProvenance?: AutomationWorkspaceProvenance
cliProvenance?: CliWorkspaceProvenance
@@ -52,7 +52,10 @@ function createDeps() {
const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'
async function startupTerminalOptions(startupPaneKey?: string): Promise<Record<string, unknown>> {
async function startupTerminalOptions(
startupPaneKey?: string,
startupTerminalHandle?: string
): Promise<Record<string, unknown>> {
const { createTerminal, deps } = createDeps()
await createRuntimeRemoteManagedWorktree(
repo,
@@ -60,7 +63,8 @@ async function startupTerminalOptions(startupPaneKey?: string): Promise<Record<s
name: 'task',
createdWithAgent: 'codex',
startup: { command: 'codex' },
...(startupPaneKey ? { startupPaneKey } : {})
...(startupPaneKey ? { startupPaneKey } : {}),
...(startupTerminalHandle ? { startupTerminalHandle } : {})
},
deps
)
@@ -79,5 +83,13 @@ describe('a remote managed create with a startup agent', () => {
const options = await startupTerminalOptions()
expect(options).not.toHaveProperty('tabId')
expect(options).not.toHaveProperty('leafId')
expect(options).not.toHaveProperty('preAllocatedHandle')
})
// Why: a worker's brief names its terminal's handle before the startup terminal exists.
it('creates the startup terminal under the handle the caller minted', async () => {
expect(await startupTerminalOptions(undefined, 'term_worker')).toMatchObject({
preAllocatedHandle: 'term_worker'
})
})
})
@@ -104,6 +104,7 @@ export async function createRuntimeRemoteManagedWorktree(
command: sequencedStartup.command,
...(args.startupCwd ? { cwd: args.startupCwd } : {}),
...paneIdentity(args.startupPaneKey),
...(args.startupTerminalHandle ? { preAllocatedHandle: args.startupTerminalHandle } : {}),
...(result.setup && args.startup
? { claudeAgentTeamsSourceCommand: args.startup.command }
: {}),
@@ -14,6 +14,7 @@ import type {
} from '../../shared/runtime-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { LaunchFile } from '../../shared/launch-prompt-file'
import type { LaunchPromptPaste } from '../../shared/launch-prompt-carry'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { RuntimeTerminalSend } from '../../shared/runtime-terminal-contracts'
import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer'
@@ -54,6 +55,9 @@ export type TerminalCreateOptions = {
startupPrompt?: string
/** Main-internal: whether `startupPrompt` rode the launch command. Called once the plan is built. */
onStartupPromptCarry?: (carried: boolean) => void
/** Main-internal: the caller's paste for an uncarried `startupPrompt`; defaults to #24257's guarded
* paste when `onStartupPromptCarry` is set. */
startupPromptPaste?: LaunchPromptPaste
/** Written by the execution host before the launch line naming it is typed (`startupPrompt`
* carries its pointer). */
launchFile?: LaunchFile
@@ -160,6 +160,24 @@ describe('buildWorktreeStartupForAgent prompt carry', () => {
expect(result.followup?.prompt).toBe(prompt)
})
it('leaves a Windows prompt the line would damage to a caller whose paste is main’s, not a file', () => {
const onPromptCarry = vi.fn()
const long = Array.from({ length: 20 }, (_, i) => `step ${i} `.padEnd(500, 'x')).join('\n')
const result = buildWorktreeStartupForAgent({
repo: makeRepo({}),
settings,
agent: 'claude',
prompt: long,
getLaunchPlatform: () => 'win32',
toSessionOptions: () => undefined,
onPromptCarry,
promptPaste: 'once-agent-runs'
})
expect(onPromptCarry).toHaveBeenCalledExactlyOnceWith(false)
expect(result.startup.launchFile).toBeUndefined()
expect(result.startup.command).not.toContain('step 1 ')
})
it('reports that prompt uncarried, with no follow-up, to a caller that pastes it itself', () => {
const onPromptCarry = vi.fn()
const result = buildWorktreeStartupForAgent({
@@ -15,6 +15,7 @@ import {
type AgentStartupPlan
} from '../../shared/tui-agent-startup'
import type { LaunchFile } from '../../shared/launch-prompt-file'
import type { LaunchPromptPaste } from '../../shared/launch-prompt-carry'
import { thisOrcaLaunchHost } from './this-orca-launch-host'
import {
detectInstalledAgentsWithShellPathHydration,
@@ -137,6 +138,8 @@ export function buildWorktreeStartupForAgent(
/** Set by a caller that pastes an uncarried prompt itself; reports whether it rode the command,
* and no follow-up is returned for it to paste twice. */
onPromptCarry?: (carried: boolean) => void
/** The caller's paste for what the line leaves; #24257's guarded paste by default. */
promptPaste?: LaunchPromptPaste
}
): {
agent: TuiAgent
@@ -165,7 +168,7 @@ export function buildWorktreeStartupForAgent(
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'
paste: environment.promptPaste ?? 'when-host-proves-agent'
})
if (!planned) {
throw new Error(`Could not build launch command for ${agent}.`)
@@ -274,16 +274,9 @@ async function releaseCompletedWorker(terminalState: 'running' | 'exited'): Prom
worktreeId: WORKTREE_ID,
title: 'PR 4626 unified correction r3'
})
// The worker's brief rides its launch line, so its handle is minted before the spawn.
// The brief names the worker's handle and CLI command, so both are settled before the spawn.
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue(TERMINAL_HANDLE)
vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope').mockResolvedValue({
id: WORKTREE_ID,
path: '/repo/worktree',
connectionId: null,
repo: null,
folderWorkspace: null
})
vi.spyOn(runtime, 'observeTerminalLaunchTurnStart').mockResolvedValue('observed')
vi.spyOn(runtime, 'predictOrchestrationCliCommandForSpawn').mockResolvedValue('orca')
vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({
handle: TERMINAL_HANDLE,
condition: 'tui-idle',