feat(FN-2662): enforce model override fallback hierarchy

- Fix triage planning model resolution to fall back through project/global planning settings and default overrides
- Fix reviewer model selection to honor validator-specific settings before default provider/model overrides
- Update merger model resolution to apply default override fallback and align shared task setting types/executor flow
- Add regression coverage for triage, reviewer, and merger fallback behavior and update settings hierarchy documentation
This commit is contained in:
Fusion
2026-04-27 02:02:01 -07:00
committed by gsxdsm
parent 221ae06a81
commit 4330eef126
9 changed files with 288 additions and 24 deletions

View File

@@ -304,6 +304,12 @@ Fusion uses a dual-scope model settings system with five lanes. Global settings
5. Global `defaultProvider` + `defaultModelId` 5. Global `defaultProvider` + `defaultModelId`
6. Automatic provider/model resolution 6. Automatic provider/model resolution
### Merger model
1. Project `defaultProviderOverride` + `defaultModelIdOverride`
2. Global `defaultProvider` + `defaultModelId`
3. Automatic provider/model resolution
### Title summarization model ### Title summarization model
1. Project `titleSummarizerProvider` + `titleSummarizerModelId` 1. Project `titleSummarizerProvider` + `titleSummarizerModelId`
@@ -313,7 +319,7 @@ Fusion uses a dual-scope model settings system with five lanes. Global settings
5. Global `defaultProvider` + `defaultModelId` 5. Global `defaultProvider` + `defaultModelId`
6. Automatic provider/model resolution 6. Automatic provider/model resolution
> **Note:** Runtime fallback precedence logic is implemented in engine and dashboard routes (FN-1711). The hierarchy above reflects the full schema contracts added in FN-1710. > **Note:** Runtime fallback precedence logic is implemented in engine and dashboard routes. The hierarchies above reflect current runtime behavior.
--- ---

View File

