fix(FN-2302): honor project default executor model precedence

- Add shared executor model pair resolution in task executor and step-session executor to apply lane hierarchy consistently
- Prefer project defaultProviderOverride/defaultModelIdOverride before global defaults when execution lanes are unset
- Update hot-swap model resolution to use the same precedence logic as runtime session creation
- Add regression tests for runtime, hot-swap, and step-session precedence/fallback behavior
- Add a changeset for @runfusion/fusion describing the executor model precedence fix
This commit is contained in:
Fusion
2026-04-23 08:57:16 -07:00
committed by gsxdsm
parent 6f26bc5d51
commit ff5df16928
5 changed files with 290 additions and 49 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix executor model resolution precedence so project `defaultProviderOverride`/`defaultModelIdOverride` is honored before falling back to global `defaultProvider`/`defaultModelId` across execute, hot-swap, and step-session paths.

View File

@@ -3692,7 +3692,7 @@ describe("TaskExecutor executor model hot-swap", () => {
expect(setModel).not.toHaveBeenCalled(); expect(setModel).not.toHaveBeenCalled();
}); });
it("hot-swaps to settings default when override is cleared", async () => { it("hot-swaps to project default override when task override is cleared", async () => {
const store = createMockStore(); const store = createMockStore();
const setModel = vi.fn().mockResolvedValue(undefined); const setModel = vi.fn().mockResolvedValue(undefined);
const findModel = vi.fn().mockReturnValue({ const findModel = vi.fn().mockReturnValue({
@@ -3702,8 +3702,14 @@ describe("TaskExecutor executor model hot-swap", () => {
}); });
store.getSettings.mockResolvedValue({ store.getSettings.mockResolvedValue({
defaultProvider: "openai", executionProvider: undefined,
defaultModelId: "gpt-4o", executionModelId: undefined,
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}); });
const executor = new TaskExecutor(store, "/tmp/test"); const executor = new TaskExecutor(store, "/tmp/test");
@@ -3726,6 +3732,46 @@ describe("TaskExecutor executor model hot-swap", () => {
expect(setModel).toHaveBeenCalledTimes(1); expect(setModel).toHaveBeenCalledTimes(1);
}); });
it("falls back to global default when project default override pair is incomplete", async () => {
const store = createMockStore();
const setModel = vi.fn().mockResolvedValue(undefined);
const findModel = vi.fn().mockReturnValue({
provider: { name: "anthropic" },
id: "claude-sonnet-4-5",
name: "Claude Sonnet",
});
store.getSettings.mockResolvedValue({
executionProvider: undefined,
executionModelId: undefined,
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
defaultProviderOverride: "openai",
defaultModelIdOverride: undefined,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any)._modelRegistry = { find: findModel };
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "openai",
lastModelId: "gpt-4o",
});
store._trigger("task:updated", buildUpdatedTask({
modelProvider: undefined,
modelId: undefined,
}));
await flushTaskUpdated();
expect(findModel).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-5");
expect(setModel).toHaveBeenCalledTimes(1);
});
it("logs error and continues when setModel fails", async () => { it("logs error and continues when setModel fails", async () => {
const store = createMockStore(); const store = createMockStore();
const setModel = vi.fn().mockRejectedValue(new Error("API key not found")); const setModel = vi.fn().mockRejectedValue(new Error("API key not found"));
@@ -6415,6 +6461,70 @@ describe("Executor lane hierarchy model resolution", () => {
expect(capturedOptions[0].defaultModelId).toBe("gemini-2.5"); expect(capturedOptions[0].defaultModelId).toBe("gemini-2.5");
}); });
it("resolves project default override when execution lanes are not set", async () => {
const store = createMockStore();
const capturedOptions: any[] = [];
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedOptions.push(opts);
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {},
},
} as any;
});
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
executionProvider: undefined,
executionModelId: undefined,
});
const executor = new TaskExecutor(store, "/tmp/test");
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No task-level model override
});
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No task-level model override
});
expect(capturedOptions[0].defaultProvider).toBe("openai");
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
});
it("falls back to default when no lane overrides are set", async () => { it("falls back to default when no lane overrides are set", async () => {
const store = createMockStore(); const store = createMockStore();
const capturedOptions: any[] = []; const capturedOptions: any[] = [];

View File

@@ -398,6 +398,41 @@ function getExecutorSystemPrompt(settings: Settings): string {
return customPrompt || EXECUTOR_SYSTEM_PROMPT; return customPrompt || EXECUTOR_SYSTEM_PROMPT;
} }
function resolveExecutorModelPair(
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Partial<Settings> | undefined,
): { provider: string | undefined; modelId: string | undefined } {
if (taskModelProvider && taskModelId) {
return { provider: taskModelProvider, modelId: taskModelId };
}
if (settings?.executionProvider && settings?.executionModelId) {
return {
provider: settings.executionProvider,
modelId: settings.executionModelId,
};
}
if (settings?.executionGlobalProvider && settings?.executionGlobalModelId) {
return {
provider: settings.executionGlobalProvider,
modelId: settings.executionGlobalModelId,
};
}
if (settings?.defaultProviderOverride && settings?.defaultModelIdOverride) {
return {
provider: settings.defaultProviderOverride,
modelId: settings.defaultModelIdOverride,
};
}
if (settings?.defaultProvider && settings?.defaultModelId) {
return {
provider: settings.defaultProvider,
modelId: settings.defaultModelId,
};
}
return { provider: undefined, modelId: undefined };
}
export interface TaskExecutorOptions { export interface TaskExecutorOptions {
semaphore?: AgentSemaphore; semaphore?: AgentSemaphore;
/** Worktree pool for recycling idle worktrees across tasks. */ /** Worktree pool for recycling idle worktrees across tasks. */
@@ -634,20 +669,11 @@ export class TaskExecutor {
const settings = await this.store.getSettings(); const settings = await this.store.getSettings();
// Resolve model using canonical lane hierarchy for hot-swap // Resolve model using canonical lane hierarchy for hot-swap
const newProvider = task.modelProvider && task.modelId const { provider: newProvider, modelId: newModelId } = resolveExecutorModelPair(
? task.modelProvider task.modelProvider,
: (settings?.executionProvider && settings?.executionModelId task.modelId,
? settings.executionProvider settings,
: (settings?.executionGlobalProvider && settings?.executionGlobalModelId );
? settings.executionGlobalProvider
: settings?.defaultProvider));
const newModelId = task.modelProvider && task.modelId
? task.modelId
: (settings?.executionProvider && settings?.executionModelId
? settings.executionModelId
: (settings?.executionGlobalProvider && settings?.executionGlobalModelId
? settings.executionGlobalModelId
: settings?.defaultModelId));
if (newProvider && newModelId) { if (newProvider && newModelId) {
try { try {
@@ -1724,23 +1750,15 @@ export class TaskExecutor {
const agentWork = async () => { const agentWork = async () => {
// Resolve model settings using canonical lane hierarchy: // Resolve model settings using canonical lane hierarchy:
// 1. Task override pair (modelProvider + modelId) // 1. Task override pair (modelProvider + modelId)
// 2. Project execution override pair (executionProvider + executionModelId) // 2. Project execution lane pair (executionProvider + executionModelId)
// 3. Global execution lane pair (executionGlobalProvider + executionGlobalModelId) // 3. Global execution lane pair (executionGlobalProvider + executionGlobalModelId)
// 4. Default pair (defaultProvider + defaultModelId) // 4. Project default override pair (defaultProviderOverride + defaultModelIdOverride)
const executorProvider = detail.modelProvider && detail.modelId // 5. Global default pair (defaultProvider + defaultModelId)
? detail.modelProvider const { provider: executorProvider, modelId: executorModelId } = resolveExecutorModelPair(
: (settings.executionProvider && settings.executionModelId detail.modelProvider,
? settings.executionProvider detail.modelId,
: (settings.executionGlobalProvider && settings.executionGlobalModelId settings,
? settings.executionGlobalProvider );
: settings.defaultProvider));
const executorModelId = detail.modelProvider && detail.modelId
? detail.modelId
: (settings.executionProvider && settings.executionModelId
? settings.executionModelId
: (settings.executionGlobalProvider && settings.executionGlobalModelId
? settings.executionGlobalModelId
: settings.defaultModelId));
const executorFallbackProvider = settings.fallbackProvider; const executorFallbackProvider = settings.fallbackProvider;
const executorFallbackModelId = settings.fallbackModelId; const executorFallbackModelId = settings.fallbackModelId;
const executorThinkingLevel = detail.thinkingLevel ?? settings.defaultThinkingLevel; const executorThinkingLevel = detail.thinkingLevel ?? settings.defaultThinkingLevel;

View File

@@ -2206,3 +2206,84 @@ describe("StepSessionExecutor tool availability", () => {
expect(toolNames).toContain("task_create"); expect(toolNames).toContain("task_create");
}); });
}); });
describe("StepSessionExecutor executor model lane hierarchy", () => {
async function captureAgentModel(settingsOverrides: Partial<Settings>, taskOverrides: Partial<TaskDetail> = {}) {
let capturedProvider: string | undefined;
let capturedModelId: string | undefined;
vi.useFakeTimers({ shouldAdvanceTime: true });
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedProvider = opts.defaultProvider;
capturedModelId = opts.defaultModelId;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const executor = new StepSessionExecutor({
taskDetail: makeTaskDetail({
prompt: makeStepPrompt("FN-MODEL", 1),
steps: [{ name: "Step 0", status: "pending" }],
...taskOverrides,
}),
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings: makeSettings({ maxParallelSteps: 1, ...settingsOverrides }),
});
try {
const executePromise = executor.executeAll();
await vi.advanceTimersByTimeAsync(30_000);
await executePromise;
} finally {
vi.useRealTimers();
}
return { provider: capturedProvider, modelId: capturedModelId };
}
beforeEach(() => {
vi.clearAllMocks();
});
it("uses project default override pair when execution lanes are absent", async () => {
const resolved = await captureAgentModel({
executionProvider: undefined,
executionModelId: undefined,
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
expect(resolved).toEqual({
provider: "openai",
modelId: "gpt-4o",
});
});
it("falls through to global default when project default override is incomplete", async () => {
const resolved = await captureAgentModel({
executionProvider: undefined,
executionModelId: undefined,
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
defaultProviderOverride: "openai",
defaultModelIdOverride: undefined,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
expect(resolved).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
});
});
});

View File

@@ -545,6 +545,41 @@ interface SessionHandle {
dispose: () => void; dispose: () => void;
} }
function resolveExecutorModelPair(
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Partial<Settings> | undefined,
): { provider: string | undefined; modelId: string | undefined } {
if (taskModelProvider && taskModelId) {
return { provider: taskModelProvider, modelId: taskModelId };
}
if (settings?.executionProvider && settings?.executionModelId) {
return {
provider: settings.executionProvider,
modelId: settings.executionModelId,
};
}
if (settings?.executionGlobalProvider && settings?.executionGlobalModelId) {
return {
provider: settings.executionGlobalProvider,
modelId: settings.executionGlobalModelId,
};
}
if (settings?.defaultProviderOverride && settings?.defaultModelIdOverride) {
return {
provider: settings.defaultProviderOverride,
modelId: settings.defaultModelIdOverride,
};
}
if (settings?.defaultProvider && settings?.defaultModelId) {
return {
provider: settings.defaultProvider,
modelId: settings.defaultModelId,
};
}
return { provider: undefined, modelId: undefined };
}
/** Fallback store used when step logging persistence is not configured. */ /** Fallback store used when step logging persistence is not configured. */
const NOOP_TASK_STORE: Pick<TaskStore, "appendAgentLog"> = { const NOOP_TASK_STORE: Pick<TaskStore, "appendAgentLog"> = {
appendAgentLog: async () => undefined, appendAgentLog: async () => undefined,
@@ -830,23 +865,15 @@ export class StepSessionExecutor {
// Create fresh agent session for this attempt // Create fresh agent session for this attempt
// Resolve executor model using canonical lane hierarchy: // Resolve executor model using canonical lane hierarchy:
// 1. Task override pair (taskDetail.modelProvider + taskDetail.modelId) // 1. Task override pair (taskDetail.modelProvider + taskDetail.modelId)
// 2. Project execution override pair (settings.executionProvider + settings.executionModelId) // 2. Project execution lane pair (settings.executionProvider + settings.executionModelId)
// 3. Global execution lane pair (settings.executionGlobalProvider + settings.executionGlobalModelId) // 3. Global execution lane pair (settings.executionGlobalProvider + settings.executionGlobalModelId)
// 4. Default pair (settings.defaultProvider + settings.defaultModelId) // 4. Project default override pair (settings.defaultProviderOverride + settings.defaultModelIdOverride)
const executorProvider = taskDetail.modelProvider && taskDetail.modelId // 5. Global default pair (settings.defaultProvider + settings.defaultModelId)
? taskDetail.modelProvider const { provider: executorProvider, modelId: executorModelId } = resolveExecutorModelPair(
: (settings.executionProvider && settings.executionModelId taskDetail.modelProvider,
? settings.executionProvider taskDetail.modelId,
: (settings.executionGlobalProvider && settings.executionGlobalModelId settings,
? settings.executionGlobalProvider );
: settings.defaultProvider));
const executorModelId = taskDetail.modelProvider && taskDetail.modelId
? taskDetail.modelId
: (settings.executionProvider && settings.executionModelId
? settings.executionModelId
: (settings.executionGlobalProvider && settings.executionGlobalModelId
? settings.executionGlobalModelId
: settings.defaultModelId));
const createResult = await createFnAgent({ const createResult = await createFnAgent({
cwd: worktreePath, cwd: worktreePath,