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:
enieuwy
2026-08-07 21:16:27 +08:00
parent ab78d3091e
commit 77ee3f2e7e
8 changed files with 184 additions and 58 deletions
+14 -8
View File
@@ -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,