feat(orchestration): reject a worker-start --model the host's CLI does not list

`worker-start --model <id>` previously passed any id straight to the agent
launch, so a typo or a retired id produced a terminal that died on startup with
no explanation from Orca.

Ask the host that will actually run the worker. `resolveWorkerLaunchModelAuthority`
reuses the probe the native chat model picker already uses
(`discoverRuntimeCommitMessageModels`), which routes local / WSL / SSH from the
worktree selector, so the set `worker-start` accepts is the set the picker lists.

Three limits keep the gate honest:

- It refuses only when the authority's `source` is `live` — the host's CLI
  actually answered. A seed fallback (probe failed, timed out, or fell back to
  Orca's own list) accepts the id and lets the agent CLI report: loss of contact
  is never evidence that a model does not exist there
  (`docs/reference/ssh-execution-boundary.md`).
- Strictness follows `discoveredModelsReplaceSeed`. Claude and
  `discoveredModelsAreAuthoritative` catalogs replace the seed, so their lists
  are complete and may refuse. Codex, Gemini and Cursor only extend the seed —
  their lists depend on the account and are not exhaustive — so they are not
  probed at all and refuse nothing.
- Effort validation stays the catalog's, unchanged. A probe reports membership
  only, so its generic level list can never narrow a seeded model's menu.

The probe is cached per executing host (`local` / `wsl:<distro>` / `ssh:<id>`)
rather than per caller, since one machine's CLI list is one fact; failures are
never cached, and a dispatch that gives up on its 10s budget leaves the probe
running to fill the cache for the next one.

The rejection names the agent, the refused id, and the sorted ids the host
listed — never a built-in list presented as authoritative.
This commit is contained in:
Merge Sim
2026-09-10 15:24:25 -07:00
parent 721a269289
commit 46dfcb7699
16 changed files with 1047 additions and 87 deletions
+1 -1
View File
@@ -34,7 +34,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.',
'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.',
'--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.',
'--model takes an id the agent CLI accepts, such as sonnet or opus[1m]. For Claude, whose CLI publishes its whole list, an id that host does not list is rejected and the error names the accepted ones; for Codex and Cursor, whose lists depend on the account and are not exhaustive, the id is passed through and the agent CLI reports any error itself. --effort requires --model and must be a level that model lists. 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.',
"How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.",
+3
View File
@@ -40,6 +40,7 @@ export class RuntimeGitCommands {
readonly generateRuntimePullRequestFields: RuntimeGitGenerationCommands['generateRuntimePullRequestFields']
readonly cancelRuntimeGeneratePullRequestFields: RuntimeGitGenerationCommands['cancelRuntimeGeneratePullRequestFields']
readonly discoverRuntimeCommitMessageModels: RuntimeGitGenerationCommands['discoverRuntimeCommitMessageModels']
readonly resolveRuntimeCommitMessageDiscoveryHostKey: RuntimeGitGenerationCommands['resolveRuntimeCommitMessageDiscoveryHostKey']
readonly stageRuntimeGitPath: RuntimeGitStagingCommands['stageRuntimeGitPath']
readonly unstageRuntimeGitPath: RuntimeGitStagingCommands['unstageRuntimeGitPath']
readonly bulkStageRuntimeGitPaths: RuntimeGitStagingCommands['bulkStageRuntimeGitPaths']
@@ -87,6 +88,8 @@ export class RuntimeGitCommands {
generation.cancelRuntimeGeneratePullRequestFields.bind(generation)
this.discoverRuntimeCommitMessageModels =
generation.discoverRuntimeCommitMessageModels.bind(generation)
this.resolveRuntimeCommitMessageDiscoveryHostKey =
generation.resolveRuntimeCommitMessageDiscoveryHostKey.bind(generation)
this.stageRuntimeGitPath = staging.stageRuntimeGitPath.bind(staging)
this.unstageRuntimeGitPath = staging.unstageRuntimeGitPath.bind(staging)
this.bulkStageRuntimeGitPaths = staging.bulkStageRuntimeGitPaths.bind(staging)
@@ -50,7 +50,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
)
}
const createsWorktree = params.worktree === 'new-top-level'
const { agent, launch } = prepareFederationAttachmentWorkerStart({
const { agent, launch } = await prepareFederationAttachmentWorkerStart({
params,
createsWorktree,
runtime
@@ -168,7 +168,7 @@ describe('orchestration RPC methods', () => {
)
})
it('applies and reports opaque per-invocation model preferences', async () => {
it('applies and reports official per-invocation model preferences', async () => {
setup()
mockCurrentWorkerStart()
const task = db.createTask({ spec: 'launch a custom model' })
@@ -177,7 +177,7 @@ describe('orchestration RPC methods', () => {
task: task.id,
from: 'term_coord',
agent: 'claude',
model: 'aws-bedrock-opus-5',
model: 'opus',
effort: 'high'
})) as {
dispatchId: string
@@ -191,15 +191,15 @@ describe('orchestration RPC methods', () => {
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' }
requested: { agent: 'claude', model: 'opus', effort: 'high' },
effective: { agent: 'claude', model: 'opus', effort: 'high' }
}
})
expect(runtime.createTerminal).toHaveBeenCalledWith(
'id:repo::worktree',
expect.objectContaining({
startupAgent: 'claude',
launchPreferences: { model: 'aws-bedrock-opus-5', effort: 'high' }
launchPreferences: { model: 'opus', effort: 'high' }
})
)
expect(JSON.parse(db.getWorkerDispatch(result.dispatchId)!.start_options)).toMatchObject({
@@ -207,6 +207,25 @@ describe('orchestration RPC methods', () => {
})
})
it('resolves the dispatching coordinator’s worktree once for a --model dispatch', async () => {
// The model probe and the placement both need it; two `showTerminal` round trips for one
// dispatch is one more than the answer costs.
setup()
mockCurrentWorkerStart()
const task = db.createTask({ spec: 'launch a custom model' })
await call('orchestration.workerStart', {
task: task.id,
from: 'term_coord',
agent: 'claude',
model: 'opus'
})
expect(
vi.mocked(runtime.showTerminal).mock.calls.filter(([handle]) => handle === 'term_coord')
).toHaveLength(1)
})
it('rejects launch preferences for an existing terminal before creating a Dispatch', async () => {
setup()
mockCurrentWorkerStart()
@@ -46,9 +46,14 @@ export async function startLocalWorker(args: {
const { params, runtime, db, run, coordinatorPane, existingTask, orchestrationMutation } = args
const requestedWorktree = params.worktree ?? 'current'
const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level'
const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime })
const { agent, launch, callerWorktreeId } = await prepareLocalWorkerStart({
params,
createsWorktree,
runtime
})
const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId(runtime, params.from)
const coordinatorWorktreeId =
callerWorktreeId ?? (await resolveDispatchCallerWorktreeId(runtime, params.from))
const creationWorktree = createsWorktree
? await runtime.showManagedWorktree(`id:${coordinatorWorktreeId}`)
: undefined
@@ -0,0 +1,424 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getAgentSessionOptionCatalog } from '../../../../../../shared/agent-session-option-catalog'
import type { CommitMessageModelCapability } from '../../../../../../shared/commit-message-agent-spec'
import {
clearWorkerLaunchModelAuthorityCacheForTests,
describeWorkerLaunchModelRejection,
resolveWorkerLaunchModelAuthority,
SEED_WORKER_LAUNCH_MODEL_AUTHORITY,
type WorkerLaunchModelAuthority,
type WorkerLaunchModelDiscoveryRuntime
} from './worker-launch-model-authority'
import { resolveWorkerLaunchPreferences } from './worker-launch-preferences'
const CLAUDE_CATALOG = getAgentSessionOptionCatalog('claude')!
const CODEX_CATALOG = getAgentSessionOptionCatalog('codex')!
const GROK_CATALOG = getAgentSessionOptionCatalog('grok')!
function liveModel(id: string, effortLevels: readonly string[] = []): CommitMessageModelCapability {
return {
id,
label: id,
...(effortLevels.length > 0
? { thinkingLevels: effortLevels.map((level) => ({ id: level, label: level })) }
: {})
}
}
function probeRuntime(
respond: (worktreeSelector: string) => unknown,
hostKeyFor: (worktreeSelector: string) => string = () => 'local'
): {
runtime: WorkerLaunchModelDiscoveryRuntime
discover: ReturnType<typeof vi.fn>
resolveHostKey: ReturnType<typeof vi.fn>
} {
const discover = vi.fn(async (worktreeSelector: string) => await respond(worktreeSelector))
const resolveHostKey = vi.fn(async (worktreeSelector: string) => hostKeyFor(worktreeSelector))
return {
runtime: {
discoverRuntimeCommitMessageModels: discover,
resolveRuntimeCommitMessageDiscoveryHostKey: resolveHostKey
} as never,
discover,
resolveHostKey
}
}
function probeSuccess(models: readonly CommitMessageModelCapability[]): unknown {
return {
success: true,
catalogOrigin: 'probe',
models,
defaultModelId: models[0]?.id ?? '',
capability: {
id: 'claude',
label: 'Claude',
modelSource: 'dynamic',
models,
defaultModelId: ''
}
}
}
describe('worker launch model authority', () => {
beforeEach(() => {
clearWorkerLaunchModelAuthorityCacheForTests()
})
it('takes the live Claude CLI list as the whole membership, dropping seed ids it omits', async () => {
const { runtime } = probeRuntime(() =>
probeSuccess([liveModel('opus[1m]', ['low', 'high', 'max']), liveModel('haiku')])
)
const authority = await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: 'id:wt_local'
})
expect(authority).toEqual({ source: 'live', modelIds: ['opus[1m]', 'haiku'] })
})
it('never asks an agent whose probe only extends the seed, and so refuses nothing', async () => {
const { runtime, discover, resolveHostKey } = probeRuntime(() =>
probeSuccess([liveModel('gpt-5.7-preview')])
)
const authority = await resolveWorkerLaunchModelAuthority({
catalog: CODEX_CATALOG,
agent: 'codex',
runtime,
worktreeSelector: 'id:wt_local'
})
// The Codex seed is deliberately short, so a list that merges into it is not a complete one.
expect(authority).toEqual(SEED_WORKER_LAUNCH_MODEL_AUTHORITY)
expect(discover).not.toHaveBeenCalled()
expect(resolveHostKey).not.toHaveBeenCalled()
})
it('refuses an unlisted id for the agent whose list replaces the seed, and not for the one that extends it', async () => {
const { runtime } = probeRuntime(() => probeSuccess([liveModel('opus[1m]')]))
const claude = await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: 'id:wt_local'
})
const codex = await resolveWorkerLaunchModelAuthority({
catalog: CODEX_CATALOG,
agent: 'codex',
runtime,
worktreeSelector: 'id:wt_local'
})
// The point of the PR: a resolved Claude id the CLI never offers stays refused.
expect(() =>
resolveWorkerLaunchPreferences({ agent: 'claude', model: 'claude-opus-5', authority: claude })
).toThrow('Agent claude does not accept model claude-opus-5')
// And an id only the account knows about reaches the launch rather than being second-guessed.
expect(
resolveWorkerLaunchPreferences({
agent: 'codex',
model: 'gpt-account-only',
authority: codex
}).preferences
).toEqual({ model: 'gpt-account-only' })
})
it.each([
{ label: 'the probe throws', respond: () => Promise.reject(new Error('ssh down')) },
{ label: 'the probe fails', respond: () => ({ success: false, error: 'no CLI' }) },
{
label: 'the probe falls back to Orca’s own list',
respond: () => ({ ...(probeSuccess([liveModel('opus')]) as object), catalogOrigin: 'spec' })
},
{ label: 'the probe returns nothing', respond: () => probeSuccess([]) }
])('falls back to the seed when $label', async ({ respond }) => {
const { runtime } = probeRuntime(respond as () => unknown)
const authority = await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: 'id:wt_local'
})
expect(authority).toEqual(SEED_WORKER_LAUNCH_MODEL_AUTHORITY)
})
it('seeds without probing when no worktree names the executing host yet', async () => {
const { runtime, discover } = probeRuntime(() => probeSuccess([liveModel('opus[1m]')]))
const authority = await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: null
})
expect(authority.source).toBe('seed')
expect(discover).not.toHaveBeenCalled()
})
it('seeds without probing when the selector names no host this client can resolve', async () => {
const { runtime, discover } = probeRuntime(
() => probeSuccess([liveModel('opus[1m]')]),
() => {
throw new Error('worktree_not_found')
}
)
const authority = await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: 'id:wt_folder_workspace'
})
expect(authority.source).toBe('seed')
expect(discover).not.toHaveBeenCalled()
})
it('reports a runtime that cannot answer at all, which would silence --model for good', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const { discover } = probeRuntime(() => probeSuccess([liveModel('opus[1m]')]))
// A runtime missing the method is a wiring failure, not an unresolvable worktree.
const unwired = { discoverRuntimeCommitMessageModels: discover } as never
const authority = await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime: unwired,
worktreeSelector: 'id:wt_local'
})
expect(authority.source).toBe('seed')
expect(consoleError).toHaveBeenCalledWith(
'[worker-launch] no discovery host key; --model cannot be checked:',
expect.any(TypeError)
)
// An unresolvable selector is ordinary and must stay silent.
consoleError.mockClear()
const { runtime: resolvable } = probeRuntime(
() => probeSuccess([liveModel('opus[1m]')]),
() => {
throw new Error('selector_not_found')
}
)
await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime: resolvable,
worktreeSelector: 'id:wt_missing'
})
expect(consoleError).not.toHaveBeenCalled()
} finally {
consoleError.mockRestore()
}
})
it('does not cache a failure, so the next dispatch retries the host', async () => {
let attempt = 0
const { runtime, discover } = probeRuntime(() => {
attempt += 1
return attempt === 1 ? { success: false, error: 'no CLI' } : probeSuccess([liveModel('opus')])
})
const args = {
catalog: CLAUDE_CATALOG,
agent: 'claude' as const,
runtime,
worktreeSelector: 'id:wt_local'
}
expect((await resolveWorkerLaunchModelAuthority(args)).source).toBe('seed')
expect((await resolveWorkerLaunchModelAuthority(args)).source).toBe('live')
expect(discover).toHaveBeenCalledTimes(2)
})
it('probes one host once for every worktree that runs on it', async () => {
const { runtime, discover } = probeRuntime(
() => probeSuccess([liveModel('opus[1m]')]),
() => 'local'
)
await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: 'id:wt_one'
})
const second = await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: 'id:wt_two'
})
expect(discover).toHaveBeenCalledTimes(1)
expect(second.modelIds).toEqual(['opus[1m]'])
})
it('reuses one host answer instead of probing on every dispatch', async () => {
const { runtime, discover } = probeRuntime(() => probeSuccess([liveModel('opus[1m]')]))
const args = {
catalog: CLAUDE_CATALOG,
agent: 'claude' as const,
runtime,
worktreeSelector: 'id:wt_local'
}
await resolveWorkerLaunchModelAuthority(args)
const second = await resolveWorkerLaunchModelAuthority(args)
expect(discover).toHaveBeenCalledTimes(1)
expect(second.modelIds).toEqual(['opus[1m]'])
})
it('shares one in-flight probe across dispatches that race it', async () => {
let release: (() => void) | undefined
const started = new Promise<void>((resolve) => {
release = resolve
})
const { runtime, discover } = probeRuntime(async () => {
await started
return probeSuccess([liveModel('opus[1m]')])
})
const args = {
catalog: CLAUDE_CATALOG,
agent: 'claude' as const,
runtime,
worktreeSelector: 'id:wt_local'
}
const both = Promise.all([
resolveWorkerLaunchModelAuthority(args),
resolveWorkerLaunchModelAuthority(args)
])
release!()
const [first, second] = await both
expect(discover).toHaveBeenCalledTimes(1)
expect(first.modelIds).toEqual(['opus[1m]'])
expect(second.modelIds).toEqual(['opus[1m]'])
})
it('answers with the seed past the dispatch budget, leaving the probe to fill the cache', async () => {
vi.useFakeTimers()
try {
let release: (() => void) | undefined
const slow = new Promise<void>((resolve) => {
release = resolve
})
const { runtime, discover } = probeRuntime(async () => {
await slow
return probeSuccess([liveModel('opus[1m]')])
})
const args = {
catalog: CLAUDE_CATALOG,
agent: 'claude' as const,
runtime,
worktreeSelector: 'id:wt_local'
}
let settled: WorkerLaunchModelAuthority | 'waiting' = 'waiting'
const dispatch = resolveWorkerLaunchModelAuthority(args).then((value) => {
settled = value
})
await vi.advanceTimersByTimeAsync(10_000)
// The dispatch does not wait out the probe's own 60s budget.
expect(settled).toEqual(SEED_WORKER_LAUNCH_MODEL_AUTHORITY)
release!()
await dispatch
const next = await resolveWorkerLaunchModelAuthority(args)
// The abandoned probe still landed in the cache, so the next dispatch pays nothing.
expect(discover).toHaveBeenCalledTimes(1)
expect(next).toEqual({ source: 'live', modelIds: ['opus[1m]'] })
} finally {
vi.useRealTimers()
}
})
it('re-probes a host once its cached list has expired', async () => {
vi.useFakeTimers()
try {
const { runtime, discover } = probeRuntime(() => probeSuccess([liveModel('opus[1m]')]))
const args = {
catalog: CLAUDE_CATALOG,
agent: 'claude' as const,
runtime,
worktreeSelector: 'id:wt_local'
}
await resolveWorkerLaunchModelAuthority(args)
vi.setSystemTime(Date.now() + 3 * 60_000 + 1)
await resolveWorkerLaunchModelAuthority(args)
expect(discover).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('keys a remote host separately from the local one', async () => {
const { runtime, discover } = probeRuntime(
(worktreeSelector) =>
probeSuccess([liveModel(worktreeSelector === 'id:wt_remote' ? 'opus' : 'opus[1m]')]),
(worktreeSelector) => (worktreeSelector === 'id:wt_remote' ? 'ssh:box' : 'local')
)
const local = await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: 'id:wt_local'
})
const remote = await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: 'id:wt_remote'
})
expect(discover).toHaveBeenCalledTimes(2)
expect(local.modelIds).toEqual(['opus[1m]'])
expect(remote.modelIds).toEqual(['opus'])
})
it('keys each agent separately on the same host', async () => {
const { runtime, discover } = probeRuntime(() => probeSuccess([liveModel('opus[1m]')]))
await resolveWorkerLaunchModelAuthority({
catalog: CLAUDE_CATALOG,
agent: 'claude',
runtime,
worktreeSelector: 'id:wt_local'
})
// Grok, not Codex: only an agent whose discovery replaces the seed is probed at all.
await resolveWorkerLaunchModelAuthority({
catalog: GROK_CATALOG,
agent: 'grok',
runtime,
worktreeSelector: 'id:wt_local'
})
expect(discover).toHaveBeenCalledTimes(2)
})
it('names the agent, the rejected id and the sorted ids the host actually listed', () => {
expect(
describeWorkerLaunchModelRejection({
agent: 'claude',
model: 'claude-opus-5',
authority: { source: 'live', modelIds: ['sonnet', 'opus'] }
})
).toBe(
'Agent claude does not accept model claude-opus-5. Accepted ids (listed by the claude CLI on the executing host): opus, sonnet.'
)
})
})
@@ -0,0 +1,197 @@
/**
* The official model ids `worker-start --model` accepts, per executing host.
*
* The agent CLI on the host that will run the worker is the only authority for which ids exist
* there, so this reuses the same probe the native chat model picker uses
* (`runtime.discoverRuntimeCommitMessageModels`), which already routes local / WSL / SSH from the
* worktree selector, and the same catalog policy that decides what the picker offers — so the set
* `worker-start` accepts is the set the picker lists.
*
* Two things answer `seed`, which claims no membership at all and so refuses nothing.
*
* A host that could not be listed: loss of contact is never evidence that a model does not exist
* there (`docs/reference/ssh-execution-boundary.md`).
*
* And an agent whose probe only EXTENDS the seed rather than replacing it — the Codex catalog is
* explicit that its seed is short and that unknown ids must pass through, so a list that merges
* into it cannot be read as complete. Only an agent whose discovery replaces the seed gets a
* `live` answer, and only a `live` answer may reject.
*/
import type { CommitMessageModelCapability } from '../../../../../../shared/commit-message-agent-spec'
import {
discoveredModelsReplaceSeed,
resolveDiscoveredCatalogModels,
type AgentSessionOptionCatalog,
type CatalogModel
} from '../../../../../../shared/agent-session-option-catalog'
import type { TuiAgent } from '../../../../../../shared/tui-agent'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
export type WorkerLaunchModelSource = 'live' | 'seed'
export type WorkerLaunchModelAuthority = {
source: WorkerLaunchModelSource
/** The host's whole membership. Empty and meaningless unless `source` is `live`. */
modelIds: readonly string[]
}
export type WorkerLaunchModelDiscoveryRuntime = Pick<
OrcaRuntimeService,
'discoverRuntimeCommitMessageModels' | 'resolveRuntimeCommitMessageDiscoveryHostKey'
>
const DISCOVERY_TTL_MS = 3 * 60_000
/** A dispatch may not wait out the probe's own 60s budget; the seed answers past this. */
const DISCOVERY_BUDGET_MS = 10_000
export const SEED_WORKER_LAUNCH_MODEL_AUTHORITY: WorkerLaunchModelAuthority = {
source: 'seed',
modelIds: []
}
type CachedModels = { expiresAt: number; models: readonly CommitMessageModelCapability[] }
/** Keyed by executing host, not by caller: one machine's CLI list is one fact. */
const cachedByHost = new Map<string, CachedModels>()
const inFlightByHost = new Map<string, Promise<readonly CommitMessageModelCapability[] | null>>()
function discoveredCatalogModel(model: CommitMessageModelCapability): CatalogModel {
return {
id: model.id,
label: model.label,
...(model.isDefault ? { isDefault: true as const } : {}),
// Only membership is read here; effort stays the catalog's, exactly as the picker's merge does.
options: []
}
}
function liveWorkerLaunchModelAuthority(args: {
catalog: AgentSessionOptionCatalog
agent: TuiAgent
models: readonly CommitMessageModelCapability[]
}): WorkerLaunchModelAuthority {
const discovered = args.models.map(discoveredCatalogModel)
return {
source: 'live',
modelIds: resolveDiscoveredCatalogModels(args.agent, args.catalog, discovered).map(
({ id }) => id
)
}
}
async function probeHostModels(
runtime: WorkerLaunchModelDiscoveryRuntime,
agent: TuiAgent,
worktreeSelector: string
): Promise<readonly CommitMessageModelCapability[] | null> {
try {
const result = await runtime.discoverRuntimeCommitMessageModels(worktreeSelector, agent)
// `catalogOrigin: 'spec'` is the probe falling back to Orca's own list, not a CLI answer.
return result.success && result.catalogOrigin === 'probe' && result.models.length > 0
? result.models
: null
} catch {
return null
}
}
function withDiscoveryBudget(
pending: Promise<readonly CommitMessageModelCapability[] | null>
): Promise<readonly CommitMessageModelCapability[] | null> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(null), DISCOVERY_BUDGET_MS)
timer.unref?.()
void pending.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
() => {
clearTimeout(timer)
resolve(null)
}
)
})
}
function readCachedModels(scope: string): readonly CommitMessageModelCapability[] | null {
const now = Date.now()
for (const [key, entry] of cachedByHost) {
if (entry.expiresAt <= now) {
cachedByHost.delete(key)
}
}
return cachedByHost.get(scope)?.models ?? null
}
/**
* `worktreeSelector` names the worktree whose host will run the worker; pass null when no
* worktree exists yet on that host, which leaves the seed as the only honest answer.
*/
export async function resolveWorkerLaunchModelAuthority(args: {
catalog: AgentSessionOptionCatalog
agent: TuiAgent
runtime: WorkerLaunchModelDiscoveryRuntime | null
worktreeSelector: string | null
}): Promise<WorkerLaunchModelAuthority> {
const { catalog, agent, runtime, worktreeSelector } = args
// Why: for an agent whose probe only EXTENDS the seed, the host's list is known not to be
// exhaustive, so it can refuse nothing — and there is correspondingly nothing to ask it.
if (!discoveredModelsReplaceSeed(agent, catalog)) {
return SEED_WORKER_LAUNCH_MODEL_AUTHORITY
}
if (!runtime || !worktreeSelector) {
return SEED_WORKER_LAUNCH_MODEL_AUTHORITY
}
// A selector this host cannot resolve (an unknown worktree, a folder workspace) has no host to
// ask; the probe would fail the same way, so skip it rather than spend the budget.
let hostKey: string
try {
hostKey = await runtime.resolveRuntimeCommitMessageDiscoveryHostKey(worktreeSelector)
} catch (error) {
// An unresolvable selector and a runtime that no longer carries this method both land here,
// and only the second makes `--model` validation a permanent no-op. A missing method is the
// TypeError; say so, because nothing else would ever surface it.
if (error instanceof TypeError) {
console.error('[worker-launch] no discovery host key; --model cannot be checked:', error)
}
return SEED_WORKER_LAUNCH_MODEL_AUTHORITY
}
const scope = `${agent} ${hostKey}`
const cached = readCachedModels(scope)
if (cached) {
return liveWorkerLaunchModelAuthority({ catalog, agent, models: cached })
}
let pending = inFlightByHost.get(scope)
if (!pending) {
// Failures are never cached, so the next dispatch retries rather than inheriting a miss.
pending = probeHostModels(runtime, agent, worktreeSelector).then((models) => {
inFlightByHost.delete(scope)
if (models) {
cachedByHost.set(scope, { expiresAt: Date.now() + DISCOVERY_TTL_MS, models })
}
return models
})
inFlightByHost.set(scope, pending)
}
// A dispatch that gives up on the budget still leaves the probe running for the next one.
const models = await withDiscoveryBudget(pending)
return models
? liveWorkerLaunchModelAuthority({ catalog, agent, models })
: SEED_WORKER_LAUNCH_MODEL_AUTHORITY
}
export function describeWorkerLaunchModelRejection(args: {
agent: TuiAgent
model: string
authority: WorkerLaunchModelAuthority
}): string {
const ids = [...args.authority.modelIds].sort()
return `Agent ${args.agent} does not accept model ${args.model}. Accepted ids (listed by the ${args.agent} CLI on the executing host): ${ids.join(', ')}.`
}
export function clearWorkerLaunchModelAuthorityCacheForTests(): void {
cachedByHost.clear()
inFlightByHost.clear()
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getAgentSessionOptionCatalog } from '../../../../../../shared/agent-session-option-catalog'
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version'
import { SEED_WORKER_LAUNCH_MODEL_AUTHORITY } from './worker-launch-model-authority'
import {
assertWorkerLaunchPreferencesCreateTerminal,
assertWorkerLaunchPreferencesRuntimeSupported,
@@ -10,23 +10,92 @@ import {
} from './worker-launch-preferences'
import { WorkerStartParams } from './worker-start-schema'
const CODEX_ULTRA_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']
const CODEX_XHIGH_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh']
describe('orchestration worker launch preferences', () => {
it('passes an opaque Claude model and portable effort through the shared catalog', () => {
it('passes an official Claude model and portable effort through the shared catalog', () => {
expect(
resolveWorkerLaunchPreferences({
agent: 'claude',
model: 'aws-bedrock-opus-5',
model: 'opus',
effort: 'high'
})
).toEqual({
preferences: { model: 'aws-bedrock-opus-5', effort: 'high' },
preferences: { model: 'opus', effort: 'high' },
receipt: {
requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' },
effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }
requested: { agent: 'claude', model: 'opus', effort: 'high' },
effective: { agent: 'claude', model: 'opus', effort: 'high' }
}
})
})
it('accepts an id only the live CLI list carries, and refuses one it has dropped', () => {
const authority = { source: 'live' as const, modelIds: ['opus[1m]'] }
expect(
resolveWorkerLaunchPreferences({
agent: 'claude',
model: 'opus[1m]',
effort: 'max',
authority
}).preferences
).toEqual({ model: 'opus[1m]', effort: 'max' })
// The live list is the whole membership: a seed id the CLI no longer lists is gone.
expect(() =>
resolveWorkerLaunchPreferences({ agent: 'claude', model: 'sonnet', authority })
).toThrow(
'Agent claude does not accept model sonnet. Accepted ids (listed by the claude CLI on the executing host): opus[1m].'
)
})
it('accepts an unlisted id when the host could not be listed', () => {
// A probe that could not run is not a statement that the model does not exist. `opus[1m]` is
// the id `worker-start --model` documents, and it is absent from the short Claude seed.
expect(
resolveWorkerLaunchPreferences({
agent: 'claude',
model: 'opus[1m]',
effort: 'max',
authority: SEED_WORKER_LAUNCH_MODEL_AUTHORITY
}).preferences
).toEqual({ model: 'opus[1m]', effort: 'max' })
})
it('keeps a seeded model’s own effort menu when the probe advertises fewer levels', () => {
// The Codex probe reports one generic level list for every model; narrowing to it would
// refuse `ultra` on a warm cache and accept it on a cold one.
const authority = { source: 'live' as const, modelIds: ['gpt-5.6-sol'] }
expect(
resolveWorkerLaunchPreferences({
agent: 'codex',
model: 'gpt-5.6-sol',
effort: 'ultra',
authority
}).preferences
).toEqual({ model: 'gpt-5.6-sol', effort: 'ultra' })
expect(() =>
resolveWorkerLaunchPreferences({
agent: 'codex',
model: 'gpt-5.6-sol',
effort: 'not-a-level',
authority
})
).toThrow('Agent codex model gpt-5.6-sol does not support effort not-a-level.')
})
it('accepts a seed id when the host could not be listed', () => {
expect(
resolveWorkerLaunchPreferences({
agent: 'codex',
model: 'gpt-5.5',
effort: 'xhigh',
authority: SEED_WORKER_LAUNCH_MODEL_AUTHORITY
}).preferences
).toEqual({ model: 'gpt-5.5', effort: 'xhigh' })
})
it('does not invent an effort when only a model is requested', () => {
expect(
resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.6-sol' }).preferences
@@ -34,64 +103,24 @@ describe('orchestration worker launch preferences', () => {
})
it.each([
{
model: 'gpt-5.6-sol',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
rejected: ['future-effort']
},
{
model: 'gpt-5.6-terra',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
rejected: ['future-effort']
},
{ model: 'gpt-5.6-sol', accepted: CODEX_ULTRA_LEVELS, rejected: ['future-effort'] },
{ model: 'gpt-5.6-terra', accepted: CODEX_ULTRA_LEVELS, rejected: ['future-effort'] },
{
model: 'gpt-5.6-luna',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
accepted: CODEX_ULTRA_LEVELS.slice(0, -1),
rejected: ['ultra', 'future-effort']
},
{
model: 'gpt-5.5',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
accepted: CODEX_XHIGH_LEVELS,
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'gpt-5.2-codex',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'gpt-5.4',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'gpt-5.4-mini',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'gpt-5.3-codex-spark',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'future-codex-model',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
accepted: CODEX_XHIGH_LEVELS,
rejected: ['max', 'ultra', 'future-effort']
}
])('enforces the Codex effort ceiling for $model', ({ model, accepted, rejected }) => {
const catalog = getAgentSessionOptionCatalog('codex')!
const effort =
catalog.models
.find((candidate) => candidate.id === model)
?.options.find((option) => option.id === 'effort') ??
catalog.unknownModelOptions?.find((option) => option.id === 'effort')
expect(effort?.kind.type).toBe('select')
expect(
effort?.kind.type === 'select' ? effort.kind.choices.map(({ value }) => value) : []
).toEqual(accepted)
for (const effortValue of accepted) {
expect(
resolveWorkerLaunchPreferences({ agent: 'codex', model, effort: effortValue }).preferences
@@ -104,6 +133,22 @@ describe('orchestration worker launch preferences', () => {
}
})
it.each(['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark', 'future-codex-model'])(
'refuses an unlisted Codex id only once the host has answered: %s',
(model) => {
expect(resolveWorkerLaunchPreferences({ agent: 'codex', model }).preferences).toEqual({
model
})
expect(() =>
resolveWorkerLaunchPreferences({
agent: 'codex',
model,
authority: { source: 'live', modelIds: ['gpt-5.6-sol'] }
})
).toThrow(`Agent codex does not accept model ${model}.`)
}
)
it('rejects effort without a model', () => {
expect(() => resolveWorkerLaunchPreferences({ agent: 'codex', effort: 'high' })).toThrow(
'--effort requires --model'
@@ -8,6 +8,10 @@ import { resolveAgentSessionOptionLaunch } from '../../../../../../shared/agent-
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version'
import type { TuiAgent } from '../../../../../../shared/tui-agent'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import {
describeWorkerLaunchModelRejection,
type WorkerLaunchModelAuthority
} from './worker-launch-model-authority'
export type OrchestrationWorkerLaunchSelection = {
agent: TuiAgent | null
@@ -48,10 +52,13 @@ export function createPendingWorkerLaunchReceipt(args: {
}
}
/** `authority` names the ids the executing host's CLI lists. Only a `live` one may refuse a
* model: a seed fallback (or no authority at all) means the host was never listed. */
export function resolveWorkerLaunchPreferences(args: {
agent: TuiAgent
model?: string
effort?: string
authority?: WorkerLaunchModelAuthority
}): {
preferences: AgentLaunchPreferences | undefined
receipt: OrchestrationWorkerLaunchReceipt
@@ -74,20 +81,31 @@ export function resolveWorkerLaunchPreferences(args: {
)
}
const model = args.model
// Only a host that actually answered may refuse an id. A seed fallback means the CLI could not
// be listed there, and an unreachable host is not a statement that the model does not exist —
// let the agent CLI itself report it.
const authority = args.authority
if (authority?.source === 'live' && !authority.modelIds.includes(model)) {
throw new OrchestrationError(
'invalid_argument',
describeWorkerLaunchModelRejection({ agent: args.agent, model, authority })
)
}
// Effort is a flag Orca emits, not a host fact, so the catalog decides it on both paths — a
// probe's generic level list must never narrow the menu a seeded model carries.
if (args.effort) {
const model = findCatalogModel(catalog, args.model)
const seeded = findCatalogModel(catalog, model)
const option =
findCatalogOption(model, 'effort') ??
(!model
? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort')
: undefined)
findCatalogOption(seeded, 'effort') ??
(seeded ? undefined : catalog.unknownModelOptions?.find(({ id }) => id === 'effort'))
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}.`
`Agent ${args.agent} model ${model} does not support effort ${args.effort}.`
)
}
}
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import type { FederationAttachStartInput } from '../federation/federation-start-schema'
import { clearWorkerLaunchModelAuthorityCacheForTests } from './worker-launch-model-authority'
import {
prepareFederationAttachmentWorkerStart,
prepareLocalWorkerStart
} from './worker-start-validation'
import type { WorkerStartInput } from './worker-start-schema'
/**
* The selector the model probe receives decides WHICH host answers, so these pin the
* placement → selector mapping. A refactor that probed the worker's target worktree
* instead of the coordinator's would still dispatch, and still pass every RPC-level test.
*/
function validationRuntime(): {
runtime: OrcaRuntimeService
resolveHostKey: ReturnType<typeof vi.fn>
} {
const resolveHostKey = vi.fn(async () => 'local')
const runtime = {
validateOrchestrationAgentLauncher: vi.fn(),
showTerminal: vi.fn(async () => ({ worktreeId: 'wt_coordinator' })),
getOrchestrationDispatchAuthority: vi.fn(() => null),
resolveRuntimeCommitMessageDiscoveryHostKey: resolveHostKey,
discoverRuntimeCommitMessageModels: vi.fn(async () => ({ success: false, error: 'no CLI' }))
} as unknown as OrcaRuntimeService
return { runtime, resolveHostKey }
}
function localParams(overrides: Partial<WorkerStartInput>): WorkerStartInput {
return {
from: 'term_coord',
agent: 'claude',
model: 'opus',
...overrides
} as WorkerStartInput
}
function federatedParams(
overrides: Partial<FederationAttachStartInput>
): FederationAttachStartInput {
return {
runId: 'run_1',
dispatchId: 'ctx_1',
taskId: 'task_1',
taskSpec: 'do the thing',
protocolVersion: 3,
worktree: 'remote-worktree',
agent: 'claude',
model: 'opus',
...overrides
} as FederationAttachStartInput
}
describe('worker start placement to probe selector', () => {
beforeEach(() => {
clearWorkerLaunchModelAuthorityCacheForTests()
})
it.each([
{ worktree: 'current', createsWorktree: false },
{ worktree: 'new-child', createsWorktree: true },
{ worktree: 'new-top-level', createsWorktree: true }
])(
'probes the coordinator’s own host for placement $worktree',
async ({ worktree, createsWorktree }) => {
const { runtime, resolveHostKey } = validationRuntime()
await prepareLocalWorkerStart({
params: localParams({ worktree, ...(createsWorktree ? { name: 'child' } : {}) }),
createsWorktree,
runtime
})
// A worktree that does not exist yet inherits the host it is about to be made on.
expect(resolveHostKey).toHaveBeenCalledWith('id:wt_coordinator')
}
)
it('probes the named worktree itself when the placement names one', async () => {
const { runtime, resolveHostKey } = validationRuntime()
await prepareLocalWorkerStart({
params: localParams({ worktree: 'id:wt_elsewhere' }),
createsWorktree: false,
runtime
})
expect(resolveHostKey).toHaveBeenCalledWith('id:wt_elsewhere')
expect(runtime.showTerminal).not.toHaveBeenCalled()
})
it('hands the coordinator’s resolved worktree back so placement need not resolve it again', async () => {
const { runtime } = validationRuntime()
const plan = await prepareLocalWorkerStart({
params: localParams({ worktree: 'current' }),
createsWorktree: false,
runtime
})
expect(plan.callerWorktreeId).toBe('wt_coordinator')
expect(runtime.showTerminal).toHaveBeenCalledTimes(1)
})
it('probes nothing when no model was requested', async () => {
const { runtime, resolveHostKey } = validationRuntime()
await prepareLocalWorkerStart({
params: localParams({ worktree: 'current', model: undefined }),
createsWorktree: false,
runtime
})
expect(resolveHostKey).not.toHaveBeenCalled()
expect(runtime.showTerminal).not.toHaveBeenCalled()
})
it('probes the remote worktree a federated attachment names', async () => {
const { runtime, resolveHostKey } = validationRuntime()
await prepareFederationAttachmentWorkerStart({
params: federatedParams({}),
createsWorktree: false,
runtime
})
expect(resolveHostKey).toHaveBeenCalledWith('remote-worktree')
})
it('probes nothing for a federated new-top-level, which has no host until the remote makes it', async () => {
const { runtime, resolveHostKey } = validationRuntime()
await prepareFederationAttachmentWorkerStart({
params: federatedParams({ name: 'remote-child', repo: 'repo_1' }),
createsWorktree: true,
runtime
})
expect(resolveHostKey).not.toHaveBeenCalled()
})
})
@@ -2,7 +2,10 @@ import { isTuiAgent } from '../../../../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../../../../shared/tui-agent'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import { getAgentSessionOptionCatalog } from '../../../../../../shared/agent-session-option-catalog'
import type { FederationAttachStartInput } from '../federation/federation-start-schema'
import { resolveDispatchCallerWorktreeId } from '../../orchestration-caller-workspace'
import { resolveWorkerLaunchModelAuthority } from './worker-launch-model-authority'
import {
assertWorkerLaunchPreferencesCreateTerminal,
createWorkerLaunchReceipt,
@@ -11,6 +14,36 @@ import {
import type { WorkerStartInput } from './worker-start-schema'
type WorkerStartLaunch = ReturnType<typeof resolveWorkerLaunchPreferences>
type WorkerStartAgentPlan = {
agent: TuiAgent | undefined
launch: WorkerStartLaunch
/** Present only when the model probe already paid for it; `startLocalWorker` reuses it
* instead of making a second `showTerminal` round trip for the same dispatch. */
callerWorktreeId?: string
}
const COORDINATOR_HOSTED_PLACEMENTS = new Set(['current', 'new-child', 'new-top-level'])
/**
* The worktree whose host will run the worker, as a selector the model probe can resolve.
* A worktree that does not exist yet inherits the coordinator's host, which is where it is made.
*/
async function resolveLocalLaunchHost(
runtime: OrcaRuntimeService,
params: WorkerStartInput
): Promise<{ selector: string | null; callerWorktreeId?: string }> {
const requested = params.worktree ?? 'current'
if (!COORDINATOR_HOSTED_PLACEMENTS.has(requested)) {
return { selector: requested }
}
try {
const callerWorktreeId = await resolveDispatchCallerWorktreeId(runtime, params.from)
return { selector: `id:${callerWorktreeId}`, callerWorktreeId }
} catch {
// The same failure resurfaces where the dispatch actually needs the coordinator's worktree.
return { selector: null }
}
}
export function validateFederatedWorkerStartPlacement(
params: WorkerStartInput,
@@ -48,11 +81,11 @@ export function validateFederatedWorkerStartPlacement(
}
}
export function prepareLocalWorkerStart(args: {
export async function prepareLocalWorkerStart(args: {
params: WorkerStartInput
createsWorktree: boolean
runtime: OrcaRuntimeService
}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } {
}): Promise<WorkerStartAgentPlan> {
const { params, createsWorktree, runtime } = args
assertWorkerLaunchPreferencesCreateTerminal(params)
if (params.terminal && params.agent) {
@@ -76,21 +109,26 @@ export function prepareLocalWorkerStart(args: {
'Creation and setup options apply only to new-child or new-top-level worktrees.'
)
}
return resolveWorkerStartAgent({
const host: { selector: string | null; callerWorktreeId?: string } = params.model
? await resolveLocalLaunchHost(runtime, params)
: { selector: null }
const plan = await resolveWorkerStartAgent({
runtime,
terminal: params.terminal,
agent: params.agent,
model: params.model,
effort: params.effort,
worktreeSelector: host.selector,
missingAgentMessage: 'A configured --agent is required when worker-start creates a terminal.'
})
return host.callerWorktreeId ? { ...plan, callerWorktreeId: host.callerWorktreeId } : plan
}
export function prepareFederationAttachmentWorkerStart(args: {
export async function prepareFederationAttachmentWorkerStart(args: {
params: FederationAttachStartInput
createsWorktree: boolean
runtime: OrcaRuntimeService
}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } {
}): Promise<WorkerStartAgentPlan> {
const { params, createsWorktree, runtime } = args
assertWorkerLaunchPreferencesCreateTerminal(params)
if (createsWorktree && (!params.name || !params.repo)) {
@@ -126,31 +164,45 @@ export function prepareFederationAttachmentWorkerStart(args: {
agent: params.agent,
model: params.model,
effort: params.effort,
// A remote new-top-level worktree has no host to probe until the remote makes it.
worktreeSelector: createsWorktree ? null : params.worktree,
missingAgentMessage:
'A configured --agent is required when federated worker-start creates a terminal.'
})
}
function resolveWorkerStartAgent(args: {
async function resolveWorkerStartAgent(args: {
runtime: OrcaRuntimeService
terminal?: string
agent?: string
model?: string
effort?: string
worktreeSelector: string | null
missingAgentMessage: string
}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } {
}): Promise<WorkerStartAgentPlan> {
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)
const catalog = args.model ? getAgentSessionOptionCatalog(agent) : null
return {
agent,
launch: resolveWorkerLaunchPreferences({
agent,
model: args.model,
effort: args.effort
effort: args.effort,
...(catalog
? {
authority: await resolveWorkerLaunchModelAuthority({
catalog,
agent,
runtime: args.runtime,
worktreeSelector: args.worktreeSelector
})
}
: {})
})
}
}
@@ -169,21 +169,21 @@ describe('orchestration new-worktree workers', () => {
mockCreatedWorktree()
const { result } = await startWorker({
model: 'custom-codex-model',
model: 'gpt-5.5',
effort: 'high'
})
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
startupAgent: 'codex',
startupLaunchPreferences: { model: 'custom-codex-model', effort: 'high' }
startupLaunchPreferences: { model: 'gpt-5.5', 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' }
requested: { agent: 'codex', model: 'gpt-5.5', effort: 'high' },
effective: { agent: 'codex', model: 'gpt-5.5', effort: 'high' }
}
})
})
@@ -25,6 +25,7 @@ type RuntimeGitCommandName =
| 'commitRuntimeGit'
| 'generateRuntimeCommitMessage'
| 'discoverRuntimeCommitMessageModels'
| 'resolveRuntimeCommitMessageDiscoveryHostKey'
| 'cancelRuntimeGenerateCommitMessage'
| 'generateRuntimePullRequestFields'
| 'cancelRuntimeGeneratePullRequestFields'
@@ -68,6 +69,8 @@ export function installRuntimeGitCommandSurface(
commitRuntimeGit: commands.commitRuntimeGit.bind(commands),
generateRuntimeCommitMessage: commands.generateRuntimeCommitMessage.bind(commands),
discoverRuntimeCommitMessageModels: commands.discoverRuntimeCommitMessageModels.bind(commands),
resolveRuntimeCommitMessageDiscoveryHostKey:
commands.resolveRuntimeCommitMessageDiscoveryHostKey.bind(commands),
cancelRuntimeGenerateCommitMessage: commands.cancelRuntimeGenerateCommitMessage.bind(commands),
generateRuntimePullRequestFields: commands.generateRuntimePullRequestFields.bind(commands),
cancelRuntimeGeneratePullRequestFields:
@@ -1,5 +1,8 @@
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import {
getCommitMessageModelDiscoveryHostKey,
getCommitMessageModelDiscoveryHostKeyForLocalRuntime
} from '../../shared/commit-message-host-key'
import type { HostedReviewProvider } from '../../shared/hosted-review'
import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables'
import type { TuiAgent } from '../../shared/tui-agent'
@@ -248,6 +251,21 @@ export class RuntimeGitGenerationCommands {
return { ok: true }
}
/**
* Which host a discovery for `worktreeSelector` would run its agent CLI on, in the same
* `local` / `wsl:<distro>` / `ssh:<id>` vocabulary the renderer caches this fact under. Every
* worktree on one host answers the same key, so a caller can cache one probe per host.
*/
async resolveRuntimeCommitMessageDiscoveryHostKey(worktreeSelector: string): Promise<string> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const route = runtimeGitRouteForTarget(target)
return route.kind === 'ssh'
? getCommitMessageModelDiscoveryHostKey(route.connectionId)
: getCommitMessageModelDiscoveryHostKeyForLocalRuntime(
localGitOptionsForTarget(target).wslDistro
)
}
async discoverRuntimeCommitMessageModels(
worktreeSelector: string,
agentId: string,
@@ -1,8 +1,7 @@
import type { AgentType } from '../../../../shared/agent-status-types'
import {
getAgentSessionOptionCatalog,
mergeCatalogModels,
mergeDiscoveredAuthoritativeModels,
resolveDiscoveredCatalogModels,
type CatalogModel
} from '../../../../shared/agent-session-option-catalog'
import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults'
@@ -101,12 +100,7 @@ export function ensureNativeChatModelEnrichment(args: {
if (!discovered || discovered.length === 0) {
return
}
entry.models =
args.agent === 'claude'
? [...discovered]
: catalog.discoveredModelsAreAuthoritative
? mergeDiscoveredAuthoritativeModels(catalog.models, discovered)
: mergeCatalogModels(catalog.models, discovered)
entry.models = resolveDiscoveredCatalogModels(args.agent, catalog, discovered)
for (const listener of entry.listeners) {
listener([...entry.models])
}
@@ -90,6 +90,45 @@ export function mergeDiscoveredAuthoritativeModels(
})
}
/**
* Whether a host's CLI-probe answer REPLACES the seed's membership or merely extends it.
*
* Its scope is CLI-probe membership only — what `listModels` stdout claims. A probe that merely
* extends is, by construction, not a complete list — the Codex catalog says so of itself — so
* nothing may be refused against it. Both the picker's merge below and `worker-start`'s reject
* gate read this, so what is offered and what is accepted cannot drift apart.
*
* A live session's own model list is a DIFFERENT authority, outside this function's scope: Codex's
* app-server `model/list` and Claude's SDK `supportedModels()` each speak for one connected
* session and already decide their own membership. Routing either through here would hand it the
* extend-only verdict and delete a rejection that exists today — see
* `applyValidatedCodexStructuredSessionOption`.
*/
export function discoveredModelsReplaceSeed(
agent: AgentType,
catalog: AgentSessionOptionCatalog
): boolean {
return agent === 'claude' || catalog.discoveredModelsAreAuthoritative === true
}
/**
* The models a host's probe answer offers for `agent`: Claude's list replaces the seed outright,
* an authoritative list decides membership while keeping seeded option menus, and a list that only
* extends unions with the seed.
*/
export function resolveDiscoveredCatalogModels(
agent: AgentType,
catalog: AgentSessionOptionCatalog,
discovered: readonly CatalogModel[]
): CatalogModel[] {
if (!discoveredModelsReplaceSeed(agent, catalog)) {
return mergeCatalogModels(catalog.models, discovered)
}
return agent === 'claude'
? [...discovered]
: mergeDiscoveredAuthoritativeModels(catalog.models, discovered)
}
export function sessionOptionValueIsValid(value: unknown): value is SessionOptionValue {
return typeof value === 'string' || typeof value === 'boolean'
}