FN-7719: derive implicit planning fallback model when no fallback configured

Triage planning-model retries no longer dead-end on "no fallback configured" when a provider primary-model call fails (e.g. a 404 wrapped in a 429 for nvidia/moonshotai/kimi-k2.6).

- Add resolveImplicitPlanningFallbackModel() to agent-session-helpers.ts: derives a fallback from the resolved project/global default (execution) model when neither planningFallback*/global fallback* is set, guarding against self-swap and skipping in test mode.
- Wire the implicit fallback into TriageProcessor.specifyTask() in triage.ts so a retryable primary planner-model failure swaps once via the derived fallback instead of failing triage outright.
- Add unit test coverage in agent-session-helpers.test.ts and triage.test.ts for the new implicit-fallback resolution and its triage integration.
- Document the new implicit-fallback behavior in docs/settings-reference.md.
- Add a patch changeset for @runfusion/fusion describing the fix.

Files changed:
 .changeset/fn-7719-triage-planning-implicit-fallback.md           |   7 +
 docs/settings-reference.md                                       |   2 +
 packages/engine/src/__tests__/agent-session-helpers.test.ts       |  75 +++++++
 packages/engine/src/__tests__/triage.test.ts                      | 237 +++++++++++++++++++++
 packages/engine/src/agent-session-helpers.ts                      |  41 ++++
 packages/engine/src/triage.ts                                     |  31 ++-
 6 files changed, 389 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7719

Fusion-Task-Lineage: 69e797e1-5bac-47f3-8dce-505b9d64d83c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 08:10:01 -07:00
parent 71e9f484bb
commit a4931a4731
6 changed files with 389 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Triage recovers automatically when the planning model hits a provider 404/429 and no fallback is set.
category: fix
dev: TriageProcessor.specifyTask now derives an implicit fallback from the project/global default (execution) model when no planningFallback*/global fallback* pair is configured, so a retryable primary planner-model failure swaps once instead of failing triage with "no fallback configured". Test mode and self-swap are excluded; the single-swap ModelFallbackExhaustedError terminal path is preserved.

View File

@@ -964,6 +964,8 @@ Short-lived token bounds are enforced server-side:
Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models. Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models.
When the planning lane has neither `planningFallback*` nor a global `fallback*` pair configured, triage now derives an **implicit fallback** from the resolved project/global default (execution) model (FN-7719). This lets a retryable primary planner-model failure (e.g. a provider 404/429) recover via one distinct swap instead of permanently failing triage with "no fallback configured" — the operator's chosen primary planner lane is unchanged, and the implicit fallback is skipped when it would equal the primary model or when test mode is active.
Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available. Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available.
Grok (`grok-cli`) is likewise seeded as a built-in provider — xAI's OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`), API key `GROK_API_KEY` — into every model registry Fusion seeds (task execution, dashboard `/api/models`, and CLI `serve`/`daemon`/`dashboard`), mirroring the Z.ai pattern above. This makes `grok-cli/<model>` selections (e.g. `grok-cli/grok-4.5`) resolvable for execution even before the `grok` CLI binary is discovered or the picker surfaces additional Grok models (see the CLI-discovery paragraph below); a missing `GROK_API_KEY` surfaces only as a normal auth error at stream time, not a model-resolution failure. If `GROK_API_KEY` is not set in the environment, provider registration falls back to `~/.grok/user-settings.json`'s `apiKey` field (the same file the `grok` CLI itself writes on login) and hydrates `process.env.GROK_API_KEY` from it, so an operator who authenticated via the `grok` CLI but never exported the env var still resolves a key; an already-set env var always wins, and a missing/malformed/empty settings file is fail-soft (no error, no env mutation). Grok (`grok-cli`) is likewise seeded as a built-in provider — xAI's OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`), API key `GROK_API_KEY` — into every model registry Fusion seeds (task execution, dashboard `/api/models`, and CLI `serve`/`daemon`/`dashboard`), mirroring the Z.ai pattern above. This makes `grok-cli/<model>` selections (e.g. `grok-cli/grok-4.5`) resolvable for execution even before the `grok` CLI binary is discovered or the picker surfaces additional Grok models (see the CLI-discovery paragraph below); a missing `GROK_API_KEY` surfaces only as a normal auth error at stream time, not a model-resolution failure. If `GROK_API_KEY` is not set in the environment, provider registration falls back to `~/.grok/user-settings.json`'s `apiKey` field (the same file the `grok` CLI itself writes on login) and hydrates `process.env.GROK_API_KEY` from it, so an operator who authenticated via the `grok` CLI but never exported the env var still resolves a key; an already-set env var always wins, and a missing/malformed/empty settings file is fail-soft (no error, no env mutation).

