FN-7787: honor assigned agent's runtimeConfig model in task execution sessions

Task execution sessions previously ignored the assigned permanent agent's
runtimeConfig model whenever the executor was handed an agents-less
worktree AgentStore, silently drifting to the pi runtime's built-in
default model instead of the configured one.

- Add TaskExecutor.getAuthoritativeAssignedAgent(): falls back to the
  authoritative project `.fusion` AgentStore when the live executor's
  worktree AgentStore has no record of the assigned agent, so
  runtimeConfig resolution matches chat-session behavior.
- Replace direct `this.options.agentStore.getAgent(...)` lookups across
  step-session, workflow-graph, and legacy execution paths with the new
  authoritative lookup helper.
- Warn and audit (`noModelResolved` / `runtimeBuiltInFallbackModel`) when
  a non-mock, non-test-mode session resolves no provider/model pair and
  falls back to the runtime's built-in default, so the drift is visible
  instead of silent.
- Add regression tests covering assigned-agent runtime-config resolution
  and the new runtime-resolved audit fields.
- Add changeset (patch) and update docs/settings-reference.md and
  AGENTS.md.

Files changed:
 .changeset/fuzzy-fable-fallback.md                 |  7 +++
 AGENTS.md                                          |  1 +
 docs/settings-reference.md                         |  2 +-
 .../executor-assigned-agent-runtime-config.test.ts | 68 ++++++++++++++++++++++
 .../run-audit-session-runtime-resolved.test.ts     | 44 ++++++++++++++
 packages/engine/src/agent-session-helpers.ts       | 31 +++++++---
 packages/engine/src/executor.ts                    | 43 +++++++++-----
 7 files changed, 174 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-7787

Fusion-Task-Lineage: 40fccad5-2e67-4ee2-8199-4548ce9025c6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 10:13:38 -07:00
parent 30bd7790e7
commit d80cdd2b3b
7 changed files with 174 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Honor assigned agent models in execution and warn on default-model fallbacks.
category: fix
dev: Executor assigned-agent lookup now falls back to the root AgentStore; session audit adds noModelResolved/runtimeBuiltInFallbackModel when resolution is empty.

View File

@@ -223,6 +223,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- FN-7158: agent performance reflections emit `reflection:generated`, `reflection:skipped`, and `reflection:failed` with ids/counts/outcomes-only metadata; never persist reflection prose or prompt text in run-audit.
- FN-7528: a deterministic, non-LLM post-task performance capture (`AgentReflectionService.captureTaskPerformance`) runs once per completed task and emits `reflection:captured` with ids/counts/outcomes-only metadata (`retryReworkCount?`, `filesTouchedCount?`, `packagesTouchedCount?`, `verificationFileScoped?`, `durationMs?`); never persists `verificationScopeReason` free-text or summary prose in run-audit.
- FN-7787: `createResolvedAgentSession` enriches `session:runtime-resolved` with `noModelResolved: true` and `runtimeBuiltInFallbackModel` when a non-mock/non-test session reaches runtime creation without a complete provider/model pair; this is a visibility signal for runtime built-in fallback usage, not a fabricated model-resolution verdict.
- FN-7011: self-healing emits `task:reconcile-engine-downtime-active-timing` when startup recovery shifts active task segment anchors to exclude proven engine-process downtime, and `task:reconcile-engine-downtime-active-timing-no-action` when no active task qualifies.
- FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes.
- FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move.

View File

