From 72391c90b2bae708dd1e8d5d4c4c34f45d72b28f Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Wed, 29 Jul 2026 00:05:04 -0700 Subject: [PATCH] fix(engine): route workflow reviews through validator models (#2533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - classify review-type workflow steps with the existing review-step classifier - resolve their primary, fallback, and thinking-level settings from the validator model lane - retain per-step model overrides and executor-purpose workflow-step tooling - keep ordinary workflow steps on the execution lane - make missing-fallback diagnostics identify the correct lane ## Why Code Review, Plan Review, verification, and inline-review gates were executed through the implementation model lane merely because they run inside `executeWorkflowStep()`. That defeats configured reviewer-model separation and can make the same model implement and validate its own work. This changes model selection—not the workflow-step session/tooling contract—so review steps remain executor-purpose sessions while using validator lane models. ## Verification - `FUSION_PG_TEST_SKIP=1 corepack pnpm@10.33.0 --filter @fusion/engine exec vitest run src/__tests__/executor-workflow-step-model.test.ts` — 14 passed - `corepack pnpm@10.33.0 --filter @fusion/engine typecheck` - `corepack pnpm@10.33.0 changeset status --since=origin/main` - `git diff --check origin/main...HEAD` ## Summary by CodeRabbit * **Bug Fixes** * Review-type workflow steps now route through the configured validator model lane (instead of the execution lane). * Validator primary/fallback and thinking-level settings are applied correctly for review steps. * Step/task overrides still take priority over lane-based resolution. * Fallback retry sessions now use the appropriate validator/executor configuration, with lane-specific fallback guidance when fallback settings are missing. * **Tests** * Expanded executor workflow-step model resolution and routing/fallback precedence assertions for validator-lane behavior. --- ...workflow-reviews-through-validator-lane.md | 7 + .../src/__tests__/executor-test-helpers.ts | 20 +- .../executor-workflow-step-model.test.ts | 187 ++++++++++++++++++ packages/engine/src/executor.ts | 82 +++++--- 4 files changed, 262 insertions(+), 34 deletions(-) create mode 100644 .changeset/route-workflow-reviews-through-validator-lane.md diff --git a/.changeset/route-workflow-reviews-through-validator-lane.md b/.changeset/route-workflow-reviews-through-validator-lane.md new file mode 100644 index 0000000000..e5934e3976 --- /dev/null +++ b/.changeset/route-workflow-reviews-through-validator-lane.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Route review-type workflow steps through validator model lanes instead of the execution model. +category: fix +dev: Review classifiers now select validator primary, fallback, and thinking-level settings while preserving executor runtime semantics and explicit step/task overrides. diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 380dcf1958..02c6a5daf9 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -124,8 +124,10 @@ function withSessionDefaults(session: any, options?: { systemPrompt?: unknown }) } vi.mock("../agent-session-helpers.js", async () => { + const actual = await vi.importActual("../agent-session-helpers.js"); const { createFnAgent } = await import("../pi.js"); return { + ...actual, createResolvedAgentSession: async (options: any) => { const result = await createFnAgent(options); return { @@ -157,15 +159,26 @@ vi.mock("../agent-session-helpers.js", async () => { ?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined) ?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined), resolveValidatorThinkingLevel: (taskThinkingLevel: string | undefined, settings: Record | undefined) => - (typeof settings?.validatorThinkingLevel === "string" ? settings.validatorThinkingLevel : undefined) - ?? taskThinkingLevel + taskThinkingLevel + ?? (typeof settings?.validatorThinkingLevel === "string" ? settings.validatorThinkingLevel : undefined) + ?? (typeof settings?.validatorGlobalThinkingLevel === "string" ? settings.validatorGlobalThinkingLevel : undefined) + ?? (typeof (settings?.selectedWorkflowModelLanes as Record | undefined)?.validatorThinkingLevel === "string" + ? (settings?.selectedWorkflowModelLanes as Record).validatorThinkingLevel as string + : undefined) ?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined) ?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined), resolveValidatorFallbackThinkingLevel: (taskThinkingLevel: string | undefined, settings: Record | undefined) => (typeof settings?.validatorFallbackThinkingLevel === "string" ? settings.validatorFallbackThinkingLevel : undefined) ?? (typeof settings?.fallbackThinkingLevel === "string" ? settings.fallbackThinkingLevel : undefined) - ?? (typeof settings?.validatorThinkingLevel === "string" ? settings.validatorThinkingLevel : undefined) + ?? (typeof (settings?.selectedWorkflowModelLanes as Record | undefined)?.validatorFallbackThinkingLevel === "string" + ? (settings?.selectedWorkflowModelLanes as Record).validatorFallbackThinkingLevel as string + : undefined) ?? taskThinkingLevel + ?? (typeof settings?.validatorThinkingLevel === "string" ? settings.validatorThinkingLevel : undefined) + ?? (typeof settings?.validatorGlobalThinkingLevel === "string" ? settings.validatorGlobalThinkingLevel : undefined) + ?? (typeof (settings?.selectedWorkflowModelLanes as Record | undefined)?.validatorThinkingLevel === "string" + ? (settings?.selectedWorkflowModelLanes as Record).validatorThinkingLevel as string + : undefined) ?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined) ?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined), resolveExecutorSessionModel: ( @@ -197,6 +210,7 @@ vi.mock("../agent-session-helpers.js", async () => { } return { provider: undefined, modelId: undefined }; }, + }; }); vi.mock("../worktree-names.js", async () => { diff --git a/packages/engine/src/__tests__/executor-workflow-step-model.test.ts b/packages/engine/src/__tests__/executor-workflow-step-model.test.ts index 928d60c1af..edd8f71e0b 100644 --- a/packages/engine/src/__tests__/executor-workflow-step-model.test.ts +++ b/packages/engine/src/__tests__/executor-workflow-step-model.test.ts @@ -9,18 +9,26 @@ import { } from "./executor-test-helpers.js"; type CapturedSession = { + sessionPurpose?: string; defaultProvider?: string; defaultModelId?: string; + fallbackProvider?: string; + fallbackModelId?: string; defaultThinkingLevel?: string; + fallbackThinkingLevel?: string; }; function captureSession(output = '{"verdict":"APPROVE","notes":""}'): { last?: CapturedSession } { const holder: { last?: CapturedSession } = {}; mockedCreateFnAgent.mockImplementation(async (opts: any) => { holder.last = { + sessionPurpose: opts.sessionPurpose, defaultProvider: opts.defaultProvider, defaultModelId: opts.defaultModelId, + fallbackProvider: opts.fallbackProvider, + fallbackModelId: opts.fallbackModelId, defaultThinkingLevel: opts.defaultThinkingLevel, + fallbackThinkingLevel: opts.fallbackThinkingLevel, }; const listeners: Array<(event: any) => void> = []; @@ -129,13 +137,22 @@ describe("executor workflow-step model resolution", () => { const captured = await runStepWithSettings({ executionProvider: "openai", executionModelId: "gpt-4o", + executionFallbackProvider: "openai", + executionFallbackModelId: "gpt-4o-mini", + executionThinkingLevel: "medium", + fallbackThinkingLevel: "low", defaultProvider: "anthropic", defaultModelId: "claude-3-5-sonnet", }); expect(captured).toMatchObject({ + sessionPurpose: "executor", defaultProvider: "openai", defaultModelId: "gpt-4o", + fallbackProvider: "openai", + fallbackModelId: "gpt-4o-mini", + defaultThinkingLevel: "medium", + fallbackThinkingLevel: "low", }); expect(captured).not.toMatchObject({ defaultProvider: "anthropic", @@ -143,6 +160,176 @@ describe("executor workflow-step model resolution", () => { }); }); + it("routes review-type workflow steps through the validator model lane", async () => { + const captured = await runStepWithSettings( + { + executionProvider: "openai-codex", + executionModelId: "gpt-5.6-terra", + validatorProvider: "openai-codex", + validatorModelId: "gpt-5.6-sol", + validatorFallbackProvider: "openai-codex", + validatorFallbackModelId: "gpt-5.6-flash", + validatorThinkingLevel: "high", + validatorFallbackThinkingLevel: "low", + }, + { + step: { + id: "graph:code-review-step", + name: "Code Review", + optionalGroupId: "code-review", + }, + }, + ); + + expect(captured).toMatchObject({ + sessionPurpose: "executor", + defaultProvider: "openai-codex", + defaultModelId: "gpt-5.6-sol", + fallbackProvider: "openai-codex", + fallbackModelId: "gpt-5.6-flash", + defaultThinkingLevel: "high", + fallbackThinkingLevel: "low", + }); + }); + + it.each([ + ["inline-fix metadata", { name: "Implementation Check", reviewCanFixInline: true }], + ["Code Review group metadata", { name: "Implementation Check", optionalGroupId: "code-review" }], + ["Plan Review identity", { id: "graph:plan-review-step", name: "Plan Review" }], + ["verification name", { name: "Artifact Verification" }], + ])("classifies %s as validator-routed", async (_label, step) => { + await expect( + runStepWithSettings( + { + executionProvider: "executor-provider", + executionModelId: "executor-model", + validatorProvider: "validator-provider", + validatorModelId: "validator-model", + }, + { step }, + ), + ).resolves.toMatchObject({ + sessionPurpose: "executor", + defaultProvider: "validator-provider", + defaultModelId: "validator-model", + }); + }); + + it("keeps near-match ordinary names on executor lanes", async () => { + await expect( + runStepWithSettings( + { + executionProvider: "executor-provider", + executionModelId: "executor-model", + validatorProvider: "validator-provider", + validatorModelId: "validator-model", + }, + { step: { name: "Implementation Overview" } }, + ), + ).resolves.toMatchObject({ + sessionPurpose: "executor", + defaultProvider: "executor-provider", + defaultModelId: "executor-model", + }); + }); + + it("uses selected-workflow validator lanes after project and global validator lanes fall through", async () => { + await expect( + runStepWithSettings( + { + selectedWorkflowModelLanes: { + validatorProvider: "workflow-validator-provider", + validatorModelId: "workflow-validator-model", + validatorFallbackProvider: "workflow-fallback-provider", + validatorFallbackModelId: "workflow-fallback-model", + validatorFallbackThinkingLevel: "low", + }, + defaultProvider: "default-provider", + defaultModelId: "default-model", + }, + { step: { name: "Code Review", optionalGroupId: "code-review" } }, + ), + ).resolves.toMatchObject({ + defaultProvider: "workflow-validator-provider", + defaultModelId: "workflow-validator-model", + fallbackProvider: "workflow-fallback-provider", + fallbackModelId: "workflow-fallback-model", + fallbackThinkingLevel: "low", + }); + }); + + it("keeps review step and task overrides ahead of validator-lane settings", async () => { + await expect( + runStepWithSettings( + { + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + validatorThinkingLevel: "medium", + }, + { + task: { + validatorModelProvider: "task-validator-provider", + validatorModelId: "task-validator-model", + validatorThinkingLevel: "high", + thinkingLevel: "low", + }, + step: { + name: "Code Review", + optionalGroupId: "code-review", + }, + }, + ), + ).resolves.toMatchObject({ + defaultProvider: "task-validator-provider", + defaultModelId: "task-validator-model", + defaultThinkingLevel: "high", + }); + + await expect( + runStepWithSettings( + { + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + }, + { + step: { + name: "Code Review", + optionalGroupId: "code-review", + modelProvider: "step-provider", + modelId: "step-model", + }, + }, + ), + ).resolves.toMatchObject({ + defaultProvider: "step-provider", + defaultModelId: "step-model", + }); + }); + + it.each([ + ["provider only", { modelProvider: "partial-provider" }], + ["model only", { modelId: "partial-model" }], + ])("does not mix a %s step override with the validator lane", async (_label, partialOverride) => { + await expect( + runStepWithSettings( + { + validatorProvider: "validator-provider", + validatorModelId: "validator-model", + }, + { + step: { + name: "Code Review", + optionalGroupId: "code-review", + ...partialOverride, + }, + }, + ), + ).resolves.toMatchObject({ + defaultProvider: "validator-provider", + defaultModelId: "validator-model", + }); + }); + it("keeps step and task overrides ahead of execution-lane settings", async () => { await expect( runStepWithSettings( diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index b9fc6db8e6..96c84b4e34 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -16,7 +16,7 @@ import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, import { getUnmetSchedulingDependencies } from "./scheduler.js"; import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js"; import { emitWorkflowLifecycleEvent } from "@fusion/core"; -import { RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel } from "@fusion/core"; +import { RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel } from "@fusion/core"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; import { generateFeatureVideo, type GenerateFeatureVideoOptions } from "./review-artifacts/feature-video.js"; @@ -106,6 +106,7 @@ import { createResolvedAgentSession, extractRuntimeHint, resolveExecutorSessionModel, + resolveValidatorSessionModel, resolveExecutorThinkingLevel, resolveExecutorFallbackThinkingLevel, resolveValidatorThinkingLevel, @@ -17535,29 +17536,42 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB }, }); - // Determine primary model and an explicit fallback. The workflow step's - // own override takes precedence; otherwise use the canonical executor - // hierarchy: task override → project execution lane → global execution lane - // → project default override → global default. The fallback is the per-step - // override's missing-counterpart settings, then the executor fallback lane, - // which itself falls through to the shared global fallback pair. - // FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires workflow steps to inherit project execution-lane model settings before default settings so configured Execution models reach step sessions unless the step itself overrides them. + // Determine primary model and an explicit fallback. Review-type workflow + // steps use the validator lane; ordinary workflow prompts use the executor + // lane. A complete per-step override remains authoritative for either lane. + // FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires ordinary workflow + // steps to inherit project execution-lane model settings before defaults. + // Review gates are independent validation surfaces and must not silently use + // the same implementation model merely because they execute in this method. const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId); - const executorModel = resolveExecutorSessionModel( - task.modelProvider, - task.modelId, - settings, - assignedRuntimeConfig, - ); - const primaryProvider = workflowStep.modelProvider || executorModel.provider; - const primaryModelId = workflowStep.modelId || executorModel.modelId; + const laneModel = isReviewTypeWorkflowStep + ? resolveValidatorSessionModel( + task.validatorModelProvider, + task.validatorModelId, + settings, + assignedRuntimeConfig, + ) + : resolveExecutorSessionModel( + task.modelProvider, + task.modelId, + settings, + assignedRuntimeConfig, + ); const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId); + const primaryProvider = useOverride ? workflowStep.modelProvider : laneModel.provider; + const primaryModelId = useOverride ? workflowStep.modelId : laneModel.modelId; - const executorFallback = resolveExecutorFallbackModel(settings); - const fallback = executorFallback.provider && executorFallback.modelId - && (executorFallback.provider !== primaryProvider || executorFallback.modelId !== primaryModelId) - ? executorFallback + const workflowFallback = isReviewTypeWorkflowStep + ? resolveValidatorFallbackModel(settings) + : resolveExecutorFallbackModel(settings); + const fallback = workflowFallback.provider && workflowFallback.modelId + && (workflowFallback.provider !== primaryProvider || workflowFallback.modelId !== primaryModelId) + ? workflowFallback : undefined; + const fallbackSettingsHint = isReviewTypeWorkflowStep + ? "settings.validatorFallbackProvider/validatorFallbackModelId or fallbackProvider/fallbackModelId" + : "settings.executionFallbackProvider/executionFallbackModelId or fallbackProvider/fallbackModelId"; + const fallbackLaneLabel = isReviewTypeWorkflowStep ? "validator" : "executor"; const timeoutMs = Math.max(60_000, settings.workflowStepTimeoutMs ?? 900_000); @@ -17566,7 +17580,6 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB modelId: string | undefined, attemptLabel: string, ): Promise => { - // Workflow step agents inherit executor instructions const stepInstructions = await this.resolveInstructionsForRole("executor", settings); const stepSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, stepInstructions); @@ -17700,16 +17713,23 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB /* * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: - * WorkflowStep sessions resolve reasoning effort as node/step `thinkingLevel` first, then task override, then settings defaults/lane fallbacks. + * WorkflowStep sessions resolve reasoning effort as node/step `thinkingLevel` first, then the task override for their selected model lane, then settings defaults/lane fallbacks. * * FNXC:Settings-ThinkingLevel 2026-07-10-14:20: * The step's own `fallback` attempt already swaps to a distinct model (validator fallback OR global fallback pair) — it must honor THAT model's fallback thinking level, not silently reuse the primary lane's thinking level. Route by which candidate `fallback.label` actually matched instead of only special-casing `validatorFallback`. */ - const workflowStepThinkingSource = workflowStep.thinkingLevel ?? task.thinkingLevel; + const workflowStepThinkingSource = workflowStep.thinkingLevel + ?? (isReviewTypeWorkflowStep ? task.validatorThinkingLevel ?? task.thinkingLevel : task.thinkingLevel); const workflowStepThinkingLevel = attemptLabel === "fallback" - ? resolveExecutorFallbackThinkingLevel(workflowStepThinkingSource, settings) - : resolveExecutorThinkingLevel(workflowStepThinkingSource, settings); - const workflowStepFallbackThinkingLevel = resolveExecutorFallbackThinkingLevel(workflowStepThinkingSource, settings); + ? isReviewTypeWorkflowStep + ? resolveValidatorFallbackThinkingLevel(workflowStepThinkingSource, settings) + : resolveExecutorFallbackThinkingLevel(workflowStepThinkingSource, settings) + : isReviewTypeWorkflowStep + ? resolveValidatorThinkingLevel(workflowStepThinkingSource, settings) + : resolveExecutorThinkingLevel(workflowStepThinkingSource, settings); + const workflowStepFallbackThinkingLevel = isReviewTypeWorkflowStep + ? resolveValidatorFallbackThinkingLevel(workflowStepThinkingSource, settings) + : resolveExecutorFallbackThinkingLevel(workflowStepThinkingSource, settings); const { session } = await createResolvedAgentSession({ sessionPurpose: "executor", runtimeHint: workflowRuntimeHint, @@ -17719,8 +17739,8 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB tools: toolMode, defaultProvider: provider, defaultModelId: modelId, - fallbackProvider: executorFallback.provider, - fallbackModelId: executorFallback.modelId, + fallbackProvider: workflowFallback.provider, + fallbackModelId: workflowFallback.modelId, fallbackThinkingLevel: workflowStepFallbackThinkingLevel, defaultThinkingLevel: workflowStepThinkingLevel, runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)), @@ -17970,7 +17990,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB if (!retryMalformed) return retryOutcome; await this.store.logEntry( task.id, - `Workflow step '${workflowStep.name}' produced malformed output on both the primary attempt and one self-retry — no fallback model configured (set settings.executionFallbackProvider/Id or fallbackProvider/Id)`, + `Workflow step '${workflowStep.name}' produced malformed output on both the primary attempt and one self-retry — no fallback model configured (set ${fallbackSettingsHint})`, ); return retryOutcome; } @@ -17978,12 +17998,12 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB executorLog.warn(`${task.id}: workflow step '${workflowStep.name}' ${reason} and no fallback model is configured`); await this.store.logEntry( task.id, - `Workflow step '${workflowStep.name}' ${reason} — no fallback model configured (set settings.executionFallbackProvider/Id or fallbackProvider/Id)`, + `Workflow step '${workflowStep.name}' ${reason} — no fallback model configured (set ${fallbackSettingsHint})`, ); return primaryOutcome; } - executorLog.log(`${task.id}: retrying workflow step '${workflowStep.name}' with executor fallback ${fallback.provider}/${fallback.modelId} after primary ${primaryOutcome.timedOut ? "timeout" : "malformed output"}`); + executorLog.log(`${task.id}: retrying workflow step '${workflowStep.name}' with ${fallbackLaneLabel} fallback ${fallback.provider}/${fallback.modelId} after primary ${primaryOutcome.timedOut ? "timeout" : "malformed output"}`); return runOnce(fallback.provider, fallback.modelId, "fallback"); }