feat(FN-3581): honor runtime model precedence for assigned agents

This merge delivers five major feature clusters: a fully rebuilt dependency graph plugin with draggable nodes, position persistence, modular architecture, highlighting and selection states, toolbar navigation, and keyboard controls; a new roadmap plugin with domain store, ordering logic, and compreh

Fusion-Task-Id: FN-3581
This commit is contained in:
Fusion
2026-05-07 06:39:55 -07:00
committed by gsxdsm
parent d135ecf1f0
commit 3575b5e445
11 changed files with 417 additions and 114 deletions

View File

@@ -74,6 +74,32 @@ vi.mock("../agent-session-helpers.js", async () => {
const hint = runtimeConfig?.runtimeHint;
return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined;
},
resolveExecutorSessionModel: (
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Record<string, unknown> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
) => {
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") {
return { provider: settings.executionProvider as string, modelId: settings.executionModelId as string };
}
if (typeof settings?.executionGlobalProvider === "string" && typeof settings?.executionGlobalModelId === "string") {
return { provider: settings.executionGlobalProvider as string, modelId: settings.executionGlobalModelId as string };
}
if (typeof settings?.defaultProviderOverride === "string" && typeof settings?.defaultModelIdOverride === "string") {
return { provider: settings.defaultProviderOverride as string, modelId: settings.defaultModelIdOverride as string };
}
if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") {
return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as string };
}
return { provider: undefined, modelId: undefined };
},
};
});
vi.mock("../worktree-names.js", async () => {
@@ -3919,8 +3945,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -3964,8 +3993,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -4004,8 +4036,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -4044,8 +4079,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "openai",
lastModelId: "gpt-4o",
lastResolvedModelProvider: "openai",
lastResolvedModelId: "gpt-4o",
lastTaskModelProvider: "openai",
lastTaskModelId: "gpt-4o",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -4073,8 +4111,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
@@ -4099,8 +4140,11 @@ describe("TaskExecutor executor model hot-swap", () => {
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose },
seenSteeringIds: new Set(),
lastModelProvider: "anthropic",
lastModelId: "claude-sonnet-4-5",
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({

View File

@@ -46,6 +46,32 @@ vi.mock("../agent-session-helpers.js", async () => {
const hint = runtimeConfig?.runtimeHint;
return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined;
},
resolveExecutorSessionModel: (
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Record<string, unknown> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
) => {
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") {
return { provider: settings.executionProvider as string, modelId: settings.executionModelId as string };
}
if (typeof settings?.executionGlobalProvider === "string" && typeof settings?.executionGlobalModelId === "string") {
return { provider: settings.executionGlobalProvider as string, modelId: settings.executionGlobalModelId as string };
}
if (typeof settings?.defaultProviderOverride === "string" && typeof settings?.defaultModelIdOverride === "string") {
return { provider: settings.defaultProviderOverride as string, modelId: settings.defaultModelIdOverride as string };
}
if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") {
return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as string };
}
return { provider: undefined, modelId: undefined };
},
};
});
vi.mock("node:child_process", () => {

View File

@@ -562,6 +562,27 @@ vi.mock("../agent-session-helpers.js", async () => {
pi.promptWithFallback(session, prompt, options as any),
),
describeAgentModel: vi.fn(async (session: any) => pi.describeModel(session)),
resolveExecutorSessionModel: vi.fn((taskModelProvider?: string, taskModelId?: string, settings?: any, assignedAgentRuntimeConfig?: Record<string, unknown>) => {
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}
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 };
}),
};
});

View File

