mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
Merge latest origin/main into brennanb2025/mobile-bound-host-autoconnect
This commit is contained in:
@@ -275,6 +275,16 @@ describe('orchestration skill guidance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('documents per-invocation model and effort for supervised workers', () => {
|
||||
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
|
||||
|
||||
expect(workerLoop).toContain('opaque provider model id with `--model`')
|
||||
expect(workerLoop).toContain('`--effort` requires `--model`')
|
||||
expect(workerLoop).toContain('neither option can combine with `--terminal`')
|
||||
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
|
||||
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
|
||||
})
|
||||
|
||||
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
|
||||
@@ -192,6 +192,14 @@ orca orchestration worker-start --task <task_b> --worktree current --agent claud
|
||||
|
||||
`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal <handle>`.
|
||||
|
||||
For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-start --task <task_id> --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json
|
||||
```
|
||||
|
||||
`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.
|
||||
|
||||
For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:
|
||||
|
||||
```bash
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@ vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
import { printResult } from '../format'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
|
||||
describe('orchestration worker-start CLI contract', () => {
|
||||
beforeEach(() => {
|
||||
@@ -84,6 +85,63 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('capability-gates and forwards per-invocation launch preferences', async () => {
|
||||
callMock
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY]
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
runId: 'run_1',
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_1',
|
||||
state: 'ready',
|
||||
effects: [],
|
||||
residualResources: []
|
||||
}
|
||||
})
|
||||
|
||||
await invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['agent', 'claude'],
|
||||
['model', 'aws-bedrock-opus-5'],
|
||||
['effort', 'high'],
|
||||
['from', 'term_coord']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'status.get')
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'orchestration.workerStart',
|
||||
expect.objectContaining({
|
||||
agent: 'claude',
|
||||
model: 'aws-bedrock-opus-5',
|
||||
effort: 'high'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('fails before worker-start when the runtime would strip launch preferences', async () => {
|
||||
callMock.mockResolvedValueOnce({ result: { capabilities: [] } })
|
||||
|
||||
await expect(
|
||||
invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['agent', 'codex'],
|
||||
['model', 'gpt-5.6-sol'],
|
||||
['from', 'term_coord']
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'incompatible_runtime' })
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sets an unsuccessful exit code for failed and unknown receipts', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
|
||||
@@ -20,7 +20,8 @@ import type {
|
||||
OrchestrationWorkerReadSource
|
||||
} from '../../shared/orchestration-worker-output'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import type { RuntimeStatus, RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination'
|
||||
import {
|
||||
@@ -852,6 +853,21 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
|
||||
'orchestration worker-start': async ({ flags, client, cwd, json }) => {
|
||||
const model = getOptionalStringFlag(flags, 'model')
|
||||
const effort = getOptionalStringFlag(flags, 'effort')
|
||||
if (model || effort) {
|
||||
const status = await client.call<RuntimeStatus>('status.get')
|
||||
if (
|
||||
!status.result.capabilities?.includes(
|
||||
ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY
|
||||
)
|
||||
) {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
const result = await callMutation<{
|
||||
runId: string
|
||||
taskId: string
|
||||
@@ -873,6 +889,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
setup: getOptionalStringFlag(flags, 'setup'),
|
||||
agent: getOptionalStringFlag(flags, 'agent'),
|
||||
model,
|
||||
effort,
|
||||
terminal: getOptionalStringFlag(flags, 'terminal'),
|
||||
retryOf: getOptionalStringFlag(flags, 'retry-of'),
|
||||
timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'),
|
||||
|
||||
@@ -552,6 +552,8 @@ export function formatFlagHelp(flag: string): string {
|
||||
local: '--local Target the current project instead of the global install',
|
||||
skill: '--skill <name> Bundled skill to act on; repeat for several',
|
||||
mode: '--mode <mode> Mode such as edit, diff, or both',
|
||||
model: '--model <id> Provider model id for a new agent launch',
|
||||
effort: '--effort <level> Reasoning effort for the selected model',
|
||||
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
|
||||
modifiers: '--modifiers <chord> Modifier keys held only for this click',
|
||||
name: '--name <name> Name for the new worktree or automation',
|
||||
|
||||
@@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'worker-start'],
|
||||
summary: 'Start one supervised worker on the Run home or a connected Orca server',
|
||||
usage:
|
||||
'orca orchestration worker-start --task <task_id> [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
'orca orchestration worker-start --task <task_id> [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--model <id>] [--effort <level>] [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'task',
|
||||
@@ -18,6 +18,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
'comment',
|
||||
'setup',
|
||||
'agent',
|
||||
'model',
|
||||
'effort',
|
||||
'terminal',
|
||||
'retry-of',
|
||||
'timeout-ms',
|
||||
@@ -27,6 +29,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
notes: [
|
||||
'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.',
|
||||
'--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.',
|
||||
'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
|
||||
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
|
||||
'--on selects only the worker server; the Run and this command remain on the current Orca server.',
|
||||
|
||||
@@ -1293,6 +1293,7 @@ type TerminalCreateOptions = {
|
||||
// CLI is `cursor-agent`). Callers that know the agent name it here instead of
|
||||
// guessing a command, and the runtime builds the configured launch.
|
||||
startupAgent?: TuiAgent
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
@@ -20956,7 +20957,8 @@ export class OrcaRuntimeService {
|
||||
private buildStartupForAgent(
|
||||
repo: Repo,
|
||||
agent: TuiAgent,
|
||||
prompt: string | undefined
|
||||
prompt: string | undefined,
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -20974,12 +20976,15 @@ export class OrcaRuntimeService {
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
const sessionOptions = this.toAgentSessionOptions(launchPreferences)
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
prompt: prompt ?? '',
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
sessionOptions,
|
||||
sessionOptionsOverrideAgentArgs: Boolean(sessionOptions),
|
||||
platform: agentLaunchPlatform,
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
@@ -21418,6 +21423,7 @@ export class OrcaRuntimeService {
|
||||
observeSetupCompletion?: boolean
|
||||
createdWithAgent?: TuiAgent
|
||||
startupAgent?: TuiAgent
|
||||
startupLaunchPreferences?: AgentLaunchPreferences
|
||||
startupPrompt?: string
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
automationProvenance?: AutomationWorkspaceProvenance
|
||||
@@ -21450,7 +21456,12 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const agentStartup =
|
||||
!args.startup && args.startupAgent
|
||||
? this.buildStartupForAgent(repo, args.startupAgent, args.startupPrompt)
|
||||
? this.buildStartupForAgent(
|
||||
repo,
|
||||
args.startupAgent,
|
||||
args.startupPrompt,
|
||||
args.startupLaunchPreferences
|
||||
)
|
||||
: null
|
||||
const draftStartup =
|
||||
!args.startup && !agentStartup && args.startupDraft
|
||||
@@ -24619,12 +24630,15 @@ export class OrcaRuntimeService {
|
||||
return opts
|
||||
}
|
||||
|
||||
const sessionOptions = this.toAgentSessionOptions(opts.launchPreferences)
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
prompt: '',
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
sessionOptions,
|
||||
sessionOptionsOverrideAgentArgs: Boolean(sessionOptions),
|
||||
platform,
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
|
||||
@@ -11,6 +11,14 @@ import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { OrchestrationDb } from '../../orchestration/db'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
import type { WorkerStartInput } from './orchestration-worker-start-schema'
|
||||
import {
|
||||
assertWorkerLaunchPreferencesRuntimeSupported,
|
||||
assertWorkerLaunchPreferencesCreateTerminal,
|
||||
createPendingWorkerLaunchReceipt,
|
||||
resolveFederatedWorkerLaunchReceipt,
|
||||
type OrchestrationWorkerLaunchReceipt
|
||||
} from './orchestration-worker-launch-preferences'
|
||||
import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation'
|
||||
|
||||
export async function startFederatedWorker(args: {
|
||||
params: WorkerStartInput
|
||||
@@ -40,7 +48,13 @@ export async function startFederatedWorker(args: {
|
||||
)
|
||||
}
|
||||
const createsWorktree = worktree === 'new-top-level'
|
||||
validateRemoteWorkerStart(params, createsWorktree)
|
||||
assertWorkerLaunchPreferencesCreateTerminal(params)
|
||||
validateFederatedWorkerStartPlacement(params, createsWorktree)
|
||||
const requestedLaunch = createPendingWorkerLaunchReceipt({
|
||||
agent: isTuiAgent(params.agent) ? params.agent : null,
|
||||
model: params.model,
|
||||
effort: params.effort
|
||||
})
|
||||
|
||||
const server = runtime.resolveOrchestrationWorkerServer(params.on as string)
|
||||
const status = (await runtime.callOrchestrationWorkerServer(
|
||||
@@ -62,6 +76,12 @@ export async function startFederatedWorker(args: {
|
||||
`Connected server ${server.name} does not support orchestration federation.`
|
||||
)
|
||||
}
|
||||
assertWorkerLaunchPreferencesRuntimeSupported({
|
||||
model: params.model,
|
||||
effort: params.effort,
|
||||
capabilities: status.capabilities,
|
||||
serverName: server.name
|
||||
})
|
||||
const federationProtocolVersion = status.capabilities?.includes(
|
||||
ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY
|
||||
)
|
||||
@@ -81,6 +101,7 @@ export async function startFederatedWorker(args: {
|
||||
baseBranch: params.baseBranch ?? null,
|
||||
terminal: params.terminal ?? null,
|
||||
agent: params.agent ?? null,
|
||||
launch: requestedLaunch,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
setup: setupDecision,
|
||||
setupSource: createsWorktree
|
||||
@@ -122,6 +143,8 @@ export async function startFederatedWorker(args: {
|
||||
: undefined,
|
||||
terminal: params.terminal,
|
||||
agent: params.agent,
|
||||
model: params.model,
|
||||
effort: params.effort,
|
||||
timeoutMs: params.timeoutMs,
|
||||
devMode: params.devMode
|
||||
},
|
||||
@@ -134,6 +157,11 @@ export async function startFederatedWorker(args: {
|
||||
'The worker server returned a different Dispatch attachment.'
|
||||
)
|
||||
}
|
||||
const launch = resolveFederatedWorkerLaunchReceipt(
|
||||
remote.launch,
|
||||
requestedLaunch,
|
||||
remote.state === 'ready'
|
||||
)
|
||||
if (remote.state === 'ready' && remote.worktreeId && remote.terminalHandle) {
|
||||
db.updateFederatedDispatchResources({
|
||||
dispatchId: started.dispatch.id,
|
||||
@@ -160,6 +188,7 @@ export async function startFederatedWorker(args: {
|
||||
stage: readyWorker.stage,
|
||||
server: { environmentId: server.environmentId, name: server.name },
|
||||
setup: remote.setup,
|
||||
launch,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
effects: remote.effects ?? [],
|
||||
residualResources: remote.residualResources ?? []
|
||||
@@ -171,7 +200,7 @@ export async function startFederatedWorker(args: {
|
||||
remote.failedStage ?? 'remote_attach',
|
||||
remote.lastError ?? 'The worker server reported an unknown start outcome.'
|
||||
)
|
||||
return federatedUnknownReceipt(worker, task.id, server.name)
|
||||
return federatedUnknownReceipt(worker, task.id, server.name, launch)
|
||||
}
|
||||
const worker = db.failWorkerStart(
|
||||
started.dispatch.id,
|
||||
@@ -188,6 +217,7 @@ export async function startFederatedWorker(args: {
|
||||
failedStage: worker.stage,
|
||||
lastError: worker.last_error,
|
||||
setup: remote.setup,
|
||||
launch,
|
||||
effects: remote.effects ?? [],
|
||||
residualResources: remote.residualResources ?? []
|
||||
}
|
||||
@@ -204,12 +234,13 @@ export async function startFederatedWorker(args: {
|
||||
server: { environmentId: server.environmentId, name: server.name },
|
||||
failedStage: worker.stage,
|
||||
lastError: worker.last_error,
|
||||
launch: requestedLaunch,
|
||||
effects: [],
|
||||
residualResources: []
|
||||
}
|
||||
}
|
||||
const worker = db.markWorkerStartUnknown(started.dispatch.id, 'remote_attach', reason)
|
||||
return federatedUnknownReceipt(worker, task.id, server.name)
|
||||
return federatedUnknownReceipt(worker, task.id, server.name, requestedLaunch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,45 +251,13 @@ type RemoteStartReceipt = {
|
||||
worktreeId?: string
|
||||
terminalHandle?: string
|
||||
setup?: { state: string }
|
||||
launch?: OrchestrationWorkerLaunchReceipt
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
failedStage?: string
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
function validateRemoteWorkerStart(params: WorkerStartInput, createsWorktree: boolean): void {
|
||||
if (createsWorktree && (!params.name || !params.repo)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Remote new-top-level requires --name and an explicit --repo from remote discovery.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && params.terminal) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal cannot combine with remote new-worktree creation.'
|
||||
)
|
||||
}
|
||||
if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Creation and setup options apply only to remote new-top-level worktrees.'
|
||||
)
|
||||
}
|
||||
if (params.terminal && params.agent) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal reuses an existing agent and cannot combine with --agent.'
|
||||
)
|
||||
}
|
||||
if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) {
|
||||
throw new OrchestrationError(
|
||||
'agent_unconfigured',
|
||||
'A configured --agent is required when remote worker-start creates a terminal.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function isKnownRemoteStartFailure(code: string): boolean {
|
||||
return [
|
||||
'invalid_argument',
|
||||
@@ -272,7 +271,8 @@ function isKnownRemoteStartFailure(code: string): boolean {
|
||||
function federatedUnknownReceipt(
|
||||
worker: { dispatch_id: string; state: string; stage: string; last_error: string | null },
|
||||
taskId: string,
|
||||
serverName: string
|
||||
serverName: string,
|
||||
launch: OrchestrationWorkerLaunchReceipt
|
||||
): unknown {
|
||||
return {
|
||||
taskId,
|
||||
@@ -280,6 +280,7 @@ function federatedUnknownReceipt(
|
||||
state: 'outcome_unknown',
|
||||
stage: worker.stage,
|
||||
server: { name: serverName },
|
||||
launch,
|
||||
failedStage: worker.stage,
|
||||
lastError: worker.last_error,
|
||||
effects: [],
|
||||
|
||||
@@ -61,7 +61,9 @@ describe('federated worker agent launch', () => {
|
||||
taskSpec: 'remote cursor worker',
|
||||
protocolVersion: 1,
|
||||
worktree: 'id:repo::remote-worktree',
|
||||
agent: 'cursor'
|
||||
agent: 'cursor',
|
||||
model: 'gpt-5.3-codex',
|
||||
effort: 'high'
|
||||
}),
|
||||
{
|
||||
runtime,
|
||||
@@ -72,14 +74,28 @@ describe('federated worker agent launch', () => {
|
||||
payloadHash: 'remote_payload'
|
||||
}
|
||||
}
|
||||
)) as { state: string; failedStage?: string; lastError?: string }
|
||||
)) as {
|
||||
state: string
|
||||
failedStage?: string
|
||||
lastError?: string
|
||||
launch: unknown
|
||||
}
|
||||
|
||||
// Why: assert the worker actually reached ready — a spy-only assertion would
|
||||
// stay green even if every stage after terminal_create regressed.
|
||||
expect(result).toMatchObject({ state: 'ready' })
|
||||
expect(result).toMatchObject({
|
||||
state: 'ready',
|
||||
launch: {
|
||||
requested: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' },
|
||||
effective: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' }
|
||||
}
|
||||
})
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
'id:repo::remote-worktree',
|
||||
expect.objectContaining({ startupAgent: 'cursor' })
|
||||
expect.objectContaining({
|
||||
startupAgent: 'cursor',
|
||||
launchPreferences: { model: 'gpt-5.3-codex', effort: 'high' }
|
||||
})
|
||||
)
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
'id:repo::remote-worktree',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OrchestrationDb } from '../../orchestration/db'
|
||||
import { isFederationEffectUnknown } from './orchestration-federation-effects'
|
||||
import type { WorkerSetupReceipt } from './orchestration-worker-topology'
|
||||
import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences'
|
||||
|
||||
export function failFederatedAttachmentWithReceipt(args: {
|
||||
db: OrchestrationDb
|
||||
@@ -9,6 +10,7 @@ export function failFederatedAttachmentWithReceipt(args: {
|
||||
failedStage: string
|
||||
error: unknown
|
||||
setup: WorkerSetupReceipt
|
||||
launch: OrchestrationWorkerLaunchReceipt
|
||||
}): unknown {
|
||||
const reason = args.error instanceof Error ? args.error.message : String(args.error)
|
||||
const unknown = isFederationEffectUnknown(args.error, args.failedStage)
|
||||
@@ -26,6 +28,7 @@ export function failFederatedAttachmentWithReceipt(args: {
|
||||
failedStage: args.failedStage,
|
||||
lastError: reason,
|
||||
setup: args.setup,
|
||||
launch: args.launch,
|
||||
effects: JSON.parse(attachment.effects) as unknown[],
|
||||
residualResources: JSON.parse(attachment.residual_resources) as unknown[]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
import { OptionalWorkerLaunchPreference } from './orchestration-worker-start-schema'
|
||||
|
||||
export const FederationAttachStartParams = z.object({
|
||||
dispatchId: requiredString('Missing Dispatch ID'),
|
||||
@@ -16,6 +17,10 @@ export const FederationAttachStartParams = z.object({
|
||||
setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(),
|
||||
terminal: OptionalString,
|
||||
agent: OptionalString,
|
||||
model: OptionalWorkerLaunchPreference,
|
||||
effort: OptionalWorkerLaunchPreference,
|
||||
timeoutMs: OptionalFiniteNumber,
|
||||
devMode: z.boolean().optional()
|
||||
})
|
||||
|
||||
export type FederationAttachStartInput = z.infer<typeof FederationAttachStartParams>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version'
|
||||
import type { RpcRequest } from '../core'
|
||||
|
||||
export function createFederationWorkerStartRequest(
|
||||
taskId: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: 'rpc_worker_start',
|
||||
authToken: 'coordinator-token',
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: 'request_windows_worker',
|
||||
method: 'orchestration.workerStart',
|
||||
params: {
|
||||
task: taskId,
|
||||
from: 'term_coord',
|
||||
on: 'windows',
|
||||
worktree: 'new-top-level',
|
||||
repo: 'id:windows-repo',
|
||||
name: 'windows-audit',
|
||||
agent: 'codex',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationDb } from '../../orchestration/db'
|
||||
import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { ORCHESTRATION_METHODS } from './orchestration'
|
||||
import { createFederationWorkerStartRequest as startRequest } from './orchestration-federation-test-request'
|
||||
|
||||
describe('orchestration federation', () => {
|
||||
const databases: OrchestrationDb[] = []
|
||||
@@ -97,26 +97,6 @@ describe('orchestration federation', () => {
|
||||
return homeDb.createTask({ spec: 'Audit Windows behavior', runId: run.id })
|
||||
}
|
||||
|
||||
function startRequest(taskId: string, overrides: Record<string, unknown> = {}): RpcRequest {
|
||||
return {
|
||||
id: 'rpc_worker_start',
|
||||
authToken: 'coordinator-token',
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: 'request_windows_worker',
|
||||
method: 'orchestration.workerStart',
|
||||
params: {
|
||||
task: taskId,
|
||||
from: 'term_coord',
|
||||
on: 'windows',
|
||||
worktree: 'new-top-level',
|
||||
repo: 'id:windows-repo',
|
||||
name: 'windows-audit',
|
||||
agent: 'codex',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function configureWorkerRuntime(runtime: OrcaRuntimeService): void {
|
||||
vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {})
|
||||
vi.spyOn(runtime, 'showRepo').mockResolvedValue({
|
||||
@@ -230,6 +210,25 @@ describe('orchestration federation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not report remotely rejected preferences as effective', async () => {
|
||||
const task = createHomeTask()
|
||||
|
||||
const response = await homeDispatcher.dispatch(
|
||||
startRequest(task.id, { agent: 'grok', model: 'unsupported-model' })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
state: 'failed',
|
||||
launch: {
|
||||
requested: { agent: 'grok', model: 'unsupported-model', effort: null },
|
||||
effective: null
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves wait-for-setup gating on the connected worker server', async () => {
|
||||
vi.mocked(workerRuntime.createManagedWorktree).mockResolvedValueOnce({
|
||||
worktree: { id: 'repo::windows-worktree', repoId: 'repo' },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { buildDispatchPreamble } from '../../orchestration/preamble'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
} from './orchestration-federation-setup'
|
||||
import { FederationAttachStartParams } from './orchestration-federation-start-schema'
|
||||
import { failFederatedAttachmentWithReceipt } from './orchestration-federation-start-receipt'
|
||||
import { prepareFederationAttachmentWorkerStart } from './orchestration-worker-start-validation'
|
||||
|
||||
export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -37,43 +37,11 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
)
|
||||
}
|
||||
const createsWorktree = params.worktree === 'new-top-level'
|
||||
if (createsWorktree && (!params.name || !params.repo)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'A remote new-top-level worktree requires --name and an explicit --repo.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && params.terminal) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal cannot combine with remote new-worktree creation.'
|
||||
)
|
||||
}
|
||||
if (
|
||||
!createsWorktree &&
|
||||
(params.name || params.repo || params.baseBranch || params.setup || params.setupSource)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Creation and setup options apply only to remote new-top-level worktrees.'
|
||||
)
|
||||
}
|
||||
if (params.terminal && params.agent) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal reuses an existing agent and cannot combine with --agent.'
|
||||
)
|
||||
}
|
||||
const agent = params.agent
|
||||
if (!params.terminal && (!agent || !isTuiAgent(agent))) {
|
||||
throw new OrchestrationError(
|
||||
'agent_unconfigured',
|
||||
'A configured --agent is required when federated worker-start creates a terminal.'
|
||||
)
|
||||
}
|
||||
if (agent) {
|
||||
runtime.validateOrchestrationAgentLauncher(agent as TuiAgent)
|
||||
}
|
||||
const { agent, launch } = prepareFederationAttachmentWorkerStart({
|
||||
params,
|
||||
createsWorktree,
|
||||
runtime
|
||||
})
|
||||
if (createsWorktree) {
|
||||
await assertOrchestrationWorktreeCreationSupported({
|
||||
runtime,
|
||||
@@ -126,6 +94,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
observeSetupCompletion: true,
|
||||
createdWithAgent: agent as TuiAgent,
|
||||
startupAgent: agent as TuiAgent,
|
||||
...(launch.preferences ? { startupLaunchPreferences: launch.preferences } : {}),
|
||||
activate: false,
|
||||
lineage: { noParent: true }
|
||||
})
|
||||
@@ -196,6 +165,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
// 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,
|
||||
...(launch.preferences ? { launchPreferences: launch.preferences } : {}),
|
||||
title: `worker-${params.taskId}`,
|
||||
presentation: 'background'
|
||||
})
|
||||
@@ -284,6 +254,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
worktreeId: worktree.id,
|
||||
terminalHandle,
|
||||
setup,
|
||||
launch: launch.receipt,
|
||||
effects,
|
||||
residualResources: []
|
||||
}
|
||||
@@ -294,7 +265,8 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
runtimeEpoch: runtime.getRuntimeId(),
|
||||
failedStage,
|
||||
error,
|
||||
setup
|
||||
setup,
|
||||
launch: launch.receipt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
assertWorkerLaunchPreferencesCreateTerminal,
|
||||
assertWorkerLaunchPreferencesRuntimeSupported,
|
||||
createPendingWorkerLaunchReceipt,
|
||||
resolveFederatedWorkerLaunchReceipt,
|
||||
resolveWorkerLaunchPreferences
|
||||
} from './orchestration-worker-launch-preferences'
|
||||
import { WorkerStartParams } from './orchestration-worker-start-schema'
|
||||
|
||||
describe('orchestration worker launch preferences', () => {
|
||||
it('passes an opaque Claude model and portable effort through the shared catalog', () => {
|
||||
expect(
|
||||
resolveWorkerLaunchPreferences({
|
||||
agent: 'claude',
|
||||
model: 'aws-bedrock-opus-5',
|
||||
effort: 'high'
|
||||
})
|
||||
).toEqual({
|
||||
preferences: { model: 'aws-bedrock-opus-5', effort: 'high' },
|
||||
receipt: {
|
||||
requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' },
|
||||
effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not invent an effort when only a model is requested', () => {
|
||||
expect(
|
||||
resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.6-sol' }).preferences
|
||||
).toEqual({ model: 'gpt-5.6-sol' })
|
||||
})
|
||||
|
||||
it('rejects model-specific effort combinations the catalog disproves', () => {
|
||||
expect(() =>
|
||||
resolveWorkerLaunchPreferences({
|
||||
agent: 'codex',
|
||||
model: 'gpt-5.6-luna',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
).toThrow('does not support effort xhigh')
|
||||
})
|
||||
|
||||
it('rejects effort without a model', () => {
|
||||
expect(() => resolveWorkerLaunchPreferences({ agent: 'codex', effort: 'high' })).toThrow(
|
||||
'--effort requires --model'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects model selection for agents without a launch catalog', () => {
|
||||
expect(() =>
|
||||
resolveWorkerLaunchPreferences({ agent: 'grok', model: 'grok-code-fast-1' })
|
||||
).toThrow('does not support launch-time model selection')
|
||||
})
|
||||
|
||||
it('does not expose deprecated Gemini model selection to worker-start', () => {
|
||||
expect(() =>
|
||||
resolveWorkerLaunchPreferences({ agent: 'gemini', model: 'gemini-3-pro-preview' })
|
||||
).toThrow('does not support launch-time model selection')
|
||||
})
|
||||
|
||||
it('rejects preferences when reusing an existing terminal', () => {
|
||||
expect(() =>
|
||||
assertWorkerLaunchPreferencesCreateTerminal({
|
||||
terminal: 'term_existing',
|
||||
model: 'gpt-5.6-sol'
|
||||
})
|
||||
).toThrow('cannot be applied when reusing an existing terminal')
|
||||
})
|
||||
|
||||
it('requires remote capability support only for explicit preferences', () => {
|
||||
expect(() =>
|
||||
assertWorkerLaunchPreferencesRuntimeSupported({
|
||||
model: 'gpt-5.6-sol',
|
||||
capabilities: [],
|
||||
serverName: 'windows'
|
||||
})
|
||||
).toThrow('does not support worker model or effort overrides')
|
||||
expect(() =>
|
||||
assertWorkerLaunchPreferencesRuntimeSupported({
|
||||
capabilities: [],
|
||||
serverName: 'windows'
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertWorkerLaunchPreferencesRuntimeSupported({
|
||||
model: 'gpt-5.6-sol',
|
||||
capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY],
|
||||
serverName: 'windows'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('uses the requested launch receipt when an older worker omits it', () => {
|
||||
const requested = createPendingWorkerLaunchReceipt({
|
||||
agent: 'codex',
|
||||
model: 'gpt-5.6-sol',
|
||||
effort: 'high'
|
||||
})
|
||||
|
||||
expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, true)).toEqual({
|
||||
requested: requested.requested,
|
||||
effective: requested.requested
|
||||
})
|
||||
expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, false)).toBe(requested)
|
||||
})
|
||||
|
||||
it.each([' custom-model', 'custom-model '])(
|
||||
'rejects model ids with surrounding whitespace: %j',
|
||||
(model) => {
|
||||
expect(WorkerStartParams.safeParse({ task: 'task_1', agent: 'codex', model }).success).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('bounds opaque launch preferences', () => {
|
||||
expect(
|
||||
WorkerStartParams.safeParse({
|
||||
task: 'task_1',
|
||||
agent: 'codex',
|
||||
model: 'm'.repeat(513)
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
WorkerStartParams.safeParse({
|
||||
task: 'task_1',
|
||||
agent: 'codex',
|
||||
model: 'custom-model',
|
||||
effort: 'e'.repeat(513)
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority'
|
||||
import {
|
||||
findCatalogModel,
|
||||
findCatalogOption,
|
||||
getAgentSessionOptionCatalog
|
||||
} from '../../../../shared/agent-session-option-catalog'
|
||||
import { resolveAgentSessionOptionLaunch } from '../../../../shared/agent-session-option-launch'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
|
||||
export type OrchestrationWorkerLaunchSelection = {
|
||||
agent: TuiAgent | null
|
||||
model: string | null
|
||||
effort: string | null
|
||||
}
|
||||
|
||||
export type OrchestrationWorkerLaunchReceipt = {
|
||||
requested: OrchestrationWorkerLaunchSelection
|
||||
effective: OrchestrationWorkerLaunchSelection | null
|
||||
}
|
||||
|
||||
export function createWorkerLaunchReceipt(args: {
|
||||
agent: TuiAgent | null
|
||||
model?: string
|
||||
effort?: string
|
||||
}): OrchestrationWorkerLaunchReceipt {
|
||||
const selection = {
|
||||
agent: args.agent,
|
||||
model: args.model ?? null,
|
||||
effort: args.effort ?? null
|
||||
}
|
||||
return { requested: selection, effective: { ...selection } }
|
||||
}
|
||||
|
||||
export function createPendingWorkerLaunchReceipt(args: {
|
||||
agent: TuiAgent | null
|
||||
model?: string
|
||||
effort?: string
|
||||
}): OrchestrationWorkerLaunchReceipt {
|
||||
return {
|
||||
requested: {
|
||||
agent: args.agent,
|
||||
model: args.model ?? null,
|
||||
effort: args.effort ?? null
|
||||
},
|
||||
effective: null
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWorkerLaunchPreferences(args: {
|
||||
agent: TuiAgent
|
||||
model?: string
|
||||
effort?: string
|
||||
}): {
|
||||
preferences: AgentLaunchPreferences | undefined
|
||||
receipt: OrchestrationWorkerLaunchReceipt
|
||||
} {
|
||||
if (args.effort && !args.model) {
|
||||
throw new OrchestrationError('invalid_argument', '--effort requires --model.')
|
||||
}
|
||||
if (!args.model) {
|
||||
return {
|
||||
preferences: undefined,
|
||||
receipt: createWorkerLaunchReceipt({ agent: args.agent })
|
||||
}
|
||||
}
|
||||
|
||||
const catalog = getAgentSessionOptionCatalog(args.agent)
|
||||
if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} does not support launch-time model selection.`
|
||||
)
|
||||
}
|
||||
|
||||
if (args.effort) {
|
||||
const model = findCatalogModel(catalog, args.model)
|
||||
const option =
|
||||
findCatalogOption(model, 'effort') ??
|
||||
(!model
|
||||
? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort')
|
||||
: undefined)
|
||||
if (
|
||||
option?.kind.type !== 'select' ||
|
||||
!option.kind.choices.some((choice) => choice.value === args.effort)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} model ${args.model} does not support effort ${args.effort}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const requested = {
|
||||
model: args.model,
|
||||
...(args.effort ? { effort: args.effort } : {})
|
||||
}
|
||||
const resolved = resolveAgentSessionOptionLaunch(args.agent, requested, [], false)
|
||||
if (
|
||||
resolved.appliedValues.model !== args.model ||
|
||||
resolved.appliedValues.effort !== args.effort
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} cannot apply the requested worker launch preferences.`
|
||||
)
|
||||
}
|
||||
|
||||
const preferences: AgentLaunchPreferences = requested
|
||||
return {
|
||||
preferences,
|
||||
receipt: createWorkerLaunchReceipt({ agent: args.agent, ...preferences })
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerLaunchPreferencesCreateTerminal(args: {
|
||||
terminal?: string
|
||||
model?: string
|
||||
effort?: string
|
||||
}): void {
|
||||
if (args.terminal && (args.model || args.effort)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--model and --effort cannot be applied when reusing an existing terminal.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerLaunchPreferencesRuntimeSupported(args: {
|
||||
model?: string
|
||||
effort?: string
|
||||
capabilities?: readonly string[]
|
||||
serverName: string
|
||||
}): void {
|
||||
if (
|
||||
(args.model || args.effort) &&
|
||||
!args.capabilities?.includes(ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'capability_unsupported',
|
||||
`Connected server ${args.serverName} does not support worker model or effort overrides.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFederatedWorkerLaunchReceipt(
|
||||
remote: OrchestrationWorkerLaunchReceipt | undefined,
|
||||
requested: OrchestrationWorkerLaunchReceipt,
|
||||
remoteReady: boolean
|
||||
): OrchestrationWorkerLaunchReceipt {
|
||||
if (remote) {
|
||||
return remote
|
||||
}
|
||||
return remoteReady
|
||||
? { requested: requested.requested, effective: { ...requested.requested } }
|
||||
: requested
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isUnknownWorkerStartOutcome,
|
||||
type WorkerSetupReceipt
|
||||
} from './orchestration-worker-topology'
|
||||
import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences'
|
||||
|
||||
export function failWorkerStartWithReceipt(args: {
|
||||
db: OrchestrationDb
|
||||
@@ -12,6 +13,7 @@ export function failWorkerStartWithReceipt(args: {
|
||||
failedStage: string
|
||||
error: unknown
|
||||
setup: WorkerSetupReceipt
|
||||
launch: OrchestrationWorkerLaunchReceipt
|
||||
}): unknown {
|
||||
const reason = args.error instanceof Error ? args.error.message : String(args.error)
|
||||
const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage)
|
||||
@@ -27,6 +29,7 @@ export function failWorkerStartWithReceipt(args: {
|
||||
failedStage: args.failedStage,
|
||||
lastError: reason,
|
||||
setup: args.setup,
|
||||
launch: args.launch,
|
||||
effects: JSON.parse(worker.effects) as unknown[],
|
||||
residualResources: JSON.parse(worker.residual_resources) as unknown[],
|
||||
...(unknown
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
export const OptionalWorkerLaunchPreference = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.refine((value) => value === value.trim(), 'Surrounding whitespace is invalid')
|
||||
.optional()
|
||||
|
||||
export const WorkerStartParams = z.object({
|
||||
task: requiredString('Missing --task'),
|
||||
on: OptionalString,
|
||||
@@ -15,6 +22,8 @@ export const WorkerStartParams = z.object({
|
||||
setup: z.enum(['run', 'skip', 'inherit']).optional(),
|
||||
terminal: OptionalString,
|
||||
agent: OptionalString,
|
||||
model: OptionalWorkerLaunchPreference,
|
||||
effort: OptionalWorkerLaunchPreference,
|
||||
retryOf: OptionalString,
|
||||
timeoutMs: OptionalFiniteNumber,
|
||||
devMode: z.boolean().optional()
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
import type { FederationAttachStartInput } from './orchestration-federation-start-schema'
|
||||
import {
|
||||
assertWorkerLaunchPreferencesCreateTerminal,
|
||||
createWorkerLaunchReceipt,
|
||||
resolveWorkerLaunchPreferences
|
||||
} from './orchestration-worker-launch-preferences'
|
||||
import type { WorkerStartInput } from './orchestration-worker-start-schema'
|
||||
|
||||
type WorkerStartLaunch = ReturnType<typeof resolveWorkerLaunchPreferences>
|
||||
|
||||
export function validateFederatedWorkerStartPlacement(
|
||||
params: WorkerStartInput,
|
||||
createsWorktree: boolean
|
||||
): void {
|
||||
if (createsWorktree && (!params.name || !params.repo)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Remote new-top-level requires --name and an explicit --repo from remote discovery.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && params.terminal) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal cannot combine with remote new-worktree creation.'
|
||||
)
|
||||
}
|
||||
if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Creation and setup options apply only to remote new-top-level worktrees.'
|
||||
)
|
||||
}
|
||||
if (params.terminal && params.agent) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal reuses an existing agent and cannot combine with --agent.'
|
||||
)
|
||||
}
|
||||
if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) {
|
||||
throw new OrchestrationError(
|
||||
'agent_unconfigured',
|
||||
'A configured --agent is required when remote worker-start creates a terminal.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareLocalWorkerStart(args: {
|
||||
params: WorkerStartInput
|
||||
createsWorktree: boolean
|
||||
runtime: OrcaRuntimeService
|
||||
}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } {
|
||||
const { params, createsWorktree, runtime } = args
|
||||
assertWorkerLaunchPreferencesCreateTerminal(params)
|
||||
if (params.terminal && params.agent) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal reuses an existing agent and cannot combine with --agent.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && params.terminal) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal cannot combine with new-worktree creation.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && !params.name) {
|
||||
throw new OrchestrationError('invalid_argument', 'New worktrees require --name.')
|
||||
}
|
||||
if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Creation and setup options apply only to new-child or new-top-level worktrees.'
|
||||
)
|
||||
}
|
||||
return resolveWorkerStartAgent({
|
||||
runtime,
|
||||
terminal: params.terminal,
|
||||
agent: params.agent,
|
||||
model: params.model,
|
||||
effort: params.effort,
|
||||
missingAgentMessage: 'A configured --agent is required when worker-start creates a terminal.'
|
||||
})
|
||||
}
|
||||
|
||||
export function prepareFederationAttachmentWorkerStart(args: {
|
||||
params: FederationAttachStartInput
|
||||
createsWorktree: boolean
|
||||
runtime: OrcaRuntimeService
|
||||
}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } {
|
||||
const { params, createsWorktree, runtime } = args
|
||||
assertWorkerLaunchPreferencesCreateTerminal(params)
|
||||
if (createsWorktree && (!params.name || !params.repo)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'A remote new-top-level worktree requires --name and an explicit --repo.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && params.terminal) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal cannot combine with remote new-worktree creation.'
|
||||
)
|
||||
}
|
||||
if (
|
||||
!createsWorktree &&
|
||||
(params.name || params.repo || params.baseBranch || params.setup || params.setupSource)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Creation and setup options apply only to remote new-top-level worktrees.'
|
||||
)
|
||||
}
|
||||
if (params.terminal && params.agent) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal reuses an existing agent and cannot combine with --agent.'
|
||||
)
|
||||
}
|
||||
return resolveWorkerStartAgent({
|
||||
runtime,
|
||||
terminal: params.terminal,
|
||||
agent: params.agent,
|
||||
model: params.model,
|
||||
effort: params.effort,
|
||||
missingAgentMessage:
|
||||
'A configured --agent is required when federated worker-start creates a terminal.'
|
||||
})
|
||||
}
|
||||
|
||||
function resolveWorkerStartAgent(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
terminal?: string
|
||||
agent?: string
|
||||
model?: string
|
||||
effort?: string
|
||||
missingAgentMessage: string
|
||||
}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } {
|
||||
if (!args.terminal && (!args.agent || !isTuiAgent(args.agent))) {
|
||||
throw new OrchestrationError('agent_unconfigured', args.missingAgentMessage)
|
||||
}
|
||||
const agent = args.agent as TuiAgent | undefined
|
||||
if (agent) {
|
||||
args.runtime.validateOrchestrationAgentLauncher(agent)
|
||||
return {
|
||||
agent,
|
||||
launch: resolveWorkerLaunchPreferences({
|
||||
agent,
|
||||
model: args.model,
|
||||
effort: args.effort
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
agent: undefined,
|
||||
launch: {
|
||||
preferences: undefined,
|
||||
receipt: createWorkerLaunchReceipt({ agent: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { OrchestrationDb } from '../../orchestration/db'
|
||||
@@ -56,6 +57,7 @@ export async function createExistingWorktreeWorkerTerminal(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
worktreeId: string
|
||||
agent: TuiAgent
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
taskId: string
|
||||
effects: WorkerEffect[]
|
||||
}): Promise<{ handle: string; warning?: string }> {
|
||||
@@ -64,6 +66,7 @@ export async function createExistingWorktreeWorkerTerminal(args: {
|
||||
// desktop app while its CLI is `cursor-agent`. Let the runtime build the
|
||||
// configured launcher instead of executing the raw id.
|
||||
startupAgent: args.agent,
|
||||
...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}),
|
||||
title: `worker-${args.taskId}`,
|
||||
// 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.
|
||||
@@ -117,6 +120,7 @@ export async function createWorkerWorktree(args: {
|
||||
from: string
|
||||
}
|
||||
agent: TuiAgent
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
effects: WorkerEffect[]
|
||||
}): Promise<{
|
||||
worktree: Awaited<ReturnType<OrcaRuntimeService['showManagedWorktree']>>
|
||||
@@ -139,6 +143,7 @@ export async function createWorkerWorktree(args: {
|
||||
observeSetupCompletion: true,
|
||||
createdWithAgent: args.agent,
|
||||
startupAgent: args.agent,
|
||||
...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}),
|
||||
activate: false,
|
||||
lineage: {
|
||||
parentWorktree: requestedWorktree === 'new-child' ? coordinatorWorktree.id : undefined,
|
||||
|
||||
@@ -157,6 +157,29 @@ describe('orchestration new-worktree workers', () => {
|
||||
expect(runtime.createTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes launch preferences into agent-first worktree creation', async () => {
|
||||
mockCreatedWorktree()
|
||||
|
||||
const { result } = await startWorker({
|
||||
model: 'custom-codex-model',
|
||||
effort: 'high'
|
||||
})
|
||||
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
startupAgent: 'codex',
|
||||
startupLaunchPreferences: { model: 'custom-codex-model', effort: 'high' }
|
||||
})
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
state: 'ready',
|
||||
launch: {
|
||||
requested: { agent: 'codex', model: 'custom-codex-model', effort: 'high' },
|
||||
effective: { agent: 'codex', model: 'custom-codex-model', effort: 'high' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a new worktree for a folder project before creating effects', async () => {
|
||||
vi.mocked(runtime.showRepo).mockResolvedValue({
|
||||
id: 'repo',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { buildDispatchPreamble } from '../../orchestration/preamble'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
persistWorkerSetupWaitOutcome
|
||||
} from './orchestration-worker-setup-gate'
|
||||
import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt'
|
||||
import { prepareLocalWorkerStart } from './orchestration-worker-start-validation'
|
||||
|
||||
export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -57,37 +57,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
const requestedWorktree = params.worktree ?? 'current'
|
||||
const createsWorktree =
|
||||
requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level'
|
||||
if (params.terminal && params.agent) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal reuses an existing agent and cannot combine with --agent.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && params.terminal) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal cannot combine with new-worktree creation.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && !params.name) {
|
||||
throw new OrchestrationError('invalid_argument', 'New worktrees require --name.')
|
||||
}
|
||||
if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Creation and setup options apply only to new-child or new-top-level worktrees.'
|
||||
)
|
||||
}
|
||||
const agent = params.agent
|
||||
if (!params.terminal && (!agent || !isTuiAgent(agent))) {
|
||||
throw new OrchestrationError(
|
||||
'agent_unconfigured',
|
||||
'A configured --agent is required when worker-start creates a terminal.'
|
||||
)
|
||||
}
|
||||
if (agent) {
|
||||
runtime.validateOrchestrationAgentLauncher(agent as TuiAgent)
|
||||
}
|
||||
const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime })
|
||||
|
||||
const coordinatorTerminal = await runtime.showTerminal(params.from)
|
||||
const coordinatorWorktree = await runtime.showManagedWorktree(
|
||||
@@ -130,6 +100,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
baseBranch: params.baseBranch ?? null,
|
||||
terminal: params.terminal ?? null,
|
||||
agent: agent ?? null,
|
||||
launch: launch.receipt,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable',
|
||||
setupSource: createsWorktree
|
||||
@@ -174,6 +145,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
coordinatorWorktree,
|
||||
params,
|
||||
agent: agent as TuiAgent,
|
||||
launchPreferences: launch.preferences,
|
||||
effects
|
||||
})
|
||||
resolvedWorktree = created.worktree
|
||||
@@ -190,6 +162,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
runtime,
|
||||
worktreeId: resolvedWorktree!.id,
|
||||
agent: agent as TuiAgent,
|
||||
launchPreferences: launch.preferences,
|
||||
taskId: task.id,
|
||||
effects
|
||||
})
|
||||
@@ -281,6 +254,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
state: worker.state,
|
||||
stage: worker.stage,
|
||||
setup: setupReceipt,
|
||||
launch: launch.receipt,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
effects,
|
||||
residualResources: [],
|
||||
@@ -294,7 +268,8 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
dispatchId: started.dispatch.id,
|
||||
failedStage,
|
||||
error,
|
||||
setup: setupReceipt
|
||||
setup: setupReceipt,
|
||||
launch: launch.receipt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2235,6 +2235,61 @@ describe('orchestration RPC methods', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('applies and reports opaque per-invocation model preferences', async () => {
|
||||
setup()
|
||||
mockCurrentWorkerStart()
|
||||
const task = db.createTask({ spec: 'launch a custom model' })
|
||||
|
||||
const result = (await call('orchestration.workerStart', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
agent: 'claude',
|
||||
model: 'aws-bedrock-opus-5',
|
||||
effort: 'high'
|
||||
})) as {
|
||||
dispatchId: string
|
||||
state: string
|
||||
launch: {
|
||||
requested: { agent: string; model: string; effort: string }
|
||||
effective: { agent: string; model: string; effort: string }
|
||||
}
|
||||
}
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'ready',
|
||||
launch: {
|
||||
requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' },
|
||||
effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }
|
||||
}
|
||||
})
|
||||
expect(runtime.createTerminal).toHaveBeenCalledWith(
|
||||
'id:repo::worktree',
|
||||
expect.objectContaining({
|
||||
startupAgent: 'claude',
|
||||
launchPreferences: { model: 'aws-bedrock-opus-5', effort: 'high' }
|
||||
})
|
||||
)
|
||||
expect(JSON.parse(db.getWorkerDispatch(result.dispatchId)!.start_options)).toMatchObject({
|
||||
launch: result.launch
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects launch preferences for an existing terminal before creating a Dispatch', async () => {
|
||||
setup()
|
||||
mockCurrentWorkerStart()
|
||||
const task = db.createTask({ spec: 'reuse exact worker' })
|
||||
|
||||
await expect(
|
||||
call('orchestration.workerStart', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_worker',
|
||||
model: 'gpt-5.6-sol'
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'invalid_argument' })
|
||||
expect(db.getDispatchContext(task.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why: `cursor` on PATH is the Cursor desktop app; passing the agent id as a
|
||||
// shell command opened the IDE and left a blank shell (issue #11926).
|
||||
it('never passes the agent id to the worker terminal as a shell command', async () => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export function agentArgOptionTokens(tokens: readonly string[]): readonly string[] {
|
||||
const terminator = tokens.indexOf('--')
|
||||
return terminator === -1 ? tokens : tokens.slice(0, terminator)
|
||||
}
|
||||
|
||||
export function removeAgentArgOption(
|
||||
tokens: readonly string[],
|
||||
aliases: readonly string[]
|
||||
): string[] {
|
||||
const result: string[] = []
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index]
|
||||
if (token === '--') {
|
||||
result.push(...tokens.slice(index))
|
||||
break
|
||||
}
|
||||
const exact = aliases.includes(token)
|
||||
const matched = aliases.some(
|
||||
(alias) =>
|
||||
token.startsWith(`${alias}=`) ||
|
||||
(alias.startsWith('-') &&
|
||||
!alias.startsWith('--') &&
|
||||
token.startsWith(alias) &&
|
||||
token.length > alias.length)
|
||||
)
|
||||
if (!exact && !matched) {
|
||||
result.push(token)
|
||||
continue
|
||||
}
|
||||
if (exact && tokens[index + 1] && !tokens[index + 1].startsWith('-')) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CatalogModel,
|
||||
CatalogOption
|
||||
} from './agent-session-option-catalog-types'
|
||||
import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args'
|
||||
import {
|
||||
CLAUDE_MODEL_LIST_ARGS,
|
||||
CLAUDE_MODEL_LIST_STDIN,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
} from './claude-model-list-probe'
|
||||
|
||||
function hasFlag(tokens: readonly string[], flags: readonly string[]): boolean {
|
||||
return tokens.some((token) =>
|
||||
return agentArgOptionTokens(tokens).some((token) =>
|
||||
flags.some(
|
||||
(flag) =>
|
||||
token === flag ||
|
||||
@@ -24,8 +25,9 @@ function hasCodexEffortOverride(tokens: readonly string[]): boolean {
|
||||
if (hasFlag(tokens, ['--reasoning-effort'])) {
|
||||
return true
|
||||
}
|
||||
return tokens.some((token, index) => {
|
||||
const previous = tokens[index - 1]
|
||||
const optionTokens = agentArgOptionTokens(tokens)
|
||||
return optionTokens.some((token, index) => {
|
||||
const previous = optionTokens[index - 1]
|
||||
return (
|
||||
(token.startsWith('model_reasoning_effort=') &&
|
||||
(previous === '-c' || previous === '--config')) ||
|
||||
@@ -36,6 +38,32 @@ function hasCodexEffortOverride(tokens: readonly string[]): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
function removeCodexEffortOverride(tokens: readonly string[]): string[] {
|
||||
const withoutFlag = removeAgentArgOption(tokens, ['--reasoning-effort'])
|
||||
const result: string[] = []
|
||||
for (let index = 0; index < withoutFlag.length; index += 1) {
|
||||
const token = withoutFlag[index]
|
||||
if (token === '--') {
|
||||
result.push(...withoutFlag.slice(index))
|
||||
break
|
||||
}
|
||||
const next = withoutFlag[index + 1]
|
||||
if ((token === '-c' || token === '--config') && next?.startsWith('model_reasoning_effort=')) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (
|
||||
token.startsWith('-cmodel_reasoning_effort=') ||
|
||||
token.startsWith('-c=model_reasoning_effort=') ||
|
||||
token.startsWith('--config=model_reasoning_effort=')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
result.push(token)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const STANDARD_EFFORT_CHOICES = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
@@ -67,6 +95,7 @@ function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): Catal
|
||||
apply: {
|
||||
launchArgs: (value) => ['--effort', String(value)],
|
||||
agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']),
|
||||
removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']),
|
||||
midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` }
|
||||
}
|
||||
}
|
||||
@@ -108,6 +137,7 @@ const CLAUDE_FAST_MODE: CatalogOption = {
|
||||
}
|
||||
|
||||
export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
supportsWorkerLaunchPreferences: true,
|
||||
// Why: these ids are Claude CLI aliases that resolve to the newest model of
|
||||
// each family on the host's CLI (`opus` is Opus 5 on current CLIs, older
|
||||
// Opus on older CLIs), so pinned version labels lie on part of the fleet.
|
||||
@@ -143,6 +173,7 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
modelApply: {
|
||||
launchArgs: (value) => ['--model', String(value)],
|
||||
agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']),
|
||||
removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']),
|
||||
midSession: {
|
||||
kind: 'command',
|
||||
build: (value) => `/model ${String(value)}`,
|
||||
@@ -152,6 +183,7 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
detectAgentInteraction: 'claude-model-switch-confirmation'
|
||||
}
|
||||
},
|
||||
unknownModelOptions: [claudeEffort(true)],
|
||||
listModels: {
|
||||
command: `echo '${CLAUDE_MODEL_LIST_STDIN.trim()}' | claude ${CLAUDE_MODEL_LIST_ARGS.join(' ')}`,
|
||||
parse: parseClaudeCatalogModels
|
||||
@@ -181,12 +213,14 @@ function codexEffort(includeExtraHigh: boolean): CatalogOption {
|
||||
apply: {
|
||||
launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`],
|
||||
agentArgsOverride: hasCodexEffortOverride,
|
||||
removeAgentArgs: removeCodexEffortOverride,
|
||||
midSession: { kind: 'agent-picker', command: '/model' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
supportsWorkerLaunchPreferences: true,
|
||||
// Why: Codex model access depends on auth. Keep this seed short and allow
|
||||
// unknown persisted ids to pass through instead of claiming a complete list.
|
||||
models: [
|
||||
@@ -203,11 +237,13 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
modelApply: {
|
||||
launchArgs: (value) => ['-m', String(value)],
|
||||
agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']),
|
||||
removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']),
|
||||
// Codex accepts a model argument in its live /model command.
|
||||
midSession: {
|
||||
kind: 'command',
|
||||
build: (value) => `/model ${String(value)}`,
|
||||
pickerCommand: '/model'
|
||||
}
|
||||
}
|
||||
},
|
||||
unknownModelOptions: [codexEffort(true)]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import type {
|
||||
CatalogModel,
|
||||
CatalogOption
|
||||
} from './agent-session-option-catalog-types'
|
||||
import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args'
|
||||
|
||||
function hasModelFlag(tokens: readonly string[]): boolean {
|
||||
return tokens.some(
|
||||
return agentArgOptionTokens(tokens).some(
|
||||
(token) =>
|
||||
token === '-m' ||
|
||||
token === '--model' ||
|
||||
@@ -77,6 +78,7 @@ function parseCursorModels(stdout: string): CatalogModel[] {
|
||||
}
|
||||
|
||||
export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
supportsWorkerLaunchPreferences: true,
|
||||
models: [
|
||||
{ id: 'auto', label: 'Auto', isDefault: true, options: [] },
|
||||
{
|
||||
@@ -93,6 +95,7 @@ export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
modelApply: {
|
||||
launchArgs: (value) => ['--model', String(value)],
|
||||
agentArgsOverride: hasModelFlag,
|
||||
removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']),
|
||||
midSession: { kind: 'command', build: (value) => `/model ${String(value)}` }
|
||||
},
|
||||
composeModelValue: (modelId, values) => {
|
||||
|
||||
@@ -23,6 +23,8 @@ export type CatalogOptionApply = {
|
||||
/** Why: later free-form args win, so the launch record must discard any
|
||||
* picker value that those args may have replaced. */
|
||||
agentArgsOverride?: (tokens: readonly string[]) => boolean
|
||||
/** Removes conflicting defaults before a more specific launch choice is inserted. */
|
||||
removeAgentArgs?: (tokens: readonly string[]) => string[]
|
||||
composedIntoModel?: true
|
||||
midSession?: CatalogMidSessionApply
|
||||
}
|
||||
@@ -53,6 +55,10 @@ export type CatalogModel = {
|
||||
export type AgentSessionOptionCatalog = {
|
||||
models: CatalogModel[]
|
||||
modelApply: CatalogOptionApply
|
||||
/** Opts this agent into structured per-worker launch overrides. */
|
||||
supportsWorkerLaunchPreferences?: true
|
||||
/** Launch-safe options for opaque model ids that are absent from the static catalog. */
|
||||
unknownModelOptions?: CatalogOption[]
|
||||
composeModelValue?: (modelId: string, values: Record<string, SessionOptionValue>) => string
|
||||
listModels?: {
|
||||
command: string
|
||||
|
||||
@@ -7,10 +7,32 @@ export type ResolvedSessionOptionLaunch = {
|
||||
appliedValues: Record<string, SessionOptionValue>
|
||||
}
|
||||
|
||||
export function removeOverriddenAgentSessionArgs(
|
||||
agent: AgentType,
|
||||
values: Record<string, SessionOptionValue> | null | undefined,
|
||||
tokens: readonly string[]
|
||||
): string[] {
|
||||
const catalog = getAgentSessionOptionCatalog(agent)
|
||||
const modelId = typeof values?.model === 'string' ? values.model : null
|
||||
if (!catalog || !values || !modelId) {
|
||||
return [...tokens]
|
||||
}
|
||||
let result = catalog.modelApply.removeAgentArgs?.(tokens) ?? [...tokens]
|
||||
const model = findCatalogModel(catalog, modelId)
|
||||
const modelOptions = model?.options ?? catalog.unknownModelOptions ?? []
|
||||
for (const option of modelOptions) {
|
||||
if (values[option.id] !== undefined && option.apply.removeAgentArgs) {
|
||||
result = option.apply.removeAgentArgs(result)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function resolveAgentSessionOptionLaunch(
|
||||
agent: AgentType,
|
||||
values: Record<string, SessionOptionValue> | null | undefined,
|
||||
trailingAgentArgs: readonly string[] = []
|
||||
trailingAgentArgs: readonly string[] = [],
|
||||
includeCatalogDefaults = true
|
||||
): ResolvedSessionOptionLaunch {
|
||||
const catalog = getAgentSessionOptionCatalog(agent)
|
||||
const modelId = typeof values?.model === 'string' ? values.model : null
|
||||
@@ -21,11 +43,23 @@ export function resolveAgentSessionOptionLaunch(
|
||||
const model = findCatalogModel(catalog, modelId)
|
||||
const appliedValues: Record<string, SessionOptionValue> = {}
|
||||
const args: string[] = []
|
||||
const modelValues = model
|
||||
? Object.fromEntries(
|
||||
model.options.map((option) => [option.id, values[option.id] ?? option.kind.defaultValue])
|
||||
)
|
||||
: {}
|
||||
const modelOptions = model?.options ?? catalog.unknownModelOptions ?? []
|
||||
const modelValues = Object.fromEntries(
|
||||
modelOptions.flatMap((option) => {
|
||||
const explicitValue = values[option.id]
|
||||
if (explicitValue !== undefined) {
|
||||
if (
|
||||
!model &&
|
||||
option.kind.type === 'select' &&
|
||||
!option.kind.choices.some((choice) => choice.value === explicitValue)
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [[option.id, explicitValue]]
|
||||
}
|
||||
return model && includeCatalogDefaults ? [[option.id, option.kind.defaultValue]] : []
|
||||
})
|
||||
)
|
||||
const composedModelId = catalog.composeModelValue
|
||||
? catalog.composeModelValue(modelId, modelValues)
|
||||
: modelId
|
||||
@@ -37,11 +71,7 @@ export function resolveAgentSessionOptionLaunch(
|
||||
appliedValues.model = modelId
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
return { args, appliedValues }
|
||||
}
|
||||
|
||||
for (const option of model.options) {
|
||||
for (const option of modelOptions) {
|
||||
const value = modelValues[option.id]
|
||||
if (value === undefined) {
|
||||
continue
|
||||
|
||||
@@ -32,6 +32,8 @@ export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-c
|
||||
export const ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY = 'orchestration.federation.v1' as const
|
||||
export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY =
|
||||
'orchestration.federation-control-mail.v1' as const
|
||||
export const ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY =
|
||||
'orchestration.worker-launch-preferences.v1' as const
|
||||
export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION = 2 as const
|
||||
export const ORCHESTRATION_CONTRACT_VERSION = 1 as const
|
||||
export const ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY = 'orchestration.contract.v1' as const
|
||||
@@ -86,6 +88,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY,
|
||||
ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
'browser.screencast.v1',
|
||||
'terminal.binary-stream.v1',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch'
|
||||
import {
|
||||
removeOverriddenAgentSessionArgs,
|
||||
resolveAgentSessionOptionLaunch
|
||||
} from './agent-session-option-launch'
|
||||
import type { SessionOptionValue } from './native-chat-session-options'
|
||||
import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import {
|
||||
@@ -25,6 +28,7 @@ export function resolveAgentLaunchCommand(args: {
|
||||
shell: AgentStartupShell
|
||||
agentArgs?: string | null
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
sessionOptionsOverrideAgentArgs?: boolean
|
||||
isRemote?: boolean
|
||||
}): ResolvedAgentLaunchCommand {
|
||||
const override = args.cmdOverrides[args.agent]
|
||||
@@ -46,17 +50,60 @@ export function resolveAgentLaunchCommand(args: {
|
||||
const resolvedOptions = resolveAgentSessionOptionLaunch(
|
||||
args.agent,
|
||||
args.sessionOptions,
|
||||
trailingTokens.tokens
|
||||
args.sessionOptionsOverrideAgentArgs ? [] : trailingTokens.tokens,
|
||||
!args.sessionOptionsOverrideAgentArgs
|
||||
)
|
||||
if (override && args.sessionOptionsOverrideAgentArgs) {
|
||||
const overrideTokens = tokenizeStartupCommand(override, args.shell)
|
||||
if (!overrideTokens.ok) {
|
||||
return { ok: false, error: `Agent command override is invalid: ${overrideTokens.error}` }
|
||||
}
|
||||
const commandOverrideOptions = resolveAgentSessionOptionLaunch(
|
||||
args.agent,
|
||||
args.sessionOptions,
|
||||
overrideTokens.tokens,
|
||||
false
|
||||
)
|
||||
if (
|
||||
Object.entries(resolvedOptions.appliedValues).some(
|
||||
([key, value]) => commandOverrideOptions.appliedValues[key] !== value
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Agent command override conflicts with the requested launch preferences. Remove model or effort flags from the command override.'
|
||||
}
|
||||
}
|
||||
}
|
||||
const optionSuffix = resolvedOptions.args.map((arg) => quoteStartupArg(arg, args.shell)).join(' ')
|
||||
const commandWithOptions = optionSuffix ? `${command} ${optionSuffix}` : command
|
||||
const commandWithoutSessionOptions = suffix.suffix ? `${command} ${suffix.suffix}` : command
|
||||
// Why: session flags precede the free-form suffix so the user's explicit
|
||||
// repeated flag remains the final, winning occurrence.
|
||||
const commandWithOptions = optionSuffix ? `${command} ${optionSuffix}` : command
|
||||
const overrideTokens = args.sessionOptionsOverrideAgentArgs
|
||||
? insertBeforeTerminator(
|
||||
removeOverriddenAgentSessionArgs(args.agent, args.sessionOptions, trailingTokens.tokens),
|
||||
resolvedOptions.args
|
||||
)
|
||||
: []
|
||||
const commandWithOverrides = overrideTokens.length
|
||||
? `${command} ${overrideTokens.map((token) => quoteStartupArg(token, args.shell)).join(' ')}`
|
||||
: command
|
||||
return {
|
||||
ok: true,
|
||||
command: suffix.suffix ? `${commandWithOptions} ${suffix.suffix}` : commandWithOptions,
|
||||
command: args.sessionOptionsOverrideAgentArgs
|
||||
? commandWithOverrides
|
||||
: suffix.suffix
|
||||
? `${commandWithOptions} ${suffix.suffix}`
|
||||
: commandWithOptions,
|
||||
commandWithoutSessionOptions,
|
||||
appliedSessionOptions: resolvedOptions.appliedValues
|
||||
}
|
||||
}
|
||||
|
||||
function insertBeforeTerminator(tokens: readonly string[], inserted: readonly string[]): string[] {
|
||||
const terminator = tokens.indexOf('--')
|
||||
if (terminator === -1) {
|
||||
return [...tokens, ...inserted]
|
||||
}
|
||||
return [...tokens.slice(0, terminator), ...inserted, ...tokens.slice(terminator)]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildAgentResumeStartupPlan,
|
||||
buildAgentStartupPlan
|
||||
} from './tui-agent-startup'
|
||||
import { resolveAgentLaunchCommand } from './tui-agent-launch-command'
|
||||
|
||||
describe('tui agent startup session options', () => {
|
||||
it('emits catalog options before user arguments without recording an overridden model', () => {
|
||||
@@ -33,6 +34,59 @@ describe('tui agent startup session options', () => {
|
||||
expect(plan?.sessionOptions).toEqual({ model: 'opus' })
|
||||
})
|
||||
|
||||
it('lets explicit worker preferences override general agent arguments', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'codex',
|
||||
prompt: '',
|
||||
cmdOverrides: {},
|
||||
platform: 'linux',
|
||||
allowEmptyPromptLaunch: true,
|
||||
sessionOptions: { model: 'custom-codex-model', effort: 'high' },
|
||||
sessionOptionsOverrideAgentArgs: true,
|
||||
agentArgs: '-m gpt-5.5 -c model_reasoning_effort=low'
|
||||
})
|
||||
expect(plan?.launchCommand).toBe(
|
||||
"codex '-m' 'custom-codex-model' '-c' 'model_reasoning_effort=high'"
|
||||
)
|
||||
expect(plan?.launchConfig.agentCommand).toBe(
|
||||
"codex '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=low'"
|
||||
)
|
||||
expect(plan?.sessionOptions).toEqual({ model: 'custom-codex-model', effort: 'high' })
|
||||
})
|
||||
|
||||
it('inserts worker preferences before an argument terminator', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'codex',
|
||||
prompt: '',
|
||||
cmdOverrides: {},
|
||||
platform: 'linux',
|
||||
allowEmptyPromptLaunch: true,
|
||||
sessionOptions: { model: 'custom-codex-model', effort: 'high' },
|
||||
sessionOptionsOverrideAgentArgs: true,
|
||||
agentArgs: '--dangerously-bypass-approvals-and-sandbox -- literal'
|
||||
})
|
||||
expect(plan?.launchCommand).toBe(
|
||||
"codex '--dangerously-bypass-approvals-and-sandbox' '-m' 'custom-codex-model' '-c' 'model_reasoning_effort=high' '--' 'literal'"
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects conflicting singleton flags in an agent command override', () => {
|
||||
expect(
|
||||
resolveAgentLaunchCommand({
|
||||
agent: 'codex',
|
||||
cmdOverrides: { codex: 'codex --profile work -m gpt-5.5' },
|
||||
platform: 'linux',
|
||||
shell: 'posix',
|
||||
sessionOptions: { model: 'custom-codex-model', effort: 'high' },
|
||||
sessionOptionsOverrideAgentArgs: true
|
||||
})
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error:
|
||||
'Agent command override conflicts with the requested launch preferences. Remove model or effort flags from the command override.'
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes a long Codex model flag overriding the generated short flag', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'codex',
|
||||
|
||||
@@ -36,9 +36,7 @@ export type AgentStartupPlan = {
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
}
|
||||
|
||||
function appliedSessionOptionProps(
|
||||
values: Record<string, SessionOptionValue>
|
||||
): Pick<AgentStartupPlan, 'sessionOptions'> {
|
||||
function appliedSessionOptionProps(values: Record<string, SessionOptionValue>) {
|
||||
return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {}
|
||||
}
|
||||
|
||||
@@ -52,6 +50,7 @@ export function buildAgentStartupPlan(args: {
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string> | null
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
sessionOptionsOverrideAgentArgs?: boolean
|
||||
/** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
|
||||
* `orca-ide` rename must be skipped for remote launches. */
|
||||
isRemote?: boolean
|
||||
@@ -68,6 +67,7 @@ export function buildAgentStartupPlan(args: {
|
||||
shell,
|
||||
agentArgs: usesQuery ? null : args.agentArgs,
|
||||
sessionOptions: args.sessionOptions,
|
||||
sessionOptionsOverrideAgentArgs: args.sessionOptionsOverrideAgentArgs,
|
||||
isRemote: args.isRemote
|
||||
})
|
||||
if (!baseCommand.ok) {
|
||||
|
||||
Reference in New Issue
Block a user