From a4931a47318aea4b472b630e8a01bc620c95c1a5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 9 Jul 2026 08:10:01 -0700 Subject: [PATCH] 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) --- ...-7719-triage-planning-implicit-fallback.md | 7 + docs/settings-reference.md | 2 + .../__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(-) create mode 100644 .changeset/fn-7719-triage-planning-implicit-fallback.md diff --git a/.changeset/fn-7719-triage-planning-implicit-fallback.md b/.changeset/fn-7719-triage-planning-implicit-fallback.md new file mode 100644 index 0000000000..34599df1a3 --- /dev/null +++ b/.changeset/fn-7719-triage-planning-implicit-fallback.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 072a06d621..211fbd5c88 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. +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. 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/` 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). diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index fe4958be2d..8df218a726 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -4,6 +4,7 @@ import { extractRuntimeModel, resolveExecutorSessionModel, resolveHeartbeatSessionModels, + resolveImplicitPlanningFallbackModel, resolveMergerSessionModel, resolvePlanningSessionModel, 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", + }); + }); +}); diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 37e0cb310d..9224c2fa3a 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -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).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).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).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).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 () => { const task = { id: "FN-201", diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index a6e4ef5268..1186df9389 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -212,6 +212,47 @@ export function resolvePlanningSessionModel( 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 | undefined, + primaryProvider: string | undefined, + primaryModelId: string | undefined, + assignedAgentRuntimeConfig?: Record, +): { 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( taskValidatorModelProvider: string | undefined, taskValidatorModelId: string | undefined, diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 416d7a4bc1..30297b9db4 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -86,6 +86,7 @@ import { ModelFallbackExhaustedError, describeModel, formatModelMarkerDetails, p import { createResolvedAgentSession, extractRuntimeHint, + resolveImplicitPlanningFallbackModel, resolvePlanningSessionModel, } from "./agent-session-helpers.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; @@ -1117,6 +1118,28 @@ export class TriageProcessor { 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({ sessionPurpose: "triage", runtimeHint: triageRuntimeHint, @@ -1131,12 +1154,12 @@ export class TriageProcessor { onToolStart: agentLogger.onToolStart, onToolEnd: agentLogger.onToolEnd, ...planningSessionModelOptions, - fallbackProvider: settings.planningFallbackProvider && settings.planningFallbackModelId + fallbackProvider: hasExplicitPlanningFallback ? settings.planningFallbackProvider - : settings.fallbackProvider, - fallbackModelId: settings.planningFallbackProvider && settings.planningFallbackModelId + : (hasExplicitGlobalFallback ? settings.fallbackProvider : implicitPlanningFallback.provider), + fallbackModelId: hasExplicitPlanningFallback ? settings.planningFallbackModelId - : settings.fallbackModelId, + : (hasExplicitGlobalFallback ? settings.fallbackModelId : implicitPlanningFallback.modelId), defaultThinkingLevel: settings.defaultThinkingLevel, runAuditor, settings,