From d80cdd2b3baa256cc5833a22b62d7e19ac33a72a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 10:13:38 -0700 Subject: [PATCH] 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) --- .changeset/fuzzy-fable-fallback.md | 7 ++ AGENTS.md | 1 + docs/settings-reference.md | 2 +- ...utor-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(-) create mode 100644 .changeset/fuzzy-fable-fallback.md create mode 100644 packages/engine/src/__tests__/executor-assigned-agent-runtime-config.test.ts diff --git a/.changeset/fuzzy-fable-fallback.md b/.changeset/fuzzy-fable-fallback.md new file mode 100644 index 0000000000..1a170088ff --- /dev/null +++ b/.changeset/fuzzy-fable-fallback.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 38418777ea..147c00ee37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 4a171900e9..b473f5e3c8 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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) diff --git a/packages/engine/src/__tests__/executor-assigned-agent-runtime-config.test.ts b/packages/engine/src/__tests__/executor-assigned-agent-runtime-config.test.ts new file mode 100644 index 0000000000..27a8b9f7bf --- /dev/null +++ b/packages/engine/src/__tests__/executor-assigned-agent-runtime-config.test.ts @@ -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 | 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" }); + }); +}); diff --git a/packages/engine/src/__tests__/run-audit-session-runtime-resolved.test.ts b/packages/engine/src/__tests__/run-audit-session-runtime-resolved.test.ts index 65da36f99e..7d0c6b240c 100644 --- a/packages/engine/src/__tests__/run-audit-session-runtime-resolved.test.ts +++ b/packages/engine/src/__tests__/run-audit-session-runtime-resolved.test.ts @@ -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).noModelResolved)).toBe(true); + warnSpy.mockRestore(); + }); }); diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 5259946e53..fa1f767b82 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -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 diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 357d2f3ea0..0bad0298e8 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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>(); /** Active configured-command abort controllers keyed by task. */ private activeConfiguredCommandControllers = new Map>(); + /** 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(); /** @@ -4492,12 +4494,33 @@ export class TaskExecutor { return activeRun !== null; } + private async getAuthoritativeAssignedAgent( + assignedAgentId: string | null | undefined, + ): Promise { + 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 | 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 | 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