FN-8042: add merger fallback model lane

Add configurable project fallback models for AI merger retries.

- Resolve complete project merger fallback pairs before the shared global fallback.
- Expose merger fallback model and thinking controls in Project Models with reset-aware persistence.
- Apply the fallback lane across merger, review, PR-response, and recovery sessions with tests and documentation.

Files changed:
 .changeset/fn-8042-merger-fallback-model.md        |  7 +++
 docs/settings-reference.md                         |  4 +-
 .../core/src/__tests__/model-resolution.test.ts    | 24 ++++++++++
 .../core/src/__tests__/settings-parity.test.ts     |  3 ++
 packages/core/src/index.gate.ts                    |  1 +
 packages/core/src/index.ts                         |  1 +
 packages/core/src/model-resolution.ts              | 22 +++++++++
 packages/core/src/settings-schema.ts               |  4 ++
 packages/core/src/types.ts                         | 13 ++++++
 .../app/__tests__/settings-save-split.test.ts      | 41 +++++++++++++++++
 .../app/__tests__/settings-sections.test.tsx       | 36 +++++++++++++++
 .../app/components/settings/save-split.ts          |  7 +--
 .../settings/sections/ProjectModelsSection.tsx     | 53 +++++++++++++++++++++-
 .../settings-default-descriptions.test.tsx         |  3 ++
 .../src/__tests__/agent-session-helpers.test.ts    |  1 +
 .../__tests__/mcp-pr-response-forwarding.test.ts   |  1 +
 packages/engine/src/agent-session-helpers.ts       | 10 +++-
 packages/engine/src/merger-ai.ts                   | 17 +++++--
 packages/engine/src/merger.ts                      | 43 +++++++++++++-----
 packages/engine/src/pr-response-run-ops.ts         | 11 +++--
 20 files changed, 276 insertions(+), 26 deletions(-)

Fusion-Task-Id: FN-8042

Fusion-Task-Lineage: 31762a72-461e-438c-a12d-2816580283fd

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 04:29:17 -07:00
parent b885aff4fd
commit 5b4ec4c08f
20 changed files with 276 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a dedicated fallback model lane for the AI merger, configurable under Project Models.
category: feature
dev: New project settings mergerFallbackProvider/mergerFallbackModelId/mergerFallbackThinkingLevel; resolveMergerFallbackModel resolves project merger-fallback → global fallbackProvider/fallbackModelId. Every merger session builder consumes the resolved merger fallback pair and lane-specific fallback thinking; unset keys preserve existing behavior.

View File

