diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index ef458ce0803..553d049f18e 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -7,9 +7,6 @@ const AGENT_PROMPT_EFFECT_POLL_MS = 50 const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['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' diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index bc3d53593cf..0fff2f1c02f 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -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() diff --git a/src/main/runtime/launch-turn-start-observation.test.ts b/src/main/runtime/launch-turn-start-observation.test.ts new file mode 100644 index 00000000000..76396f33ac0 --- /dev/null +++ b/src/main/runtime/launch-turn-start-observation.test.ts @@ -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 { + 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') + }) +}) diff --git a/src/main/runtime/launch-turn-start-observation.ts b/src/main/runtime/launch-turn-start-observation.ts new file mode 100644 index 00000000000..29a83c15ce7 --- /dev/null +++ b/src/main/runtime/launch-turn-start-observation.ts @@ -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 +} + +/** + * 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 { + 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 { + 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(promises: Promise[]): Promise { + 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 { + await new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index eeebc75c398..9b53fbfa71b 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -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) diff --git a/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts b/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts index 531f04ced21..f4912c06d2a 100644 --- a/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts +++ b/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts @@ -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 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 { - 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' } } diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index cdc174d66a4..c496ead624e 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 2e52e6f0748..aaf2521ccc1 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -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 { + 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 diff --git a/src/main/runtime/orca-runtime-launch-turn-start.test.ts b/src/main/runtime/orca-runtime-launch-turn-start.test.ts index 5e1c3d2c674..80d517ce0b5 100644 --- a/src/main/runtime/orca-runtime-launch-turn-start.test.ts +++ b/src/main/runtime/orca-runtime-launch-turn-start.test.ts @@ -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') + } + ) }) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index d60a9a443ac..645734f25dd 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -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 diff --git a/src/main/runtime/orchestration/spawn-cli-command-prediction.test.ts b/src/main/runtime/orchestration/spawn-cli-command-prediction.test.ts new file mode 100644 index 00000000000..b0d875eccaa --- /dev/null +++ b/src/main/runtime/orchestration/spawn-cli-command-prediction.test.ts @@ -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>()), + 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') + }) +}) diff --git a/src/main/runtime/orchestration/spawn-cli-command-prediction.ts b/src/main/runtime/orchestration/spawn-cli-command-prediction.ts new file mode 100644 index 00000000000..9a30309c4a5 --- /dev/null +++ b/src/main/runtime/orchestration/spawn-cli-command-prediction.ts @@ -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> +}): 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() + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts index ad8b37f6c10..5e301f13802 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts @@ -33,7 +33,6 @@ vi.mock('./orchestration/worker/worker-start-validation', () => ({ }) })) vi.mock('./orchestration/worker/worker-setup-gate', () => ({ - createSetupBeforeAgentGate: () => async () => {}, persistGatedSetupSpawnFailure: () => false, persistWorkerReadinessStage: () => {}, persistWorkerSetupWaitOutcome: () => {}, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index af624669575..a789bb10492 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts index 972ff237bf9..f630ceac34a 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts index 8ed8cb741aa..c3902150bf7 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts @@ -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() }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts index f93df6e4d7a..64760e0253c 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts @@ -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() vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index 26feaeb8467..cc3d25804e8 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -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() }) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 0bcb5286c64..3ff23ddb1b0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -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> | 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 }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-brief.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-brief.test.ts index 41e7c69e31f..bb674ab6066 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-brief.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-brief.test.ts @@ -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) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-brief.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-brief.ts index 3c14198421e..54875a83b44 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-brief.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-brief.ts @@ -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 +/** 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 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 { + 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 + } } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts index d45815d5731..014cd0500f7 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts index 0da7bb6303e..02f002af950 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts @@ -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() { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts index 54ebb9dfc98..079daee8103 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts @@ -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 - 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 { - 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) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts index d867a0c7dff..9a4473bcebc 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts @@ -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 } /** 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 { 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, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-structured-setup-gate.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-setup-gate.ts similarity index 69% rename from src/main/runtime/rpc/methods/orchestration/worker/worker-start-structured-setup-gate.ts rename to src/main/runtime/rpc/methods/orchestration/worker/worker-start-setup-gate.ts index d4068ac6776..d4d97db583e 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-structured-setup-gate.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-setup-gate.ts @@ -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 setup: WorkerSetupReceipt effects: WorkerEffect[] timeoutMs: number -}): Promise { +}): Promise { 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((resolve) => { + new Promise((resolve) => { timer = setTimeout(() => resolve({ satisfied: false, status: 'timeout' }), args.timeoutMs) }) ]) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts index 72a0c491c6a..89c358b894b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts @@ -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 { - 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 { await new Promise((resolve) => { const timer = setTimeout(done, ms) @@ -264,3 +229,17 @@ async function abortableDelay(ms: number, signal: AbortSignal): Promise { async function settledOrNull(promise: Promise): Promise { return await Promise.race([promise, Promise.resolve(null)]) } + +async function settledWithin(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), ms) + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts index 38c9ed99ada..49acb560f9d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts @@ -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, + handle: string +): Promise { + 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. * diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts index cbdab8d1f94..5a195380f40 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts @@ -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> @@ -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, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree-launch-brief.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree-launch-brief.test.ts new file mode 100644 index 00000000000..61dec622f70 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree-launch-brief.test.ts @@ -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) + ) + } + ) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test-support.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test-support.ts new file mode 100644 index 00000000000..a1c5c45efe2 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test-support.ts @@ -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 + ) => Promise<{ result: unknown; task: ReturnType }> +} + +/** 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 = {}) { + 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 } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index 23e70349014..d4de46551d7 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -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> @@ -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 = {}) { - 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>) => void) + let finishWait: + | ((value: Awaited>) => void) | undefined - let finishTurn: ((value: 'observed') => void) | undefined - vi.mocked(runtime.createTerminal).mockImplementationOnce( + let finishPrompt: + | ((value: Awaited>) => 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' } }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts index 711a8a878a6..a210041616a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts @@ -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() vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) const pending = h.startWorker({ agent }) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts index a80351c6af1..9ebd3001b9f 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -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' + }) }) }) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index ce332360730..674313b09dd 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -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 } : {}), diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 0a87b7ce7bf..fbdc956a932 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -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 diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.test.ts b/src/main/runtime/runtime-remote-managed-worktree-create.test.ts index f642a07aeab..b77cb420dfe 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.test.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.test.ts @@ -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> { +async function startupTerminalOptions( + startupPaneKey?: string, + startupTerminalHandle?: string +): Promise> { const { createTerminal, deps } = createDeps() await createRuntimeRemoteManagedWorktree( repo, @@ -60,7 +63,8 @@ async function startupTerminalOptions(startupPaneKey?: string): Promise { 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' + }) }) }) diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.ts b/src/main/runtime/runtime-remote-managed-worktree-create.ts index 0f55d3db59c..046da32a7b0 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.ts @@ -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 } : {}), diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 1af1b767335..7e92947d4af 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -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 diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index e369af607b2..b909c695644 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -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({ diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 7a7e255c1c5..03b9b778f53 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -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}.`) diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts index 4478e0d9e35..ff061ebb951 100644 --- a/tests/e2e/completed-worker-retirement-resume.unit.test.ts +++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts @@ -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',