View File

@@ -4,6 +4,7 @@ import {
extractRuntimeModel, extractRuntimeModel,
resolveExecutorSessionModel, resolveExecutorSessionModel,
resolveHeartbeatSessionModels, resolveHeartbeatSessionModels,
resolveImplicitPlanningFallbackModel,
resolveMergerSessionModel, resolveMergerSessionModel,
resolvePlanningSessionModel, resolvePlanningSessionModel,
resolveValidatorSessionModel, resolveValidatorSessionModel,
@@ -629,3 +630,77 @@ describe("resolveMergerSessionModel", () => {
}); });
}); });
}); });
describe("resolveImplicitPlanningFallbackModel (FN-7719)", () => {
it("derives a distinct implicit fallback from the project/global default model", () => {
expect(
resolveImplicitPlanningFallbackModel(
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
},
"9router",
"nvidia/moonshotai/kimi-k2.6",
),
).toEqual({
provider: "openai",
modelId: "gpt-4o",
});
});
it("returns undefined/undefined when the implicit fallback would equal the primary (self-swap guard)", () => {
expect(
resolveImplicitPlanningFallbackModel(
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
},
"openai",
"gpt-4o",
),
).toEqual({
provider: undefined,
modelId: undefined,
});
});
it("returns undefined/undefined when no project/global default model is configured", () => {
expect(
resolveImplicitPlanningFallbackModel({}, "9router", "nvidia/moonshotai/kimi-k2.6"),
).toEqual({
provider: undefined,
modelId: undefined,
});
});
it("does not inject an implicit fallback in test mode", () => {
expect(
resolveImplicitPlanningFallbackModel(
{
testMode: true,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
},
"9router",
"nvidia/moonshotai/kimi-k2.6",
),
).toEqual({
provider: undefined,
modelId: undefined,
});
});
it("prefers the assigned agent runtime model when no default model pair is configured", () => {
expect(
resolveImplicitPlanningFallbackModel(
{},
"9router",
"nvidia/moonshotai/kimi-k2.6",
{ model: "anthropic/claude-3-5-sonnet-20241022" },
),
).toEqual({
provider: "anthropic",
modelId: "claude-3-5-sonnet-20241022",
});
});
});

View File