@@ -1090,7 +1090,9 @@ Dedicated model lane for merger agent sessions (conflict resolution, clean-room
Thinking level for merger sessions: project `mergerThinkingLevel` → global `mergerGlobalThinkingLevel` → project `defaultThinkingLevelOverride` → global `defaultThinkingLevel`.
Session-level model fallback on retryable failures still uses the shared global `fallbackProvider` + `fallbackModelId` pair (not a merger-specific fallback lane).
Session-level fallback on retryable failures resolves project `mergerFallbackProvider` + `mergerFallbackModelId` first, then the shared global `fallbackProvider` + `fallbackModelId` pair. Partial project fallback pairs are ignored. This lane applies to every merger session, including merger-ai mutating and review agents and the PR-response runner.
Fallback thinking level resolves project `mergerFallbackThinkingLevel` → global `fallbackThinkingLevel` → the merger thinking chain above.
For post-merge prompt workflow steps, explicit step-level `modelProvider` + `modelId` overrides take precedence over the merger lane above.

View File

@@ -7,6 +7,7 @@ import {
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveMergerFallbackModel,
resolveMergerSettingsModel,
resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel,
@@ -144,6 +145,29 @@ describe("model-resolution", () => {
})).toEqual({ provider: "project-merger-provider", modelId: "project-merger-model" });
});
it("resolves merger fallback project pair, global fallback, partial pairs, and test mode", () => {
expect(resolveMergerFallbackModel({
mergerFallbackProvider: "project-merger-fallback-provider",
mergerFallbackModelId: "project-merger-fallback-model",
fallbackProvider: "global-fallback-provider",
fallbackModelId: "global-fallback-model",
})).toEqual({ provider: "project-merger-fallback-provider", modelId: "project-merger-fallback-model" });
expect(resolveMergerFallbackModel({
mergerFallbackProvider: "partial-provider",
fallbackProvider: "global-fallback-provider",
fallbackModelId: "global-fallback-model",
})).toEqual({ provider: "global-fallback-provider", modelId: "global-fallback-model" });
expect(resolveMergerFallbackModel({
fallbackProvider: "global-fallback-provider",
fallbackModelId: "global-fallback-model",
})).toEqual({ provider: "global-fallback-provider", modelId: "global-fallback-model" });
expect(resolveMergerFallbackModel({
testMode: true,
mergerFallbackProvider: "project-merger-fallback-provider",
mergerFallbackModelId: "project-merger-fallback-model",
})).toEqual(TEST_MODE_RESOLVED);
});
it("does not mix partial project lane pairs with lower precedence model fields", () => {
expect(resolveExecutionSettingsModel({
executionProvider: "project-exec-provider",

View File

@@ -106,6 +106,7 @@ describe("settings key parity", () => {
expect(DEFAULT_PROJECT_SETTINGS.titleSummarizerThinkingLevel).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.titleSummarizerFallbackThinkingLevel).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.mergerThinkingLevel).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.mergerFallbackThinkingLevel).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.fallbackThinkingLevel).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.executionGlobalThinkingLevel).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.planningGlobalThinkingLevel).toBeUndefined();
@@ -117,6 +118,7 @@ describe("settings key parity", () => {
expect(isProjectSettingsKey("titleSummarizerThinkingLevel")).toBe(true);
expect(isProjectSettingsKey("titleSummarizerFallbackThinkingLevel")).toBe(true);
expect(isProjectSettingsKey("mergerThinkingLevel")).toBe(true);
expect(isProjectSettingsKey("mergerFallbackThinkingLevel")).toBe(true);
expect(isGlobalSettingsKey("fallbackThinkingLevel")).toBe(true);
expect(isProjectSettingsKey("planningFallbackThinkingLevel")).toBe(false);
expect(isGlobalSettingsKey("planningFallbackThinkingLevel")).toBe(false);
@@ -582,6 +584,7 @@ describe("model lane key parity regression (FN-1729)", () => {
{ provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "project" },
// Merger lane (project-scoped like summarizer; not workflow-moved)
{ provider: "mergerProvider", modelId: "mergerModelId", expectedScope: "project" },
{ provider: "mergerFallbackProvider", modelId: "mergerFallbackModelId", expectedScope: "project" },
{ provider: "mergerGlobalProvider", modelId: "mergerGlobalModelId", expectedScope: "global" },
] as const;

View File

@@ -1371,6 +1371,7 @@ export {
applyTestModeOverrides,
isTestModeActive,
resolveExecutionSettingsModel,
resolveMergerFallbackModel,
resolveMergerSettingsModel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,

View File

@@ -1423,6 +1423,7 @@ export {
applyTestModeOverrides,
isTestModeActive,
resolveExecutionSettingsModel,
resolveMergerFallbackModel,
resolveMergerSettingsModel,
resolvePhaseThinkingLevel,
resolvePlanningSettingsModel,

View File

@@ -240,6 +240,28 @@ export function resolveMergerSettingsModel(settings?: Partial<Settings>): Resolv
);
}
/**
* FNXC:Settings-MergerModel 2026-07-16-00:00:
* Retryable merger sessions resolve a project merger-fallback pair before the shared
* global fallback pair. Complete-pair selection and test-mode override behavior match
* all other model lanes, preserving existing behavior while this lane is unset.
*/
export function resolveMergerFallbackModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.mergerFallbackProvider,
modelId: settings?.mergerFallbackModelId,
},
{
provider: settings?.fallbackProvider,
modelId: settings?.fallbackModelId,
},
),
settings,
);
}
export function resolveTaskExecutionModel(
task: TaskModelLike,
settings?: Partial<Settings>,

View File

@@ -641,6 +641,10 @@ export const DEFAULT_PROJECT_SETTINGS = {
mergerProvider: undefined,
mergerModelId: undefined,
mergerThinkingLevel: undefined,
// FNXC:Settings-MergerModel 2026-07-16-00:00: project merger fallback overrides shared global fallback only when its provider/model pair is complete.
mergerFallbackProvider: undefined,
mergerFallbackModelId: undefined,
mergerFallbackThinkingLevel: undefined,
prTitlePromptInstructions: undefined,
prDescriptionPromptInstructions: undefined,
scripts: undefined,

View File

@@ -3921,6 +3921,19 @@ export interface ProjectSettings {
/** Optional project merger-lane thinking override. Inherits through global merger thinking then default thinking when unset. */
mergerThinkingLevel?: ThinkingLevel;
/*
FNXC:Settings-MergerModel 2026-07-16-00:00:
Merger session retries need a project-scoped fallback lane so operators can pin a merge-capable recovery model without changing the shared global fallback. Both provider and model id must be set; partial pairs fall through to the shared global fallback pair.
*/
/** Project fallback AI model provider for merger agent sessions.
* Must be set together with `mergerFallbackModelId`. Resolves before the global
* `fallbackProvider`/`fallbackModelId` pair. */
mergerFallbackProvider?: string;
/** Project fallback AI model ID for merger agent sessions.
* Must be set together with `mergerFallbackProvider`. */
mergerFallbackModelId?: string;
/** Optional project merger-fallback thinking override. Falls through to global fallback thinking, then merger thinking. */
mergerFallbackThinkingLevel?: ThinkingLevel;
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Import Tasks auto-translation is a dedicated one-off AI helper lane, kept separate from the summarization lane so operators can pin a cheap/fast translation model without dragging title summarization onto it.
Both provider and model id must be set together; partial pairs are ignored and fall through to global translate lane, then summarization, then project/global default.

View File

@@ -648,6 +648,47 @@ describe("splitSettingsSave", () => {
expect(projectClearResult.projectPatch).toEqual({ titleSummarizerFallbackThinkingLevel: null });
});
it("emits set and null-cleared merger fallback lane values in the project patch", () => {
const setResult = splitSettingsSave({
payload: {
mergerFallbackProvider: "anthropic",
mergerFallbackModelId: "claude-sonnet-4-5",
mergerFallbackThinkingLevel: "high",
},
initialValues: {} as never,
initialScopedValues: { global: {}, project: {} } as never,
activeSection: "project-models",
});
expect(setResult.projectPatch).toEqual({
mergerFallbackProvider: "anthropic",
mergerFallbackModelId: "claude-sonnet-4-5",
mergerFallbackThinkingLevel: "high",
});
const clearResult = splitSettingsSave({
payload: {
mergerFallbackProvider: undefined,
mergerFallbackModelId: undefined,
mergerFallbackThinkingLevel: undefined,
},
initialValues: {} as never,
initialScopedValues: {
global: {},
project: {
mergerFallbackProvider: "anthropic",
mergerFallbackModelId: "claude-sonnet-4-5",
mergerFallbackThinkingLevel: "high",
},
} as never,
activeSection: "project-models",
});
expect(clearResult.projectPatch).toEqual({
mergerFallbackProvider: null,
mergerFallbackModelId: null,
mergerFallbackThinkingLevel: null,
});
});
it("drops plain-undefined global keys that were never set", () => {
const payload: Record<string, unknown> = {
ntfyTopic: undefined, // never had a value → passed through as undefined

View File

@@ -596,6 +596,42 @@ describe("ProjectModelsSection", () => {
expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-thinking-value", "");
});
it("renders merger fallback directly after merger and resets its complete lane", () => {
function ProjectMergerFallbackHost() {
const [form, setForm] = useState<SettingsFormState>({
mergerFallbackProvider: "anthropic",
mergerFallbackModelId: "claude-sonnet-4-5",
mergerFallbackThinkingLevel: "high",
} as SettingsFormState);
return <ProjectModelsSection
form={form}
setForm={setForm}
models={{
...models,
modelLanes: [
{ laneId: "default", label: "Default", helperText: "Default", fallbackOrder: "global" },
{ laneId: "merger", label: "Merger", helperText: "Merger", fallbackOrder: "global" },
{ laneId: "import-translate", label: "Translate", helperText: "Translate", fallbackOrder: "global" },
] as never,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
addToast={vi.fn()}
/>;
}
render(<ProjectMergerFallbackHost />);
const dropdown = screen.getByTestId("mock-model-dropdown-mergerFallbackModel");
expect(dropdown).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5");
expect(dropdown).toHaveAttribute("data-thinking-value", "high");
const dropdowns = Array.from(document.querySelectorAll("[data-testid^='mock-model-dropdown-']"));
const mergerIndex = dropdowns.indexOf(screen.getByTestId("mock-model-dropdown-mergerModel"));
expect(dropdowns[mergerIndex + 1]).toBe(dropdown);
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
expect(dropdown).toHaveAttribute("data-value", "");
expect(dropdown).toHaveAttribute("data-thinking-value", "");
});
it("wires workflow fallback lane thinking render, persist, and reset", async () => {
let saver: (() => Promise<void>) | null = null;
vi.mocked(fetchWorkflow).mockResolvedValue({

View File

@@ -43,9 +43,9 @@ import type { GlobalSettings, McpServersSettings, Settings } from "@fusion/core"
* null-as-delete instead of being dropped as an unchanged inherited value.
*
* FNXC:Settings-MergerModel 2026-07-13-07:52:
* Merger project lane (provider/model/thinking) is project-scoped like
* title summarizer — not workflow-moved — so it participates in the same
* changed-only/null-as-delete project-branch write path.
* Merger primary and merger-fallback lanes (provider/model/thinking) are project-scoped
* like title summarizer — not workflow-moved — so set edits and null-as-delete resets
* participate in the same changed-only project-branch write path instead of being dropped.
*
* FNXC:GitHubImportTranslate 2026-07-15-09:30:
* The import auto-translation settings are PROJECT-scoped: which language a repo's
@@ -63,6 +63,7 @@ export const MODEL_LANE_KEYS = [
"titleSummarizerProvider", "titleSummarizerModelId",
"titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", "titleSummarizerFallbackThinkingLevel",
"mergerProvider", "mergerModelId", "mergerThinkingLevel",
"mergerFallbackProvider", "mergerFallbackModelId", "mergerFallbackThinkingLevel",
"githubImportAutoTranslate", "importTranslateTargetLocale",
"importTranslateProvider", "importTranslateModelId", "importTranslateThinkingLevel",
] as const;

View File

@@ -312,6 +312,55 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
}
return lane.helperText;
};
/*
* FNXC:SettingsModels 2026-07-16-00:00:
* The merger fallback is project-scoped and must sit directly after Project Merger
* in Model Lanes so operators configure the primary and retry models together.
*/
const mergerFallbackValue = form.mergerFallbackProvider && form.mergerFallbackModelId
? `${form.mergerFallbackProvider}/${form.mergerFallbackModelId}`
: "";
const mergerFallbackThinkingValue = typeof form.mergerFallbackThinkingLevel === "string"
? form.mergerFallbackThinkingLevel
: "";
const mergerFallbackCustomized = Boolean(mergerFallbackValue || mergerFallbackThinkingValue);
const setMergerFallbackValue = (value: string) => {
if (!value) {
setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined } as SettingsFormState));
return;
}
const slashIdx = value.indexOf("/");
setForm((f) => ({
...f,
mergerFallbackProvider: value.slice(0, slashIdx),
mergerFallbackModelId: value.slice(slashIdx + 1),
} as SettingsFormState));
};
const setMergerFallbackThinkingValue = (value: string) => {
setForm((f) => ({ ...f, mergerFallbackThinkingLevel: value || undefined } as SettingsFormState));
};
const resetMergerFallbackValue = () => {
setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined } as SettingsFormState));
};
const renderMergerFallbackLane = () => (
<div className="form-group" data-testid="project-model-lane-merger-fallback">
<div className="settings-model-lane-label-row">
<label htmlFor="mergerFallbackModel">{t("settings.projectModels.mergerFallbackModel", "Merger Fallback Model")}</label>
<span className={`settings-lane-badge ${mergerFallbackCustomized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`} title={mergerFallbackCustomized ? "Explicitly set for this project" : "Inherited from global settings"}>
{mergerFallbackCustomized ? "Override (Project)" : "Inherited (Global)"}
</span>
<SettingsHelpTip settingKey="mergerFallbackModel">
{t("settings.projectModels.mergerFallbackHelp", "Fallback provider and model used when a merger session retries. Leave unset to use the shared global fallback model pair.")}
</SettingsHelpTip>
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id="mergerFallbackModel" label="Merger Fallback Model" models={availableModels} value={mergerFallbackValue} onChange={setMergerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={mergerFallbackThinkingValue} onThinkingLevelChange={setMergerFallbackThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div>
{mergerFallbackCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={resetMergerFallbackValue}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>
</div>
);
const titleSummarizerFallbackValue = form.titleSummarizerFallbackProvider && form.titleSummarizerFallbackModelId
? `${form.titleSummarizerFallbackProvider}/${form.titleSummarizerFallbackModelId}`
: "";
@@ -431,7 +480,9 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
<SettingsHelpTip settingKey="project-model-lanes">{t("settings.projectModels.overrideGlobalModelSettingsAtTheProjectLevel", " Override global model settings at the project level. Each lane controls a specific AI usage context. Unset lanes inherit from the corresponding global lane. The Project Default Model is the fallback for this project when a more specific lane is unset. ")}</SettingsHelpTip>
</div>
{modelsLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.projectModels.loadingAvailableModels", "Loading available models\u2026")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationFirst", " No models available. Configure authentication first. ")}</div>) : (<>
{projectModelLanes.map(renderProjectLane)}
{projectModelLanes.filter((lane) => lane.laneId === "default" || lane.laneId === "merger").map(renderProjectLane)}
{renderMergerFallbackLane()}
{projectModelLanes.filter((lane) => lane.laneId === "import-translate").map(renderProjectLane)}
</>)}
{/* FNXC:ChatModels 2026-07-12-20:45: Project Models owns the Direct-chat default because New Chat needs a project-scoped model-or-agent target plus prompt-vs-direct creation mode without changing workflow or in-chat switcher settings. */}

View File

@@ -351,6 +351,8 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
titleSummarizerGlobalModelId: "configured via the model-lane picker, not a plain description field",
mergerProvider: "configured via the model-lane picker, not a plain description field",
mergerModelId: "configured via the model-lane picker, not a plain description field",
mergerFallbackProvider: "configured via the inline merger-fallback model picker, not a plain description field",
mergerFallbackModelId: "configured via the inline merger-fallback model picker, not a plain description field",
mergerGlobalProvider: "configured via the model-lane picker, not a plain description field",
mergerGlobalModelId: "configured via the model-lane picker, not a plain description field",
executionGlobalProvider: "configured via the model-lane picker, not a plain description field",
@@ -376,6 +378,7 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
titleSummarizerThinkingLevel: "project title-summarizer inline thinking companion, configured via the model-lane picker, not a plain description field",
titleSummarizerFallbackThinkingLevel: "project title-summarizer fallback inline thinking companion, configured via the model-lane picker, not a plain description field",
mergerThinkingLevel: "project merger inline thinking companion, configured via the model-lane picker, not a plain description field",
mergerFallbackThinkingLevel: "project merger-fallback inline thinking companion, configured via the model-lane picker, not a plain description field",
fallbackThinkingLevel: "global fallback model inline thinking companion, configured via the model-lane picker, not a plain description field",
agentPrompts2: "not a real key (placeholder guard)",
promptOverrides2: "not a real key (placeholder guard)",

View File

@@ -124,6 +124,7 @@ describe("resolve model-lane thinking levels", () => {
expect(resolveTitleSummarizerFallbackThinkingLevel({ defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "minimal" })).toBe("medium");
expect(resolveTitleSummarizerFallbackThinkingLevel({ defaultThinkingLevel: "minimal" })).toBe("minimal");
expect(resolveMergerFallbackThinkingLevel({ mergerFallbackThinkingLevel: "xhigh", fallbackThinkingLevel: "high", mergerThinkingLevel: "medium" })).toBe("xhigh");
expect(resolveMergerFallbackThinkingLevel({ fallbackThinkingLevel: "high", defaultThinkingLevel: "low" })).toBe("high");
expect(resolveMergerFallbackThinkingLevel({ defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "low" })).toBe("medium");
expect(resolveMergerFallbackThinkingLevel({ defaultThinkingLevel: "low" })).toBe("low");

View File

@@ -22,6 +22,7 @@ vi.mock("../agent-session-helpers.js", () => ({
// The mock must surface it or every PR-response run throws on the missing
// export. Neutral undefined return — no test asserts on thinking level.
resolveMergerThinkingLevel: vi.fn(() => undefined),
resolveMergerFallbackThinkingLevel: vi.fn((settings: Settings) => settings.mergerFallbackThinkingLevel ?? settings.fallbackThinkingLevel),
}));
vi.mock("../pi.js", () => ({

View File

@@ -320,8 +320,16 @@ export function resolveTitleSummarizerFallbackThinkingLevel(settings: Partial<Se
);
}
/*
FNXC:Settings-MergerModel 2026-07-16-00:00:
Merger recovery sessions use a lane-specific thinking level when configured, then preserve the former global-fallback and merger-primary inheritance order.
*/
export function resolveMergerFallbackThinkingLevel(settings: Partial<Settings> | undefined): string | undefined {
return firstThinkingLevel(settings?.fallbackThinkingLevel, resolveMergerThinkingLevel(settings));
return firstThinkingLevel(
settings?.mergerFallbackThinkingLevel,
settings?.fallbackThinkingLevel,
resolveMergerThinkingLevel(settings),
);
}
function hasCompleteRuntimeModel(

View File

@@ -50,6 +50,7 @@ import {
resolvePersistAgentThinkingLog,
resolveTaskMergeTarget,
resolveValidatorSettingsModel,
resolveMergerFallbackModel,
type MergeDetails,
type MergeResult,
type MergeTargetResolution,
@@ -61,7 +62,7 @@ import { selectUserCommentsForAgentContext } from "./agent-user-comments.js";
import { resolveTaskWorkingBranch } from "./worktree-names.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel, resolveValidatorThinkingLevel } from "./agent-session-helpers.js";
import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel, resolveMergerFallbackThinkingLevel, resolveValidatorThinkingLevel } from "./agent-session-helpers.js";
import { promptWithFallback } from "./pi.js";
import { AgentLogger } from "./agent-logger.js";
import { withRateLimitRetry } from "./rate-limit-retry.js";
@@ -381,6 +382,8 @@ interface AgentDeps {
function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string, options: MergerOptions, audit: RunAuditor, systemPrompt: string) {
return async (cwd: string, prompt: string): Promise<void> => {
const model = resolveMergerSessionModel(settings);
// FNXC:Settings-MergerModel 2026-07-16-00:00: mutating merger retries resolve the project merger fallback lane before the shared global fallback.
const mergerFallbackModel = resolveMergerFallbackModel(settings);
const logger = new AgentLogger({
store,
taskId,
@@ -406,8 +409,9 @@ function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string,
onToolEnd: logger.onToolEnd,
defaultProvider: model.provider,
defaultModelId: model.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: mergerFallbackModel.provider,
fallbackModelId: mergerFallbackModel.modelId,
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings),
defaultThinkingLevel: resolveMergerThinkingLevel(settings),
runAuditor: audit,
settings,
@@ -436,6 +440,8 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
// that lane resolves to nothing.
const validator = resolveValidatorSettingsModel(settings);
const model = validator.provider && validator.modelId ? validator : resolveMergerSessionModel(settings);
// FNXC:Settings-MergerModel 2026-07-16-00:00: review merger retries share the dedicated merger fallback provider/model and thinking lane.
const mergerFallbackModel = resolveMergerFallbackModel(settings);
// FNXC:Settings-ThinkingLevel 2026-07-10-00:00: The review agent's model falls back
// between the validator lane and the merger default lane, so its thinking level
// must follow the same lane it actually resolved a model from.
@@ -471,8 +477,9 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
onToolEnd: logger.onToolEnd,
defaultProvider: model.provider,
defaultModelId: model.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: mergerFallbackModel.provider,
fallbackModelId: mergerFallbackModel.modelId,
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings),
defaultThinkingLevel: reviewThinkingLevel,
runAuditor: audit,
settings,

View File

@@ -104,6 +104,7 @@ import {
type AutostashOrphanRecord,
normalizeMergeAdvanceAutoSyncMode,
isMergeRequestContractShadowEnabled,
resolveMergerFallbackModel,
} from "@fusion/core";
import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js";
import { resolveMergePolicy } from "./merge-trait.js";
@@ -1112,7 +1113,11 @@ async function attemptInMergeVerificationFix(
: null;
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
// FN-5279: verification-fix sessions run in the resolved integration root,
// FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair.
const mergerFallbackModel = resolveMergerFallbackModel(settings);
// FN-5279: verification-fix sessions run in the resolved integration root,
// which is the reused task worktree in handoff mode.
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
@@ -1142,8 +1147,8 @@ Do not refactor, rename broadly, or make opportunistic improvements.
onToolEnd: logger.onToolEnd,
defaultProvider: mergerSessionModel.provider,
defaultModelId: mergerSessionModel.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: mergerFallbackModel.provider,
fallbackModelId: mergerFallbackModel.modelId,
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings),
defaultThinkingLevel: resolveMergerThinkingLevel(settings),
runAuditor: createRunAuditor(store, {
@@ -2391,6 +2396,10 @@ async function runAiAgentForAutostashConflict(params: {
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
// FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair.
const mergerFallbackModel = resolveMergerFallbackModel(settings);
const systemPrompt = `You are an autostash-conflict resolution agent running after a Fusion merge has already committed on the main branch.
Before the merge ran, the developer had uncommitted local changes in their working tree. The merger snapshotted those changes into a git stash, ran the merge cleanly, and is now reapplying the stash on top of the merged HEAD. The reapply hit conflicts because the merge committed changes that overlap the developer's stashed edits.
@@ -2435,8 +2444,8 @@ ${fileList}
onToolEnd: agentLogger.onToolEnd,
defaultProvider: mergerSessionModel.provider,
defaultModelId: mergerSessionModel.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: mergerFallbackModel.provider,
fallbackModelId: mergerFallbackModel.modelId,
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings),
defaultThinkingLevel: resolveMergerThinkingLevel(settings),
runAuditor: createRunAuditor(store, {
@@ -2800,6 +2809,10 @@ async function runAiAgentForAutostashHardFail(params: {
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
// FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair.
const mergerFallbackModel = resolveMergerFallbackModel(settings);
const systemPrompt = `You are an autostash hard-failure recovery agent for the Fusion merger.
Before the merge ran, the developer had uncommitted local changes. We snapshotted them into a git stash, ran the merge cleanly on top, and tried to re-apply the stash. Both \`git stash apply\` and \`git apply --3way\` failed without producing conflict markers — meaning git refused to attempt the apply at all (typical causes: untracked-file overwrite, a path in the stash no longer exists at HEAD, or an index conflict that produced no in-tree markers).
@@ -2854,8 +2867,8 @@ ${fileList}
onToolEnd: agentLogger.onToolEnd,
defaultProvider: mergerSessionModel.provider,
defaultModelId: mergerSessionModel.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: mergerFallbackModel.provider,
fallbackModelId: mergerFallbackModel.modelId,
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings),
defaultThinkingLevel: resolveMergerThinkingLevel(settings),
runAuditor: createRunAuditor(store, {
@@ -5860,6 +5873,10 @@ You are assisting with a paused \`git pull --rebase\`.
throwIfAborted(options?.signal, taskId);
const mergerSessionModel = resolveMergerSessionModel(settings, options?.assignedAgentRuntimeConfig);
// FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair.
const mergerFallbackModel = resolveMergerFallbackModel(settings);
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
runtimeHint: options?.runtimeHint,
@@ -5873,8 +5890,8 @@ You are assisting with a paused \`git pull --rebase\`.
onToolEnd: agentLogger.onToolEnd,
defaultProvider: mergerSessionModel.provider,
defaultModelId: mergerSessionModel.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: mergerFallbackModel.provider,
fallbackModelId: mergerFallbackModel.modelId,
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings),
defaultThinkingLevel: resolveMergerThinkingLevel(settings),
runAuditor: createRunAuditor(store, {
@@ -10822,6 +10839,10 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
// FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair.
const mergerFallbackModel = resolveMergerFallbackModel(settings);
// FN-5279: Layer 3 / merge-authoring AI runs in the resolved integration
// root so arbiter edits land in the reused task worktree when handoff mode
// is active.
@@ -10839,8 +10860,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
onToolEnd: agentLogger.onToolEnd,
defaultProvider: mergerSessionModel.provider,
defaultModelId: mergerSessionModel.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: mergerFallbackModel.provider,
fallbackModelId: mergerFallbackModel.modelId,
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings),
defaultThinkingLevel: resolveMergerThinkingLevel(settings),
runAuditor: createRunAuditor(store, {

View File

@@ -10,8 +10,8 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { PrEntity, Settings, TaskStore } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core";
import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel } from "./agent-session-helpers.js";
import { resolveAgentPrompt, resolveMergerFallbackModel } from "@fusion/core";
import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel, resolveMergerFallbackThinkingLevel } from "./agent-session-helpers.js";
import { resolveMcpServersForStore } from "./mcp-resolution.js";
import { promptWithFallback } from "./pi.js";
import { withRateLimitRetry } from "./rate-limit-retry.js";
@@ -101,6 +101,8 @@ export function makePrResponseAgentRunner(
}) => Promise<PrAgentRunResult> {
return async ({ prompt, systemPrompt, signal, threads }) => {
const model = resolveMergerSessionModel(settings);
// FNXC:Settings-MergerModel 2026-07-16-00:00: PR-response retries use the merger-only fallback lane, retaining shared fallback inheritance when unset.
const mergerFallbackModel = resolveMergerFallbackModel(settings);
let captured = "";
/*
* FNXC:McpConfig 2026-06-26-00:00:
@@ -129,8 +131,9 @@ export function makePrResponseAgentRunner(
},
defaultProvider: model.provider,
defaultModelId: model.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: mergerFallbackModel.provider,
fallbackModelId: mergerFallbackModel.modelId,
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings),
defaultThinkingLevel: resolveMergerThinkingLevel(settings),
settings,
taskId,