From e514e134dad3813447cb09f520d62ffced9c61fa Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 08:53:31 -0700 Subject: [PATCH] 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) ## 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. --- .changeset/project-workflow-model-lanes.md | 7 + CONCEPTS.md | 4 +- docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 97 +++++++----- .../src/__tests__/model-resolution.test.ts | 67 ++++++++ .../workflow-settings-resolver.test.ts | 149 +++++++++++++++++- .../core/src/effective-settings-overlay.ts | 2 +- packages/core/src/index.gate.ts | 4 + packages/core/src/index.ts | 5 + packages/core/src/model-resolution.ts | 103 +++++++++++- packages/core/src/settings-schema.ts | 2 +- packages/core/src/types.ts | 7 + .../core/src/workflow-settings-resolver.ts | 99 +++++++++++- .../SettingsModal.models-auth.test.tsx | 7 +- .../sections/ProjectModelsSection.tsx | 17 +- .../__tests__/agent-session-helpers.test.ts | 41 ++++- .../engine/src/__tests__/cron-runner.test.ts | 26 +++ .../effective-settings-model-lane.test.ts | 58 ++++++- .../engine/src/__tests__/reviewer.test.ts | 52 +++++- packages/engine/src/__tests__/triage.test.ts | 2 + packages/engine/src/agent-heartbeat.ts | 5 + packages/engine/src/agent-session-helpers.ts | 6 +- packages/engine/src/cron-runner.ts | 11 +- packages/engine/src/effective-settings.ts | 31 +++- packages/engine/src/reviewer.ts | 64 +++++--- packages/engine/src/triage.ts | 25 +-- 26 files changed, 781 insertions(+), 112 deletions(-) create mode 100644 .changeset/project-workflow-model-lanes.md diff --git a/.changeset/project-workflow-model-lanes.md b/.changeset/project-workflow-model-lanes.md new file mode 100644 index 0000000000..d18f3420a5 --- /dev/null +++ b/.changeset/project-workflow-model-lanes.md @@ -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. diff --git a/CONCEPTS.md b/CONCEPTS.md index 48ace438e4..9944c79252 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -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`-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`-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). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2c6b32d36e..2985c1eb63 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 329376f8e1..5b9717bbf6 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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) diff --git a/packages/core/src/__tests__/model-resolution.test.ts b/packages/core/src/__tests__/model-resolution.test.ts index 7d6769cc46..53a0ccc9f1 100644 --- a/packages/core/src/__tests__/model-resolution.test.ts +++ b/packages/core/src/__tests__/model-resolution.test.ts @@ -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", diff --git a/packages/core/src/__tests__/workflow-settings-resolver.test.ts b/packages/core/src/__tests__/workflow-settings-resolver.test.ts index a33247de24..cbeff38904 100644 --- a/packages/core/src/__tests__/workflow-settings-resolver.test.ts +++ b/packages/core/src/__tests__/workflow-settings-resolver.test.ts @@ -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" }); diff --git a/packages/core/src/effective-settings-overlay.ts b/packages/core/src/effective-settings-overlay.ts index aca43efca0..467a2964bd 100644 --- a/packages/core/src/effective-settings-overlay.ts +++ b/packages/core/src/effective-settings-overlay.ts @@ -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>( base: T, diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index ee9ac7c6e2..5cf636dcd1 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -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, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b382d43ea..a14a309f0a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/model-resolution.ts b/packages/core/src/model-resolution.ts index eaa1e1a850..0937ccc6ca 100644 --- a/packages/core/src/model-resolution.ts +++ b/packages/core/src/model-resolution.ts @@ -68,9 +68,49 @@ function firstThinkingLevel(...levels: Array | 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 | 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): 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): 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): 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): Reso provider: settings?.fallbackProvider, modelId: settings?.fallbackModelId, }, + { + provider: resolveSelectedWorkflowModelLane(settings, "executionFallbackProvider"), + modelId: resolveSelectedWorkflowModelLane(settings, "executionFallbackModelId"), + }, + ), + settings, + ); +} + +export function resolvePlanningFallbackModel(settings?: Partial): 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): 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, ); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 15dc8394f3..3b8a0e6d97 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -59,7 +59,7 @@ type MovedProjectSettingsKey = | "validatorFallbackModelId" | "validatorFallbackThinkingLevel"; -type NonDefaultProjectSettingsKey = "ephemeralAgentTaskCreationPolicy"; +type NonDefaultProjectSettingsKey = "ephemeralAgentTaskCreationPolicy" | "selectedWorkflowModelLanes"; type ProjectSettingsSchema = Omit; /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f6e101b7c2..1a21fbdb14 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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>; /** * FNXC:TaskRevert 2026-07-05-00:00 (FN-7556): * Workflow selected for AI-undo board tasks (`createAiUndoTask`, engine diff --git a/packages/core/src/workflow-settings-resolver.ts b/packages/core/src/workflow-settings-resolver.ts index dc323849a1..6f44ed1734 100644 --- a/packages/core/src/workflow-settings-resolver.ts +++ b/packages/core/src/workflow-settings-resolver.ts @@ -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; +} + +/** + * 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 = 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 { + try { + return (await store.getDefaultWorkflowId?.())?.trim() || "builtin:coding"; + } catch { + return "builtin:coding"; + } +} + +async function projectWorkflowModelLaneBaseline( + store: WorkflowSettingsResolverStore, + projectId: string, + irCache?: Map, + workflowId?: string, +): Promise { + const resolvedWorkflowId = workflowId ?? await projectWorkflowModelLaneWorkflowId(store); + const ir = await resolveWorkflowIrById(store, resolvedWorkflowId, irCache); + const detailed = await effectiveFrom(store, ir, resolvedWorkflowId, projectId); + const effective: Record = {}; + const storedKeys = new Set(); + 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, +): Promise { + 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 = {}; + 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 { diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index 911051ad9f..9934a9110a 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -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", () => { }); }); }); - diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index 14c1ef6405..43ecd22f3c 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -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 ? ({t("settings.projectModels.chatDefaultAgentEmpty", "No agents are available for this project yet.")}) : null} )} - {/* --- 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. */}
-

