fix: apply project model lanes across workflows (#2400)

## Summary

Project workflow model lanes now apply to tasks on every workflow
instead of only tasks using the active default workflow. Model selection
consistently resolves task-specific choice -> project workflow baseline
-> global lane -> selected-workflow value -> project/global default for
primary models, fallback models, and thinking levels.

The active default workflow remains the storage owner for backward
compatibility, while runtime resolution keeps its project baseline
distinct from lower-priority selected-workflow values. Non-model
workflow policies remain isolated to their selected workflow.

## Validation

- Core workflow/model resolution: 60 tests passed
- Engine effective settings and session resolution: 59 tests passed
- Reviewer: 85 tests passed
- Scheduler: 154 tests passed
- Heartbeat: 90 tests passed
- Settings UI: 67 tests passed
- Workspace lint and core/engine/dashboard typechecks passed
- `pnpm verify:fast` passed workspace builds, the published CLI build,
and real `/api/health` boot smoke

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added **Project workflow model lanes** to establish a project baseline
for model selection and thinking levels across workflows.
* Updated model/fallback resolution to account for task overrides,
project baselines, global lanes, and selected-workflow values.
* **Bug Fixes**
* Improved effective settings merging so project baselines are applied
correctly (including scheduled/idle and heartbeat flows) while
preserving selected-workflow provenance.
* **Documentation**
* Refreshed settings and dashboard guidance for workflow lane
inheritance and resolution precedence.
* **Tests**
* Expanded unit test coverage for lane precedence, fallback detection,
and thinking-level behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-22 08:53:31 -07:00
committed by GitHub
parent 71baf98855
commit e514e134da
26 changed files with 781 additions and 112 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Apply project workflow model lanes to every workflow ahead of global and workflow values.
category: fix
dev: Resolution is task override, project baseline, global lane, selected-workflow value, then default model.

View File

@@ -11,10 +11,10 @@ One of Fusion's user-facing frontends — the browser dashboard and the terminal
User-level settings persisted server-side that apply across all Surfaces and all projects, as opposed to per-project settings. Values are validated at the write boundary — an invalid value is dropped rather than persisted — so every reader can trust what it loads.
### Workflow Setting
A typed setting declared by a workflow in its IR (id, type, default, options), mirroring the custom-task-field shape. Declarations describe the schema; *values* persist per workflow + project through a single validating store authority, so built-in workflows can carry values without their IR being editable. The engine consumes **effective settings** — stored value falling back to declaration default, with values that no longer validate against the current declaration dropped (never fed to execution).
A typed setting declared by a workflow in its IR (id, type, default, options), mirroring the custom-task-field shape. Declarations describe the schema; *values* persist per workflow + project through a single validating store authority, so built-in workflows can carry values without their IR being editable. The active default workflow's model-lane values also serve as the Project workflow-model baseline inherited by every workflow. The engine consumes **effective settings** — stored value falling back to declaration default, with values that no longer validate against the current declaration dropped (never fed to execution).
### Effective Settings
The per-task, flat `Partial<Settings>`-shaped value map the engine reads at executor entry, composed from the task's resolved workflow: for each declared Workflow Setting, the stored `(workflowId, projectId)` value falls back to the declaration default, with stored values that no longer validate against the current declaration dropped. Resolution never throws — a missing or corrupt workflow degrades to the built-in coding declarations — so every read site receives a usable value. Because built-in declaration defaults are byte-equal to the legacy project-settings defaults, an untuned project resolves to identical behavior across the settings hard-move.
The per-task `Partial<Settings>`-shaped value map the engine reads at runtime entry. Workflow policy comes from the task's selected workflow: each stored `(workflowId, projectId)` value falls back to its declaration default, with invalid values dropped. Model lanes resolve separately as task override -> Project workflow-model baseline -> Global lane -> selected-workflow value -> project/global default model; the lower-priority selected-workflow lanes are retained in runtime-only provenance rather than flattened over the Project baseline. Resolution never throws — a missing or corrupt workflow degrades to the built-in coding declarations — so every read site receives a usable value.
### Moved Settings Keys
The tombstone allowlist (`MOVED_SETTINGS_KEYS`) of the step-execution, review/approval, and per-phase model-lane keys that the one-time hard-move migration relocated from project/global settings into Workflow Settings. It is the single record of the old names and shields every surface that can encounter a legacy payload — cross-node sync diffs, v1 settings imports, and stale writers — from resurrecting a moved key. A consistency test enforces that a key lives in exactly one regime (project settings *or* the tombstone list, never both).

View File

@@ -439,7 +439,7 @@ Behavior:
FNXC:WorkflowEditor 2026-06-29-21:10: Optional-group template entry/exit ownership remains visual-only. Non-editable boundary connector lines must explain how template children attach to the container without persisting fake edges into workflow IR. -->
- Optional-group, foreach, and loop containers show their template nodes inside the block. Canvas connections between the surrounding workflow and the block attach to the container boundary; connections between template nodes stay inside the block. Optional groups also draw non-editable entry/exit connector lines between the boundary and the template entry/exit nodes so single-step blocks such as Plan Review and Code Review do not look disconnected; those visual connectors are not saved into workflow IR.
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs and their inline Thinking Level companions are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring.
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, Reviewer, and declared Planning/Reviewer fallback model lanes from **Project Models**; those dropdown values auto-save as workflow setting values for the active default workflow. Project Models also includes project-scoped **Merger** and **Title Summarization** lanes (not workflow-moved). Settings fallback model pickers (Global Fallback Model, workflow fallbacks, and project Title Summarizer Fallback) include the same inline Thinking Level selector when their companion key exists.
- The main Settings modal exposes project-baseline Plan/Triage, Executor, Reviewer, and declared fallback model lanes from **Project Models**. Those dropdown values auto-save on the active default workflow and are inherited by every workflow. Resolution is task-specific selection → project baseline → global lane → selected-workflow value; Project Models therefore wins over values configured in a workflow's **Settings → Values**. Project Models also includes project-scoped **Merger** and **Title Summarization** lanes (not workflow-moved). Settings fallback model pickers (Global Fallback Model, workflow fallbacks, and project Title Summarizer Fallback) include the same inline Thinking Level selector when their companion key exists.
- On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs.
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the Board/List workflow dropdown row edit action opens directly to the selected workflow editor when that selected workflow is available.
- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and optional-group/foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. The structural **start** node opens an inspector for the workflow entry column when the workflow defines columns; the **Name** field remains unavailable because the start label is structural. For custom workflows, editable outline rows also expose **Move up** and **Move down** controls that reorder steps within their current column or template parent; built-in workflows remain read-only and hide those controls.

View File

@@ -268,12 +268,15 @@ Some knobs that used to live in this Settings reference as project settings are
*how* tasks execute, so the timeouts, review gates, and per-phase model lanes that
govern that execution belong to the workflow.
**Where to set them.** The common model lanes for a project's default workflow are
available directly in **Settings → Project Models → Default workflow model lanes**:
Plan/Triage, Executor, Reviewer, and the Planning/Reviewer fallback lanes declared
**Where to set them.** The common model lanes for every workflow in a project are
available directly in **Settings → Project Models → Project workflow model lanes**:
Plan/Triage, Executor, Reviewer, and their fallback lanes declared
by the default workflow. Primary Plan/Triage, Executor, Reviewer, and declared fallback rows show an inline Thinking Level control when the workflow declares the companion `*ThinkingLevel` setting; unset means inherit. Those dropdown controls use the shared model picker and are auto-saved by the Settings modal after an edit, which writes
workflow setting values for the active project's default workflow; they do not
restore the old project settings keys. The global **Fallback Model** remains in
workflow setting values on the active project's default workflow. Those stored
values are the project model baseline inherited by every selected workflow. The
baseline wins over global and per-workflow values; task-specific selections win
over the baseline. They do
not restore the old project settings keys. The global **Fallback Model** remains in
Settings → General Models and includes its own inline Thinking Level selector for `fallbackThinkingLevel`; workflow-specific fallbacks are also editable from
the workflow editor Values tab. Title summarization is separate: set it in
**Settings → Project Models → AI Title and Git Commit Message Summarization**,
@@ -313,16 +316,19 @@ Actions. It has two tabs:
heartbeat patrol task creation separately and defaults to `true`. Edits batch
and commit through a single **Save** in the Values tab.
**How values resolve.** The engine resolves *effective settings* per task as
`stored value ?? declaration default`. The task-detail Workflow, Chat, and Agent
Log model displays use the same per-task effective workflow values, so configured
Plan/Triage, Executor, Reviewer, and fallback lanes match what task execution
will use instead of falling back to the ambient project settings response. A
built-in workflow with no stored value falls back to the declaration default,
**How values resolve.** For non-model workflow settings, the engine resolves
*effective settings* per task as `stored value ?? declaration default`. A built-in
workflow with no stored non-model value falls back to the declaration default,
which is byte-equal to the legacy project default — so an untuned project behaves
exactly as before. Switching a project to a **new** custom workflow starts that
workflow from its own declaration defaults, not the project's prior customized
values.
exactly as before. Switching a project to a **new** custom workflow starts those
non-model settings from that workflow's own declaration defaults.
Model lanes use the cross-workflow hierarchy instead: task-specific selection →
project workflow-lane baseline stored on the active default workflow → global lane
→ selected-workflow lane → project default override → global default. The
task-detail Workflow, Chat, and Agent Log displays use this same effective model
resolution, so their Plan/Triage, Executor, Reviewer, and fallback lanes match the
sessions that actually run.
**Built-in prompt overrides.** Built-in workflow prompt/gate node text has a similar project-scoped persistence model, but it is separate from workflow settings: prompt overrides are stored per `(workflowId, nodeId, projectId)` and resolve as `stored prompt ?? shipped prompt`. Resetting a prompt deletes the stored node override and restores the built-in IR text; graph structure and setting declarations remain read-only for built-ins. See [Workflow Steps → Overriding built-in workflow prompts](./workflow-steps.md#overriding-built-in-workflow-prompts).
@@ -398,10 +404,12 @@ The built-in workflows also declare triage/spec policy settings that were **not*
When `triageProactiveSubtaskSplittingEnabled` is `true` (the default), triage may proactively replace a large task with 2-5 child tasks when the size, step-count, package breadth, file-scope, or remediation-batch signals justify the coordination overhead. When it is `false`, those automatic oversized-task signals are advisory only for writing a realistic single-task spec; triage must not split solely because the task is large. The per-task `breakIntoSubtasks: true` flag is separate and remains mandatory: if a user explicitly asks for subtask breakdown, triage still evaluates and creates child tasks when the work is meaningfully decomposable.
In the dashboard Settings modal, Project Models exposes Plan/Triage, Executor,
Reviewer, and declared fallback dropdown controls for the default workflow. The
Settings modal auto-save persists pending default-workflow model lane
overrides; there is no separate workflow-model save button. The workflow editor's
In the dashboard Settings modal, Project Models exposes project-baseline
Plan/Triage, Executor, Reviewer, and declared fallback dropdown controls. The
Settings modal auto-save persists pending model lane values on the active default
workflow, and every workflow inherits those values. Task-specific selections win,
while global and per-workflow values are lower-priority fallbacks; there is no
separate workflow-model save button. The workflow editor's
Settings → Values tab uses the same dropdown picker for declared provider/model
pairs, including fallbacks. Former locations for advanced workflow policy still
show a short redirect stub linking to the workflow editor (for one release).
@@ -1028,11 +1036,11 @@ Short-lived token bounds are enforced server-side:
## Model Selection Hierarchy
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 (auto-saved by the Settings modal) 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 as task-specific selection -> project workflow-model baseline -> global lane -> selected-workflow value -> project/global default model. The project baseline is stored as setting values on the project's active default workflow and can be edited from Settings -> Project Models -> Project workflow model lanes (auto-saved by the Settings modal). Lower-priority per-workflow values remain editable 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.
Direct-chat defaults are project-scoped and independent of task workflow lanes. Configure them in **Settings -> Project Models -> Chat**. `chatDefaultKind: "agent"` resolves only when `chatDefaultAgentId` is set; `chatDefaultKind: "model"` resolves only when both `chatDefaultModelProvider` and `chatDefaultModelId` are set, with optional `chatDefaultThinkingLevel`. If `chatNewSessionMode` is `"always-default"` and that target resolves, every New Chat entry point creates the session directly. If the target is incomplete, or the mode is unset/`"prompt"`, Fusion opens the New Chat dialog instead and preselects the resolved default when one exists. Chat Rooms additionally support a per-room `thinkingLevel` default that applies to every room responder; clearing it inherits the resolved project/global default.
Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; executor/planning/reviewer fallback lanes declare `executionFallbackThinkingLevel`, `planningFallbackThinkingLevel`, and `validatorFallbackThinkingLevel`; global fallback uses `fallbackThinkingLevel`; and project title summarization fallback uses `titleSummarizerFallbackThinkingLevel`. Empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence for task/workflow execution is node/step `config.thinkingLevel` > lane-specific task override (`planningThinkingLevel` or `validatorThinkingLevel`) > shared task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`; executor sessions continue to use shared task `thinkingLevel` directly. Model-mode Chat sessions use the same executor-lane resolver with session `thinkingLevel` in the task slot, so an empty chat-session value inherits project/global defaults while a concrete New Chat selection wins for that session. The resolved value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters).
Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; executor/planning/reviewer fallback lanes declare `executionFallbackThinkingLevel`, `planningFallbackThinkingLevel`, and `validatorFallbackThinkingLevel`; global fallback uses `fallbackThinkingLevel`; and project title summarization fallback uses `titleSummarizerFallbackThinkingLevel`. Empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence for task/workflow execution is node/step `config.thinkingLevel` > lane-specific task override (`planningThinkingLevel` or `validatorThinkingLevel`) > shared task `thinkingLevel` > project workflow-lane baseline > global lane thinking override > selected-workflow lane > project default thinking override > global `defaultThinkingLevel`; executor sessions continue to use shared task `thinkingLevel` directly. Model-mode Chat sessions use the same executor-lane resolver with session `thinkingLevel` in the task slot, so an empty chat-session value inherits project/global defaults while a concrete New Chat selection wins for that session. The resolved value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters).
Executor sessions, including workflow-step timeout/malformed-output recovery and durable heartbeats, resolve `executionFallbackProvider`/`executionFallbackModelId` first and otherwise inherit the global `fallbackProvider`/`fallbackModelId` pair. For a distinct complete fallback pair, model-selection recovery is bounded to **primary → fallback → primary**. If all three attempts fail, Fusion raises an operator-actionable terminal failure with the standard retry affordance; missing, incomplete, or equal fallback pairs remain terminal after the initial primary failure.
@@ -1057,21 +1065,23 @@ The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol`
### Planning model
1. Per-task `planningModelProvider` + `planningModelId`
2. Default workflow lane value `planningProvider` + `planningModelId`
2. Project workflow-lane baseline `planningProvider` + `planningModelId` stored on the active default workflow
3. Global `planningGlobalProvider` + `planningGlobalModelId`
4. Project `defaultProviderOverride` + `defaultModelIdOverride`
5. Global `defaultProvider` + `defaultModelId`
6. Automatic provider/model resolution
4. Selected-workflow lane value `planningProvider` + `planningModelId`
5. Project `defaultProviderOverride` + `defaultModelIdOverride`
6. Global `defaultProvider` + `defaultModelId`
7. Automatic provider/model resolution
### Executor model
1. Per-task `modelProvider` + `modelId`
2. Default workflow lane value `executionProvider` + `executionModelId`
2. Project workflow-lane baseline `executionProvider` + `executionModelId` stored on the active default workflow
3. Global `executionGlobalProvider` + `executionGlobalModelId`
4. Project `defaultProviderOverride` + `defaultModelIdOverride`
5. Global `defaultProvider` + `defaultModelId`
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
4. Selected-workflow lane value `executionProvider` + `executionModelId`
5. Project `defaultProviderOverride` + `defaultModelIdOverride`
6. Global `defaultProvider` + `defaultModelId`
7. 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
8. 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. Automation AI Prompt steps also apply an explicit step `thinkingLevel` at session creation, while Create Task automation steps copy that reasoning-effort value onto the spawned task; leaving it empty preserves the lane/default thinking-level inheritance. If a non-mock, non-test-mode session still reaches runtime creation without a complete provider/model pair, Fusion logs a warning and records `noModelResolved` plus `runtimeBuiltInFallbackModel` on `session:runtime-resolved` so the runtime's built-in fallback model is observable.
@@ -1079,23 +1089,25 @@ Workflow prompt steps and scheduled/manual AI-prompt automation steps use the sa
Heartbeat sessions for durable agents use this order:
1. Default workflow lane value `executionProvider` + `executionModelId`
1. Project workflow-lane baseline `executionProvider` + `executionModelId` stored on the active default workflow
2. Global `executionGlobalProvider` + `executionGlobalModelId`
3. Project `defaultProviderOverride` + `defaultModelIdOverride`
4. Global `defaultProvider` + `defaultModelId`
5. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no execution/default pair is configured
6. Automatic provider/model resolution
3. Selected-workflow lane value `executionProvider` + `executionModelId`
4. Project `defaultProviderOverride` + `defaultModelIdOverride`
5. Global `defaultProvider` + `defaultModelId`
6. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no execution/default pair is configured
7. Automatic provider/model resolution
On timer-triggered runs, unrecoverable missing-provider credential/registry failures complete as `heartbeat_model_unavailable` instead of permanently setting the durable agent to `state=error`.
### Reviewer model
1. Per-task `validatorModelProvider` + `validatorModelId`
2. Default workflow lane value `validatorProvider` + `validatorModelId`
2. Project workflow-lane baseline `validatorProvider` + `validatorModelId` stored on the active default workflow
3. Global `validatorGlobalProvider` + `validatorGlobalModelId`
4. Project `defaultProviderOverride` + `defaultModelIdOverride`
5. Global `defaultProvider` + `defaultModelId`
6. Automatic provider/model resolution
4. Selected-workflow lane value `validatorProvider` + `validatorModelId`
5. Project `defaultProviderOverride` + `defaultModelIdOverride`
6. Global `defaultProvider` + `defaultModelId`
7. Automatic provider/model resolution
Mission validation sessions use this same validator lane; assigned durable agent runtime models are only used as a fallback when no complete validator/default pair is configured.
@@ -1731,13 +1743,14 @@ Project-scoped policy for ephemeral/runtime-managed task workers calling `fn_tas
## Model selection hierarchy
All three lanes (planning / executor / reviewer) follow the same 5-tier precedence:
All three lanes (planning / executor / reviewer) follow the same precedence:
1. Per-task override (`planningModelProvider`/`Id`, `modelProvider`/`Id`, `validatorModelProvider`/`Id`)
2. Default workflow lane value (`planningProvider`/`Id`, `executionProvider`/`Id`, `validatorProvider`/`Id`)
2. Project workflow-lane baseline (`planningProvider`/`Id`, `executionProvider`/`Id`, `validatorProvider`/`Id`) stored on the active default workflow
3. Global lane (`planningGlobalProvider`/`Id`, `executionGlobalProvider`/`Id`, `validatorGlobalProvider`/`Id`)
4. Project `defaultProviderOverride` / `defaultModelIdOverride`
5. Global `defaultProvider` / `defaultModelId` → automatic resolution
4. Selected-workflow lane (`planningProvider`/`Id`, `executionProvider`/`Id`, `validatorProvider`/`Id`)
5. Project `defaultProviderOverride` / `defaultModelIdOverride`
6. Global `defaultProvider` / `defaultModelId` → automatic resolution
## Mock provider (test mode)

View File

@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import {
applyTestModeOverrides,
hasConfiguredFallbackLane,
resolveExecutionSettingsModel,
resolveExecutorFallbackModel,
resolvePlanningFallbackModel,
resolveValidatorFallbackModel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,
@@ -17,6 +20,27 @@ import {
} from "../model-resolution.js";
describe("model-resolution", () => {
it("detects complete fallback pairs at the lane, global, and selected-workflow tiers", () => {
expect(hasConfiguredFallbackLane({
planningFallbackProvider: "project-provider",
planningFallbackModelId: "project-model",
}, "planning")).toBe(true);
expect(hasConfiguredFallbackLane({
fallbackProvider: "global-provider",
fallbackModelId: "global-model",
}, "validation")).toBe(true);
expect(hasConfiguredFallbackLane({
selectedWorkflowModelLanes: {
executionFallbackProvider: "workflow-provider",
executionFallbackModelId: "workflow-model",
},
}, "execution")).toBe(true);
expect(hasConfiguredFallbackLane({
planningFallbackProvider: "incomplete-provider",
selectedWorkflowModelLanes: { planningFallbackModelId: "incomplete-model" },
}, "planning")).toBe(false);
});
it("uses only a complete task merger pair before settings and preserves test mode", () => {
const settings = { mergerProvider: "settings-provider", mergerModelId: "settings-model" };
expect(resolveTaskMergerModel({ mergerModelProvider: "task-provider", mergerModelId: "task-model" }, settings)).toEqual({ provider: "task-provider", modelId: "task-model" });
@@ -167,6 +191,49 @@ describe("model-resolution", () => {
})).toEqual({ provider: "project-merger-provider", modelId: "project-merger-model" });
});
it.each([
["execution", resolveExecutionSettingsModel, resolveTaskExecutionModel, "executionProvider", "executionModelId", "executionGlobalProvider", "executionGlobalModelId", "modelProvider", "modelId"],
["planning", resolvePlanningSettingsModel, resolveTaskPlanningModel, "planningProvider", "planningModelId", "planningGlobalProvider", "planningGlobalModelId", "planningModelProvider", "planningModelId"],
["validation", resolveValidatorSettingsModel, resolveTaskValidatorModel, "validatorProvider", "validatorModelId", "validatorGlobalProvider", "validatorGlobalModelId", "validatorModelProvider", "validatorModelId"],
] as const)("resolves %s as task → project → global → selected workflow", (_lane, resolveSettings, resolveTask, projectProviderKey, projectModelKey, globalProviderKey, globalModelKey, taskProviderKey, taskModelKey) => {
const settings = {
[projectProviderKey]: "project-provider",
[projectModelKey]: "project-model",
[globalProviderKey]: "global-provider",
[globalModelKey]: "global-model",
selectedWorkflowModelLanes: {
[projectProviderKey]: "workflow-provider",
[projectModelKey]: "workflow-model",
},
};
expect(resolveTask({ [taskProviderKey]: "task-provider", [taskModelKey]: "task-model" }, settings)).toEqual({ provider: "task-provider", modelId: "task-model" });
expect(resolveSettings(settings)).toEqual({ provider: "project-provider", modelId: "project-model" });
expect(resolveSettings({ ...settings, [projectProviderKey]: undefined, [projectModelKey]: undefined })).toEqual({ provider: "global-provider", modelId: "global-model" });
expect(resolveSettings({ ...settings, [projectProviderKey]: undefined, [projectModelKey]: undefined, [globalProviderKey]: undefined, [globalModelKey]: undefined })).toEqual({ provider: "workflow-provider", modelId: "workflow-model" });
});
it.each([
["execution", resolveExecutorFallbackModel, "executionFallbackProvider", "executionFallbackModelId"],
["planning", resolvePlanningFallbackModel, "planningFallbackProvider", "planningFallbackModelId"],
["validation", resolveValidatorFallbackModel, "validatorFallbackProvider", "validatorFallbackModelId"],
] as const)("resolves %s fallback as project → global → selected workflow", (_lane, resolveFallback, projectProviderKey, projectModelKey) => {
const settings = {
[projectProviderKey]: "project-provider",
[projectModelKey]: "project-model",
fallbackProvider: "global-provider",
fallbackModelId: "global-model",
selectedWorkflowModelLanes: {
[projectProviderKey]: "workflow-provider",
[projectModelKey]: "workflow-model",
},
};
expect(resolveFallback(settings)).toEqual({ provider: "project-provider", modelId: "project-model" });
expect(resolveFallback({ ...settings, [projectProviderKey]: undefined, [projectModelKey]: undefined })).toEqual({ provider: "global-provider", modelId: "global-model" });
expect(resolveFallback({ ...settings, [projectProviderKey]: undefined, [projectModelKey]: undefined, fallbackProvider: undefined, fallbackModelId: undefined })).toEqual({ provider: "workflow-provider", modelId: "workflow-model" });
});
it("resolves merger fallback project pair, global fallback, partial pairs, and test mode", () => {
expect(resolveMergerFallbackModel({
mergerFallbackProvider: "project-merger-fallback-provider",

View File

@@ -43,6 +43,8 @@ function makeStore(opts: {
valuesThrows?: boolean;
projectId?: string;
projectIdThrows?: boolean;
defaultWorkflowId?: string;
defaultWorkflowIdThrows?: boolean;
}): WorkflowSettingsResolverStore {
const store: WorkflowSettingsResolverStore = {
getTaskWorkflowSelection: vi.fn((taskId: string) => {
@@ -58,6 +60,10 @@ function makeStore(opts: {
if (opts.projectIdThrows) throw new Error("identity boom");
return opts.projectId ?? PROJECT;
}),
getDefaultWorkflowId: vi.fn(async () => {
if (opts.defaultWorkflowIdThrows) throw new Error("default workflow boom");
return opts.defaultWorkflowId;
}),
};
if (opts.asyncSelection) {
store.getTaskWorkflowSelectionAsync = vi.fn(async (taskId: string) => opts.asyncSelection?.[taskId]);
@@ -222,11 +228,150 @@ describe("resolveEffectiveSettings (per-task)", () => {
expect(Object.keys(eff)).toHaveLength(0);
});
it("new custom workflow with empty settings does NOT inherit another workflow's values", async () => {
it("inherits every project model lane from the active default workflow across custom workflows", async () => {
const projectModelLanes = {
executionProvider: "exec-provider",
executionModelId: "exec-model",
executionThinkingLevel: "high",
executionFallbackProvider: "exec-fallback-provider",
executionFallbackModelId: "exec-fallback-model",
executionFallbackThinkingLevel: "medium",
planningProvider: "plan-provider",
planningModelId: "plan-model",
planningThinkingLevel: "xhigh",
planningFallbackProvider: "plan-fallback-provider",
planningFallbackModelId: "plan-fallback-model",
planningFallbackThinkingLevel: "low",
validatorProvider: "review-provider",
validatorModelId: "review-model",
validatorThinkingLevel: "minimal",
validatorFallbackProvider: "review-fallback-provider",
validatorFallbackModelId: "review-fallback-model",
validatorFallbackThinkingLevel: "off",
};
const store = makeStore({
defaultWorkflowId: "builtin:coding",
selection: { t1: { workflowId: "wf-new", stepIds: [] } },
defs: { "wf-new": { ir: CUSTOM_NO_SETTINGS } },
values: { "builtin:coding::proj-1": projectModelLanes },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
expect(eff).toMatchObject(projectModelLanes);
});
it("keeps project model lanes ahead of selected-workflow values while retaining the workflow fallback", async () => {
const customWithModelLanes: WorkflowIr = {
...CUSTOM_NO_SETTINGS,
settings: BUILTIN_WORKFLOW_SETTINGS.filter((setting) =>
["executionProvider", "executionModelId", "executionThinkingLevel", "planningProvider", "planningModelId"].includes(setting.id)),
};
const store = makeStore({
defaultWorkflowId: "builtin:coding",
selection: { t1: { workflowId: "wf-custom", stepIds: [] } },
defs: { "wf-custom": { ir: customWithModelLanes } },
values: {
"builtin:coding::proj-1": {
executionProvider: "project-provider",
executionModelId: "project-model",
executionThinkingLevel: "medium",
planningProvider: "project-plan-provider",
planningModelId: "project-plan-model",
},
"wf-custom::proj-1": {
executionProvider: "workflow-provider",
executionModelId: "workflow-model",
executionThinkingLevel: "high",
},
},
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
expect(eff).toMatchObject({
executionProvider: "project-provider",
executionModelId: "project-model",
executionThinkingLevel: "medium",
planningProvider: "project-plan-provider",
planningModelId: "project-plan-model",
selectedWorkflowModelLanes: {
executionProvider: "workflow-provider",
executionModelId: "workflow-model",
executionThinkingLevel: "high",
},
});
});
it("applies a custom active-default model baseline to selection-less tasks", async () => {
const customDefaultWithModelLanes: WorkflowIr = {
...CUSTOM_NO_SETTINGS,
settings: BUILTIN_WORKFLOW_SETTINGS.filter((setting) =>
["validatorProvider", "validatorModelId", "validatorThinkingLevel"].includes(setting.id)),
};
const store = makeStore({
defaultWorkflowId: "wf-project-default",
selection: {},
defs: { "wf-project-default": { ir: customDefaultWithModelLanes } },
values: {
"wf-project-default::proj-1": {
validatorProvider: "project-review-provider",
validatorModelId: "project-review-model",
validatorThinkingLevel: "high",
},
},
});
const eff = await resolveEffectiveSettings(store, { id: "t-none" });
expect(eff).toMatchObject({
validatorProvider: "project-review-provider",
validatorModelId: "project-review-model",
validatorThinkingLevel: "high",
});
});
it("uses authoritative async values for both selected-workflow overrides and the project model baseline", async () => {
const customWithPlanningLane: WorkflowIr = {
...CUSTOM_NO_SETTINGS,
settings: BUILTIN_WORKFLOW_SETTINGS.filter((setting) =>
["planningProvider", "planningModelId"].includes(setting.id)),
};
const store = makeStore({
defaultWorkflowId: "builtin:coding",
selection: {},
values: {
"builtin:coding::proj-1": { planningProvider: "stale-project", planningModelId: "stale-project-model" },
"wf-custom::proj-1": { planningProvider: "stale-workflow", planningModelId: "stale-workflow-model" },
},
asyncSelection: { t1: { workflowId: "wf-custom", stepIds: [] } },
defs: { "wf-custom": { ir: customWithPlanningLane } },
asyncValues: {
"builtin:coding::proj-1": { planningProvider: "project-provider", planningModelId: "project-model" },
"wf-custom::proj-1": { planningProvider: "workflow-provider", planningModelId: "workflow-model" },
},
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });
expect(eff).toMatchObject({
planningProvider: "project-provider",
planningModelId: "project-model",
selectedWorkflowModelLanes: {
planningProvider: "workflow-provider",
planningModelId: "workflow-model",
},
});
expect(store.getWorkflowSettingValues).not.toHaveBeenCalled();
expect(store.getWorkflowSettingValuesAsync).toHaveBeenCalledWith("builtin:coding", PROJECT);
expect(store.getWorkflowSettingValuesAsync).toHaveBeenCalledWith("wf-custom", PROJECT);
});
it("does not inherit non-model policy values from the active default workflow", async () => {
const store = makeStore({
defaultWorkflowId: "builtin:coding",
selection: { t1: { workflowId: "wf-new", stepIds: [] } },
defs: { "wf-new": { ir: CUSTOM_NO_SETTINGS } },
// A different workflow has a customized value; the new one must not see it.
values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 } },
});
const eff = await resolveEffectiveSettings(store, { id: "t1" });

View File

@@ -7,7 +7,7 @@ export interface WorkflowSettingsOverlayInput {
/**
* FNXC:ModelResolution 2026-06-27-10:52:
* Per-task workflow setting values are where the moved model lanes now live, so the engine execution path and dashboard task-detail display must share one overlay rule. Stored workflow values override base settings while declaration defaults only fill missing base keys, ensuring the Workflow tab shows the same model the engine runs for FN-7123.
* Per-task workflow setting values and the project workflow-lane baseline share one overlay rule. The resolver presents project model lanes as stored flat values and retains lower-precedence selected-workflow lanes in `selectedWorkflowModelLanes`; other stored workflow values remain flat. Stored flat values override base settings while declaration defaults only fill missing base keys.
*/
export function applyWorkflowSettingsOverlay<T extends Partial<Settings>>(
base: T,

View File

@@ -504,6 +504,7 @@ export {
export {
resolveEffectiveSettings,
resolveEffectiveSettingsDetailed,
resolveProjectWorkflowModelLaneBaseline,
resolveEffectiveSettingsById,
resolveOptionalReviewRevisionBudget,
resolveEffectivePlannerOversightLevel,
@@ -1439,6 +1440,9 @@ export {
isTestModeActive,
resolveExecutionSettingsModel,
resolveExecutorFallbackModel,
resolvePlanningFallbackModel,
resolveValidatorFallbackModel,
resolveSelectedWorkflowModelLane,
resolveMergerFallbackModel,
resolveMergerSettingsModel,
resolvePlanningSettingsModel,

View File

@@ -568,6 +568,7 @@ export {
export {
resolveEffectiveSettings,
resolveEffectiveSettingsDetailed,
resolveProjectWorkflowModelLaneBaseline,
resolveEffectiveSettingsById,
resolveOptionalReviewRevisionBudget,
resolveEffectivePlannerOversightLevel,
@@ -1572,9 +1573,13 @@ export {
} from "./ai-summarize.js";
export {
applyTestModeOverrides,
hasConfiguredFallbackLane,
isTestModeActive,
resolveExecutionSettingsModel,
resolveExecutorFallbackModel,
resolvePlanningFallbackModel,
resolveValidatorFallbackModel,
resolveSelectedWorkflowModelLane,
resolveMergerFallbackModel,
resolveMergerSettingsModel,
resolvePhaseThinkingLevel,

View File

@@ -68,9 +68,49 @@ function firstThinkingLevel(...levels: Array<ThinkingLevel | string | undefined
return undefined;
}
export function resolveSelectedWorkflowModelLane(
settings: Partial<Settings> | undefined,
key: string,
): string | undefined {
/*
* FNXC:WorkflowModelLaneLookup 2026-07-22-00:00:
* Selected-workflow model lanes are stored as a dynamic key/value overlay. Keep lookup centralized: only a trimmed, non-empty string is a configured lane value; every other stored shape preserves inheritance by resolving to undefined.
*/
const value = settings?.selectedWorkflowModelLanes?.[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function hasConfiguredFallbackLane(
settings: Partial<Settings> | undefined,
phase: ModelThinkingPhase,
): boolean {
const laneProvider = phase === "execution"
? settings?.executionFallbackProvider
: phase === "planning"
? settings?.planningFallbackProvider
: settings?.validatorFallbackProvider;
const laneModelId = phase === "execution"
? settings?.executionFallbackModelId
: phase === "planning"
? settings?.planningFallbackModelId
: settings?.validatorFallbackModelId;
const workflowPrefix = phase === "execution"
? "executionFallback"
: phase === "planning"
? "planningFallback"
: "validatorFallback";
return Boolean(
(laneProvider && laneModelId)
|| (settings?.fallbackProvider && settings?.fallbackModelId)
|| (resolveSelectedWorkflowModelLane(settings, `${workflowPrefix}Provider`)
&& resolveSelectedWorkflowModelLane(settings, `${workflowPrefix}ModelId`)),
);
}
/**
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Workflow model-lane thinking companions are workflow-declared settings whose unset state means inherit. Resolve them centrally so executor, reviewer, triage, step sessions, and merger-adjacent validation agree on precedence: node/step override > task thinking > workflow lane > global lane > project default thinking override > global default thinking level.
* Workflow model-lane thinking companions are workflow-declared settings whose unset state means inherit. Resolve them centrally so executor, reviewer, triage, step sessions, and merger-adjacent validation agree on precedence: node/step override > task thinking > project lane > global lane > selected-workflow lane > project default thinking override > global default thinking level.
*/
export function resolveSettingsLaneThinkingLevel(
phase: ModelThinkingPhase,
@@ -95,6 +135,11 @@ export function resolvePhaseThinkingLevel(
nodeOrTaskThinkingLevel,
resolveSettingsLaneThinkingLevel(phase, settings),
globalLane,
resolveSelectedWorkflowModelLane(settings, phase === "execution"
? "executionThinkingLevel"
: phase === "planning"
? "planningThinkingLevel"
: "validatorThinkingLevel"),
settings?.defaultThinkingLevelOverride,
settings?.defaultThinkingLevel,
);
@@ -127,6 +172,10 @@ export function resolveExecutionSettingsModel(settings?: Partial<Settings>): Res
provider: settings?.executionGlobalProvider,
modelId: settings?.executionGlobalModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "executionProvider"),
modelId: resolveSelectedWorkflowModelLane(settings, "executionModelId"),
},
resolveProjectDefaultModel(settings),
),
settings,
@@ -144,6 +193,10 @@ export function resolvePlanningSettingsModel(settings?: Partial<Settings>): Reso
provider: settings?.planningGlobalProvider,
modelId: settings?.planningGlobalModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "planningProvider"),
modelId: resolveSelectedWorkflowModelLane(settings, "planningModelId"),
},
resolveProjectDefaultModel(settings),
),
settings,
@@ -161,6 +214,10 @@ export function resolveValidatorSettingsModel(settings?: Partial<Settings>): Res
provider: settings?.validatorGlobalProvider,
modelId: settings?.validatorGlobalModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "validatorProvider"),
modelId: resolveSelectedWorkflowModelLane(settings, "validatorModelId"),
},
resolveProjectDefaultModel(settings),
),
settings,
@@ -280,6 +337,50 @@ export function resolveExecutorFallbackModel(settings?: Partial<Settings>): Reso
provider: settings?.fallbackProvider,
modelId: settings?.fallbackModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "executionFallbackProvider"),
modelId: resolveSelectedWorkflowModelLane(settings, "executionFallbackModelId"),
},
),
settings,
);
}
export function resolvePlanningFallbackModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.planningFallbackProvider,
modelId: settings?.planningFallbackModelId,
},
{
provider: settings?.fallbackProvider,
modelId: settings?.fallbackModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "planningFallbackProvider"),
modelId: resolveSelectedWorkflowModelLane(settings, "planningFallbackModelId"),
},
),
settings,
);
}
export function resolveValidatorFallbackModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.validatorFallbackProvider,
modelId: settings?.validatorFallbackModelId,
},
{
provider: settings?.fallbackProvider,
modelId: settings?.fallbackModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "validatorFallbackProvider"),
modelId: resolveSelectedWorkflowModelLane(settings, "validatorFallbackModelId"),
},
),
settings,
);

View File

@@ -59,7 +59,7 @@ type MovedProjectSettingsKey =
| "validatorFallbackModelId"
| "validatorFallbackThinkingLevel";
type NonDefaultProjectSettingsKey = "ephemeralAgentTaskCreationPolicy";
type NonDefaultProjectSettingsKey = "ephemeralAgentTaskCreationPolicy" | "selectedWorkflowModelLanes";
type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey | NonDefaultProjectSettingsKey>;
/**

View File

@@ -2958,6 +2958,13 @@ export interface ProjectSettings {
/** Default custom workflow (WF-…) applied to newly created tasks when the
* caller does not specify enabledWorkflowSteps. Overridable per task. */
defaultWorkflowId?: string;
/**
* Runtime-only model lanes from the task's selected workflow. They are kept
* separate from the project baseline so model resolution can enforce task →
* project → global → workflow precedence. This field is never persisted as a
* project setting.
*/
selectedWorkflowModelLanes?: Readonly<Record<string, unknown>>;
/**
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7556):
* Workflow selected for AI-undo board tasks (`createAiUndoTask`, engine

View File

@@ -113,6 +113,77 @@ export interface WorkflowSettingsResolverStore extends WorkflowIrResolverStore {
* instance is bound to one project, so the resolver derives the project key from
* the store rather than from the task (Task carries no projectId field). */
getWorkflowSettingsProjectId(): string;
/** Active project workflow whose stored model lanes act as the project-wide
* baseline for tasks selecting any other workflow. */
getDefaultWorkflowId?(): Promise<string | undefined>;
}
/**
* Model lanes exposed in Settings -> Project Models are persisted on the active
* default workflow for backward compatibility. Unlike workflow policy values,
* these lanes are a project baseline: every selected workflow inherits them and
* resolves them ahead of global and selected-workflow values.
*/
const PROJECT_WORKFLOW_MODEL_LANE_SETTING_IDS: ReadonlySet<string> = new Set([
"executionProvider",
"executionModelId",
"executionThinkingLevel",
"executionFallbackProvider",
"executionFallbackModelId",
"executionFallbackThinkingLevel",
"planningProvider",
"planningModelId",
"planningThinkingLevel",
"planningFallbackProvider",
"planningFallbackModelId",
"planningFallbackThinkingLevel",
"validatorProvider",
"validatorModelId",
"validatorThinkingLevel",
"validatorFallbackProvider",
"validatorFallbackModelId",
"validatorFallbackThinkingLevel",
]);
interface ProjectWorkflowModelLaneBaseline extends EffectiveSettingsResult {
workflowId: string;
}
async function projectWorkflowModelLaneWorkflowId(store: WorkflowSettingsResolverStore): Promise<string> {
try {
return (await store.getDefaultWorkflowId?.())?.trim() || "builtin:coding";
} catch {
return "builtin:coding";
}
}
async function projectWorkflowModelLaneBaseline(
store: WorkflowSettingsResolverStore,
projectId: string,
irCache?: Map<string, WorkflowIr>,
workflowId?: string,
): Promise<ProjectWorkflowModelLaneBaseline> {
const resolvedWorkflowId = workflowId ?? await projectWorkflowModelLaneWorkflowId(store);
const ir = await resolveWorkflowIrById(store, resolvedWorkflowId, irCache);
const detailed = await effectiveFrom(store, ir, resolvedWorkflowId, projectId);
const effective: Record<string, unknown> = {};
const storedKeys = new Set<string>();
for (const id of detailed.storedKeys) {
if (!PROJECT_WORKFLOW_MODEL_LANE_SETTING_IDS.has(id)) continue;
effective[id] = detailed.effective[id];
storedKeys.add(id);
}
return { workflowId: resolvedWorkflowId, effective, storedKeys };
}
/** Resolve only the model-lane values configured in Project Models. */
export async function resolveProjectWorkflowModelLaneBaseline(
store: WorkflowSettingsResolverStore,
projectId: string,
irCache?: Map<string, WorkflowIr>,
): Promise<EffectiveSettingsResult> {
const { effective, storedKeys } = await projectWorkflowModelLaneBaseline(store, projectId, irCache);
return { effective, storedKeys };
}
/** The declarations carried by a resolved IR, with the built-in catalog as the
@@ -231,7 +302,33 @@ export async function resolveEffectiveSettingsDetailed(
// Keep the resolved workflowId so builtin graphs still pick up the catalog fallback.
return effectiveFrom(store, ir, effectiveWorkflowId, "");
}
return effectiveFrom(store, ir, effectiveWorkflowId, projectId);
const selected = await effectiveFrom(store, ir, effectiveWorkflowId, projectId);
const projectBaselineWorkflowId = await projectWorkflowModelLaneWorkflowId(store);
if (projectBaselineWorkflowId === effectiveWorkflowId) return selected;
const projectBaseline = await projectWorkflowModelLaneBaseline(
store,
projectId,
irCache,
projectBaselineWorkflowId,
);
const effective = { ...selected.effective };
const storedKeys = new Set(selected.storedKeys);
const selectedWorkflowModelLanes: Record<string, unknown> = {};
for (const id of PROJECT_WORKFLOW_MODEL_LANE_SETTING_IDS) {
if (Object.prototype.hasOwnProperty.call(selected.effective, id)) {
selectedWorkflowModelLanes[id] = selected.effective[id];
delete effective[id];
storedKeys.delete(id);
}
if (!projectBaseline.storedKeys.has(id)) continue;
effective[id] = projectBaseline.effective[id];
storedKeys.add(id);
}
if (Object.keys(selectedWorkflowModelLanes).length > 0) {
effective.selectedWorkflowModelLanes = selectedWorkflowModelLanes;
}
return { effective, storedKeys };
}
function isPlannerOversightLevel(value: unknown): value is PlannerOversightLevel {

View File

@@ -452,7 +452,7 @@ describe("SettingsModal", () => {
const onOpenWorkflowSettings = vi.fn();
await setupWorkflowModelLaneTest({ renderProps: { onOpenWorkflowSettings } });
const workflowHeading = screen.getByRole("heading", { name: "Default workflow model lanes" });
const workflowHeading = screen.getByRole("heading", { name: "Project workflow model lanes" });
const advancedButton = screen.getByRole("button", { name: "Advanced workflow policy" });
const actionRow = advancedButton.closest(".settings-model-lane-actions");
const presetsHeading = screen.getByRole("heading", { name: "Model Presets" });
@@ -528,7 +528,7 @@ describe("SettingsModal", () => {
await setupWorkflowModelLaneTest({ stored: expectedPatch, effective: expectedPatch });
const lane = screen.getByTestId("workflow-model-lane-planning");
expect(within(lane).getByText("Override (Project)")).toBeInTheDocument();
expect(within(lane).getByText("Project baseline")).toBeInTheDocument();
expect(within(lane).getByText("GPT-4o")).toBeInTheDocument();
expect(screen.getByTestId("workflow-model-lane-execution")).toHaveTextContent("Inherited (Workflow)");
});
@@ -583,7 +583,7 @@ describe("SettingsModal", () => {
});
const lane = screen.getByTestId("workflow-model-lane-validator-fallback");
expect(within(lane).getByText("Override (Project)")).toBeInTheDocument();
expect(within(lane).getByText("Project baseline")).toBeInTheDocument();
await settingsModalUser.click(within(lane).getByRole("button", { name: "Reset" }));
await waitFor(() => {
@@ -1860,4 +1860,3 @@ describe("SettingsModal", () => {
});
});
});

View File

@@ -40,7 +40,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
modelId: "planningModelId",
thinkingId: "planningThinkingLevel",
label: "Plan/Triage Model",
help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the workflow default.",
help: "Provider and model used when planning or triaging tasks. Leave unset to fall through to the global lane, then the selected workflow.",
},
{
id: "planning-fallback",
@@ -56,7 +56,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
modelId: "executionModelId",
thinkingId: "executionThinkingLevel",
label: "Executor Model",
help: "Provider and model used while executing workflow steps. Leave unset to inherit from the workflow default.",
help: "Provider and model used while executing workflow steps. Leave unset to fall through to the global lane, then the selected workflow.",
},
{
id: "execution-fallback",
@@ -72,7 +72,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
modelId: "validatorModelId",
thinkingId: "validatorThinkingLevel",
label: "Reviewer Model",
help: "Provider and model used for workflow review or validation lanes. Leave unset to inherit from the workflow default.",
help: "Provider and model used for workflow review or validation lanes. Leave unset to fall through to the global lane, then the selected workflow.",
},
{
id: "validator-fallback",
@@ -574,12 +574,13 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
{agents.length === 0 && !agentsLoading ? (<small>{t("settings.projectModels.chatDefaultAgentEmpty", "No agents are available for this project yet.")}</small>) : null}
</div>)}
{/* --- Default workflow model lanes --- */}
{/* --- Project workflow model lanes --- */}
{/* FNXC:SettingsHelp 2026-07-16-12:45: Section description moved behind the shared "?" affordance beside the heading — operator requirement: no inline description paragraphs in Settings. */}
{/* FNXC:ProjectWorkflowModelBaseline 2026-07-22-00:00: These controls persist on the active default workflow as the cross-workflow project baseline. Runtime precedence is task-specific selection > project baseline > global lane > selected-workflow lane; keep this explanation in the shared heading help tip rather than restoring per-control prose. */}
<div className="settings-field-label-row">
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.defaultWorkflowModelLanes", "Default workflow model lanes")}</h4>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.defaultWorkflowModelLanes", "Project workflow model lanes")}</h4>
<SettingsHelpTip settingKey="default-workflow-model-lanes">
{t("settings.movedStub.modelLanes", "Per-phase model lanes (execution, planning, reviewer, and their fallbacks) now live on the workflow.")}{t("settings.projectModels.theseProjectOverridesApplyToTheActiveDefault", " These project overrides apply to the active default workflow. ")}</SettingsHelpTip>
{t("settings.movedStub.modelLanes", "Per-phase model lanes (execution, planning, reviewer, and their fallbacks) are stored on the active default workflow.")}{t("settings.projectModels.theseProjectOverridesApplyToTheActiveDefault", " They apply as the project baseline for every workflow and take precedence over global and per-workflow values; task-specific selections still win. ")}</SettingsHelpTip>
</div>
{!projectId ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.openAProjectToEditWorkflowModelLanes", "Open a project to edit workflow model lanes.")}</div>) : workflowLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.projectModels.loadingWorkflowModelLanes", "Loading workflow model lanes\u2026")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow", " No models available. Configure authentication before selecting workflow model lanes. ")}</div>) : (<>
{workflowModelPairs.map((pair) => {
@@ -600,8 +601,8 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
return (<div className="form-group" key={pair.id} data-testid={`workflow-model-lane-${pair.id}`}>
<div className="settings-model-lane-label-row">
<label htmlFor={`workflow-${pair.id}-model`}>{pair.label}</label>
<span className={`settings-lane-badge ${customized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`} title={customized ? "Explicitly set for this project workflow" : "Inherited from workflow defaults"}>
{customized ? "Override (Project)" : "Inherited (Workflow)"}
<span className={`settings-lane-badge ${customized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`} title={customized ? "Explicitly set as the project baseline" : "Inherited from global, workflow, or default settings"}>
{customized ? "Project baseline" : "Inherited (Workflow)"}
</span>
{/* FNXC:SettingsHelp 2026-07-15-23:10: Same affordance as the project lanes above — a workflow lane is the same shape, so its help hangs off the label row too rather than sitting under the dropdown as prose. */}
{pair.help ? <SettingsHelpTip settingKey={`workflow-${pair.id}-model`}>{pair.help}</SettingsHelpTip> : null}

View File

@@ -49,7 +49,7 @@ describe("non-pi custom tool wrapping", () => {
});
describe("resolve model-lane thinking levels", () => {
it("applies node/task > workflow execution lane > global lane > project default lane > global default precedence", () => {
it("applies node/task > project execution lane > global lane > selected-workflow lane > project default > global default precedence", () => {
const settings = {
defaultThinkingLevel: "low",
defaultThinkingLevelOverride: "medium",
@@ -96,6 +96,31 @@ describe("resolve model-lane thinking levels", () => {
expect(resolveValidatorThinkingLevel(nodeThinkingLevel ?? task.validatorThinkingLevel ?? task.thinkingLevel, settings)).toBe("minimal");
});
it("resolves primary thinking as task → project → global → selected workflow", () => {
const settings = {
executionThinkingLevel: "minimal",
executionGlobalThinkingLevel: "low",
planningThinkingLevel: "minimal",
planningGlobalThinkingLevel: "low",
validatorThinkingLevel: "minimal",
validatorGlobalThinkingLevel: "low",
selectedWorkflowModelLanes: {
executionThinkingLevel: "high",
planningThinkingLevel: "high",
validatorThinkingLevel: "high",
},
} as const;
expect(resolveExecutorThinkingLevel("xhigh", settings)).toBe("xhigh");
expect(resolveExecutorThinkingLevel(undefined, settings)).toBe("minimal");
expect(resolvePlanningThinkingLevel({ ...settings, planningThinkingLevel: undefined })).toBe("low");
expect(resolveValidatorThinkingLevel(undefined, {
...settings,
validatorThinkingLevel: undefined,
validatorGlobalThinkingLevel: undefined,
})).toBe("high");
});
it("resolves fallback thinking through fallback key then executor lane then defaults", () => {
expect(resolveExecutorFallbackThinkingLevel("task", { fallbackThinkingLevel: "high", executionThinkingLevel: "low" })).toBe("high");
expect(resolveExecutorFallbackThinkingLevel(undefined, { executionThinkingLevel: "minimal", defaultThinkingLevel: "low" })).toBe("minimal");
@@ -103,7 +128,7 @@ describe("resolve model-lane thinking levels", () => {
expect(resolveExecutorFallbackThinkingLevel(undefined, { defaultThinkingLevel: "low" })).toBe("low");
});
it("resolves workflow fallback thinking before global fallback then lane defaults", () => {
it("resolves project fallback thinking before global fallback then selected-workflow and lane defaults", () => {
expect(resolvePlanningFallbackThinkingLevel({ planningFallbackThinkingLevel: "xhigh", fallbackThinkingLevel: "high", planningThinkingLevel: "low" })).toBe("xhigh");
expect(resolvePlanningFallbackThinkingLevel({ fallbackThinkingLevel: "high", planningThinkingLevel: "low" })).toBe("high");
expect(resolvePlanningFallbackThinkingLevel({ planningThinkingLevel: "low", defaultThinkingLevel: "minimal" })).toBe("low");
@@ -115,6 +140,18 @@ describe("resolve model-lane thinking levels", () => {
expect(resolveValidatorFallbackThinkingLevel(undefined, { validatorThinkingLevel: "low", defaultThinkingLevel: "minimal" })).toBe("low");
expect(resolveValidatorFallbackThinkingLevel(undefined, { defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "minimal" })).toBe("medium");
expect(resolveValidatorFallbackThinkingLevel(undefined, { defaultThinkingLevel: "minimal" })).toBe("minimal");
const selectedWorkflowFallbacks = {
fallbackThinkingLevel: "high",
selectedWorkflowModelLanes: {
planningFallbackThinkingLevel: "low",
validatorFallbackThinkingLevel: "low",
},
} as const;
expect(resolvePlanningFallbackThinkingLevel(selectedWorkflowFallbacks)).toBe("high");
expect(resolvePlanningFallbackThinkingLevel({ ...selectedWorkflowFallbacks, fallbackThinkingLevel: undefined })).toBe("low");
expect(resolveValidatorFallbackThinkingLevel(undefined, selectedWorkflowFallbacks)).toBe("high");
expect(resolveValidatorFallbackThinkingLevel(undefined, { ...selectedWorkflowFallbacks, fallbackThinkingLevel: undefined })).toBe("low");
});
it("resolves title summarizer and merger fallback thinking through fallback and default chains", () => {

View File

@@ -1280,6 +1280,32 @@ describe("CronRunner", () => {
expect(mockExecutor).toHaveBeenNthCalledWith(2, "Use default", "anthropic", "claude-sonnet-4-5", undefined, undefined);
});
it("inherits scheduled AI prompt thinking from the execution lane hierarchy", async () => {
const store = createMockStore({
executionThinkingLevel: "medium",
executionGlobalThinkingLevel: "high",
selectedWorkflowModelLanes: { executionThinkingLevel: "low" },
defaultThinkingLevel: "xhigh",
});
const mockExecutor = createAiMockExecutor("response");
const schedule = createMockSchedule({
command: "",
steps: [makeStep({ type: "ai-prompt", name: "Inherited thinking", prompt: "Think", command: undefined })],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
await runner.executeSchedule(schedule);
expect(mockExecutor).toHaveBeenCalledWith(
"Think",
"anthropic",
"claude-sonnet-4-5",
undefined,
"medium",
);
});
it("passes step model provider and model ID to executor", async () => {
const store = createMockStore();
const mockExecutor = createAiMockExecutor("response");

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { resolveExecutionSettingsModel, type Settings } from "@fusion/core";
import { mergeEffectiveSettings } from "../effective-settings.js";
import { mergeEffectiveSettings, mergeProjectWorkflowModelLaneBaseline } from "../effective-settings.js";
const PROJECT = "proj-1";
@@ -16,13 +16,11 @@ function makeStore(values?: Record<string, unknown>) {
/**
* KTD-7 model-lane chain, pinned AFTER the entry merge. The chain reads
* `settings.executionProvider` (workflow lane, now from the effective merge) →
* `executionGlobalProvider` (stays global) → project default override → global
* default. The entry merge feeds the workflow lane into that same field, so the
* documented chain is unchanged.
* `settings.executionProvider` (Project Models baseline) → global lane →
* selected-workflow value → project/global default.
*/
describe("model-lane resolution after effective-settings merge (KTD-7)", () => {
it("workflow lane set → wins over global lane and defaults", async () => {
it("project workflow baseline set → wins over global lane and defaults", async () => {
const base = {
executionGlobalProvider: "global-prov",
executionGlobalModelId: "global-model",
@@ -37,6 +35,54 @@ describe("model-lane resolution after effective-settings merge (KTD-7)", () => {
expect(resolveExecutionSettingsModel(merged)).toEqual({ provider: "wf-prov", modelId: "wf-model" });
});
it("global lane wins over a non-default selected workflow value when the project baseline is empty", async () => {
const store = {
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "wf-custom", stepIds: [] })),
getDefaultWorkflowId: vi.fn(async () => "builtin:coding"),
getWorkflowDefinition: vi.fn(async (id: string) => id === "wf-custom"
? {
ir: {
version: "v2",
name: "Custom",
columns: [],
nodes: [],
edges: [],
settings: [
{ id: "executionProvider", name: "Execution provider", type: "string" },
{ id: "executionModelId", name: "Execution model", type: "string" },
],
},
}
: undefined),
getWorkflowSettingValues: vi.fn((workflowId: string) => workflowId === "wf-custom"
? { executionProvider: "workflow-prov", executionModelId: "workflow-model" }
: {}),
getWorkflowSettingsProjectId: vi.fn(() => PROJECT),
};
const merged = await mergeEffectiveSettings(store as any, { id: "t1" }, {
executionGlobalProvider: "global-prov",
executionGlobalModelId: "global-model",
} as unknown as Settings);
expect(resolveExecutionSettingsModel(merged)).toEqual({ provider: "global-prov", modelId: "global-model" });
expect(merged.selectedWorkflowModelLanes).toMatchObject({ executionProvider: "workflow-prov", executionModelId: "workflow-model" });
});
it("loads the project workflow baseline for model sessions without a task", async () => {
const store = {
getDefaultWorkflowId: vi.fn(async () => "builtin:coding"),
getWorkflowDefinition: vi.fn(async () => undefined),
getWorkflowSettingValues: vi.fn(() => ({ executionProvider: "project-prov", executionModelId: "project-model" })),
getWorkflowSettingsProjectId: vi.fn(() => PROJECT),
};
const merged = await mergeProjectWorkflowModelLaneBaseline(store as any, {
executionGlobalProvider: "global-prov",
executionGlobalModelId: "global-model",
} as unknown as Settings);
expect(resolveExecutionSettingsModel(merged)).toEqual({ provider: "project-prov", modelId: "project-model" });
});
it("workflow lane empty → falls through to the global lane", async () => {
const base = {
executionGlobalProvider: "global-prov",

View File

@@ -213,6 +213,51 @@ describe("reviewStep — model settings threading", () => {
expect(result.verdict).toBe("APPROVE");
});
it("uses the selected workflow reviewer lane after project and global lanes fall through", async () => {
mockedCreateFnAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nWorkflow reviewer honored."),
);
const task = { id: "FN-100", column: "in-review", steps: [] } as any;
const store = {
getSettings: vi.fn().mockResolvedValue({
defaultProvider: "default-provider",
defaultModelId: "default-model",
}),
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "wf-custom", stepIds: [] })),
getDefaultWorkflowId: vi.fn(async () => "builtin:coding"),
getWorkflowDefinition: vi.fn(async (workflowId: string) => workflowId === "wf-custom"
? {
ir: {
version: "v2",
name: "Custom",
columns: [],
nodes: [],
edges: [],
settings: [
{ id: "validatorProvider", name: "Validator provider", type: "string" },
{ id: "validatorModelId", name: "Validator model", type: "string" },
],
},
}
: undefined),
getWorkflowSettingValues: vi.fn((workflowId: string) => workflowId === "wf-custom"
? { validatorProvider: "workflow-provider", validatorModelId: "workflow-model" }
: {}),
getWorkflowSettingsProjectId: vi.fn(() => "project-1"),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
};
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "code", "# prompt", "abc123",
{ store: store as any, taskId: task.id, task },
);
const opts = mockedCreateFnAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("workflow-provider");
expect(opts.defaultModelId).toBe("workflow-model");
});
it("logs reviewer model rows with default thinking effort", async () => {
mockedCreateFnAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
@@ -789,7 +834,11 @@ describe("reviewStep — fallback retry for terminal unavailable", () => {
const task = { id: "FN-4092", column: "in-progress", description: "d", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# prompt", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", reviewerFallbackRetryCount: 0 };
const store = {
getSettings: vi.fn().mockResolvedValue({ maxReviewerFallbackRetries: 2, maxTotalRetriesBeforeFail: 25 }),
getSettings: vi.fn().mockResolvedValue({
maxReviewerFallbackRetries: 2,
maxTotalRetriesBeforeFail: 25,
validatorFallbackThinkingLevel: "xhigh",
}),
getTask: vi.fn().mockImplementation(async () => task),
updateTask: vi.fn().mockImplementation(async (_id: string, patch: Record<string, unknown>) => Object.assign(task, patch)),
logEntry: vi.fn().mockResolvedValue(undefined),
@@ -808,6 +857,7 @@ describe("reviewStep — fallback retry for terminal unavailable", () => {
expect(result.verdict).toBe("APPROVE");
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
expect(mockedCreateFnAgent.mock.calls.every(([options]) => options.fallbackThinkingLevel === "xhigh")).toBe(true);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-4092",
expect.stringContaining("review retry with fallback model after UNAVAILABLE verdict"),

View File

@@ -4495,6 +4495,7 @@ describe("taskCreate tool model inheritance", () => {
defaultModelId: "gpt-4o",
planningFallbackProvider: "anthropic",
planningFallbackModelId: "claude-3-5-haiku-20241022",
planningFallbackThinkingLevel: "xhigh",
} as Settings),
});
mockCreateFnAgent.mockResolvedValue({ session: baseSession() });
@@ -4513,6 +4514,7 @@ describe("taskCreate tool model inheritance", () => {
defaultModelId: "nvidia/moonshotai/kimi-k2.6",
fallbackProvider: "anthropic",
fallbackModelId: "claude-3-5-haiku-20241022",
fallbackThinkingLevel: "xhigh",
}),
);
});

View File

@@ -49,6 +49,7 @@ import { resolveHeartbeatPromptTemplate, resolveHeartbeatScopeDisciplineMode, se
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js";
import { createLogger, heartbeatLog, formatError } from "./logger.js";
import { mergeEffectiveSettings, mergeProjectWorkflowModelLaneBaseline } from "./effective-settings.js";
import {
extractConcurrentSoftDeleteRaceDetails,
isConcurrentSoftDeleteRaceError,
@@ -2867,6 +2868,10 @@ export class HeartbeatMonitor {
}
}
const heartbeatBaseSettings = heartbeatModelSettings ?? ({} as Settings);
heartbeatModelSettings = taskDetail
? await mergeEffectiveSettings(taskStore, taskDetail, heartbeatBaseSettings)
: await mergeProjectWorkflowModelLaneBaseline(taskStore, heartbeatBaseSettings);
const heartbeatSessionModels = resolveHeartbeatSessionModels(heartbeatModelSettings, agent.runtimeConfig);
/*
* FNXC:McpConfig 2026-06-26-00:00:

View File

@@ -20,6 +20,7 @@ import {
resolveMergerSettingsModel,
resolvePhaseThinkingLevel,
resolveProjectDefaultModel,
resolveSelectedWorkflowModelLane,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
@@ -225,7 +226,7 @@ function firstThinkingLevel(...levels: Array<ThinkingLevel | string | undefined
/**
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Model-lane thinking overrides must resolve through the same session option (`defaultThinkingLevel`) pi.ts already guards with the thinking/reasoning conflict fallback. Keep node/step thinking first when supplied by callers, then task, workflow lane, global lane, project default thinking override, and global default.
* Model-lane thinking overrides must resolve through the same session option (`defaultThinkingLevel`) that pi.ts already guards with the thinking/reasoning conflict fallback. Keep node/step thinking first when supplied by callers, then task, project lane, global lane, selected-workflow lane, project default thinking override, and global default.
*/
export function resolveExecutorThinkingLevel(
taskThinkingLevel: ThinkingLevel | string | undefined,
@@ -292,6 +293,7 @@ export function resolveExecutorFallbackThinkingLevel(
return firstThinkingLevel(
settings?.executionFallbackThinkingLevel,
settings?.fallbackThinkingLevel,
resolveSelectedWorkflowModelLane(settings, "executionFallbackThinkingLevel"),
resolveExecutorThinkingLevel(taskThinkingLevel, settings),
);
}
@@ -303,6 +305,7 @@ export function resolvePlanningFallbackThinkingLevel(
return firstThinkingLevel(
settings?.planningFallbackThinkingLevel,
settings?.fallbackThinkingLevel,
resolveSelectedWorkflowModelLane(settings, "planningFallbackThinkingLevel"),
resolvePlanningThinkingLevel(settings, taskThinkingLevel),
);
}
@@ -314,6 +317,7 @@ export function resolveValidatorFallbackThinkingLevel(
return firstThinkingLevel(
settings?.validatorFallbackThinkingLevel,
settings?.fallbackThinkingLevel,
resolveSelectedWorkflowModelLane(settings, "validatorFallbackThinkingLevel"),
resolveValidatorThinkingLevel(taskThinkingLevel, settings),
);
}

View File

@@ -21,6 +21,8 @@ import { createFnAgent, promptWithFallback } from "./pi.js";
import { HybridEvaluatorService } from "./evaluator.js";
import { buildSessionSkillContextSync } from "./session-skill-context.js";
import { resolveMcpServersForStore } from "./mcp-resolution.js";
import { mergeEffectiveSettings, mergeProjectWorkflowModelLaneBaseline } from "./effective-settings.js";
import { resolveExecutorThinkingLevel } from "./agent-session-helpers.js";
const log = createLogger("cron-runner");
@@ -604,7 +606,8 @@ export class CronRunner {
if (!taskDetail) {
throw new Error(`Task not found for evaluation: ${task.id}`);
}
return evaluator.evaluateTask(taskDetail as TaskDetail, { runId: run.id, startedAt: run.startedAt ?? startedAt }, settings, {
const taskSettings = await mergeEffectiveSettings(this.store, taskDetail, settings);
return evaluator.evaluateTask(taskDetail as TaskDetail, { runId: run.id, startedAt: run.startedAt ?? startedAt }, taskSettings, {
provider: evalSettings.taskEvaluationProvider,
modelId: evalSettings.taskEvaluationModelId,
});
@@ -887,13 +890,13 @@ export class CronRunner {
};
}
// Resolve model: step override → project execution lane → global execution lane → project default override → global default
// Resolve model: step override → project execution lane → global execution lane → selected-workflow 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 mergeProjectWorkflowModelLaneBaseline(this.store, await this.store.getSettings());
const defaultModel = resolveExecutionSettingsModel(settings);
const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
const modelId = step.modelId?.trim() || defaultModel.modelId;
const thinkingLevel = step.thinkingLevel?.trim() || undefined;
const thinkingLevel = resolveExecutorThinkingLevel(step.thinkingLevel, settings);
const model = modelProvider && modelId
? `${modelProvider}/${modelId}`

View File

@@ -5,7 +5,8 @@
* the read sites.
*
* TWO-TIER MERGE (the parity-preserving rule):
* - a STORED workflow value ALWAYS overrides the base (the workflow tuned it);
* - a STORED flat value ALWAYS overrides the base (workflow policy, or the
* project workflow-model baseline);
* - a declaration-DEFAULT-only key (no stored value) only FILLS the base when the
* base lacks the key.
*
@@ -13,7 +14,8 @@
* project setting still present in the base is NOT clobbered by a declaration
* default; only a real stored workflow value overrides it. After the hard-move the
* base lacks the moved key, so the declaration default fills it. Absent-default
* model lanes contribute nothing, so they never override a real project value.
* model lanes contribute nothing. Lower-precedence selected-workflow model lanes
* are retained separately for the centralized model resolver.
*
* `resolveEffectiveSettingsDetailed` never throws (degrades to declaration
* defaults), so this helper is a thin store-coupled wrapper that also never throws.
@@ -22,6 +24,7 @@
import {
applyWorkflowSettingsOverlay,
resolveEffectiveSettingsDetailed,
resolveProjectWorkflowModelLaneBaseline,
type Settings,
type TaskStore,
} from "@fusion/core";
@@ -40,6 +43,7 @@ export interface EffectiveSettingsTask {
export async function mergeEffectiveSettings<T extends Partial<Settings>>(
store: Pick<
TaskStore,
| "getDefaultWorkflowId"
| "getTaskWorkflowSelection"
| "getTaskWorkflowSelectionAsync"
| "getWorkflowDefinition"
@@ -59,3 +63,26 @@ export async function mergeEffectiveSettings<T extends Partial<Settings>>(
return base;
}
}
/** Merge the Project Models workflow-lane baseline when no task-selected
* workflow exists, such as scheduled AI prompts and idle heartbeats. */
export async function mergeProjectWorkflowModelLaneBaseline<T extends Partial<Settings>>(
store: Pick<
TaskStore,
| "getDefaultWorkflowId"
| "getWorkflowDefinition"
| "getWorkflowSettingValues"
| "getWorkflowSettingsProjectId"
>,
base: T,
): Promise<T> {
try {
const detailed = await resolveProjectWorkflowModelLaneBaseline(
store as Parameters<typeof resolveProjectWorkflowModelLaneBaseline>[0],
store.getWorkflowSettingsProjectId(),
);
return applyWorkflowSettingsOverlay(base, detailed);
} catch {
return base;
}
}

View File

@@ -12,10 +12,12 @@
import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core";
import {
buildReviewerMemoryInstructions,
hasConfiguredFallbackLane,
resolveAgentMemoryInclusionMode,
resolveAgentPrompt,
resolvePersistAgentThinkingLog,
resolveTaskSeamPrompt,
resolveValidatorFallbackModel,
} from "@fusion/core";
import { recordRetry } from "./retry-burned-logger.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
@@ -23,7 +25,12 @@ import { describeModel, formatModelMarkerDetails, promptWithFallback } from "./p
import { isContextLimitError } from "./context-limit-detector.js";
import { classifyError } from "./transient-error-detector.js";
import { withRetry } from "./retry-with-backoff.js";
import { createResolvedAgentSession, extractRuntimeHint, resolveValidatorSessionModel } from "./agent-session-helpers.js";
import {
createResolvedAgentSession,
extractRuntimeHint,
resolveValidatorFallbackThinkingLevel,
resolveValidatorSessionModel,
} from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { AgentLogger } from "./agent-logger.js";
import { reviewerLog } from "./logger.js";
@@ -208,6 +215,13 @@ export async function reviewStep(
// Fall back to the snapshot — better to spawn than crash on a transient store error.
}
}
if (options.store && options.taskId && liveSettings) {
try {
liveSettings = await mergeEffectiveSettings(options.store, { id: options.taskId }, liveSettings);
} catch {
// Keep the best available snapshot when task/workflow resolution fails.
}
}
if (liveSettings?.globalPause || liveSettings?.enginePaused) {
const reason = liveSettings.globalPause ? "Global pause" : "Engine paused";
reviewerLog.log(
@@ -292,12 +306,27 @@ export async function reviewStep(
const validatorProvider = reviewerModel.provider;
const validatorModelId = reviewerModel.modelId;
const validatorFallbackProvider = options.projectValidatorFallbackProvider && options.projectValidatorFallbackModelId
? options.projectValidatorFallbackProvider
: options.fallbackProvider;
const validatorFallbackModelId = options.projectValidatorFallbackProvider && options.projectValidatorFallbackModelId
? options.projectValidatorFallbackModelId
: options.fallbackModelId;
const reviewerFallbackSettings: Partial<Settings> = {
...reviewerModelSettings,
validatorFallbackProvider: effectiveSettings?.validatorFallbackProvider,
validatorFallbackModelId: effectiveSettings?.validatorFallbackModelId,
fallbackProvider: effectiveSettings?.fallbackProvider,
fallbackModelId: effectiveSettings?.fallbackModelId,
};
if (options.projectValidatorFallbackProvider && options.projectValidatorFallbackModelId) {
reviewerFallbackSettings.validatorFallbackProvider = options.projectValidatorFallbackProvider;
reviewerFallbackSettings.validatorFallbackModelId = options.projectValidatorFallbackModelId;
}
if (options.fallbackProvider && options.fallbackModelId) {
reviewerFallbackSettings.fallbackProvider = options.fallbackProvider;
reviewerFallbackSettings.fallbackModelId = options.fallbackModelId;
}
const hasConfiguredValidatorFallback = hasConfiguredFallbackLane(reviewerFallbackSettings, "validation");
const validatorFallback = hasConfiguredValidatorFallback
? resolveValidatorFallbackModel(reviewerFallbackSettings)
: { provider: undefined, modelId: undefined };
const validatorFallbackProvider = validatorFallback.provider;
const validatorFallbackModelId = validatorFallback.modelId;
let reviewerInstructions = "";
if (options.agentStore && options.rootDir) {
@@ -473,7 +502,8 @@ export async function reviewStep(
defaultModelId: overrides?.forceModelId ?? validatorModelId,
fallbackProvider: validatorFallbackProvider,
fallbackModelId: validatorFallbackModelId,
fallbackThinkingLevel: options.fallbackThinkingLevel,
fallbackThinkingLevel: options.fallbackThinkingLevel
?? resolveValidatorFallbackThinkingLevel(options.defaultThinkingLevel, reviewerFallbackSettings),
defaultThinkingLevel: options.defaultThinkingLevel,
runAuditor,
settings: effectiveSettings,
@@ -661,21 +691,9 @@ export async function reviewStep(
};
const hasConfiguredFallback = Boolean(validatorFallbackProvider && validatorFallbackModelId);
// Merge per-task effective workflow settings (U3, KTD-3) over the base so the
// retry-budget reads (maxReviewerContextRetries / maxReviewerFallbackRetries via
// recordRetry) pick up workflow values. `liveSettings`/`options.settings` are the
// base; the merge is behavior-inert when nothing is customized. Resolved once
// here (all recordRetry sites share it).
const retrySettingsBase = liveSettings ?? options.settings;
let retrySettings = retrySettingsBase;
if (options.store && options.taskId && retrySettingsBase) {
try {
const retryTask = await options.store.getTask(options.taskId);
retrySettings = await mergeEffectiveSettings(options.store, retryTask, retrySettingsBase);
} catch {
// Keep the base snapshot on any store/resolve error (never-throw).
}
}
// The task-effective snapshot used for model selection also carries workflow
// retry-budget values, so every recordRetry site shares the same resolution.
const retrySettings = effectiveSettings;
const resetReviewerFallbackRetryCount = async (): Promise<void> => {
if (!options.store || !options.taskId || typeof options.store.updateTask !== "function") {

View File

@@ -12,6 +12,7 @@ import type {
} from "@fusion/core";
import {
DUPLICATE_OF_METADATA_KEY,
hasConfiguredFallbackLane,
PLAN_REVIEW_GROUP_ID,
TaskDeletedError,
buildTriageMemoryInstructions,
@@ -26,6 +27,7 @@ import {
resolveTaskPlanningPrompt,
resolveTaskSeamPrompt,
resolvePersistAgentThinkingLog,
resolvePlanningFallbackModel,
compareTaskIdNumeric,
resolveAgentMemoryInclusionMode,
resolvePlanApprovalRequired,
@@ -118,6 +120,7 @@ import {
createResolvedAgentSession,
extractRuntimeHint,
resolveImplicitPlanningFallbackModel,
resolvePlanningFallbackThinkingLevel,
resolvePlanningSessionModel,
resolvePlanningThinkingLevel,
} from "./agent-session-helpers.js";
@@ -1610,9 +1613,11 @@ export class TriageProcessor {
* 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)
const hasConfiguredPlanningFallback = hasConfiguredFallbackLane(settings, "planning");
const planningFallback = hasConfiguredPlanningFallback
? resolvePlanningFallbackModel(settings)
: { provider: undefined, modelId: undefined };
const implicitPlanningFallback = !hasConfiguredPlanningFallback
? resolveImplicitPlanningFallbackModel(
settings,
planningModel.provider,
@@ -1642,15 +1647,15 @@ export class TriageProcessor {
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
...planningSessionModelOptions,
fallbackProvider: hasExplicitPlanningFallback
? settings.planningFallbackProvider
: (hasExplicitGlobalFallback ? settings.fallbackProvider : implicitPlanningFallback.provider),
fallbackModelId: hasExplicitPlanningFallback
? settings.planningFallbackModelId
: (hasExplicitGlobalFallback ? settings.fallbackModelId : implicitPlanningFallback.modelId),
fallbackProvider: planningFallback.provider ?? implicitPlanningFallback.provider,
fallbackModelId: planningFallback.modelId ?? implicitPlanningFallback.modelId,
fallbackThinkingLevel: resolvePlanningFallbackThinkingLevel(
settings,
task.planningThinkingLevel ?? task.thinkingLevel,
),
/*
* FNXC:Settings-ThinkingLevel 2026-07-13-00:27:
* Planning sessions honor the per-task planning override before the shared task thinking level, then the workflow-declared planning lane, global lane, and default thinking settings.
* Planning sessions honor the per-task planning override before the shared task thinking level, then the project lane, global lane, selected-workflow lane, and default thinking settings.
*/
defaultThinkingLevel: resolvePlanningThinkingLevel(settings, task.planningThinkingLevel ?? task.thinkingLevel),
runAuditor,