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:
7
.changeset/fn-7039-workflow-execution-model.md
Normal file
7
.changeset/fn-7039-workflow-execution-model.md
Normal 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.
|
||||||
@@ -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
|
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
|
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 model (durable agents)
|
||||||
|
|
||||||
Heartbeat sessions for durable agents use this order:
|
Heartbeat sessions for durable agents use this order:
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ describe("model-resolution", () => {
|
|||||||
).toEqual({ provider: "google", modelId: "gemini-2.5-pro" });
|
).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", () => {
|
it("falls back from planning global to the project default override", () => {
|
||||||
expect(
|
expect(
|
||||||
resolvePlanningSettingsModel({
|
resolvePlanningSettingsModel({
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
readAgentMemoryFile,
|
readAgentMemoryFile,
|
||||||
resolvePlanningSettingsModel,
|
resolvePlanningSettingsModel,
|
||||||
resolvePluginEntryPath,
|
resolvePluginEntryPath,
|
||||||
resolveProjectDefaultModel,
|
resolveExecutionSettingsModel,
|
||||||
resolveTitleSummarizerSettingsModel,
|
resolveTitleSummarizerSettingsModel,
|
||||||
writeAgentMemoryFile,
|
writeAgentMemoryFile,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
@@ -5121,7 +5121,9 @@ async function executeAiPromptStep(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings = await taskStore.getSettings();
|
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 modelProvider = step.modelProvider?.trim() || defaultModel.provider;
|
||||||
const modelId = step.modelId?.trim() || defaultModel.modelId;
|
const modelId = step.modelId?.trim() || defaultModel.modelId;
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
|
|||||||
@@ -79,10 +79,8 @@ vi.mock("../agent-session-helpers.js", async () => {
|
|||||||
settings: Record<string, unknown> | undefined,
|
settings: Record<string, unknown> | undefined,
|
||||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||||
) => {
|
) => {
|
||||||
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
|
if (settings?.testMode === true || (typeof settings?.defaultProvider === "string" && settings.defaultProvider.trim().toLowerCase() === "mock")) {
|
||||||
const slash = model.indexOf("/");
|
return { provider: "mock", modelId: "scripted" };
|
||||||
if (slash > 0 && slash < model.length - 1) {
|
|
||||||
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
|
|
||||||
}
|
}
|
||||||
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
|
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
|
||||||
if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") {
|
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") {
|
if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") {
|
||||||
return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as 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 };
|
return { provider: undefined, modelId: undefined };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resolveProjectDefaultModel,
|
resolveExecutionSettingsModel,
|
||||||
runScheduledEvalBatch,
|
runScheduledEvalBatch,
|
||||||
resolveTaskEvaluationSettings,
|
resolveTaskEvaluationSettings,
|
||||||
isEvalsExperimentalEnabled,
|
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 settings = await this.store.getSettings();
|
||||||
const defaultModel = resolveProjectDefaultModel(settings);
|
const defaultModel = resolveExecutionSettingsModel(settings);
|
||||||
const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
|
const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
|
||||||
const modelId = step.modelId?.trim() || defaultModel.modelId;
|
const modelId = step.modelId?.trim() || defaultModel.modelId;
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ import {
|
|||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
resolvePersistAgentThinkingLog,
|
resolvePersistAgentThinkingLog,
|
||||||
resolveEffectiveAgentPermissionPolicy,
|
resolveEffectiveAgentPermissionPolicy,
|
||||||
resolveProjectDefaultModel,
|
|
||||||
resolveAgentMemoryInclusionMode,
|
resolveAgentMemoryInclusionMode,
|
||||||
loadWorkspaceConfig,
|
loadWorkspaceConfig,
|
||||||
type WorkspaceConfig,
|
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
|
// Determine primary model and an explicit fallback. The workflow step's
|
||||||
// own override takes precedence; otherwise we use the project default
|
// own override takes precedence; otherwise use the canonical executor
|
||||||
// override before falling through to the global default. The
|
// hierarchy: task override → project execution lane → global execution lane
|
||||||
// fallback is the per-step override's missing-counterpart settings, then
|
// → project default override → global default. The fallback is the per-step
|
||||||
// the global validator/fallback pair, then the executor's `fallbackProvider`.
|
// override's missing-counterpart settings, then the global validator/fallback
|
||||||
const defaultModel = resolveProjectDefaultModel(settings);
|
// pair, then the executor's `fallbackProvider`.
|
||||||
const primaryProvider = workflowStep.modelProvider || defaultModel.provider;
|
// 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 primaryModelId = workflowStep.modelId || defaultModel.modelId;
|
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);
|
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
|
||||||
|
|
||||||
type ModelTuple = { provider?: string; modelId?: string };
|
type ModelTuple = { provider?: string; modelId?: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user