{t("settings.projectModels.defaultWorkflowModelLanes", "Default workflow model lanes")}

+

{t("settings.projectModels.defaultWorkflowModelLanes", "Project 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. ")} + {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. ")}
{!projectId ? (
{t("settings.projectModels.openAProjectToEditWorkflowModelLanes", "Open a project to edit workflow model lanes.")}
) : workflowLoading ? (
) : availableModels.length === 0 ? (
{t("settings.projectModels.noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow", " No models available. Configure authentication before selecting workflow model lanes. ")}
) : (<> {workflowModelPairs.map((pair) => { @@ -600,8 +601,8 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW return (
- - {customized ? "Override (Project)" : "Inherited (Workflow)"} + + {customized ? "Project baseline" : "Inherited (Workflow)"} {/* 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 ? {pair.help} : null} diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index cef741878d..5193d4dad2 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -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", () => { diff --git a/packages/engine/src/__tests__/cron-runner.test.ts b/packages/engine/src/__tests__/cron-runner.test.ts index c0a6c262f5..d0abc319b3 100644 --- a/packages/engine/src/__tests__/cron-runner.test.ts +++ b/packages/engine/src/__tests__/cron-runner.test.ts @@ -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"); diff --git a/packages/engine/src/__tests__/effective-settings-model-lane.test.ts b/packages/engine/src/__tests__/effective-settings-model-lane.test.ts index 3c28a19066..97d4284778 100644 --- a/packages/engine/src/__tests__/effective-settings-model-lane.test.ts +++ b/packages/engine/src/__tests__/effective-settings-model-lane.test.ts @@ -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) { /** * 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", diff --git a/packages/engine/src/__tests__/reviewer.test.ts b/packages/engine/src/__tests__/reviewer.test.ts index 14566f4172..54c8b6e0c5 100644 --- a/packages/engine/src/__tests__/reviewer.test.ts +++ b/packages/engine/src/__tests__/reviewer.test.ts @@ -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) => 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"), diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 60aeaed0aa..cbf0e4e7e5 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -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", }), ); }); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 1eba33d601..e7216da54e 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -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: diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 64bbb00e9d..908073e217 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -20,6 +20,7 @@ import { resolveMergerSettingsModel, resolvePhaseThinkingLevel, resolveProjectDefaultModel, + resolveSelectedWorkflowModelLane, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel, @@ -225,7 +226,7 @@ function firstThinkingLevel(...levels: Array>( store: Pick< TaskStore, + | "getDefaultWorkflowId" | "getTaskWorkflowSelection" | "getTaskWorkflowSelectionAsync" | "getWorkflowDefinition" @@ -59,3 +63,26 @@ export async function mergeEffectiveSettings>( 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>( + store: Pick< + TaskStore, + | "getDefaultWorkflowId" + | "getWorkflowDefinition" + | "getWorkflowSettingValues" + | "getWorkflowSettingsProjectId" + >, + base: T, +): Promise { + try { + const detailed = await resolveProjectWorkflowModelLaneBaseline( + store as Parameters[0], + store.getWorkflowSettingsProjectId(), + ); + return applyWorkflowSettingsOverlay(base, detailed); + } catch { + return base; + } +} diff --git a/packages/engine/src/reviewer.ts b/packages/engine/src/reviewer.ts index 5eac7fad84..538c16e1ee 100644 --- a/packages/engine/src/reviewer.ts +++ b/packages/engine/src/reviewer.ts @@ -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 = { + ...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 => { if (!options.store || !options.taskId || typeof options.store.updateTask !== "function") { diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index f4519c2f49..b4e494e624 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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,