diff --git a/src/main/agent-launch/agent-launch-decided-mode-contract.test.ts b/src/main/agent-launch/agent-launch-decided-mode-contract.test.ts new file mode 100644 index 00000000000..0286a613381 --- /dev/null +++ b/src/main/agent-launch/agent-launch-decided-mode-contract.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentLaunchExecution } from './agent-launch-executor' +import type { AgentLaunchModeReceipt } from './agent-launch-mode' +import type { AgentLaunchSurfaceFactory } from './agent-launch-surface-factories' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the execution's type is checked; nothing runs it. +const runtime = {} as AgentLaunchExecution['runtime'] +const decidedMode: AgentLaunchModeReceipt = { + mode: 'terminal', + preferred: 'terminal', + reason: 'user_default', + detail: 'Started a terminal agent, the default for new agent tabs in your settings.' +} + +describe('a caller-decided launch mode', () => { + it('cannot be combined with the pre-flight inputs it replaces', () => { + const intent = { + agent: 'claude' as const, + target: { kind: 'existing' as const, worktree: 'wt' } + } + const surfaces: AgentLaunchSurfaceFactory = { + createStructuredSession: vi.fn(), + createTerminalAgent: vi.fn() + } + const accepts = (execution: AgentLaunchExecution) => execution + // @ts-expect-error a decided mode already settled what terminalOnly would force + accepts({ runtime, intent, surfaces, decidedMode, terminalOnly: true }) + // @ts-expect-error a decided mode already settled what the caller's chat support would downgrade + accepts({ runtime, intent, surfaces, decidedMode, callerRendersStructured: false }) + expect(accepts({ runtime, intent, surfaces, decidedMode }).decidedMode).toBe(decidedMode) + expect(accepts({ runtime, intent, surfaces, terminalOnly: true }).terminalOnly).toBe(true) + }) +}) diff --git a/src/main/agent-launch/agent-launch-execution.ts b/src/main/agent-launch/agent-launch-execution.ts index d847d4ba3d7..4568c3a70bd 100644 --- a/src/main/agent-launch/agent-launch-execution.ts +++ b/src/main/agent-launch/agent-launch-execution.ts @@ -11,7 +11,7 @@ import type { AgentLaunchTarget } from '../../shared/agent-launch-intent' import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { AgentLaunchModeVocabulary } from './agent-launch-mode' +import type { AgentLaunchModeReceipt, AgentLaunchModeVocabulary } from './agent-launch-mode' import type { AgentLaunchSurfaceFactory, AgentLaunchWorkspaceFactory @@ -46,7 +46,19 @@ export type AgentLaunchSurfaceExecution = AgentLaunchExecutionBase & { terminalOnly?: boolean /** The launch's own delivery: argv only when the typed line carries it, else a paste. */ promptPolicy?: undefined -} +} & ( + | { decidedMode?: undefined } + | { + /** Host-internal: the pre-flight a caller decided itself because it records the receipt + * before the launch runs (an orchestration dispatch). For an existing workspace it must + * already carry that host's answer; the executor asks the host only about a workspace it + * creates. It replaces the pre-flight, so `terminalOnly` and `callerRendersStructured` are + * refused. Temporary until one planner settles every launch. */ + decidedMode: AgentLaunchModeReceipt + terminalOnly?: never + callerRendersStructured?: never + } + ) /** * Host-internal, never on the wire. `legacy-host` is `worktree.create`'s own delivery, kept for the @@ -64,6 +76,7 @@ export type AgentLaunchLegacyHostExecution = AgentLaunchExecutionBase & { terminalOnly: true workspaces: AgentLaunchWorkspaceFactory surfaces?: never + decidedMode?: never } export type AgentLaunchExecution = AgentLaunchSurfaceExecution | AgentLaunchLegacyHostExecution diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index a618c4171a1..618f1eb1e49 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -1,8 +1,8 @@ /** - * The one place an agent launch is sequenced — for the surfaces moved onto it: `agent.launch`, and + * The one place an agent launch is sequenced — for the surfaces moved onto it: `agent.launch`, * `worktree.create` (CLI and mobile create) through `createWorktreeWithStartupAgent`, whose - * `legacy-host` create still starts the agent itself. Orchestration dispatch and the desktop agent - * tab each still start agents their own way; moving them here is later stack work. + * `legacy-host` create still starts the agent itself, and orchestration workers, local and + * federated. The desktop agent tab still starts agents its own way; moving it here is later work. * * The mode decision is shared, not copied: `agent-launch-mode` owns it, and * `orchestration-worker-start-mode` is a thin adapter over it supplying orchestration's receipt @@ -81,22 +81,23 @@ export async function executeAgentLaunch( assertOpenCodeModelLaunchPreferencesAbsent(intent.agent, intent.sessionOptions) } const vocabulary = execution.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY - const settings = readAgentLaunchModeSettings(runtime) - const preflight = decideAgentLaunchMode({ - placement: { - agent: intent.agent, - workspaceKind: launchWorkspaceKind(intent.target), - ...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}), - ...(intent.cwd ? { cwd: intent.cwd } : {}), - ...(intent.target.kind === 'existing' && intent.target.workspacePath - ? { workspacePath: intent.target.workspacePath } - : {}), - ...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {}) - }, - settings, - ...(execution.terminalOnly ? { terminalOnly: true } : {}), - vocabulary - }) + const preflight = + execution.decidedMode ?? + decideAgentLaunchMode({ + placement: { + agent: intent.agent, + workspaceKind: launchWorkspaceKind(intent.target), + ...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}), + ...(intent.cwd ? { cwd: intent.cwd } : {}), + ...(intent.target.kind === 'existing' && intent.target.workspacePath + ? { workspacePath: intent.target.workspacePath } + : {}), + ...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {}) + }, + settings: readAgentLaunchModeSettings(runtime), + ...(execution.terminalOnly ? { terminalOnly: true } : {}), + vocabulary + }) // The create's startup terminal is this launch's only surface, and the create delivers the text. if (execution.promptPolicy === 'legacy-host') { assertLegacyHostTarget(execution) @@ -152,13 +153,17 @@ export async function executeAgentLaunch( } execution.onStage?.('mode_settle') - let settled = await resolveAgentLaunchModeOnHost( - runtime, - preflight, - placed.worktreeId, - intent.agent, - vocabulary - ) + // A caller's own decision about an existing workspace already carries that host's answer. + let settled = + execution.decidedMode && intent.target.kind === 'existing' + ? preflight + : await resolveAgentLaunchModeOnHost( + runtime, + preflight, + placed.worktreeId, + intent.agent, + vocabulary + ) execution.onStage?.('surface_create') let created: CreatedSurface @@ -228,7 +233,7 @@ async function resolveWorkspace( preflight: AgentLaunchModeReceipt ): Promise<{ worktreeId: string - connectionId: string | null | undefined + connectionId?: string | null startupTerminalHandle: string | undefined startupTerminalPaneKey?: string warning?: string @@ -275,7 +280,7 @@ export type CreatedSurface = { async function createSurface( execution: AgentLaunchSurfaceExecution, - workspace: { worktreeId: string; connectionId: string | null | undefined }, + workspace: { worktreeId: string; connectionId?: string | null }, settled: AgentLaunchModeReceipt ): Promise { const { intent, surfaces } = execution @@ -336,7 +341,7 @@ function terminalLaunchInputs(intent: AgentLaunchIntent) { */ async function createTerminalSurface( execution: AgentLaunchSurfaceExecution, - workspace: { worktreeId: string; connectionId: string | null | undefined } + workspace: { worktreeId: string; connectionId?: string | null } ): Promise { const { intent, surfaces } = execution const startupPrompt = argvLaunchPrompt(intent) diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 168c157b16f..c4b913180fa 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -123,8 +123,9 @@ export type AgentLaunchWorkspaceFactory = { options?: Readonly> }): Promise<{ worktreeId: string - /** The new workspace's SSH connection; `null` is local. Decides what its agent tab can show. */ - connectionId: string | null + /** The new workspace's SSH connection; `null` is local. The executor carries it but nothing + * reads it yet; absent when the factory did not resolve it. */ + connectionId?: string | null startupTerminalHandle: string | undefined /** The pane minted with the startup terminal, when the runtime reported one. */ startupTerminalPaneKey?: string diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 3b1bc09f51e..3fc96c595b7 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -85,7 +85,12 @@ export async function createStructuredWorkerSession(args: { options?: Readonly> /** Retried whenever the session's journal moves, which is the structured idle edge. */ onJournalActivity: (sessionId: string) => void -}): Promise<{ identity: StructuredWorkerIdentity; host: StructuredAgentSessionHost }> { +}): Promise<{ + identity: StructuredWorkerIdentity + host: StructuredAgentSessionHost + /** The lease the create was admitted at. */ + fence: number +}> { const sessionId = randomUUID() // Registered BEFORE the session is created, because `attach` is what spawns the provider child // and the child's environment is read from this registry at spawn time. Registering afterwards @@ -148,7 +153,7 @@ export async function createStructuredWorkerSession(args: { handle: identity.handle, disposeSubscription }) - return { identity, host } + return { identity, host, fence: created.value.fence } } catch (error) { // A start that fails after the session exists would otherwise strand a live provider child // that no dispatch owns and that nothing else in the runtime will ever retire. diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-agent-launch.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-agent-launch.test.ts new file mode 100644 index 00000000000..23176159713 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-agent-launch.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { WorkerSetupReceipt } from '../worker/worker-topology' +import { launchFederatedWorkerAgent } from './federated-worker-agent-launch' +import type { FederationEffect } from './federation-effects' +import { FederationAttachStartParams } from './federation-start-schema' + +function fakes(created: { startupTerminal?: { handle: string } } = {}) { + const stages: string[] = [] + const runtime = { + createManagedWorktree: vi.fn(async () => ({ + worktree: { id: 'repo::remote-wt', repoId: 'repo' }, + setupReceipt: { + hookFound: true, + startupPolicy: 'wait-for-setup', + state: 'running', + terminalHandle: 'term_setup' + }, + ...created + })), + listTerminals: vi.fn(async () => ({ + terminals: [ + { handle: 'term_setup', title: 'setup', tabId: 'tab_s', leafId: 'leaf_s' }, + { handle: 'term_agent', title: 'claude', tabId: 'tab_a', leafId: 'leaf_a' } + ] + })), + createTerminal: vi.fn(async () => ({ handle: 'term_worker' })), + // Chat default on: federation still starts a terminal and never asks about a session. + getClientSettings: vi.fn(() => ({ experimentalNativeChat: true })), + getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })) + } + const db = { + recordRemoteAttachmentStage: vi.fn((row: { stage: string }) => { + stages.push(row.stage) + }) + } + return { runtime, db, stages } +} + +const PARAMS = FederationAttachStartParams.parse({ + runId: 'run_home', + dispatchId: 'ctx_remote', + taskId: 'task_remote', + taskSpec: 'remote work', + protocolVersion: 3, + worktree: 'new-top-level', + repo: 'repo', + name: 'remote-wt', + agent: 'claude' +}) + +function launch(f: ReturnType, worktree?: { id: string }) { + const effects: FederationEffect[] = [] + const setups: WorkerSetupReceipt[] = [] + const stagesSeen: string[] = [] + const done = launchFederatedWorkerAgent({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch calls only the methods faked here. + runtime: f.runtime as unknown as OrcaRuntimeService, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch records only attachment stages. + db: f.db as unknown as OrchestrationDb, + params: PARAMS, + agent: 'claude', + launchPreferences: { model: 'opus' }, + ...(worktree ? { worktree } : {}), + setupSource: 'orchestration_default', + effects, + onSetup: (setup) => setups.push(setup), + onStage: (stage) => stagesSeen.push(stage) + }) + return { done, effects, setups, stagesSeen } +} + +describe('a federated worker through the launch executor', () => { + it('creates the new top-level worktree agent-first with the attachment’s own records', async () => { + const f = fakes({ startupTerminal: { handle: 'term_agent' } }) + const run = launch(f) + + await expect(run.done).resolves.toEqual({ + worktree: { id: 'repo::remote-wt', repoId: 'repo' }, + terminalHandle: 'term_agent' + }) + expect(f.runtime.createManagedWorktree).toHaveBeenCalledWith({ + repoSelector: 'repo', + name: 'remote-wt', + baseBranch: undefined, + displayName: undefined, + displayNameKind: undefined, + comment: undefined, + runHooks: false, + setupDecision: 'run', + awaitTerminalProvisioning: true, + observeSetupCompletion: true, + createdWithAgent: 'claude', + startupAgent: 'claude', + startupLaunchSource: 'orchestration', + startupLaunchPreferences: { model: 'opus' }, + activate: false, + lineage: { noParent: true } + }) + expect(f.stages).toEqual(['worktree_creating']) + expect(run.stagesSeen).toEqual(['worktree_create']) + expect(f.runtime.createTerminal).not.toHaveBeenCalled() + expect(f.runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled() + expect(run.effects).toEqual([ + { kind: 'worktree', action: 'created_top_level', id: 'repo::remote-wt' }, + { + kind: 'terminal', + role: 'setup', + action: 'created', + id: 'term_setup', + tabId: 'tab_s', + leafId: 'leaf_s' + }, + { + kind: 'terminal', + role: 'agent', + action: 'created', + id: 'term_agent', + tabId: 'tab_a', + leafId: 'leaf_a' + }, + { + kind: 'setup', + action: 'run', + requested: 'run', + effective: 'run', + source: 'orchestration_default', + hookFound: true, + startupPolicy: 'wait-for-setup', + state: 'running', + terminalId: 'term_setup' + } + ]) + }) + + it('reports the created worktree’s setup before failing on a missing agent terminal', async () => { + const f = fakes({}) + const run = launch(f) + + await expect(run.done).rejects.toThrow('Agent-first worktree creation returned no terminal.') + expect(run.setups).toEqual([ + { + requested: 'run', + effective: 'run', + source: 'orchestration_default', + hookFound: true, + startupPolicy: 'wait-for-setup', + state: 'running' + } + ]) + expect(f.runtime.createTerminal).not.toHaveBeenCalled() + }) + + it('opens a background worker- terminal in an existing worktree', async () => { + const f = fakes() + const run = launch(f, { id: 'folder:remote' }) + + await expect(run.done).resolves.toEqual({ + worktree: { id: 'folder:remote' }, + terminalHandle: 'term_worker' + }) + expect(f.runtime.createTerminal).toHaveBeenCalledWith('id:folder:remote', { + startupAgent: 'claude', + launchSource: 'orchestration', + launchPreferences: { model: 'opus' }, + title: 'worker-task_remote', + presentation: 'background' + }) + expect(run.effects).toEqual([ + { kind: 'terminal', role: 'agent', action: 'created', id: 'term_worker' } + ]) + expect(f.runtime.createManagedWorktree).not.toHaveBeenCalled() + expect(f.runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled() + expect(run.stagesSeen).toEqual(['terminal_create', 'terminal_create']) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-agent-launch.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-agent-launch.ts new file mode 100644 index 00000000000..3a43fa29801 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-agent-launch.ts @@ -0,0 +1,150 @@ +/** + * A federated worker's agent, started by the host that runs it through the shared executor. + * + * The receiving host owns execution, and federation creates terminal agents only, so the launch is + * `terminalOnly`. What makes it federation's is in the factories: a new top-level worktree created + * agent-first with the attachment's stages and effects, or a background `worker-` terminal in + * an existing one. The dispatch brief is a later turn, outside the launch. + */ + +import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority' +import type { TuiAgent } from '../../../../../../shared/tui-agent' +import { executeAgentLaunch } from '../../../../../agent-launch/agent-launch-executor' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import { WORKER_START_VOCABULARY } from '../../orchestration-worker-start-mode' +import type { WorkerSetupReceipt } from '../worker/worker-topology' +import { + appendFederationSetupEffect, + appendFederationTerminalEffects, + type FederationEffect +} from './federation-effects' +import type { FederationAttachStartInput } from './federation-start-schema' + +const TERMINAL_ONLY_MESSAGE = 'A federated worker starts a terminal agent only.' + +export async function launchFederatedWorkerAgent(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + params: FederationAttachStartInput + agent: TuiAgent | undefined + launchPreferences: AgentLaunchPreferences | undefined + /** The workspace to start in; absent creates the new top-level worktree the params describe. */ + worktree?: { id: string } + setupSource: string + effects: FederationEffect[] + /** The created worktree's setup receipt, as soon as it exists, so a later failure reports it. */ + onSetup: (setup: WorkerSetupReceipt) => void + onStage: (stage: 'worktree_create' | 'terminal_create') => void +}): Promise<{ worktree: { id: string }; terminalHandle: string }> { + const { runtime, db, params, effects } = args + if (!args.agent) { + // Validation refuses a fresh launch with no agent; this only narrows the type. + throw new Error( + 'A configured --agent is required when federated worker-start creates a terminal.' + ) + } + const agent = args.agent + let created: { id: string } | undefined + const launched = await executeAgentLaunch({ + runtime, + intent: { + agent, + // Federation's own factory builds the create from the attachment, so the target carries none. + target: args.worktree + ? { kind: 'existing', worktree: args.worktree.id } + : { kind: 'create-worktree', create: {} }, + launchSource: 'orchestration' + }, + terminalOnly: true, + vocabulary: WORKER_START_VOCABULARY, + onStage: (stage) => + args.onStage(stage === 'worktree_create' ? 'worktree_create' : 'terminal_create'), + workspaces: { + createWorktree: async ({ startupAgent }) => { + // Agent-first is the only create here; a structured pre-flight must not reach it. + if (startupAgent === undefined) { + throw new Error(TERMINAL_ONLY_MESSAGE) + } + const { repo, name } = params + if (!repo || !name) { + // Validation refuses this before the attachment exists; this only narrows the types. + throw new Error('A remote new-top-level worktree requires --name and an explicit --repo.') + } + db.recordRemoteAttachmentStage({ + dispatchId: params.dispatchId, + stage: 'worktree_creating' + }) + const setupDecision = params.setup ?? 'run' + const result = await runtime.createManagedWorktree({ + repoSelector: repo, + name, + baseBranch: params.baseBranch, + displayName: params.displayName, + displayNameKind: params.displayNameKind, + comment: params.comment, + // setupDecision runs setup without the legacy runHooks activation side effect. + runHooks: false, + setupDecision, + awaitTerminalProvisioning: true, + observeSetupCompletion: true, + createdWithAgent: agent, + startupAgent: agent, + startupLaunchSource: 'orchestration', + ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}), + activate: false, + lineage: { noParent: true } + }) + created = result.worktree + const terminalHandle = result.startupTerminal?.handle + effects.push({ kind: 'worktree', action: 'created_top_level', id: result.worktree.id }) + const setup: WorkerSetupReceipt = { + requested: setupDecision, + effective: setupDecision, + source: args.setupSource, + hookFound: result.setupReceipt?.hookFound ?? false, + startupPolicy: result.setupReceipt?.startupPolicy ?? 'start-immediately', + state: result.setupReceipt?.state ?? 'not_configured' + } + args.onSetup(setup) + if (!terminalHandle) { + throw new Error(result.warning ?? 'Agent-first worktree creation returned no terminal.') + } + const listed = await runtime.listTerminals(`id:${result.worktree.id}`, undefined, { + includeVisualLayouts: false + }) + appendFederationTerminalEffects( + effects, + listed.terminals, + terminalHandle, + result.setupReceipt?.terminalHandle + ) + appendFederationSetupEffect(effects, setup) + return { worktreeId: result.worktree.id, startupTerminalHandle: terminalHandle } + } + }, + surfaces: { + createStructuredSession: () => { + throw new Error(TERMINAL_ONLY_MESSAGE) + }, + createTerminalAgent: async ({ worktreeId }) => { + const terminal = await runtime.createTerminal(`id:${worktreeId}`, { + // Why: agent ids are not shell commands (`cursor` is the desktop app, + // its CLI is `cursor-agent`); resolve through the TUI agent config. + startupAgent: agent, + launchSource: 'orchestration', + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + title: `worker-${params.taskId}`, + presentation: 'background' + }) + effects.push({ kind: 'terminal', role: 'agent', action: 'created', id: terminal.handle }) + return { handle: terminal.handle } + } + } + }) + const worktree = args.worktree ?? created + if (!worktree) { + throw new Error('Federated worker topology did not resolve.') + } + return { worktree, terminalHandle: launched.outcome.handle } +} diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 2fb84779c6e..542ba45e958 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,5 +1,4 @@ import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' -import type { TuiAgent } from '../../../../../../shared/tui-agent' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { sendAgentTurn } from '../../../../orchestration/send-agent-turn' @@ -7,11 +6,8 @@ import { createWorkerBriefWriteGuard } from '../../../../launched-agent-write-gu import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { defineMethod } from '../../../core' import { assertOrchestrationWorktreeCreationSupported } from '../worker/folder-worktree-placement' -import { - appendFederationSetupEffect, - appendFederationTerminalEffects, - type FederationEffect -} from './federation-effects' +import type { FederationEffect } from './federation-effects' +import { launchFederatedWorkerAgent } from './federated-worker-agent-launch' import type { WorkerSetupReceipt } from '../worker/worker-topology' import { monitorFederatedSetup, @@ -96,61 +92,25 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ state: createsWorktree ? 'not_configured' : 'not_applicable' } try { - if (createsWorktree) { - db.recordRemoteAttachmentStage({ - dispatchId: params.dispatchId, - stage: 'worktree_creating' - }) - const setupDecision = params.setup ?? 'run' - const created = await runtime.createManagedWorktree({ - repoSelector: params.repo as string, - name: params.name as string, - baseBranch: params.baseBranch, - displayName: params.displayName, - displayNameKind: params.displayNameKind, - comment: params.comment, - // setupDecision runs setup without the legacy runHooks activation side effect. - runHooks: false, - setupDecision, - awaitTerminalProvisioning: true, - observeSetupCompletion: true, - createdWithAgent: agent as TuiAgent, - startupAgent: agent as TuiAgent, - startupLaunchSource: 'orchestration', - ...(launch.preferences ? { startupLaunchPreferences: launch.preferences } : {}), - activate: false, - lineage: { noParent: true } - }) - worktree = created.worktree - terminalHandle = created.startupTerminal?.handle - effects.push({ - kind: 'worktree', - action: 'created_top_level', - id: created.worktree.id - }) - setup = { - requested: setupDecision, - effective: setupDecision, - source: setupSource, - hookFound: created.setupReceipt?.hookFound ?? false, - startupPolicy: created.setupReceipt?.startupPolicy ?? 'start-immediately', - state: created.setupReceipt?.state ?? 'not_configured' - } - if (!terminalHandle) { - throw new Error( - created.warning ?? 'Agent-first worktree creation returned no terminal.' - ) - } - const listed = await runtime.listTerminals(`id:${created.worktree.id}`, undefined, { - includeVisualLayouts: false - }) - appendFederationTerminalEffects( + const launchAgent = (existing?: { id: string }) => + launchFederatedWorkerAgent({ + runtime, + db, + params, + agent, + launchPreferences: launch.preferences, + ...(existing ? { worktree: existing } : {}), + setupSource, effects, - listed.terminals, - terminalHandle, - created.setupReceipt?.terminalHandle - ) - appendFederationSetupEffect(effects, setup) + onSetup: (created) => { + setup = created + }, + onStage: (stage) => { + failedStage = stage + } + }) + if (createsWorktree) { + ;({ worktree, terminalHandle } = await launchAgent()) } else { worktree = await runtime.showManagedTerminalWorkspace(params.worktree).catch(() => { throw new OrchestrationError( @@ -184,22 +144,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ }) } else { failedStage = 'terminal_create' - const terminal = await runtime.createTerminal(`id:${worktree.id}`, { - // Why: agent ids are not shell commands (`cursor` is the desktop app, - // its CLI is `cursor-agent`); resolve through the TUI agent config. - startupAgent: agent as TuiAgent, - launchSource: 'orchestration', - ...(launch.preferences ? { launchPreferences: launch.preferences } : {}), - title: `worker-${params.taskId}`, - presentation: 'background' - }) - terminalHandle = terminal.handle - effects.push({ - kind: 'terminal', - role: 'agent', - action: 'created', - id: terminal.handle - }) + ;({ terminalHandle } = await launchAgent(worktree)) } } if (!worktree || !terminalHandle) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement-teardown.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement-teardown.test.ts new file mode 100644 index 00000000000..65ed0403d73 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement-teardown.test.ts @@ -0,0 +1,102 @@ +/** + * A structured worker whose launch throws after its session exists: the caller never gets a + * placement to tear down, so the placement itself must discard the session. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentLaunchModeReceipt } from '../../../../../agent-launch/agent-launch-mode' +import type { AgentLaunchExecution } from '../../../../../agent-launch/agent-launch-executor' +import { setStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type * as StructuredWorkerSessionModule from '../../orchestration-structured-worker-session' +import { structuredWorkerIdentities } from '../../../../structured-worker-identity' +import { WorkerStartParams } from './worker-start-schema' + +const discard = vi.hoisted(() => vi.fn(async (_sessionId: string) => {})) +const surfaced = vi.hoisted((): { sessionId: string | null } => ({ sessionId: null })) + +vi.mock('../../structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: async (args: { envelope: { sessionId: string } }) => ({ + ok: true, + value: { sessionId: args.envelope.sessionId, fence: 7 } + }) +})) + +vi.mock('../../orchestration-structured-worker-session', async (importOriginal) => ({ + ...(await importOriginal()), + discardStructuredWorkerSession: discard +})) + +// No executor step after the surface throws today, so the seam stands in for a future one. +vi.mock('../../../../../agent-launch/agent-launch-executor', () => ({ + executeAgentLaunch: async (execution: AgentLaunchExecution) => { + if (!execution.surfaces) { + throw new Error('a worker launch always brings its surfaces') + } + const session = await execution.surfaces.createStructuredSession({ + agent: 'claude', + worktreeId: 'wt_existing' + }) + surfaced.sessionId = session.sessionId + throw new Error('failed after the surface') + } +})) + +const { placeWorkerAgent } = await import('./worker-start-agent-placement') + +const STRUCTURED: AgentLaunchModeReceipt = { + mode: 'structured', + preferred: 'structured', + reason: 'user_default', + detail: + 'Started a structured chat session worker, the default for new agent tabs in your settings.' +} + +beforeEach(() => { + discard.mockClear() + surfaced.sessionId = null + structuredWorkerIdentities.clear() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worker create reads only `subscribe` and the record's location. + setStructuredAgentSessionHost({ + subscribe: () => () => {}, + deps: { + store: { getRecord: () => ({ location: { executionHostId: 'local', wslDistro: null } }) } + } + } as never) +}) + +describe('a worker launch that throws after its session exists', () => { + it('discards the session once before rethrowing', async () => { + const runtime = { + ensureStructuredAgentSessionHost: async () => {}, + forgetStructuredSessionMail: vi.fn() + } + const db = { recordWorkerStage: vi.fn() } + + await expect( + placeWorkerAgent({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the placement calls only the methods faked here. + runtime: runtime as unknown as OrcaRuntimeService, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the placement records only worker stages. + db: db as unknown as OrchestrationDb, + dispatchId: 'ctx_1', + taskId: 'task_1', + params: WorkerStartParams.parse({ from: 'term_coord', task: 'task_1' }), + requestedWorktree: 'id:wt_existing', + creationWorktree: undefined, + resolvedWorktree: { id: 'wt_existing', repoId: 'repo_1' }, + mode: STRUCTURED, + agent: 'claude', + launchPreferences: undefined, + effects: [], + onStage: () => {} + }) + ).rejects.toThrow('failed after the surface') + + expect(surfaced.sessionId).not.toBeNull() + expect(discard).toHaveBeenCalledTimes(1) + expect(discard).toHaveBeenCalledWith(surfaced.sessionId, runtime) + expect(structuredWorkerIdentities.list()).toEqual([]) + }) +}) 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 8d87ea894c8..075f4ec04bf 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 @@ -1,26 +1,29 @@ /** - * Where a worker's agent comes from: a worktree this start creates, a structured session, a new - * terminal in an existing worktree, or the terminal the caller passed. + * Where a worker's agent comes from: the terminal the caller passed, or a fresh launch through the + * shared executor — into a worktree this start creates or into an existing one. * - * A structured worker never takes the agent-first worktree path. `createWorkerWorktree` used to be - * the only way a new worktree was made, and it creates one WITH its startup agent terminal, which - * left the structured branch below it unreachable for every `--worktree new-child` dispatch. Here - * the worktree is created without a startup agent and the structured session is created for it - * afterwards — the same order the renderer's own structured worktree create uses. + * The executor owns the sequencing: a structured worker's worktree is created with no startup + * agent, the host is then asked about the workspace that now exists, and only then is the session + * created. What stays here is what makes it a worker: the worktree is created with the dispatch's + * lineage and effects, and the surface is a background session or a `worker-` terminal that + * does not take the sidebar. The mode was decided before the dispatch record existed, so it is + * handed in rather than decided again. * - * 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 refused structured session fails the start: the worker factory throws its own error, never the + * executor's downgrade signal, so a dispatch that asked for a chat does not quietly get a terminal. */ import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority' import type { TuiAgent } from '../../../../../../shared/tui-agent' +import { executeAgentLaunch } from '../../../../../agent-launch/agent-launch-executor' +import type { AgentLaunchSurfaceFactory } from '../../../../../agent-launch/agent-launch-surface-factories' import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' import { - resolveWorkerStartModeOnHost, + WORKER_START_VOCABULARY, type WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' +import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import type { WorkerStartInput } from './worker-start-schema' import { createExistingWorktreeWorkerTerminal, @@ -56,6 +59,7 @@ type WorkerAgentPlacementArgs = { creationWorktree: PlacedWorktree | undefined /** The already-resolved placement, present only when this start does not create one. */ resolvedWorktree: PlacedWorktree | undefined + /** Decided before the dispatch record; for an existing worktree, already settled on its host. */ mode: WorkerStartModeReceipt agent: TuiAgent | undefined launchPreferences: AgentLaunchPreferences | undefined @@ -78,11 +82,8 @@ export const EXISTING_WORKTREE_SETUP: WorkerSetupReceipt = { export async function placeWorkerAgent( args: WorkerAgentPlacementArgs ): Promise { - if (args.creationWorktree) { - return placeInCreatedWorktree(args, args.creationWorktree) - } - const worktree = requireWorktree(args.resolvedWorktree) - if (args.params.terminal) { + const existing = args.creationWorktree ? undefined : requireWorktree(args.resolvedWorktree) + if (existing && args.params.terminal) { args.effects.push({ kind: 'terminal', role: 'agent', @@ -91,92 +92,127 @@ export async function placeWorkerAgent( }) return { mode: args.mode, - worktree, + worktree: existing, terminalHandle: args.params.terminal, structuredSession: null, setupReceipt: EXISTING_WORKTREE_SETUP } } - return { - mode: args.mode, - worktree, - ...(await createWorkerAgentSurface(args, worktree.id, args.mode)), - setupReceipt: EXISTING_WORKTREE_SETUP - } + return launchWorkerAgent(args, existing) } -async function placeInCreatedWorktree( +async function launchWorkerAgent( args: WorkerAgentPlacementArgs, - coordinatorWorktree: PlacedWorktree + existing: PlacedWorktree | undefined ): Promise { - args.onStage('worktree_create') - const created = await createWorkerWorktree({ - runtime: args.runtime, - db: args.db, - dispatchId: args.dispatchId, - requestedWorktree: args.requestedWorktree, - coordinatorWorktree, - params: args.params, - agent: args.agent as TuiAgent, - withAgentTerminal: args.mode.mode !== 'structured', - ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), - effects: args.effects - }) - const worktree = requireWorktree(created.worktree) - if (args.mode.mode !== 'structured') { - return { - mode: args.mode, - worktree, - terminalHandle: requireTerminal(created.terminalHandle), - structuredSession: null, - setupReceipt: created.setupReceipt - } + const agent = requireAgent(args.agent) + let created: Awaited> | undefined + let structuredSession: WorkerStructuredSession | null = null + let launched: Awaited> + try { + launched = await executeAgentLaunch({ + runtime: args.runtime, + intent: { + agent, + // The worker's own factory builds the create from the dispatch, so the target carries none. + target: existing + ? { kind: 'existing', worktree: existing.id } + : { kind: 'create-worktree', create: {} }, + launchSource: 'orchestration' + }, + decidedMode: args.mode, + vocabulary: WORKER_START_VOCABULARY, + onStage: (stage) => + args.onStage(stage === 'worktree_create' ? 'worktree_create' : 'terminal_create'), + workspaces: { + createWorktree: async ({ startupAgent }) => { + created = await createWorkerWorktree({ + runtime: args.runtime, + db: args.db, + dispatchId: args.dispatchId, + requestedWorktree: args.requestedWorktree, + coordinatorWorktree: requireWorktree(args.creationWorktree), + params: args.params, + agent, + // The executor withholds the startup agent exactly when the worker is to be a session. + withAgentTerminal: startupAgent !== undefined, + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + effects: args.effects + }) + return { + worktreeId: created.worktree.id, + startupTerminalHandle: created.terminalHandle + } + } + }, + surfaces: workerSurfaceFactory(args, agent, (session) => { + structuredSession = session + }) + }) + } catch (error) { + // The caller only sees a returned placement, so a session made before this throw is ours. + await tearDownFailedWorkerStart({ + runtime: args.runtime, + structuredSession, + dispatchId: args.dispatchId + }) + throw error } - args.onStage('terminal_create') - const mode = await resolveWorkerStartModeOnHost(args.runtime, args.mode, worktree.id, args.agent) return { - mode, - worktree, - ...(await createWorkerAgentSurface(args, worktree.id, mode)), - setupReceipt: created.setupReceipt + mode: launched.receipt, + worktree: existing ?? requireWorktree(created?.worktree), + terminalHandle: launched.outcome.handle, + structuredSession, + setupReceipt: created?.setupReceipt ?? EXISTING_WORKTREE_SETUP, + ...(launched.warning ? { warning: launched.warning } : {}) } } -/** The agent surface for a worktree that exists; the settled mode picks which one. */ -async function createWorkerAgentSurface( +/** A worker's surface in a worktree that exists: a background session with the dispatch's redrive + * and mailbox, or a `worker-` terminal that leaves the sidebar where the user has it. */ +function workerSurfaceFactory( args: WorkerAgentPlacementArgs, - worktreeId: string, - mode: WorkerStartModeReceipt -): Promise> { - args.db.recordWorkerStage({ - dispatchId: args.dispatchId, - stage: 'terminal_creating', - worktreeId, - effects: args.effects - }) - if (mode.mode === 'structured') { - const structuredSession = await createStructuredWorkerSessionForWorktree({ - runtime: args.runtime, - worktreeId, - agent: args.agent as TuiAgent, + agent: TuiAgent, + onStructuredSession: (session: WorkerStructuredSession) => void +): AgentLaunchSurfaceFactory { + const recordSurfaceStage = (worktreeId: string): void => { + args.db.recordWorkerStage({ dispatchId: args.dispatchId, - ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + stage: 'terminal_creating', + worktreeId, effects: args.effects }) - return { terminalHandle: structuredSession.identity.handle, structuredSession } } - const terminal = await createExistingWorktreeWorkerTerminal({ - runtime: args.runtime, - worktreeId, - agent: args.agent as TuiAgent, - ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), - taskId: args.taskId, - effects: args.effects - }) return { - terminalHandle: terminal.handle, - structuredSession: null, - ...(terminal.warning ? { warning: terminal.warning } : {}) + createStructuredSession: async ({ worktreeId }) => { + recordSurfaceStage(worktreeId) + const session = await createStructuredWorkerSessionForWorktree({ + runtime: args.runtime, + worktreeId, + agent, + dispatchId: args.dispatchId, + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + effects: args.effects + }) + onStructuredSession(session) + return { + sessionId: session.identity.sessionId, + handle: session.identity.handle, + fence: session.fence + } + }, + createTerminalAgent: async ({ worktreeId }) => { + recordSurfaceStage(worktreeId) + const terminal = await createExistingWorktreeWorkerTerminal({ + runtime: args.runtime, + worktreeId, + agent, + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + taskId: args.taskId, + effects: args.effects + }) + return { handle: terminal.handle, ...(terminal.warning ? { warning: terminal.warning } : {}) } + } } } @@ -187,9 +223,10 @@ function requireWorktree(worktree: PlacedWorktree | undefined): PlacedWorktree { return worktree } -function requireTerminal(terminalHandle: string | undefined): string { - if (!terminalHandle) { - throw new Error('Worker topology did not resolve an agent terminal.') +/** Validation refuses a fresh launch with no agent; this only narrows the type. */ +function requireAgent(agent: TuiAgent | undefined): TuiAgent { + if (!agent) { + throw new Error('Worker topology did not resolve an agent.') } - return terminalHandle + return agent } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts new file mode 100644 index 00000000000..c63c60d65c7 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts @@ -0,0 +1,278 @@ +/** + * A fresh worker starts through the shared launch executor, and its durable dispatch records must + * read exactly as they did when the placement sequenced the launch itself: the same effects, the + * same stage rows in the same order, one host question, and a refused chat that fails the start. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + downgradeAgentLaunchModeForHost, + type AgentLaunchModeReceipt +} from '../../../../../agent-launch/agent-launch-mode' +import { setStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import { WORKER_START_VOCABULARY } from '../../orchestration-worker-start-mode' +import { structuredWorkerIdentities } from '../../../../structured-worker-identity' +import type { WorkerEffect } from './worker-topology' +import { WorkerStartParams } from './worker-start-schema' + +const sessionCreate = vi.hoisted((): { refusal: { code: string } | null } => ({ refusal: null })) + +vi.mock('../../structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: async (args: { envelope: { sessionId: string } }) => + sessionCreate.refusal + ? { ok: false, refusal: { code: sessionCreate.refusal.code, message: 'refused here' } } + : { ok: true, value: { sessionId: args.envelope.sessionId, fence: 7 } } +})) + +const { placeWorkerAgent } = await import('./worker-start-agent-placement') + +const STRUCTURED: AgentLaunchModeReceipt = { + mode: 'structured', + preferred: 'structured', + reason: 'user_default', + detail: + 'Started a structured chat session worker, the default for new agent tabs in your settings.' +} +const TERMINAL: AgentLaunchModeReceipt = { + mode: 'terminal', + preferred: 'terminal', + reason: 'user_default', + detail: 'Started a terminal agent worker, the default for new agent tabs in your settings.' +} +const COORDINATOR = { id: 'wt_coord', repoId: 'repo_1' } +const EXISTING = { id: 'wt_existing', repoId: 'repo_1' } + +function fakes() { + const stages: string[] = [] + const runtime = { + createManagedWorktree: vi.fn(async (args: { startupAgent?: string }) => ({ + worktree: { id: 'wt_new', repoId: 'repo_1' }, + ...(args.startupAgent ? { startupTerminal: { handle: 'term_startup' } } : {}) + })), + listTerminals: vi.fn(async (_selector: string) => ({ + terminals: [{ handle: 'term_startup', title: 'claude', tabId: 'tab_1', leafId: 'leaf_1' }] + })), + createTerminal: vi.fn(async () => ({ handle: 'term_worker', surface: 'background' })), + getStructuredAgentSessionCreateSupport: vi.fn( + async (): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> => ({ + supported: true + }) + ), + getClientSettings: vi.fn(() => ({})), + ensureStructuredAgentSessionHost: async () => {} + } + const db = { + recordWorkerStage: vi.fn((row: { stage: string }) => { + stages.push(row.stage) + }) + } + const failedStages: string[] = [] + const effects: WorkerEffect[] = [] + return { runtime, db, stages, failedStages, effects } +} + +function place( + f: ReturnType, + args: { mode: AgentLaunchModeReceipt; existing?: boolean; terminal?: string } +) { + return placeWorkerAgent({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the placement calls only the methods faked here. + runtime: f.runtime as unknown as OrcaRuntimeService, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the placement records only worker stages. + db: f.db as unknown as OrchestrationDb, + dispatchId: 'ctx_1', + taskId: 'task_1', + params: WorkerStartParams.parse({ + from: 'term_coord', + task: 'task_1', + ...(args.existing ? {} : { name: 'child' }), + ...(args.terminal ? { terminal: args.terminal } : {}) + }), + requestedWorktree: args.existing ? 'id:wt_existing' : 'new-child', + creationWorktree: args.existing ? undefined : COORDINATOR, + resolvedWorktree: args.existing ? EXISTING : undefined, + mode: args.mode, + agent: args.terminal ? undefined : 'claude', + launchPreferences: undefined, + effects: f.effects, + onStage: (stage) => f.failedStages.push(stage) + }) +} + +function installHost() { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worker create reads only `subscribe` and the record's location. + setStructuredAgentSessionHost({ + subscribe: () => () => {}, + deps: { + store: { getRecord: () => ({ location: { executionHostId: 'local', wslDistro: null } }) } + } + } as never) +} + +beforeEach(() => { + sessionCreate.refusal = null + structuredWorkerIdentities.clear() + installHost() +}) + +describe('a fresh worker through the launch executor', () => { + it('fails a refused structured worker in an existing worktree instead of starting a terminal', async () => { + const f = fakes() + sessionCreate.refusal = { code: 'structured_agent_session_unsupported' } + + await expect(place(f, { mode: STRUCTURED, existing: true })).rejects.toMatchObject({ + code: 'agent_unconfigured' + }) + expect(f.runtime.createTerminal).not.toHaveBeenCalled() + expect(f.stages).toEqual(['terminal_creating']) + }) + + it('fails a refused structured worker in the worktree it created, with no terminal', async () => { + const f = fakes() + sessionCreate.refusal = { code: 'structured_agent_session_unsupported' } + + await expect(place(f, { mode: STRUCTURED })).rejects.toMatchObject({ + code: 'agent_unconfigured' + }) + expect(f.runtime.createTerminal).not.toHaveBeenCalled() + expect(f.failedStages).toEqual(['worktree_create', 'terminal_create', 'terminal_create']) + }) + + it('does not ask the host again about an existing worktree whose mode it already settled', async () => { + const f = fakes() + + const placed = await place(f, { mode: STRUCTURED, existing: true }) + + expect(f.runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled() + expect(f.runtime.getClientSettings).not.toHaveBeenCalled() + expect(placed.mode).toBe(STRUCTURED) + expect(placed.structuredSession?.identity.handle).toBe(placed.terminalHandle) + expect(f.stages).toEqual(['terminal_creating']) + expect(f.effects).toEqual([ + { + kind: 'terminal', + role: 'agent', + action: 'created', + id: placed.terminalHandle, + surface: 'background' + } + ]) + }) + + it('creates a structured worker’s worktree with no agent, asks the host once, then records the surface stage', async () => { + const f = fakes() + + const placed = await place(f, { mode: STRUCTURED }) + + expect(f.runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.not.objectContaining({ startupAgent: expect.anything() }) + ) + expect(f.runtime.getStructuredAgentSessionCreateSupport).toHaveBeenCalledTimes(1) + expect(f.runtime.getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith( + 'id:wt_new', + 'claude' + ) + expect(f.stages).toEqual(['worktree_creating', 'worktree_created', 'terminal_creating']) + expect(f.failedStages).toEqual(['worktree_create', 'terminal_create', 'terminal_create']) + expect(placed.worktree.id).toBe('wt_new') + expect(placed.structuredSession).not.toBeNull() + }) + + it('starts a worker- terminal in the worktree it created when the host refuses a chat', async () => { + const f = fakes() + f.runtime.getStructuredAgentSessionCreateSupport.mockResolvedValue({ + supported: false, + reason: 'agent' + }) + + const placed = await place(f, { mode: STRUCTURED }) + + expect(f.runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.not.objectContaining({ startupAgent: expect.anything() }) + ) + expect(f.runtime.createTerminal).toHaveBeenCalledTimes(1) + expect(f.runtime.createTerminal).toHaveBeenCalledWith('id:wt_new', { + startupAgent: 'claude', + launchSource: 'orchestration', + title: 'worker-task_1', + surfaceOwner: false + }) + expect(f.stages).toEqual(['worktree_creating', 'worktree_created', 'terminal_creating']) + expect(f.failedStages).toEqual(['worktree_create', 'terminal_create', 'terminal_create']) + expect(placed.mode).toEqual( + downgradeAgentLaunchModeForHost( + STRUCTURED, + { supported: false, reason: 'agent' }, + WORKER_START_VOCABULARY + ) + ) + expect(placed.mode.reason).toBe('structured_unsupported_on_host') + expect(placed).toMatchObject({ terminalHandle: 'term_worker', structuredSession: null }) + expect(f.effects.at(-1)).toEqual({ + kind: 'terminal', + role: 'agent', + action: 'created', + id: 'term_worker', + surface: 'background', + warning: undefined + }) + }) + + it('creates a terminal worker’s worktree agent-first: no host question and no surface stage', async () => { + const f = fakes() + + const placed = await place(f, { mode: TERMINAL }) + + expect(f.runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ startupAgent: 'claude', startupLaunchSource: 'orchestration' }) + ) + expect(f.runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled() + expect(f.runtime.createTerminal).not.toHaveBeenCalled() + expect(f.stages).toEqual(['worktree_creating', 'worktree_created']) + expect(f.failedStages).toEqual(['worktree_create']) + expect(placed).toMatchObject({ + mode: TERMINAL, + worktree: { id: 'wt_new' }, + terminalHandle: 'term_startup', + structuredSession: null, + setupReceipt: { + requested: 'run', + effective: 'run', + source: 'orchestration_default', + hookFound: false, + startupPolicy: 'start-immediately', + state: 'not_configured' + } + }) + expect(placed).not.toHaveProperty('warning') + }) + + it('opens an existing worktree’s terminal worker as worker-, leaving the sidebar alone', async () => { + const f = fakes() + + const placed = await place(f, { mode: TERMINAL, existing: true }) + + expect(f.runtime.createTerminal).toHaveBeenCalledTimes(1) + expect(f.runtime.createTerminal).toHaveBeenCalledWith('id:wt_existing', { + startupAgent: 'claude', + launchSource: 'orchestration', + title: 'worker-task_1', + surfaceOwner: false + }) + expect(f.stages).toEqual(['terminal_creating']) + expect(placed).toMatchObject({ terminalHandle: 'term_worker', structuredSession: null }) + }) + + it('reuses --terminal without a launch', async () => { + const f = fakes() + + const placed = await place(f, { mode: TERMINAL, existing: true, terminal: 'term_running' }) + + expect(placed.terminalHandle).toBe('term_running') + expect(f.runtime.createTerminal).not.toHaveBeenCalled() + expect(f.runtime.createManagedWorktree).not.toHaveBeenCalled() + expect(f.stages).toEqual([]) + }) +})