@@ -4263,6 +4263,243 @@ describe("taskCreate tool model inheritance", () => {
})); }));
}); });
describe("implicit planning fallback (FN-7719)", () => {
const baseSession = () => ({
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
sessionManager: {
getLeafId: vi.fn().mockReturnValue(null),
navigateTree: vi.fn(),
},
});
it("recovers from the reported 404/429 planner failure via a derived implicit fallback", async () => {
const task = {
id: "FN-7719",
description: "Bug: 9router/Planning 404 on nvidia/moonshotai/kimi-k2.6",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const onSpecifyError = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
// Primary planner lane ("9router/Planning") — distinct from the project default.
planningProvider: "9router",
planningModelId: "nvidia/moonshotai/kimi-k2.6",
defaultProvider: "openai",
defaultModelId: "gpt-4o",
// No planningFallback*/global fallback* configured — this is the reported gap.
defaultThinkingLevel: "low",
} as Settings),
});
mockCreateFnAgent.mockResolvedValue({ session: baseSession() });
const { promptWithFallback } = await import("../pi.js");
// With a distinct implicit fallback now supplied, pi.ts's real single-swap
// loop (covered by pi.test.ts) recovers instead of throwing
// ModelFallbackExhaustedError — simulate that recovered outcome here.
(promptWithFallback as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
onSpecifyError,
});
await processor.specifyTask(task);
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "9router",
defaultModelId: "nvidia/moonshotai/kimi-k2.6",
fallbackProvider: "openai",
fallbackModelId: "gpt-4o",
}),
);
expect(store.updateTask).not.toHaveBeenCalledWith("FN-7719", expect.objectContaining({
status: "failed",
error: expect.stringContaining("no fallback configured"),
}));
});
it("stays terminal when the implicit fallback would equal the primary planner model (self-swap guard)", async () => {
const task = {
id: "FN-7719-SELF-SWAP",
description: "No distinct default model available for implicit fallback",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const onSpecifyError = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
// No planningProvider/planningModelId — the primary planning model
// resolves through to the project default itself, so the implicit
// fallback would equal the primary. Must NOT self-swap.
defaultProvider: "openai",
defaultModelId: "gpt-4o",
defaultThinkingLevel: "low",
} as Settings),
});
mockCreateFnAgent.mockResolvedValue({ session: baseSession() });
const { ModelFallbackExhaustedError, promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new ModelFallbackExhaustedError({
primaryModel: "openai/gpt-4o",
triggerPoint: "prompt-time",
attempts: 1,
underlyingReason: "model not found: no distinct fallback available",
}),
);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
onSpecifyError,
});
await processor.specifyTask(task);
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
fallbackProvider: undefined,
fallbackModelId: undefined,
}),
);
expect(store.updateTask).toHaveBeenCalledWith("FN-7719-SELF-SWAP", expect.objectContaining({
status: "failed",
recoveryRetryCount: null,
nextRecoveryAt: null,
}));
expect(onSpecifyError).toHaveBeenCalledTimes(1);
});
it("does not override an explicitly configured planningFallback* pair", async () => {
const task = {
id: "FN-7719-EXPLICIT-PLANNING",
description: "Explicit planning fallback stays authoritative",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
planningProvider: "9router",
planningModelId: "nvidia/moonshotai/kimi-k2.6",
defaultProvider: "openai",
defaultModelId: "gpt-4o",
planningFallbackProvider: "anthropic",
planningFallbackModelId: "claude-3-5-haiku-20241022",
} as Settings),
});
mockCreateFnAgent.mockResolvedValue({ session: baseSession() });
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 });
await processor.specifyTask(task);
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "9router",
defaultModelId: "nvidia/moonshotai/kimi-k2.6",
fallbackProvider: "anthropic",
fallbackModelId: "claude-3-5-haiku-20241022",
}),
);
});
it("does not override an explicitly configured global fallback* pair", async () => {
const task = {
id: "FN-7719-EXPLICIT-GLOBAL",
description: "Explicit global fallback stays authoritative",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
planningProvider: "9router",
planningModelId: "nvidia/moonshotai/kimi-k2.6",
defaultProvider: "openai",
defaultModelId: "gpt-4o",
fallbackProvider: "google",
fallbackModelId: "gemini-2.5-pro",
} as Settings),
});
mockCreateFnAgent.mockResolvedValue({ session: baseSession() });
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 });
await processor.specifyTask(task);
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "9router",
defaultModelId: "nvidia/moonshotai/kimi-k2.6",
fallbackProvider: "google",
fallbackModelId: "gemini-2.5-pro",
}),
);
});
// NOTE: test-mode exclusion (isTestModeActive -> no implicit fallback
// injected) is covered directly at the resolver-unit level in
// agent-session-helpers.test.ts ("resolveImplicitPlanningFallbackModel
// (FN-7719)"). The mock runtime used by createResolvedAgentSession in
// test mode does not route through createFnAgent, so it cannot assert
// fallbackProvider/fallbackModelId via mockCreateFnAgent call args here.
});
it("escalates to error state when triage retries are exhausted via specifyTask", async () => { it("escalates to error state when triage retries are exhausted via specifyTask", async () => {
const task = { const task = {
id: "FN-201", id: "FN-201",

View File

@@ -212,6 +212,47 @@ export function resolvePlanningSessionModel(
return pickSettingsThenRuntimeModel(resolvedTaskPlanningModel, assignedAgentRuntimeConfig); return pickSettingsThenRuntimeModel(resolvedTaskPlanningModel, assignedAgentRuntimeConfig);
} }
/**
* FNXC:TriageModelFallback 2026-07-09-00:00:
* When no explicit `planningFallback*`/global `fallback*` pair is configured, the
* planning lane must still get a working fallback. Derive one implicitly from the
* resolved project/global default (execution) model — the same resolver
* `resolveHeartbeatSessionModels`/`resolveMergerSessionModel` use — so a retryable
* primary-planner failure (e.g. provider 404/429) recovers via one distinct swap
* instead of failing triage permanently (see FN-7719). Guard against self-swap
* (implicit fallback === primary planning model) and skip entirely in test mode,
* so the single-swap `usingFallback` ceiling in pi.ts and the terminal
* ModelFallbackExhaustedError path are preserved unchanged.
*/
export function resolveImplicitPlanningFallbackModel(
settings: Partial<Settings> | undefined,
primaryProvider: string | undefined,
primaryModelId: string | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
): { provider: string | undefined; modelId: string | undefined } {
if (isTestModeActive(settings)) {
return { provider: undefined, modelId: undefined };
}
const defaultModel = resolveProjectDefaultModel(settings);
const resolvedModel = pickSettingsThenRuntimeModel(defaultModel, assignedAgentRuntimeConfig);
if (!resolvedModel.provider || !resolvedModel.modelId) {
return { provider: undefined, modelId: undefined };
}
// Self-swap guard: an implicit fallback identical to the primary planner
// model would produce a misleading "fallback configured" message while
// still hitting the terminal ModelFallbackExhaustedError path in pi.ts
// (hasDistinctFallback requires the models to differ). Leave both fields
// undefined so the existing terminal behavior is preserved cleanly.
if (resolvedModel.provider === primaryProvider && resolvedModel.modelId === primaryModelId) {
return { provider: undefined, modelId: undefined };
}
return resolvedModel;
}
export function resolveValidatorSessionModel( export function resolveValidatorSessionModel(
taskValidatorModelProvider: string | undefined, taskValidatorModelProvider: string | undefined,
taskValidatorModelId: string | undefined, taskValidatorModelId: string | undefined,

View File

@@ -86,6 +86,7 @@ import { ModelFallbackExhaustedError, describeModel, formatModelMarkerDetails, p
import { import {
createResolvedAgentSession, createResolvedAgentSession,
extractRuntimeHint, extractRuntimeHint,
resolveImplicitPlanningFallbackModel,
resolvePlanningSessionModel, resolvePlanningSessionModel,
} from "./agent-session-helpers.js"; } from "./agent-session-helpers.js";
import { mergeEffectiveSettings } from "./effective-settings.js"; import { mergeEffectiveSettings } from "./effective-settings.js";
@@ -1117,6 +1118,28 @@ export class TriageProcessor {
defaultModelId: planningModel.modelId, defaultModelId: planningModel.modelId,
}; };
/*
* FNXC:TriageModelFallback 2026-07-09-00:00:
* When neither `planningFallback*` nor global `fallback*` is configured,
* derive an implicit fallback from the project/global default (execution)
* model so a retryable primary-planner failure (e.g. provider 404/429)
* recovers via one distinct swap instead of failing triage permanently
* (FN-7719: nvidia/moonshotai/kimi-k2.6 404 wrapped in a 429 stalled a
* whole board's triage with "no fallback configured"). Self-swap (implicit
* fallback === primary) and test mode are excluded so the single-swap,
* no-loop invariant and the mock lane stay unchanged.
*/
const hasExplicitPlanningFallback = Boolean(settings.planningFallbackProvider && settings.planningFallbackModelId);
const hasExplicitGlobalFallback = Boolean(settings.fallbackProvider && settings.fallbackModelId);
const implicitPlanningFallback = (!hasExplicitPlanningFallback && !hasExplicitGlobalFallback)
? resolveImplicitPlanningFallbackModel(
settings,
planningModel.provider,
planningModel.modelId,
assignedAgent?.runtimeConfig,
)
: { provider: undefined, modelId: undefined };
const { session } = await createResolvedAgentSession({ const { session } = await createResolvedAgentSession({
sessionPurpose: "triage", sessionPurpose: "triage",
runtimeHint: triageRuntimeHint, runtimeHint: triageRuntimeHint,
@@ -1131,12 +1154,12 @@ export class TriageProcessor {
onToolStart: agentLogger.onToolStart, onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd, onToolEnd: agentLogger.onToolEnd,
...planningSessionModelOptions, ...planningSessionModelOptions,
fallbackProvider: settings.planningFallbackProvider && settings.planningFallbackModelId fallbackProvider: hasExplicitPlanningFallback
? settings.planningFallbackProvider ? settings.planningFallbackProvider
: settings.fallbackProvider, : (hasExplicitGlobalFallback ? settings.fallbackProvider : implicitPlanningFallback.provider),
fallbackModelId: settings.planningFallbackProvider && settings.planningFallbackModelId fallbackModelId: hasExplicitPlanningFallback
? settings.planningFallbackModelId ? settings.planningFallbackModelId
: settings.fallbackModelId, : (hasExplicitGlobalFallback ? settings.fallbackModelId : implicitPlanningFallback.modelId),
defaultThinkingLevel: settings.defaultThinkingLevel, defaultThinkingLevel: settings.defaultThinkingLevel,
runAuditor, runAuditor,
settings, settings,