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 index 8a067e7b6f9..c7730259666 100644 --- 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 @@ -87,8 +87,11 @@ describe('worker launch model authority', () => { expect(authority).toEqual({ source: 'live', - modelIds: ['opus[1m]', 'haiku', 'claude-opus-5[1m]', 'opus'] + modelIds: ['opus[1m]', 'haiku', 'claude-opus-5[1m]', 'opus', 'default'] }) + expect( + resolveWorkerLaunchPreferences({ agent: 'claude', model: 'default', authority }).preferences + ).toEqual({ model: 'default' }) }) it('never asks an agent whose probe only extends the seed, and so refuses nothing', async () => { @@ -309,6 +312,27 @@ describe('worker launch model authority', () => { expect(second.modelIds).toContain('opus[1m]') }) + it('does not reuse a catalog from a different agent command', async () => { + const { runtime, discover } = probeRuntime(() => probeSuccess([liveModel('opus[1m]')])) + const base = { + catalog: CLAUDE_CATALOG, + agent: 'claude' as const, + runtime, + worktreeSelector: 'id:wt_local' + } + + await resolveWorkerLaunchModelAuthority({ ...base, agentCommandOverride: 'claude-stable' }) + await resolveWorkerLaunchModelAuthority({ ...base, agentCommandOverride: 'claude-preview' }) + + expect(discover).toHaveBeenCalledTimes(2) + expect(discover).toHaveBeenNthCalledWith(1, 'id:wt_local', 'claude', { + agentCmdOverrides: { claude: 'claude-stable' } + }) + expect(discover).toHaveBeenNthCalledWith(2, 'id:wt_local', 'claude', { + agentCmdOverrides: { claude: 'claude-preview' } + }) + }) + it('shares one in-flight probe across dispatches that race it', async () => { let release: (() => void) | undefined const started = new Promise((resolve) => { 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 index c995728427b..9ebf2678f84 100644 --- 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 @@ -7,11 +7,14 @@ * worktree selector. Dynamic membership is combined with stable CLI aliases: a picker need not * display an alias such as `opus`, but the launch flag still accepts it. * - * Two things answer `seed`, which claims no membership at all and so refuses nothing. + * Several conditions 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`). * + * A launch whose terminal-only arguments or environment are absent from the probe: that answer is + * not evidence about the CLI invocation that will actually run. + * * 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 @@ -54,9 +57,9 @@ export const SEED_WORKER_LAUNCH_MODEL_AUTHORITY: WorkerLaunchModelAuthority = { 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>() +/** Callers that share an agent, execution host, and resolved command share one CLI fact. */ +const cachedByScope = new Map() +const inFlightByScope = new Map>() function discoveredCatalogModel(model: CommitMessageModelCapability): CatalogModel { return { @@ -89,7 +92,8 @@ function liveWorkerLaunchModelAuthority(args: { ...new Set([ ...listedIds, ...args.models.flatMap(({ resolvedModel }) => (resolvedModel ? [resolvedModel] : [])), - ...listedAliases + ...listedAliases, + ...(args.catalog.launchModelAliases ?? []) ]) ] } @@ -98,10 +102,13 @@ function liveWorkerLaunchModelAuthority(args: { async function probeHostModels( runtime: WorkerLaunchModelDiscoveryRuntime, agent: TuiAgent, - target: WorkerLaunchModelDiscoveryTarget + target: WorkerLaunchModelDiscoveryTarget, + agentCommandOverride: string ): Promise { try { - const result = await runtime.discoverRuntimeCommitMessageModels(target, agent) + const result = await runtime.discoverRuntimeCommitMessageModels(target, agent, { + agentCmdOverrides: { [agent]: agentCommandOverride } + }) // `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 @@ -136,12 +143,12 @@ function withDiscoveryDeadline( function readCachedModels(scope: string): readonly CommitMessageModelCapability[] | null { const now = Date.now() - for (const [key, entry] of cachedByHost) { + for (const [key, entry] of cachedByScope) { if (entry.expiresAt <= now) { - cachedByHost.delete(key) + cachedByScope.delete(key) } } - return cachedByHost.get(scope)?.models ?? null + return cachedByScope.get(scope)?.models ?? null } /** @@ -151,10 +158,12 @@ function readCachedModels(scope: string): readonly CommitMessageModelCapability[ export async function resolveWorkerLaunchModelAuthority(args: { catalog: AgentSessionOptionCatalog agent: TuiAgent + agentCommandOverride?: string runtime: WorkerLaunchModelDiscoveryRuntime | null worktreeSelector: WorkerLaunchModelDiscoveryTarget | null }): Promise { const { catalog, agent, runtime, worktreeSelector: target } = args + const agentCommandOverride = args.agentCommandOverride?.trim() ?? '' // 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)) { @@ -185,22 +194,22 @@ export async function resolveWorkerLaunchModelAuthority(args: { } return SEED_WORKER_LAUNCH_MODEL_AUTHORITY } - const scope = `${agent} ${hostKey}` + const scope = JSON.stringify([agent, hostKey, agentCommandOverride]) const cached = readCachedModels(scope) if (cached) { return liveWorkerLaunchModelAuthority({ catalog, agent, models: cached }) } - let pending = inFlightByHost.get(scope) + let pending = inFlightByScope.get(scope) if (!pending) { // Failures are never cached, so the next dispatch retries rather than inheriting a miss. - pending = probeHostModels(runtime, agent, target).then((models) => { - inFlightByHost.delete(scope) + pending = probeHostModels(runtime, agent, target, agentCommandOverride).then((models) => { + inFlightByScope.delete(scope) if (models) { - cachedByHost.set(scope, { expiresAt: Date.now() + DISCOVERY_TTL_MS, models }) + cachedByScope.set(scope, { expiresAt: Date.now() + DISCOVERY_TTL_MS, models }) } return models }) - inFlightByHost.set(scope, pending) + inFlightByScope.set(scope, pending) } // A dispatch that gives up on the budget still leaves the probe running for the next one. const models = await withDiscoveryDeadline(pending, deadlineAt) @@ -219,6 +228,6 @@ export function describeWorkerLaunchModelRejection(args: { } export function clearWorkerLaunchModelAuthorityCacheForTests(): void { - cachedByHost.clear() - inFlightByHost.clear() + cachedByScope.clear() + inFlightByScope.clear() } 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 index 3bd43e64e4b..ac433af997b 100644 --- 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 @@ -13,7 +13,13 @@ import type { WorkerStartInput } from './worker-start-schema' * 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(): { +function validationRuntime( + settings: { + agentCmdOverrides?: Record + agentDefaultArgs?: Record + agentDefaultEnv?: Record> + } = {} +): { runtime: OrcaRuntimeService discover: ReturnType resolveHostKey: ReturnType @@ -22,6 +28,7 @@ function validationRuntime(): { const discover = vi.fn(async () => ({ success: false, error: 'no CLI' })) const runtime = { validateOrchestrationAgentLauncher: vi.fn(), + getClientSettings: vi.fn(() => settings), showTerminal: vi.fn(async () => ({ worktreeId: 'wt_coordinator' })), getOrchestrationDispatchAuthority: vi.fn(() => null), resolveRuntimeCommitMessageDiscoveryHostKey: resolveHostKey, @@ -166,6 +173,43 @@ describe('worker start placement to probe selector', () => { expect(discover).not.toHaveBeenCalled() }) + it.each([ + { label: 'default arguments', settings: { agentDefaultArgs: { claude: '--settings corp' } } }, + { + label: 'default environment', + settings: { agentDefaultEnv: { claude: { ANTHROPIC_BASE_URL: 'https://corp.invalid' } } } + } + ])('does not reject against a probe that omits the launch $label', async ({ settings }) => { + const { runtime, discover, resolveHostKey } = validationRuntime(settings) + + const plan = await prepareLocalWorkerStart({ + params: localParams({ model: 'gateway-only-model' }), + createsWorktree: false, + runtime + }) + + expect(plan.launch.preferences).toEqual({ model: 'gateway-only-model' }) + expect(resolveHostKey).not.toHaveBeenCalled() + expect(discover).not.toHaveBeenCalled() + }) + + it('still probes through a configured command override, which discovery applies', async () => { + const { runtime, discover, resolveHostKey } = validationRuntime({ + agentCmdOverrides: { claude: 'corp-claude' } + }) + + await prepareLocalWorkerStart({ + params: localParams({}), + createsWorktree: false, + runtime + }) + + expect(resolveHostKey).toHaveBeenCalledWith('id:wt_coordinator') + expect(discover).toHaveBeenCalledWith('id:wt_coordinator', 'claude', { + agentCmdOverrides: { claude: 'corp-claude' } + }) + }) + it('probes the remote worktree a federated attachment names', async () => { const { runtime, resolveHostKey } = validationRuntime() 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 8bcc2c3621e..71edc45d7e6 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 @@ -6,6 +6,7 @@ import { discoveredModelsReplaceSeed, getAgentSessionOptionCatalog } from '../../../../../../shared/agent-session-option-catalog' +import { hasExplicitTuiLaunchCustomization } from '../../../../../../shared/tui-agent-launch-customization' import type { FederationAttachStartInput } from '../federation/federation-start-schema' import { resolveDispatchCallerWorktreeId } from '../../orchestration-caller-workspace' import { @@ -30,6 +31,27 @@ type WorkerStartAgentPlan = { const COORDINATOR_HOSTED_PLACEMENTS = new Set(['current', 'new-child', 'new-top-level']) +function modelProbeMissesLaunchCustomization( + settings: ReturnType, + agent: TuiAgent +): boolean { + const { agentDefaultArgs, agentDefaultEnv } = settings + // Command overrides are part of model discovery. Terminal-only args and env are not, so their + // catalog cannot safely reject a value the actual launch may accept. + return hasExplicitTuiLaunchCustomization({ agentDefaultArgs, agentDefaultEnv }, agent) +} + +function readWorkerLaunchSettings( + runtime: OrcaRuntimeService +): ReturnType | null { + try { + return runtime.getClientSettings() + } catch { + // Missing settings cannot prove that discovery matches the eventual launch command. + return null + } +} + function canResolveWorkerLaunchModelAuthority( agent: string | undefined, model: string | undefined @@ -221,6 +243,10 @@ async function resolveWorkerStartAgent(args: { if (!catalog || !discoveredModelsReplaceSeed(agent, catalog)) { return { agent, launch } } + const settings = readWorkerLaunchSettings(args.runtime) + if (!settings || modelProbeMissesLaunchCustomization(settings, agent)) { + return { agent, launch } + } return { agent, launch: resolveWorkerLaunchPreferences({ @@ -230,6 +256,7 @@ async function resolveWorkerStartAgent(args: { authority: await resolveWorkerLaunchModelAuthority({ catalog, agent, + agentCommandOverride: settings.agentCmdOverrides?.[agent], runtime: args.runtime, worktreeSelector: args.worktreeSelector }) diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index a3e25242e14..b6a9c00d66a 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -128,6 +128,8 @@ const CLAUDE_FAST_MODE: CatalogOption = { export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { supportsWorkerLaunchPreferences: true, + // Why: list_models publishes `default`, but picker discovery drops that mirror row. + launchModelAliases: ['default'], // Why: these ids are Claude CLI aliases that resolve to the newest model of // each family on the host's CLI (`opus` is Opus 5 on current CLIs, older // Opus on older CLIs), so pinned version labels lie on part of the fleet. diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index 826b1edb162..b2c032bdb66 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -58,6 +58,8 @@ export type CatalogModel = { export type AgentSessionOptionCatalog = { models: CatalogModel[] modelApply: CatalogOptionApply + /** Stable launch values intentionally omitted from picker membership. */ + launchModelAliases?: readonly string[] /** Opts this agent into structured per-worker launch overrides. */ supportsWorkerLaunchPreferences?: true /** Launch-safe options for opaque model ids that are absent from the static catalog. */