@@ -11,6 +11,7 @@ import type { AgentRuntimeOptions } from "./agent-runtime.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { resolveTaskExecutionModel, type Settings } from "@fusion/core";
import { resolveRuntime, buildRuntimeResolutionContext, type SessionPurpose } from "./runtime-resolution.js";
import { createLogger } from "./logger.js";
import { promptWithFallback, describeModel } from "./pi.js";
@@ -112,6 +113,31 @@ export function extractRuntimeModel(
};
}
export function resolveExecutorSessionModel(
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Partial<Settings> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
): { provider: string | undefined; modelId: string | undefined } {
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) {
return assignedRuntimeModel;
}
const resolvedTaskModel = resolveTaskExecutionModel(
{
modelProvider: taskModelProvider,
modelId: taskModelId,
},
settings,
);
return {
provider: resolvedTaskModel.provider,
modelId: resolvedTaskModel.modelId,
};
}
/**
* Create an agent session using runtime resolution.
*

View File

@@ -25,7 +25,11 @@ import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import {
createResolvedAgentSession,
extractRuntimeHint,
resolveExecutorSessionModel,
} from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
@@ -498,40 +502,6 @@ function getExecutorSystemPrompt(settings: Settings): string {
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 {
semaphore?: AgentSemaphore;
@@ -573,8 +543,11 @@ export class TaskExecutor {
private activeSessions = new Map<string, {
session: AgentSession;
seenSteeringIds: Set<string>;
lastModelProvider?: string | null;
lastModelId?: string | null;
lastResolvedModelProvider?: string;
lastResolvedModelId?: string;
lastTaskModelProvider?: string | null;
lastTaskModelId?: string | null;
lastAssignedAgentId?: string | null;
}>();
/** Active step-session executors per task (mutually exclusive with activeSessions). */
private activeStepExecutors = new Map<string, StepSessionExecutor>();
@@ -1030,21 +1003,32 @@ export class TaskExecutor {
// Handle executor model hot-swap on active single-session executions
if (this.activeSessions.has(task.id) && !task.paused) {
const activeEntry = this.activeSessions.get(task.id)!;
const providerChanged = task.modelProvider !== activeEntry.lastModelProvider;
const modelIdChanged = task.modelId !== activeEntry.lastModelId;
const taskModelProviderChanged = task.modelProvider !== activeEntry.lastTaskModelProvider;
const taskModelIdChanged = task.modelId !== activeEntry.lastTaskModelId;
const assignedAgentChanged = (task.assignedAgentId ?? null) !== (activeEntry.lastAssignedAgentId ?? null);
if (providerChanged || modelIdChanged) {
activeEntry.lastModelProvider = task.modelProvider;
activeEntry.lastModelId = task.modelId;
if (taskModelProviderChanged || taskModelIdChanged || assignedAgentChanged) {
activeEntry.lastTaskModelProvider = task.modelProvider;
activeEntry.lastTaskModelId = task.modelId;
activeEntry.lastAssignedAgentId = task.assignedAgentId ?? null;
const settings = await this.store.getSettings();
// Resolve model using canonical lane hierarchy for hot-swap
const { provider: newProvider, modelId: newModelId } = resolveExecutorModelPair(
const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId);
const { provider: newProvider, modelId: newModelId } = resolveExecutorSessionModel(
task.modelProvider,
task.modelId,
settings,
assignedRuntimeConfig,
);
const providerChanged = newProvider !== activeEntry.lastResolvedModelProvider;
const modelIdChanged = newModelId !== activeEntry.lastResolvedModelId;
if (!providerChanged && !modelIdChanged) {
return;
}
activeEntry.lastResolvedModelProvider = newProvider;
activeEntry.lastResolvedModelId = newModelId;
if (newProvider && newModelId) {
try {
const model = this.modelRegistry.find(newProvider, newModelId);
@@ -1685,7 +1669,7 @@ export class TaskExecutor {
private async executeReviewHandoff(
task: Task,
_session: AgentSession,
_sessionEntry: { session: AgentSession; seenSteeringIds: Set<string>; lastModelProvider?: string | null; lastModelId?: string | null },
_sessionEntry: { session: AgentSession; seenSteeringIds: Set<string>; lastResolvedModelProvider?: string; lastResolvedModelId?: string; lastTaskModelProvider?: string | null; lastTaskModelId?: string | null; lastAssignedAgentId?: string | null },
): Promise<void> {
try {
executorLog.log(`Executing review handoff for ${task.id}`);
@@ -1875,6 +1859,15 @@ export class TaskExecutor {
return activeRun !== null;
}
private async getAssignedAgentRuntimeConfig(
assignedAgentId: string | null | undefined,
): Promise<Record<string, unknown> | undefined> {
const normalizedId = assignedAgentId?.trim();
if (!normalizedId || !this.options.agentStore) return undefined;
const agent = await this.options.agentStore.getAgent(normalizedId).catch(() => null);
return (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined;
}
/**
* Re-dispatch execute() for any unstarted in-progress task belonging to the
* given agent. Called after a heartbeat run completes to unblock tasks that
@@ -2398,6 +2391,7 @@ export class TaskExecutor {
stuckTaskDetector: this.options.stuckTaskDetector,
pluginRunner: this.options.pluginRunner,
runtimeHint: stepSessionRuntimeHint,
assignedAgentRuntimeConfig: (stepSessionAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
// Pass skill selection context from the main executor session
skillSelection: skillContext.skillSelectionContext,
// Pass agentStore and messageStore for delegation and messaging tools
@@ -2892,10 +2886,11 @@ export class TaskExecutor {
// 3. Global execution lane pair (executionGlobalProvider + executionGlobalModelId)
// 4. Project default override pair (defaultProviderOverride + defaultModelIdOverride)
// 5. Global default pair (defaultProvider + defaultModelId)
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorModelPair(
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel(
detail.modelProvider,
detail.modelId,
settings,
(assignedAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
);
const executorFallbackProvider = settings.fallbackProvider;
const executorFallbackModelId = settings.fallbackModelId;
@@ -2962,17 +2957,20 @@ export class TaskExecutor {
}),
});
const executorModelDesc = describeModel(session);
const executorModelMarker = `Executor using model: ${executorModelDesc}`;
if (isResuming) {
executorLog.log(`${task.id}: resumed session from ${task.sessionFile}`);
await this.store.logEntry(task.id, `Resumed agent session after unpause (model: ${describeModel(session)})`, undefined, this.currentRunContext);
await this.store.logEntry(task.id, `Resumed agent session after unpause (model: ${executorModelDesc})`, undefined, this.currentRunContext);
} else {
executorLog.log(`${task.id}: using model ${describeModel(session)}`);
await this.store.logEntry(task.id, `Executor using model: ${describeModel(session)}`, undefined, this.currentRunContext);
executorLog.log(`${task.id}: using model ${executorModelDesc}`);
await this.store.logEntry(task.id, executorModelMarker, undefined, this.currentRunContext);
// Persist session file path so pause/resume can reopen it
if (sessionFile) {
await this.store.updateTask(task.id, { sessionFile });
}
}
await this.store.appendAgentLog(task.id, executorModelMarker, "text", undefined, "executor");
// Make session available to custom tools (fn_task_update checkpoint capture, fn_review_step rewind)
sessionRef.current = session;
@@ -2988,8 +2986,11 @@ export class TaskExecutor {
this.activeSessions.set(task.id, {
session,
seenSteeringIds,
lastModelProvider: detail.modelProvider,
lastModelId: detail.modelId,
lastResolvedModelProvider: executorProvider,
lastResolvedModelId: executorModelId,
lastTaskModelProvider: detail.modelProvider,
lastTaskModelId: detail.modelId,
lastAssignedAgentId: detail.assignedAgentId ?? null,
});
// Register with stuck task detector for heartbeat monitoring
@@ -3274,8 +3275,11 @@ export class TaskExecutor {
this.activeSessions.set(task.id, {
session: retrySession,
seenSteeringIds,
lastModelProvider: detail.modelProvider,
lastModelId: detail.modelId,
lastResolvedModelProvider: executorProvider,
lastResolvedModelId: executorModelId,
lastTaskModelProvider: detail.modelProvider,
lastTaskModelId: detail.modelId,
lastAssignedAgentId: detail.assignedAgentId ?? null,
});
stuckDetector?.trackTask(task.id, retrySession);
@@ -4563,10 +4567,12 @@ ${feedback}
}
// Resolve model using the executor's model hierarchy
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorModelPair(
const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId);
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel(
task.modelProvider,
task.modelId,
settings,
assignedRuntimeConfig,
);
// Create the fix agent session
@@ -6919,7 +6925,7 @@ Child agent: ${agent.id} (${name})`;
// honor project executionProvider/executionModelId overrides (parity
// with main executor at the top of agentWork()).
const { provider: childExecutorProvider, modelId: childExecutorModelId } =
resolveExecutorModelPair(undefined, undefined, settings);
resolveExecutorSessionModel(undefined, undefined, settings, agent.runtimeConfig as Record<string, unknown> | undefined);
// Create child agent session
const { session: childSession } = await createResolvedAgentSession({

View File

@@ -553,9 +553,12 @@ export async function reviewStep(
throw err;
}
reviewerLog.log(`${taskId}: reviewer using model ${describeModel(session)}`);
const reviewerModelDesc = describeModel(session);
const reviewerModelMarker = `Reviewer using model: ${reviewerModelDesc}`;
reviewerLog.log(`${taskId}: reviewer using model ${reviewerModelDesc}`);
if (options.store && options.taskId) {
await options.store.logEntry(options.taskId, `Reviewer using model: ${describeModel(session)}`);
await options.store.logEntry(options.taskId, reviewerModelMarker);
await options.store.appendAgentLog(options.taskId, reviewerModelMarker, "text", undefined, "reviewer").catch(() => undefined);
}
// Notify the caller so it can track this session in a per-task subagent map.

View File

@@ -23,6 +23,7 @@ import {
createResolvedAgentSession,
describeAgentModel,
promptWithAutoRetry,
resolveExecutorSessionModel,
} from "./agent-session-helpers.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import { generateWorktreeName } from "./worktree-names.js";
@@ -96,6 +97,8 @@ export interface StepSessionExecutorOptions {
pluginRunner?: import("./plugin-runner.js").PluginRunner;
/** Optional runtime hint resolved from assigned agent runtimeConfig. */
runtimeHint?: string;
/** Optional assigned-agent runtime config for model override precedence. */
assignedAgentRuntimeConfig?: Record<string, unknown>;
/** Callback invoked when a step starts executing. */
onStepStart?: (stepIndex: number) => void;
/** Callback invoked when a step completes (success or failure). */
@@ -564,40 +567,6 @@ interface SessionHandle {
abortBash: () => 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. */
const NOOP_TASK_STORE: Pick<TaskStore, "appendAgentLog"> = {
@@ -961,10 +930,11 @@ export class StepSessionExecutor {
// 3. Global execution lane pair (settings.executionGlobalProvider + settings.executionGlobalModelId)
// 4. Project default override pair (settings.defaultProviderOverride + settings.defaultModelIdOverride)
// 5. Global default pair (settings.defaultProvider + settings.defaultModelId)
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorModelPair(
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel(
taskDetail.modelProvider,
taskDetail.modelId,
settings,
this.options.assignedAgentRuntimeConfig,
);
const createResult = await createResolvedAgentSession({