fix(task): key subagent fallback chains off the pre-expansion model role
A single-model subagent is pinned to a `subagent:<id>` role whose `retry.fallbackChains` entry shadows every configured role chain, so the chain it inherits decides where the child retries. Inheritance resolved the role by re-deriving it from the child's `modelPatterns` — but every spawn path expands the role alias into `modelOverride` before calling `runSubprocess` (`modelPatterns = normalizeModelPatterns(modelOverride ?? agent.model)`), so `@task` never reached the derivation and it returned `undefined` every time. Every task subagent inherited `chains.default`. With `modelRoles.task: anthropic/claude-sonnet-5`, `task` chained to sonnet alone, and `default` chained to sonnet plus a second provider, a transient stall on sonnet routed the child onto the default chain's second model — one the operator had deliberately kept out of the `task` chain — and a quota error there killed a 28-minute run. #7694 fixed only the shape where an unexpanded alias reaches the executor, which no production caller produces; its tests supplied a bare `agent.model: ["@smol"]` with no `modelOverride`. The incident above happened on v17.2.10, which contains that fix. Route inheritance off the role identity the spawn path already computes and passes as `modelRole`. Since that leaves the pattern-derived operand unreachable, drop it and the parameter it was the only user of. The vibe worker path had the same defect independently: `#resolveWorker` expanded `@task`/`@smol` for the bundled `task`/`sonic` workers and kept no role, so vibe children inherited `default` no matter what the executor did. It now carries `modelRole` on `ResolvedVibeWorker` and `VibeRecord` through both the spawn and rehydrate sites. To stop the two halves drifting apart again — the mistake that caused this bug — `resolveAgentModelSelection` returns the expanded `patterns` and the pre-expansion `role` from one call, and both spawn paths take both from it. `resolveAgentModelSource` is removed: its only use was being fed to `resolveExplicitModelRole`, and keeping it invites the same split derivation. `resolveAgentModelPatterns` stays for the UI callers that legitimately want patterns alone. Tests cover the producible shapes: the incident's chain layout (role chain equal to the primary, default chain a superset), role identity surviving expansion for every alias-routed bundled agent, and the patterns/role pairing itself. #7694's two tests are re-anchored to a shape a real caller produces.
This commit is contained in:
@@ -189,19 +189,25 @@ function resolveSubagentRetryFallbackCandidates(
|
||||
* Chain a single-model subagent inherits when its own model patterns supply no
|
||||
* fallbacks of their own. The child is pinned to a `subagent:<id>` role whose
|
||||
* chain shadows every configured role chain (see
|
||||
* {@link installSubagentRetryFallbackChain}), so a role-alias request (`@smol`)
|
||||
* MUST inherit that role's chain — otherwise the pin silently re-routes the
|
||||
* child onto the `default` role's chain. Explicit model selectors keep
|
||||
* inheriting `default`: they carry no role identity, and a role that happens to
|
||||
* be assigned the same model must not capture the child's fallback routing.
|
||||
* {@link installSubagentRetryFallbackChain}), so a role-alias request (`@smol`,
|
||||
* the bundled `task` agent's `@task`) MUST inherit that role's chain —
|
||||
* otherwise the pin silently re-routes the child onto the `default` role's
|
||||
* chain. Explicit model selectors keep inheriting `default`: they carry no role
|
||||
* identity, and a role that happens to be assigned the same model must not
|
||||
* capture the child's fallback routing.
|
||||
*
|
||||
* `modelRole` is the sole witness of that identity. Callers hand
|
||||
* `runSubprocess` the model patterns already expanded by
|
||||
* `resolveAgentModelSelection`, so `@task` never survives into them — the role
|
||||
* has to travel beside the patterns, and re-deriving it from them yields
|
||||
* `undefined` every time.
|
||||
*/
|
||||
function resolveSubagentInheritedRetryFallbackChain(
|
||||
settings: Settings,
|
||||
modelRegistry: ModelRegistry,
|
||||
modelPatterns: string[],
|
||||
role: string | undefined,
|
||||
): string[] | undefined {
|
||||
const configuredChains = settings.get("retry.fallbackChains");
|
||||
const role = resolveExplicitModelRole(modelPatterns, settings);
|
||||
// An explicitly emptied role chain means "no fallbacks", not "inherit
|
||||
// default" — mirrors expandDefaultRetryFallbackChains.
|
||||
const fallbackChain = (role !== undefined ? configuredChains?.[role] : undefined) ?? configuredChains?.default;
|
||||
@@ -2835,7 +2841,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
const configuredModelPatterns = resolveConfiguredModelPatterns(modelPatterns, settings);
|
||||
const inheritedRetryFallbackChain =
|
||||
configuredModelPatterns.length === 1
|
||||
? resolveSubagentInheritedRetryFallbackChain(subagentSettings, modelRegistry, modelPatterns)
|
||||
? resolveSubagentInheritedRetryFallbackChain(subagentSettings, modelRegistry, modelRole)
|
||||
: undefined;
|
||||
const {
|
||||
model,
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import path from "node:path";
|
||||
import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { resolveAgentModelPatterns, resolveAgentModelSource, resolveExplicitModelRole } from "../config/model-resolver";
|
||||
import { resolveAgentModelSelection } from "../config/model-resolver";
|
||||
import type { LocalProtocolOptions } from "../internal-urls";
|
||||
import { registerArtifactsDir } from "../internal-urls/registry-helpers";
|
||||
import { MCPManager } from "../mcp/manager";
|
||||
@@ -288,10 +288,10 @@ export async function resolveEffectiveSubagentPolicy(
|
||||
activeModelPattern: parentActiveModelPattern,
|
||||
fallbackModelPattern: request.session.getModelString?.(),
|
||||
};
|
||||
// Keep role identity from the same effective non-empty source that supplies
|
||||
// model selection: caller request, settings override, then agent definition.
|
||||
const modelRole = resolveExplicitModelRole(resolveAgentModelSource(modelResolution), request.session.settings);
|
||||
const modelOverride = resolveAgentModelPatterns(modelResolution);
|
||||
// Role identity and patterns come from one call so they cannot be derived
|
||||
// from different sources: the expansion below discards the alias, and the
|
||||
// child's inherited retry-fallback chain is keyed off the role.
|
||||
const { patterns: modelOverride, role: modelRole } = resolveAgentModelSelection(modelResolution);
|
||||
const isolationMode = request.session.settings.get("task.isolation.mode");
|
||||
const isIsolated = request.isolation?.requested === true;
|
||||
if (isIsolated && isolationMode === "none") {
|
||||
|
||||
Reference in New Issue
Block a user