FN-7039: apply execution-lane models to workflow steps

Route workflow and automation prompt steps through the execution-lane model hierarchy.

- Use executor session model resolution for task workflow prompt steps while preserving per-step overrides.
- Use execution settings model resolution for scheduled and manual AI-prompt automation runs.
- Document the model precedence and add regression coverage plus a patch changeset.

Files changed:
 .changeset/fn-7039-workflow-execution-model.md     |   7 +
 docs/settings-reference.md                         |   2 +
 .../core/src/__tests__/model-resolution.test.ts    |  12 ++
 packages/dashboard/src/routes.ts                   |   6 +-
 .../engine/src/__tests__/executor-test-helpers.ts  |  11 +-
 .../__tests__/executor-workflow-step-model.test.ts | 234 +++++++++++++++++++++
 packages/engine/src/cron-runner.ts                 |   7 +-
 packages/engine/src/executor.ts                    |  23 +-
 8 files changed, 285 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7039

Fusion-Task-Lineage: f27c4be8-7793-4212-b9ee-679f50193406
This commit is contained in:
gsxdsm
2026-06-25 21:05:33 -07:00
parent 79c602d9d4
commit b6b5583f01
8 changed files with 285 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Workflow and automation steps now use the configured project Execution model instead of the default.
category: fix
dev: Workflow/AI-prompt step model resolution now consults the execution lane (resolveExecutorSessionModel / resolveExecutionSettingsModel) instead of resolveProjectDefaultModel, fixing executeWorkflowStep (executor.ts), cron-runner.ts, and dashboard routes.ts. FN-7039.

View File

