diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index c6a54ff1e1a..f78f64fb635 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -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.", diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index b5a5041fd19..fa12743c018 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -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) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 785f6a67eec..c7dda280789 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index aee45e25259..ed171633f92 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -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() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index ec188695f5c..de4d7a17e43 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-model-authority.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-model-authority.test.ts new file mode 100644 index 00000000000..4b8b87633ac --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-model-authority.test.ts @@ -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 + resolveHostKey: ReturnType +} { + 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((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((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.' + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-model-authority.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-model-authority.ts new file mode 100644 index 00000000000..5e2b0796d98 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-model-authority.ts @@ -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() +const inFlightByHost = new Map>() + +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 { + 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 +): Promise { + 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 { + 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() +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts index 1cf02efa012..0c7f5b1170b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts @@ -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' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts index c89212c6725..02849a7bc26 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts @@ -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}.` ) } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.test.ts new file mode 100644 index 00000000000..8201904628d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.test.ts @@ -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 +} { + 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 { + return { + from: 'term_coord', + agent: 'claude', + model: 'opus', + ...overrides + } as WorkerStartInput +} + +function federatedParams( + overrides: Partial +): 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() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts index 1ecce8e557f..2889d9cccca 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts @@ -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 +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 { 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 { 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 { 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 + }) + } + : {}) }) } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index 5164ac0b22f..42d8a28588f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -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' } } }) }) diff --git a/src/main/runtime/runtime-git-command-surface.ts b/src/main/runtime/runtime-git-command-surface.ts index 766174e05d0..3501234051a 100644 --- a/src/main/runtime/runtime-git-command-surface.ts +++ b/src/main/runtime/runtime-git-command-surface.ts @@ -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: diff --git a/src/main/runtime/runtime-git-generation-commands.ts b/src/main/runtime/runtime-git-generation-commands.ts index bea10b2ef7d..a52bf345594 100644 --- a/src/main/runtime/runtime-git-generation-commands.ts +++ b/src/main/runtime/runtime-git-generation-commands.ts @@ -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:` / `ssh:` 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 { + 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, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts index 1bb49699050..761d62e8e0b 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -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]) } diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index b242b6b27c9..f74869c9b1d 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -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' }