From 46dfcb7699c9f2dc4b9a80c5bec18bb2a0fbae7d Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 10 Sep 2026 15:24:25 -0700 Subject: [PATCH] feat(orchestration): reject a worker-start --model the host's CLI does not list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `worker-start --model ` 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:` / `ssh:`) 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. --- src/cli/specs/orchestration-worker-specs.ts | 2 +- src/main/runtime/orca-runtime-git.ts | 3 + .../orchestration/federation/federation.ts | 2 +- .../worker/composed-workers.test.ts | 29 +- .../worker/local-worker-start.ts | 9 +- .../worker-launch-model-authority.test.ts | 424 ++++++++++++++++++ .../worker/worker-launch-model-authority.ts | 197 ++++++++ .../worker/worker-launch-preferences.test.ts | 147 +++--- .../worker/worker-launch-preferences.ts | 30 +- .../worker/worker-start-validation.test.ts | 143 ++++++ .../worker/worker-start-validation.ts | 68 ++- .../worker/workers-new-worktree.test.ts | 8 +- .../runtime/runtime-git-command-surface.ts | 3 + .../runtime-git-generation-commands.ts | 20 +- .../native-chat-session-option-enrichment.ts | 10 +- src/shared/agent-session-option-catalog.ts | 39 ++ 16 files changed, 1047 insertions(+), 87 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-launch-model-authority.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-launch-model-authority.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.test.ts 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' }