@@ -1352,9 +1352,7 @@ export interface ProjectSettings {
/** Project-level override for the base default AI model provider. /** Project-level override for the base default AI model provider.
* When set, this overrides the global `defaultProvider`/`defaultModelId` baseline * When set, this overrides the global `defaultProvider`/`defaultModelId` baseline
* for all lanes that don't have their own explicit project override. * for all lanes that don't have their own explicit project override.
* Must be set together with `defaultModelIdOverride`. * Must be set together with `defaultModelIdOverride`. */
* NOTE: This is a schema/persistence contract only. Runtime fallback precedence
* logic is deferred to FN-1711. */
defaultProviderOverride?: string; defaultProviderOverride?: string;
/** Project-level override for the base default AI model ID. /** Project-level override for the base default AI model ID.
* Must be set together with `defaultProviderOverride`. */ * Must be set together with `defaultProviderOverride`. */
@@ -1364,9 +1362,7 @@ export interface ProjectSettings {
* Must be set together with `executionModelId`. Falls back to * Must be set together with `executionModelId`. Falls back to
* `executionGlobalProvider`/`executionGlobalModelId` or * `executionGlobalProvider`/`executionGlobalModelId` or
* `defaultProviderOverride`/`defaultModelIdOverride` or * `defaultProviderOverride`/`defaultModelIdOverride` or
* `defaultProvider`/`defaultModelId` when undefined. * `defaultProvider`/`defaultModelId` when undefined. */
* NOTE: This is a schema/persistence contract only. Runtime fallback precedence
* logic is deferred to FN-1711. */
executionProvider?: string; executionProvider?: string;
/** Project-level AI model ID for task execution. /** Project-level AI model ID for task execution.
* Must be set together with `executionProvider`. */ * Must be set together with `executionProvider`. */

View File

@@ -6331,7 +6331,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
return Buffer.from(""); return Buffer.from("");
}); });
mockedCreateFnAgent.mockImplementation(async (opts: any) => { mockedCreateFnAgent.mockImplementation(async (_opts: any) => {
return { return {
session: { session: {
prompt: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue(undefined),
@@ -6362,6 +6362,61 @@ describe("aiMergeTask — in-merge verification fix", () => {
expect(fixAgentCall[0].defaultModelId).toBe("claude-sonnet-4-5"); expect(fixAgentCall[0].defaultModelId).toBe("claude-sonnet-4-5");
}); });
it("uses project default override for merge agent model", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const err = new Error("Test failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (_opts: any) => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
verificationFixRetries: 0,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
});
const mergeAgentCall = mockedCreateFnAgent.mock.calls[0];
expect(mergeAgentCall[0].defaultProvider).toBe("openai");
expect(mergeAgentCall[0].defaultModelId).toBe("gpt-4o");
});
it("fix agent session is disposed", async () => { it("fix agent session is disposed", async () => {
const disposeMock = vi.fn(); const disposeMock = vi.fn();

View File

@@ -425,6 +425,51 @@ describe("reviewStep — validator model overrides", () => {
expect(opts.defaultModelId).toBe("gemini-2.5"); expect(opts.defaultModelId).toBe("gemini-2.5");
}); });
it("uses project default override when validator lanes are absent", async () => {
mockedCreateFnAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
projectDefaultOverrideProvider: "openai",
projectDefaultOverrideModelId: "gpt-4o",
// No validator lanes set
},
);
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateFnAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-4o");
});
it("falls through to execution default when project default override is incomplete", async () => {
mockedCreateFnAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
projectDefaultOverrideProvider: "openai",
// projectDefaultOverrideModelId intentionally omitted
},
);
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateFnAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("anthropic");
expect(opts.defaultModelId).toBe("claude-sonnet-4-5");
});
it("falls back to execution default when no validator lanes are set", async () => { it("falls back to execution default when no validator lanes are set", async () => {
mockedCreateFnAgent.mockResolvedValue( mockedCreateFnAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."), createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),

View File

@@ -2195,9 +2195,139 @@ describe("taskCreate tool model inheritance", () => {
); );
}); });
it("falls back to global defaults when neither task nor settings have planning model", async () => { it("uses project default override when planning lanes are absent", async () => {
const task = { const task = {
id: "FN-402", id: "FN-402",
description: "Test fallback to project default override",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const mockDispose = vi.fn();
const mockPrompt = vi.fn().mockResolvedValue(undefined);
const mockGetLeafId = vi.fn().mockReturnValue(null);
const mockNavigateTree = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
// No planningProvider/planningModelId set
} as Settings),
});
mockCreateFnAgent.mockResolvedValue({
session: {
prompt: mockPrompt,
dispose: mockDispose,
sessionManager: {
getLeafId: mockGetLeafId,
navigateTree: mockNavigateTree,
},
},
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
await processor.specifyTask(task);
// Should use project default override when planning lanes are absent
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
}),
);
});
it("falls through to global default when project default override is incomplete", async () => {
const task = {
id: "FN-403",
description: "Test fallback when project default override is incomplete",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const mockDispose = vi.fn();
const mockPrompt = vi.fn().mockResolvedValue(undefined);
const mockGetLeafId = vi.fn().mockReturnValue(null);
const mockNavigateTree = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
defaultProviderOverride: "openai",
// defaultModelIdOverride intentionally omitted
// No planningProvider/planningModelId set
} as Settings),
});
mockCreateFnAgent.mockResolvedValue({
session: {
prompt: mockPrompt,
dispose: mockDispose,
sessionManager: {
getLeafId: mockGetLeafId,
navigateTree: mockNavigateTree,
},
},
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
await processor.specifyTask(task);
// Incomplete override should fall through to global defaults
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}),
);
});
it("falls back to global defaults when neither task nor settings have planning model", async () => {
const task = {
id: "FN-404",
description: "Test fallback to global defaults", description: "Test fallback to global defaults",
column: "triage", column: "triage",
dependencies: [], dependencies: [],

View File

@@ -2908,6 +2908,9 @@ export class TaskExecutor {
// Global validator lane // Global validator lane
globalValidatorProvider: settings.validatorGlobalProvider, globalValidatorProvider: settings.validatorGlobalProvider,
globalValidatorModelId: settings.validatorGlobalModelId, globalValidatorModelId: settings.validatorGlobalModelId,
// Project-level default override (fallback before execution defaults)
projectDefaultOverrideProvider: settings.defaultProviderOverride,
projectDefaultOverrideModelId: settings.defaultModelIdOverride,
store, store,
taskId, taskId,
task: detail, task: detail,

View File

@@ -922,8 +922,12 @@ A merge has been applied and the verification command failed. Your job is to fix
onThinking: logger.onThinking, onThinking: logger.onThinking,
onToolStart: logger.onToolStart, onToolStart: logger.onToolStart,
onToolEnd: logger.onToolEnd, onToolEnd: logger.onToolEnd,
defaultProvider: settings.defaultProvider, defaultProvider: settings.defaultProviderOverride && settings.defaultModelIdOverride
defaultModelId: settings.defaultModelId, ? settings.defaultProviderOverride
: settings.defaultProvider,
defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel, defaultThinkingLevel: settings.defaultThinkingLevel,
// Skill selection: use assigned agent skills if available, otherwise role fallback // Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
@@ -1808,8 +1812,12 @@ You are assisting with a paused \`git pull --rebase\`.
onThinking: agentLogger.onThinking, onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart, onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd, onToolEnd: agentLogger.onToolEnd,
defaultProvider: settings.defaultProvider, defaultProvider: settings.defaultProviderOverride && settings.defaultModelIdOverride
defaultModelId: settings.defaultModelId, ? settings.defaultProviderOverride
: settings.defaultProvider,
defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel, defaultThinkingLevel: settings.defaultThinkingLevel,
}); });
@@ -3490,8 +3498,12 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
onThinking: agentLogger.onThinking, onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart, onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd, onToolEnd: agentLogger.onToolEnd,
defaultProvider: settings.defaultProvider, defaultProvider: settings.defaultProviderOverride && settings.defaultModelIdOverride
defaultModelId: settings.defaultModelId, ? settings.defaultProviderOverride
: settings.defaultProvider,
defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel, defaultThinkingLevel: settings.defaultThinkingLevel,
// Skill selection: use assigned agent skills if available, otherwise role fallback // Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),

View File

@@ -201,10 +201,14 @@ export interface ReviewOptions {
projectValidatorProvider?: string; projectValidatorProvider?: string;
/** Project-level validator model ID override. Takes precedence over global validator lane. */ /** Project-level validator model ID override. Takes precedence over global validator lane. */
projectValidatorModelId?: string; projectValidatorModelId?: string;
/** Global validator lane provider. Takes precedence over execution defaults. */ /** Global validator lane provider. Takes precedence over project default override + execution defaults. */
globalValidatorProvider?: string; globalValidatorProvider?: string;
/** Global validator lane model ID. Takes precedence over execution defaults. */ /** Global validator lane model ID. Takes precedence over project default override + execution defaults. */
globalValidatorModelId?: string; globalValidatorModelId?: string;
/** Project-level default provider override, used when validator lanes are absent. */
projectDefaultOverrideProvider?: string;
/** Project-level default model override, used when validator lanes are absent. */
projectDefaultOverrideModelId?: string;
/** Fallback model provider used when the primary reviewer model hits a retryable provider-side error. */ /** Fallback model provider used when the primary reviewer model hits a retryable provider-side error. */
fallbackProvider?: string; fallbackProvider?: string;
/** Fallback model ID used with `fallbackProvider`. */ /** Fallback model ID used with `fallbackProvider`. */
@@ -269,21 +273,26 @@ export async function reviewStep(
// 1. Task-level validator override pair (taskValidatorProvider + taskValidatorModelId) // 1. Task-level validator override pair (taskValidatorProvider + taskValidatorModelId)
// 2. Project-level validator override pair (projectValidatorProvider + projectValidatorModelId) // 2. Project-level validator override pair (projectValidatorProvider + projectValidatorModelId)
// 3. Global validator lane pair (globalValidatorProvider + globalValidatorModelId) // 3. Global validator lane pair (globalValidatorProvider + globalValidatorModelId)
// 4. Execution default pair (defaultProvider + defaultModelId) // 4. Project default override pair (projectDefaultOverrideProvider + projectDefaultOverrideModelId)
// 5. Execution default pair (defaultProvider + defaultModelId)
const validatorProvider = options.taskValidatorProvider && options.taskValidatorModelId const validatorProvider = options.taskValidatorProvider && options.taskValidatorModelId
? options.taskValidatorProvider ? options.taskValidatorProvider
: (options.projectValidatorProvider && options.projectValidatorModelId : (options.projectValidatorProvider && options.projectValidatorModelId
? options.projectValidatorProvider ? options.projectValidatorProvider
: (options.globalValidatorProvider && options.globalValidatorModelId : (options.globalValidatorProvider && options.globalValidatorModelId
? options.globalValidatorProvider ? options.globalValidatorProvider
: options.defaultProvider)); : (options.projectDefaultOverrideProvider && options.projectDefaultOverrideModelId
? options.projectDefaultOverrideProvider
: options.defaultProvider)));
const validatorModelId = options.taskValidatorProvider && options.taskValidatorModelId const validatorModelId = options.taskValidatorProvider && options.taskValidatorModelId
? options.taskValidatorModelId ? options.taskValidatorModelId
: (options.projectValidatorProvider && options.projectValidatorModelId : (options.projectValidatorProvider && options.projectValidatorModelId
? options.projectValidatorModelId ? options.projectValidatorModelId
: (options.globalValidatorProvider && options.globalValidatorModelId : (options.globalValidatorProvider && options.globalValidatorModelId
? options.globalValidatorModelId ? options.globalValidatorModelId
: options.defaultModelId)); : (options.projectDefaultOverrideProvider && options.projectDefaultOverrideModelId
? options.projectDefaultOverrideModelId
: options.defaultModelId)));
// Resolve validator fallback using lane hierarchy: // Resolve validator fallback using lane hierarchy:
// 1. Project-level validator fallback (projectValidatorFallbackProvider + projectValidatorFallbackModelId) // 1. Project-level validator fallback (projectValidatorFallbackProvider + projectValidatorFallbackModelId)

View File

@@ -909,23 +909,28 @@ export class TriageProcessor {
onToolEnd: agentLogger.onToolEnd, onToolEnd: agentLogger.onToolEnd,
// Resolve planning model using canonical lane hierarchy: // Resolve planning model using canonical lane hierarchy:
// 1. Task planning override pair (planningModelProvider + planningModelId) // 1. Task planning override pair (planningModelProvider + planningModelId)
// 2. Project planning override pair (planningProvider + planningModelId) // 2. Project planning lane pair (planningProvider + planningModelId)
// 3. Global planning lane pair (planningGlobalProvider + planningGlobalModelId) // 3. Global planning lane pair (planningGlobalProvider + planningGlobalModelId)
// 4. Default pair (defaultProvider + defaultModelId) // 4. Project default override pair (defaultProviderOverride + defaultModelIdOverride)
// 5. Global default pair (defaultProvider + defaultModelId)
defaultProvider: task.planningModelProvider && task.planningModelId defaultProvider: task.planningModelProvider && task.planningModelId
? task.planningModelProvider ? task.planningModelProvider
: (settings.planningProvider && settings.planningModelId : (settings.planningProvider && settings.planningModelId
? settings.planningProvider ? settings.planningProvider
: (settings.planningGlobalProvider && settings.planningGlobalModelId : (settings.planningGlobalProvider && settings.planningGlobalModelId
? settings.planningGlobalProvider ? settings.planningGlobalProvider
: settings.defaultProvider)), : (settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultProviderOverride
: settings.defaultProvider))),
defaultModelId: task.planningModelProvider && task.planningModelId defaultModelId: task.planningModelProvider && task.planningModelId
? task.planningModelId ? task.planningModelId
: (settings.planningProvider && settings.planningModelId : (settings.planningProvider && settings.planningModelId
? settings.planningModelId ? settings.planningModelId
: (settings.planningGlobalProvider && settings.planningGlobalModelId : (settings.planningGlobalProvider && settings.planningGlobalModelId
? settings.planningGlobalModelId ? settings.planningGlobalModelId
: settings.defaultModelId)), : (settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId))),
fallbackProvider: settings.planningFallbackProvider && settings.planningFallbackModelId fallbackProvider: settings.planningFallbackProvider && settings.planningFallbackModelId
? settings.planningFallbackProvider ? settings.planningFallbackProvider
: settings.fallbackProvider, : settings.fallbackProvider,
@@ -1711,6 +1716,9 @@ export class TriageProcessor {
// Global validator lane // Global validator lane
globalValidatorProvider: currentSettings.validatorGlobalProvider, globalValidatorProvider: currentSettings.validatorGlobalProvider,
globalValidatorModelId: currentSettings.validatorGlobalModelId, globalValidatorModelId: currentSettings.validatorGlobalModelId,
// Project-level default override (fallback before execution defaults)
projectDefaultOverrideProvider: currentSettings.defaultProviderOverride,
projectDefaultOverrideModelId: currentSettings.defaultModelIdOverride,
defaultThinkingLevel: currentSettings.defaultThinkingLevel, defaultThinkingLevel: currentSettings.defaultThinkingLevel,
store, store,
taskId, taskId,