@@ -1006,7 +1006,7 @@ The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol`
6. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no task/lane/default pair is configured
7. Automatic provider/model resolution
Workflow prompt steps and scheduled/manual AI-prompt automation steps use the same executor lane before falling back to project/global defaults; explicit step-level `modelProvider` + `modelId` values still take precedence for that individual step.
Workflow prompt steps and scheduled/manual AI-prompt automation steps use the same executor lane before falling back to project/global defaults; explicit step-level `modelProvider` + `modelId` values still take precedence for that individual step. If a non-mock, non-test-mode session still reaches runtime creation without a complete provider/model pair, Fusion logs a warning and records `noModelResolved` plus `runtimeBuiltInFallbackModel` on `session:runtime-resolved` so the runtime's built-in fallback model is observable.
### Heartbeat model (durable agents)

View File

@@ -0,0 +1,68 @@
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentStore } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
import { resolveExecutorSessionModel } from "../agent-session-helpers.js";
function createStore() {
return {
on: vi.fn(),
getFusionDir: vi.fn(() => "/worktree/.fusion"),
} as any;
}
describe("TaskExecutor assigned-agent runtimeConfig lookup", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function createHarness(runtimeConfig: Record<string, unknown> | undefined) {
const rootDir = await mkdtemp(join(tmpdir(), "fn-7787-"));
roots.push(rootDir);
const authoritative = new AgentStore({ rootDir: join(rootDir, ".fusion") });
await authoritative.init();
const agent = await authoritative.createAgent({
name: `executor-${Math.random().toString(16).slice(2)}`,
role: "executor",
...(runtimeConfig ? { runtimeConfig } : {}),
});
const worktreeAgentStore = { getAgent: vi.fn().mockResolvedValue(null) };
const executor = new TaskExecutor(createStore(), rootDir, { agentStore: worktreeAgentStore } as any);
return { executor: executor as any, agent, worktreeAgentStore };
}
it.each([
[{ model: "anthropic/claude-fable-5", modelProvider: "ignored", modelId: "ignored" }, { provider: "anthropic", modelId: "claude-fable-5" }],
[{ modelProvider: "anthropic", modelId: "claude-fable-5" }, { provider: "anthropic", modelId: "claude-fable-5" }],
])("falls back from an agents-less execution store to the authoritative project agent runtimeConfig %#", async (runtimeConfig, expected) => {
const { executor, agent, worktreeAgentStore } = await createHarness(runtimeConfig);
const foundRuntimeConfig = await executor.getAssignedAgentRuntimeConfig(agent.id);
expect(worktreeAgentStore.getAgent).toHaveBeenCalledWith(agent.id);
expect(resolveExecutorSessionModel(undefined, undefined, {}, foundRuntimeConfig)).toEqual(expected);
});
it("returns undefined when the assigned agent is missing or has no complete runtime model", async () => {
const { executor, agent } = await createHarness(undefined);
expect(await executor.getAssignedAgentRuntimeConfig("missing-agent")).toBeUndefined();
const runtimeConfig = await executor.getAssignedAgentRuntimeConfig(agent.id);
expect(resolveExecutorSessionModel(undefined, undefined, {}, runtimeConfig)).toEqual({ provider: undefined, modelId: undefined });
});
it("keeps configured settings ahead of the assigned agent runtimeConfig", async () => {
const { executor, agent } = await createHarness({ model: "anthropic/claude-fable-5" });
const runtimeConfig = await executor.getAssignedAgentRuntimeConfig(agent.id);
expect(resolveExecutorSessionModel(undefined, undefined, {
executionProvider: "openai",
executionModelId: "gpt-4.1",
}, runtimeConfig)).toEqual({ provider: "openai", modelId: "gpt-4.1" });
});
});

View File

@@ -73,4 +73,48 @@ describe("FN-5544 session:runtime-resolved audit event", () => {
expect(events).toHaveLength(1);
expect(events[0]?.metadata).toEqual(expect.objectContaining({ sessionPurpose: "heartbeat", runtimeHint: "hermes", testModeActive: true }));
});
it("warns and audits the runtime built-in fallback when no non-mock model resolves", async () => {
resolveRuntimeMock.mockResolvedValue({
runtime: {
id: "pi",
name: "pi",
createSession: vi.fn().mockResolvedValue({ session: { model: { provider: "anthropic", id: "claude-opus-4-8" }, prompt: vi.fn() } }),
promptWithFallback: vi.fn(),
describeModel: vi.fn(() => "anthropic/claude-opus-4-8"),
},
runtimeId: "pi",
wasConfigured: false,
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const auditor = createRunAuditor(store, { runId: "r5", agentId: "a1", taskId: "FN-7787", phase: "execute", source: "executor" });
await createResolvedAgentSession({ sessionPurpose: "executor", cwd: "/tmp/project", systemPrompt: "system", runAuditor: auditor, settings: {} as any });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("built-in fallback model \"anthropic/claude-opus-4-8\""));
const events = store.getRunAuditEvents({ mutationType: "session:runtime-resolved" });
expect(events).toHaveLength(1);
expect(events[0]?.metadata).toEqual(expect.objectContaining({
provider: null,
modelId: null,
noModelResolved: true,
runtimeBuiltInFallbackModel: "anthropic/claude-opus-4-8",
}));
warnSpy.mockRestore();
});
it("does not warn about built-in fallback for mock, test-mode, or fully resolved sessions", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const auditor = createRunAuditor(store, { runId: "r6", agentId: "a1", taskId: "FN-7787", phase: "execute", source: "executor" });
await createResolvedAgentSession({ sessionPurpose: "executor", cwd: "/tmp/project", systemPrompt: "system", defaultProvider: MOCK_PROVIDER_ID, defaultModelId: "scripted", runAuditor: auditor });
await createResolvedAgentSession({ sessionPurpose: "executor", cwd: "/tmp/project", systemPrompt: "system", runAuditor: auditor, settings: { testMode: true } as any });
await createResolvedAgentSession({ sessionPurpose: "executor", cwd: "/tmp/project", systemPrompt: "system", defaultProvider: "anthropic", defaultModelId: "claude-fable-5", runAuditor: auditor });
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("no complete provider/model resolved"));
const events = store.getRunAuditEvents({ mutationType: "session:runtime-resolved" });
expect(events).toHaveLength(3);
expect(events.every((event) => !(event.metadata as Record<string, unknown>).noModelResolved)).toBe(true);
warnSpy.mockRestore();
});
});

View File

@@ -526,6 +526,26 @@ export async function createResolvedAgentSession(
`[${sessionPurpose}] Using runtime "${resolved.runtimeId}" (configured=${resolved.wasConfigured})`,
);
// Forward `beforeSpawnSession` to the runtime so it fires at the true
// latest sync point (just before LLM session instantiation) rather than
// here, before the runtime's own awaited setup work runs. See
// AgentRuntimeOptions.beforeSpawnSession for the contract.
const result = await resolved.runtime.createSession(effectiveRuntimeOptionsWithModel);
const testModeActive = settings ? isTestModeActive(settings) : false;
const mockProviderActive = isMockProviderId(runtimeOptions.defaultProvider);
const noModelResolved = !mockProviderActive && !testModeActive && (!runtimeOptions.defaultProvider || !runtimeOptions.defaultModelId);
const runtimeBuiltInFallbackModel = noModelResolved ? resolved.runtime.describeModel(result.session) : undefined;
if (noModelResolved) {
/*
FNXC:ModelResolution 2026-07-10-00:00:
Fusion#1984 showed that non-mock/non-test task sessions could resolve no provider+model pair and then quietly run the pi runtime's built-in default, creating unexpected spend. Keep the fallback non-fatal for existing default-model deployments, but warn and audit the actual runtime model so the drift is visible.
*/
sessionLog.warn(
`[${sessionPurpose}] no complete provider/model resolved; runtime "${resolved.runtimeId}" is using built-in fallback model "${runtimeBuiltInFallbackModel ?? "unknown model"}"`,
);
}
try {
await runAuditor?.database({
type: "session:runtime-resolved",
@@ -536,8 +556,9 @@ export async function createResolvedAgentSession(
wasConfigured: resolved.wasConfigured,
provider: runtimeOptions.defaultProvider ?? null,
modelId: runtimeOptions.defaultModelId ?? null,
mockProviderActive: isMockProviderId(runtimeOptions.defaultProvider),
testModeActive: settings ? isTestModeActive(settings) : false,
mockProviderActive,
testModeActive,
...(noModelResolved ? { noModelResolved: true, runtimeBuiltInFallbackModel } : {}),
...(effectiveRuntimeHint ? { runtimeHint: effectiveRuntimeHint } : {}),
...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}),
...(!autoGrokRuntimeHint && "fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}),
@@ -547,12 +568,6 @@ export async function createResolvedAgentSession(
sessionLog.warn(`[${sessionPurpose}] failed to record session:runtime-resolved audit: ${String(err)}`);
}
// Forward `beforeSpawnSession` to the runtime so it fires at the true
// latest sync point (just before LLM session instantiation) rather than
// here, before the runtime's own awaited setup work runs. See
// AgentRuntimeOptions.beforeSpawnSession for the contract.
const result = await resolved.runtime.createSession(effectiveRuntimeOptionsWithModel);
// Attach the resolved runtime's promptWithFallback as a bound method on the
// session object when it is not already present. This is the dispatch hook
// that pi.promptWithFallback (pi.ts:175) checks before falling through to its

View File

@@ -13,7 +13,7 @@ import { existsSync, lstatSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core";
import { getUnmetSchedulingDependencies } from "./scheduler.js";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore } from "@fusion/core";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
@@ -1722,6 +1722,8 @@ export class TaskExecutor {
private activeWorkflowStepSessionSeenSteeringIds = new Map<string, Set<string>>();
/** Active configured-command abort controllers keyed by task. */
private activeConfiguredCommandControllers = new Map<string, Set<AbortController>>();
/** Lazily-created root-project reader used only when an execution lookup is handed an agents-less worktree store. */
private authoritativeAssignedAgentStore: AgentStore | null = null;
/** Active workflow-graph runner abort controllers keyed by task. */
private activeWorkflowGraphAbortControllers = new Map<string, AbortController>();
/**
@@ -4492,12 +4494,33 @@ export class TaskExecutor {
return activeRun !== null;
}
private async getAuthoritativeAssignedAgent(
assignedAgentId: string | null | undefined,
): Promise<Agent | null> {
const normalizedId = assignedAgentId?.trim();
if (!normalizedId) return null;
const configuredAgent = await this.options.agentStore?.getAgent(normalizedId).catch(() => null) ?? null;
if (configuredAgent) return configuredAgent;
/*
FNXC:ModelResolution 2026-07-10-00:00:
Task execution sessions must honor the assigned permanent agent's runtimeConfig like chat sessions do. If the live executor was handed an agents-less worktree AgentStore, fall back to the authoritative project `.fusion` AgentStore instead of letting `resolveExecutorSessionModel` see an empty runtimeConfig and silently drift to the pi built-in model.
*/
try {
this.authoritativeAssignedAgentStore ??= new AgentStore({ rootDir: join(this.rootDir, ".fusion"), taskStore: this.store });
await this.authoritativeAssignedAgentStore.init();
return await this.authoritativeAssignedAgentStore.getAgent(normalizedId).catch(() => null);
} catch (err: unknown) {
executorLog.warn(`Failed to read assigned agent ${normalizedId} from authoritative project AgentStore: ${err instanceof Error ? err.message : String(err)}`);
return 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);
const agent = await this.getAuthoritativeAssignedAgent(assignedAgentId);
return (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined;
}
@@ -9829,9 +9852,7 @@ export class TaskExecutor {
// ── Step-Session Path ──────────────────────────────────────────
executorLog.log(`${task.id}: using step-session mode (maxParallel=${settings.maxParallelSteps ?? 2}${forceStepSession ? ", graph-pinned" : ""})`);
const stepSessionAgent = detail.assignedAgentId && this.options.agentStore
? await this.options.agentStore.getAgent(detail.assignedAgentId).catch(() => null)
: null;
const stepSessionAgent = await this.getAuthoritativeAssignedAgent(detail.assignedAgentId);
// Column-agent SESSION IDENTITY (U4, R2/R3/R4/R8): when the governing
// step-execute node's declared column binds an agent that supersedes the
@@ -10402,9 +10423,7 @@ export class TaskExecutor {
const reflectionTools = this.options.reflectionService && settings.reflectionEnabled && assignedAgentId
? [createReflectOnPerformanceTool(this.options.reflectionService, assignedAgentId)]
: [];
const assignedAgent = assignedAgentId && this.options.agentStore
? await this.options.agentStore.getAgent(assignedAgentId).catch(() => null)
: null;
const assignedAgent = await this.getAuthoritativeAssignedAgent(assignedAgentId);
// Column-agent SESSION IDENTITY (U4, R2/R3/R4/R8): when the governing execute
// seam node's declared column binds an agent that supersedes the task's
@@ -15006,9 +15025,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
pluginRunner: this.options.pluginRunner,
});
const workflowAgent = task.assignedAgentId && this.options.agentStore
? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null)
: null;
const workflowAgent = await this.getAuthoritativeAssignedAgent(task.assignedAgentId);
const workflowRuntimeHint = extractRuntimeHint(workflowAgent?.runtimeConfig);
// Signal to skills running in this step (e.g. compound-engineering ce-plan /
// ce-work) that they are inside a Fusion autonomous workflow step, NOT an