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:
5
.changeset/fix-project-default-executor-model.md
Normal file
5
.changeset/fix-project-default-executor-model.md
Normal 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.
|
||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user