@@ -877,6 +877,8 @@ Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` envi
6. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no task/lane/default pair is configured
7. Automatic provider/model resolution
Workflow prompt steps and scheduled/manual AI-prompt automation steps use the same executor lane before falling back to project/global defaults; explicit step-level `modelProvider` + `modelId` values still take precedence for that individual step.
### Heartbeat model (durable agents)
Heartbeat sessions for durable agents use this order:

View File

@@ -35,6 +35,18 @@ describe("model-resolution", () => {
).toEqual({ provider: "google", modelId: "gemini-2.5-pro" });
});
it("selects the project execution lane over the base default for workflow-step callers", () => {
const resolved = resolveExecutionSettingsModel({
executionProvider: "openai",
executionModelId: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-3-5-sonnet",
});
expect(resolved).toEqual({ provider: "openai", modelId: "gpt-4o" });
expect(resolved).not.toEqual({ provider: "anthropic", modelId: "claude-3-5-sonnet" });
});
it("falls back from planning global to the project default override", () => {
expect(
resolvePlanningSettingsModel({

View File

@@ -31,7 +31,7 @@ import {
readAgentMemoryFile,
resolvePlanningSettingsModel,
resolvePluginEntryPath,
resolveProjectDefaultModel,
resolveExecutionSettingsModel,
resolveTitleSummarizerSettingsModel,
writeAgentMemoryFile,
} from "@fusion/core";
@@ -5121,7 +5121,9 @@ async function executeAiPromptStep(
}
const settings = await taskStore.getSettings();
const defaultModel = resolveProjectDefaultModel(settings);
// Resolve model: step override → project execution lane → global execution lane → project default override → global default
// FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires manual AI-prompt workflow runs to use execution-lane settings before default settings because these runs have no task/runtime model context.
const defaultModel = resolveExecutionSettingsModel(settings);
const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
const modelId = step.modelId?.trim() || defaultModel.modelId;
let responseText = "";

View File

@@ -79,10 +79,8 @@ vi.mock("../agent-session-helpers.js", async () => {
settings: Record<string, unknown> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
) => {
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
if (settings?.testMode === true || (typeof settings?.defaultProvider === "string" && settings.defaultProvider.trim().toLowerCase() === "mock")) {
return { provider: "mock", modelId: "scripted" };
}
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") {
@@ -97,6 +95,11 @@ vi.mock("../agent-session-helpers.js", async () => {
if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") {
return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as string };
}
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}
return { provider: undefined, modelId: undefined };
},
};

View File

@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import {
createMockStore,
mockedCreateFnAgent,
mockedExecSync,
resetExecutorMocks,
} from "./executor-test-helpers.js";
type CapturedSession = {
defaultProvider?: string;
defaultModelId?: string;
};
function captureSession(output = '{"verdict":"APPROVE","notes":""}'): { last?: CapturedSession } {
const holder: { last?: CapturedSession } = {};
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
holder.last = {
defaultProvider: opts.defaultProvider,
defaultModelId: opts.defaultModelId,
};
const listeners: Array<(event: any) => void> = [];
const session: any = {
state: {},
subscribe: (fn: (event: any) => void) => {
listeners.push(fn);
return () => {};
},
prompt: vi.fn(async () => {
for (const fn of listeners) {
fn({
type: "message_update",
assistantMessageEvent: {
type: "text_delta",
partial: output,
contentIndex: 0,
delta: output,
},
});
}
}),
dispose: vi.fn(),
};
return { session };
});
return holder;
}
function quietGit() {
mockedExecSync.mockImplementation(() => Buffer.from(""));
}
function makeExecutor(store: ReturnType<typeof createMockStore>) {
const agentStore = { getAgent: vi.fn().mockResolvedValue(null), createAgent: vi.fn() };
return new TaskExecutor(store as any, "/tmp/test", { agentStore } as any);
}
function baseTask(overrides: Record<string, unknown> = {}) {
return {
id: "FN-MODEL-1",
title: "Model resolution",
description: "verify model resolution",
column: "in-progress" as const,
worktree: "/tmp/wt",
branch: "fusion/fn-model-1",
baseCommitSha: "abc123",
dependencies: [],
steps: [{ name: "s", status: "in-progress" as const }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function workflowStep(overrides: Record<string, unknown> = {}) {
const now = new Date().toISOString();
return {
id: "step:model",
name: "Model Step",
description: "",
mode: "prompt" as const,
phase: "pre-merge" as const,
gateMode: "advisory" as const,
prompt: "Check the model.",
toolMode: "readonly" as const,
enabled: true,
createdAt: now,
updatedAt: now,
...overrides,
};
}
async function runStepWithSettings(
settings: Record<string, unknown>,
options: {
task?: Record<string, unknown>;
step?: Record<string, unknown>;
} = {},
) {
const store = createMockStore();
store.getSettings.mockResolvedValue(settings);
const executor = makeExecutor(store);
const captured = captureSession();
await (executor as any).executeWorkflowStep(
baseTask(options.task),
workflowStep(options.step),
"/tmp/wt",
settings,
undefined,
);
return captured.last;
}
describe("executor workflow-step model resolution", () => {
beforeEach(() => {
resetExecutorMocks();
quietGit();
});
it("uses the project execution lane instead of the global default when the step has no override", async () => {
const captured = await runStepWithSettings({
executionProvider: "openai",
executionModelId: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-3-5-sonnet",
});
expect(captured).toMatchObject({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
expect(captured).not.toMatchObject({
defaultProvider: "anthropic",
defaultModelId: "claude-3-5-sonnet",
});
});
it("keeps step and task overrides ahead of execution-lane settings", async () => {
await expect(
runStepWithSettings(
{
executionProvider: "project-exec-provider",
executionModelId: "project-exec-model",
},
{
step: {
modelProvider: "step-provider",
modelId: "step-model",
},
},
),
).resolves.toMatchObject({
defaultProvider: "step-provider",
defaultModelId: "step-model",
});
await expect(
runStepWithSettings(
{
executionProvider: "project-exec-provider",
executionModelId: "project-exec-model",
},
{
task: {
modelProvider: "task-provider",
modelId: "task-model",
},
},
),
).resolves.toMatchObject({
defaultProvider: "task-provider",
defaultModelId: "task-model",
});
});
it("falls through the execution hierarchy without mixing partial pairs", async () => {
await expect(
runStepWithSettings({
executionProvider: "partial-project-provider",
executionGlobalProvider: "global-exec-provider",
executionGlobalModelId: "global-exec-model",
defaultProvider: "global-default-provider",
defaultModelId: "global-default-model",
}),
).resolves.toMatchObject({
defaultProvider: "global-exec-provider",
defaultModelId: "global-exec-model",
});
await expect(
runStepWithSettings({
executionGlobalModelId: "partial-global-model",
defaultProviderOverride: "project-default-provider",
defaultModelIdOverride: "project-default-model",
defaultProvider: "global-default-provider",
defaultModelId: "global-default-model",
}),
).resolves.toMatchObject({
defaultProvider: "project-default-provider",
defaultModelId: "project-default-model",
});
await expect(
runStepWithSettings({
defaultProvider: "global-default-provider",
defaultModelId: "global-default-model",
}),
).resolves.toMatchObject({
defaultProvider: "global-default-provider",
defaultModelId: "global-default-model",
});
});
it("forces mock/scripted for workflow steps when test mode is active", async () => {
await expect(
runStepWithSettings({
testMode: true,
executionProvider: "project-exec-provider",
executionModelId: "project-exec-model",
defaultProvider: "anthropic",
defaultModelId: "claude-3-5-sonnet",
}),
).resolves.toMatchObject({
defaultProvider: "mock",
defaultModelId: "scripted",
});
});
});

View File

@@ -1,7 +1,7 @@
import { exec } from "node:child_process";
import {
resolveProjectDefaultModel,
resolveExecutionSettingsModel,
runScheduledEvalBatch,
resolveTaskEvaluationSettings,
isEvalsExperimentalEnabled,
@@ -855,9 +855,10 @@ export class CronRunner {
};
}
// Resolve model: step override → project default override → global default
// Resolve model: step override → project execution lane → global execution lane → project default override → global default
// FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires scheduled AI-prompt automation steps to use execution-lane settings before default settings because these steps have no task/runtime model context.
const settings = await this.store.getSettings();
const defaultModel = resolveProjectDefaultModel(settings);
const defaultModel = resolveExecutionSettingsModel(settings);
const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
const modelId = step.modelId?.trim() || defaultModel.modelId;

View File

@@ -53,7 +53,6 @@ import {
resolveAgentPrompt,
resolvePersistAgentThinkingLog,
resolveEffectiveAgentPermissionPolicy,
resolveProjectDefaultModel,
resolveAgentMemoryInclusionMode,
loadWorkspaceConfig,
type WorkspaceConfig,
@@ -13423,13 +13422,21 @@ You have access to the file system to review changes.${verdictBlock}`;
});
// Determine primary model and an explicit fallback. The workflow step's
// own override takes precedence; otherwise we use the project default
// override before falling through to the global default. The
// fallback is the per-step override's missing-counterpart settings, then
// the global validator/fallback pair, then the executor's `fallbackProvider`.
const defaultModel = resolveProjectDefaultModel(settings);
const primaryProvider = workflowStep.modelProvider || defaultModel.provider;
const primaryModelId = workflowStep.modelId || defaultModel.modelId;
// 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 global validator/fallback
// pair, then the executor's `fallbackProvider`.
// 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.
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 useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
type ModelTuple = { provider?: string; modelId?: string };