FN-8681: retarget executor step credential instances

Enable executor step sessions to use selected and rotated credential instances.

- Pass task-selected credential instances into step-session execution.
- Re-resolve live credential targets after usage-limit retries using the effective agent runtime configuration.
- Retarget future sessions safely and cover retry behavior.
- Document the runtime behavior and add a patch changeset.

Files changed:
 .changeset/fn-8681-credential-instance-retarget.md |   7 +
 docs/settings-reference.md                         |   7 +-
 .../src/__tests__/step-session-executor.test.ts    | 211 +++++++++++++++++++++
 packages/engine/src/executor.ts                    |  21 ++
 packages/engine/src/step-session-executor.ts       |  94 ++++++++-
 5 files changed, 332 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-8681

Fusion-Task-Lineage: 99724283-6f5d-4890-b7f8-65af1d88b12c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 03:45:59 -07:00
parent 4009eb34cb
commit 4f09758dc8
5 changed files with 332 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Let executor steps use a rotated credential account without interrupting active work.
category: feature
dev: StepSessionExecutor accepts credentialInstanceId and retargetCredentialInstance(ref).

View File

@@ -1880,9 +1880,10 @@ Credential instance ids are validated when authored. Invalid values (including e
whitespace-only, bracket-containing, oversized, or non-string values) are rejected for project and whitespace-only, bracket-containing, oversized, or non-string values) are rejected for project and
global settings, workflow setting values, workflow IR node overrides, and task writes. Settings global settings, workflow setting values, workflow IR node overrides, and task writes. Settings
validation also visits every `modelPresets[]` element: one invalid executor or validator instance validation also visits every `modelPresets[]` element: one invalid executor or validator instance
id rejects the entire settings write without changing the stored presets. These values are id rejects the entire settings write without changing the stored presets. Runtime session resolution consumes the selected instance for supported lanes, including
persisted-but-inert in this release; runtime credential resolution will consume them in the executor step sessions. When an executor-step credential is retargeted after a usage-limit
follow-up runtime-resolution work. failure, only sessions created after the active attempt completes use the new instance; work
already in progress continues on its existing session.
### Authentication credential instances ### Authentication credential instances

View File

@@ -1031,6 +1031,7 @@ vi.mock("../context-limit-detector.js", () => ({
// Mock usage-limit-detector // Mock usage-limit-detector
vi.mock("../usage-limit-detector.js", () => ({ vi.mock("../usage-limit-detector.js", () => ({
checkSessionError: vi.fn(), checkSessionError: vi.fn(),
isUsageLimitError: (message: string) => /usage limit|rate limit|\b429\b/i.test(message),
})); }));
// Mock worktree-names // Mock worktree-names
@@ -1086,8 +1087,10 @@ import { generateWorktreeName } from "../worktree-names.js";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { AgentSemaphore } from "../concurrency.js"; import { AgentSemaphore } from "../concurrency.js";
import { createLogger } from "../logger.js"; import { createLogger } from "../logger.js";
import { promptWithAutoRetry, resolveExecutorSessionModel } from "../agent-session-helpers.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent); const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedResolveExecutorSessionModel = vi.mocked(resolveExecutorSessionModel);
const mockedExecSync = vi.mocked(execSync); const mockedExecSync = vi.mocked(execSync);
const mockedInstallTaskWorktreeIdentityGuard = vi.mocked(installTaskWorktreeIdentityGuard); const mockedInstallTaskWorktreeIdentityGuard = vi.mocked(installTaskWorktreeIdentityGuard);
const mockedGenerateWorktreeName = vi.mocked(generateWorktreeName); const mockedGenerateWorktreeName = vi.mocked(generateWorktreeName);
@@ -3281,3 +3284,211 @@ describe("StepSessionExecutor executor model lane hierarchy", () => {
}); });
}); });
}); });
describe("StepSessionExecutor credential-instance retargeting", () => {
function makeCredentialExecutor(options: {
steps?: number;
runStepsInNewSessions?: boolean;
credentialInstanceId?: string;
maxParallelSteps?: number;
resolveCredentialInstanceRetarget?: () => Promise<{ providerId: string; instanceId: string } | undefined>;
} = {}) {
const stepCount = options.steps ?? 1;
return new StepSessionExecutor({
taskDetail: makeTaskDetail({
prompt: makeStepPrompt("FN-CREDENTIAL", stepCount),
steps: Array.from({ length: stepCount }, (_, index) => ({ name: `Step ${index}`, status: "pending" as const })),
}),
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings: makeSettings({ maxParallelSteps: options.maxParallelSteps ?? 1, runStepsInNewSessions: options.runStepsInNewSessions }),
credentialInstanceId: options.credentialInstanceId,
resolveCredentialInstanceRetarget: options.resolveCredentialInstanceRetarget,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(promptWithAutoRetry).mockImplementation(async (session: any, prompt: string, options?: unknown) =>
session.prompt(prompt, options),
);
});
afterEach(() => {
vi.useRealTimers();
});
it("omits an unset credential instance from legacy session creation", async () => {
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
const executor = makeCredentialExecutor();
await executor.executeAll();
expect(mockedCreateFnAgent.mock.calls[0]?.[0]).not.toHaveProperty("credentialInstanceId");
});
it("forwards the initial instance to each newly-created sequential session", async () => {
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
const executor = makeCredentialExecutor({ steps: 2, runStepsInNewSessions: true, credentialInstanceId: "account-a" });
await executor.executeAll();
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
expect(mockedCreateFnAgent.mock.calls.map(([options]) => options.credentialInstanceId)).toEqual(["account-a", "account-a"]);
expect(mockedResolveExecutorSessionModel.mock.calls.map((args) => args[4])).toEqual(["account-a", "account-a"]);
});
it("forwards the initial instance to every fresh session in a parallel wave", async () => {
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
const executor = makeCredentialExecutor({ steps: 2, maxParallelSteps: 2, credentialInstanceId: "account-a" });
await executor.executeAll();
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
expect(mockedCreateFnAgent.mock.calls.map(([options]) => options.credentialInstanceId)).toEqual(["account-a", "account-a"]);
});
it("retargets only subsequent fresh sessions and clears back to omitted resolution", async () => {
const first = makeMockSession();
const second = makeMockSession();
const third = makeMockSession();
mockedCreateFnAgent
.mockResolvedValueOnce({ session: first } as any)
.mockResolvedValueOnce({ session: second } as any)
.mockResolvedValueOnce({ session: third } as any);
const executor = makeCredentialExecutor({ steps: 3, runStepsInNewSessions: true, credentialInstanceId: "account-a" });
await (executor as any).executeStep(0, "/project/.worktrees/main");
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
await (executor as any).executeStep(1, "/project/.worktrees/main");
await executor.retargetCredentialInstance(undefined);
await (executor as any).executeStep(2, "/project/.worktrees/main");
expect(mockedCreateFnAgent.mock.calls.map(([options]) => options.credentialInstanceId)).toEqual([
"account-a",
"account-b",
undefined,
]);
expect(mockedCreateFnAgent.mock.calls[2]?.[0]).not.toHaveProperty("credentialInstanceId");
});
it("defers reusable-primary disposal until an active prompt completes, then uses the retargeted instance", async () => {
let finishFirstPrompt: (() => void) | undefined;
let firstSession: ReturnType<typeof makeMockSession> | undefined;
const firstPromptStarted = new Promise<void>((resolve) => {
firstSession = {
...makeMockSession(),
abortBash: vi.fn(),
prompt: vi.fn(() => new Promise<void>((finish) => {
finishFirstPrompt = finish;
resolve();
})),
};
const second = { ...makeMockSession(), abortBash: vi.fn() };
mockedCreateFnAgent
.mockResolvedValueOnce({ session: firstSession } as any)
.mockResolvedValueOnce({ session: second } as any);
});
const executor = makeCredentialExecutor({ steps: 2, runStepsInNewSessions: false, credentialInstanceId: "account-a" });
const execution = executor.executeAll();
await firstPromptStarted;
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
expect(firstSession?.abortBash).not.toHaveBeenCalled();
expect(firstSession?.dispose).not.toHaveBeenCalled();
finishFirstPrompt?.();
await expect(execution).resolves.toEqual([
expect.objectContaining({ stepIndex: 0, success: true, retries: 0 }),
expect.objectContaining({ stepIndex: 1, success: true, retries: 0 }),
]);
expect(firstSession?.dispose).toHaveBeenCalledTimes(1);
expect(mockedCreateFnAgent.mock.calls[1]?.[0]).toMatchObject({ credentialInstanceId: "account-b" });
});
it("clears a deferred retarget during cleanup", async () => {
let finishPrompt: (() => void) | undefined;
const promptStarted = new Promise<void>((resolve) => {
const session = {
...makeMockSession(),
abortBash: vi.fn(),
prompt: vi.fn(() => new Promise<void>((finish) => {
finishPrompt = finish;
resolve();
})),
};
mockedCreateFnAgent.mockResolvedValue({ session } as any);
});
const executor = makeCredentialExecutor({ runStepsInNewSessions: false, credentialInstanceId: "account-a" });
const execution = executor.executeAll();
await promptStarted;
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
await executor.cleanup();
expect((executor as any).reusablePrimaryRetargetPending).toBe(false);
finishPrompt?.();
await execution;
});
it("immediately disposes an idle reusable primary session and ignores equivalent or invalid retargets", async () => {
const session = { ...makeMockSession(), abortBash: vi.fn() };
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = makeCredentialExecutor({ runStepsInNewSessions: false, credentialInstanceId: "account-a" });
await (executor as any).executeStep(0, "/project/.worktrees/main");
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-a" });
expect(session.dispose).not.toHaveBeenCalled();
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
expect(session.abortBash).toHaveBeenCalledTimes(1);
expect(session.dispose).toHaveBeenCalledTimes(1);
await expect(executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "bad id" })).resolves.toBeUndefined();
expect(getStepSessionLogger().warn).toHaveBeenCalledWith(expect.stringContaining("Ignoring invalid credential instance id"));
});
it("retargets a usage-limit retry through the owning executor's live selection", async () => {
const first = {
...makeMockSession(),
prompt: vi.fn()
.mockRejectedValueOnce(new Error("429 usage limit reached"))
.mockResolvedValueOnce(undefined),
};
const second = makeMockSession();
const resolveCredentialInstanceRetarget = vi.fn().mockResolvedValue({ providerId: "anthropic", instanceId: "account-b" });
mockedCreateFnAgent
.mockResolvedValueOnce({ session: first } as any)
.mockResolvedValueOnce({ session: second } as any);
const executor = makeCredentialExecutor({ credentialInstanceId: "account-a", resolveCredentialInstanceRetarget });
const execution = executor.executeAll();
await vi.advanceTimersByTimeAsync(10_000);
await expect(execution).resolves.toEqual([expect.objectContaining({ success: true, retries: 1 })]);
expect(resolveCredentialInstanceRetarget).toHaveBeenCalledTimes(1);
expect(mockedCreateFnAgent.mock.calls[1]?.[0]).toMatchObject({ credentialInstanceId: "account-b" });
});
it("applies a manual retarget before a retry without changing retry accounting", async () => {
let rejectFirstPrompt: ((error: Error) => void) | undefined;
const first = {
...makeMockSession(),
prompt: vi.fn(() => new Promise<void>((_resolve, reject) => { rejectFirstPrompt = reject; })),
};
const second = makeMockSession();
mockedCreateFnAgent
.mockResolvedValueOnce({ session: first } as any)
.mockResolvedValueOnce({ session: second } as any);
const executor = makeCredentialExecutor({ credentialInstanceId: "account-a" });
const execution = executor.executeAll();
await vi.waitFor(() => expect(rejectFirstPrompt).toBeTypeOf("function"));
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
rejectFirstPrompt?.(new Error("retry me"));
await vi.advanceTimersByTimeAsync(10_000);
await expect(execution).resolves.toEqual([expect.objectContaining({ success: true, retries: 1 })]);
expect(mockedCreateFnAgent.mock.calls[1]?.[0]).toMatchObject({ credentialInstanceId: "account-b" });
});
});

View File

@@ -13341,6 +13341,27 @@ export class TaskExecutor {
pluginRunner: this.options.pluginRunner, pluginRunner: this.options.pluginRunner,
runtimeHint: stepSessionRuntimeHint, runtimeHint: stepSessionRuntimeHint,
assignedAgentRuntimeConfig: (stepIdentityAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined, assignedAgentRuntimeConfig: (stepIdentityAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
/*
* FNXC:CredentialInstanceRotation 2026-08-01-10:41:
* Step sessions must start on the task-selected account. On a usage-limit
* retry, re-read the live selection and resolve its provider with the same
* effective column-agent runtime config used to create the session.
*/
credentialInstanceId: detail.credentialInstanceId,
resolveCredentialInstanceRetarget: async () => {
const liveDetail = await this.store.getTask(task.id);
if (!liveDetail) return undefined;
const resolvedModel = resolveExecutorSessionModel(
liveDetail.modelProvider,
liveDetail.modelId,
settings,
(stepIdentityAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
liveDetail.credentialInstanceId ?? undefined,
);
return resolvedModel.provider && resolvedModel.credentialInstanceId
? { providerId: resolvedModel.provider, instanceId: resolvedModel.credentialInstanceId }
: undefined;
},
// Attribute the per-step run auditor to the column agent when it governs // Attribute the per-step run auditor to the column agent when it governs
// (U4); absent → StepSessionExecutor falls back to assignedAgentId. // (U4); absent → StepSessionExecutor falls back to assignedAgentId.
effectiveAgentId: stepColumnAgent?.agent.id, effectiveAgentId: stepColumnAgent?.agent.id,

View File

@@ -18,8 +18,8 @@ const execAsync = promisify(exec);
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import type { AgentSession } from "@earendil-works/pi-coding-agent"; import type { AgentSession } from "@earendil-works/pi-coding-agent";
import type { AgentHeartbeatRun, AgentStore, MessageStore, PermanentAgentGatingContext, ResolvedMcpServerDefinition, TaskDetail, Settings, SteeringComment, TaskStore } from "@fusion/core"; import type { AgentHeartbeatRun, AgentStore, MessageStore, PermanentAgentGatingContext, ProviderInstanceRef, ResolvedMcpServerDefinition, TaskDetail, Settings, SteeringComment, TaskStore } from "@fusion/core";
import { resolvePersistAgentThinkingLog, resolveExecutorFallbackModel } from "@fusion/core"; import { isValidProviderInstanceId, resolvePersistAgentThinkingLog, resolveExecutorFallbackModel } from "@fusion/core";
import { import {
createResolvedAgentSession, createResolvedAgentSession,
@@ -41,7 +41,7 @@ import { createLogger } from "./logger.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { isContextLimitError } from "./context-limit-detector.js"; import { isContextLimitError } from "./context-limit-detector.js";
import { checkSessionError } from "./usage-limit-detector.js"; import { checkSessionError, isUsageLimitError } from "./usage-limit-detector.js";
import { import {
createDelegateTaskTool, createDelegateTaskTool,
createTaskAssignTool, createTaskAssignTool,
@@ -124,6 +124,20 @@ export interface StepSessionExecutorOptions {
runtimeHint?: string; runtimeHint?: string;
/** Optional assigned-agent runtime config for model override precedence. */ /** Optional assigned-agent runtime config for model override precedence. */
assignedAgentRuntimeConfig?: Record<string, unknown>; assignedAgentRuntimeConfig?: Record<string, unknown>;
/*
* FNXC:CredentialInstanceRotation 2026-08-01-09:58:
* FN-8654 rotates executor-step work after a usage-limit error. This optional
* initial target lets every new session use that selected credential instance;
* when absent, session creation omits the key to preserve legacy resolution.
*/
credentialInstanceId?: string;
/*
* FNXC:CredentialInstanceRotation 2026-08-01-10:41:
* A usage-limit retry must re-read the task's selected account through the
* owning executor, which alone has the live task and effective agent identity.
* Returning undefined deliberately restores provider-default resolution.
*/
resolveCredentialInstanceRetarget?: () => Promise<ProviderInstanceRef | undefined>;
/** /**
* FNXC:StepLifecycle 2026-07-22-09:53: This awaitable pre-start contract lets the * FNXC:StepLifecycle 2026-07-22-09:53: This awaitable pre-start contract lets the
* authoritative lifecycle projection reject execution before session allocation; * authoritative lifecycle projection reject execution before session allocation;
@@ -741,6 +755,15 @@ export class StepSessionExecutor {
private reusablePrimaryHandle: SessionHandle | null = null; private reusablePrimaryHandle: SessionHandle | null = null;
private reusableStepTelemetry: { agentLogger: AgentLogger; trackingKey: string } | null = null; private reusableStepTelemetry: { agentLogger: AgentLogger; trackingKey: string } | null = null;
private reusablePrimaryLastTokenUsage: StepResult["tokenUsage"] | undefined; private reusablePrimaryLastTokenUsage: StepResult["tokenUsage"] | undefined;
/*
* FNXC:CredentialInstanceRotation 2026-08-01-09:58:
* Credential rotation is mutable run state, never an options mutation. Only
* the reusable primary session can outlive an attempt; fresh and parallel
* sessions naturally read this target when they are created.
*/
private credentialInstanceId: string | undefined;
private reusablePrimaryRetargetPending = false;
private reusablePrimaryAttemptActive = false;
private registerActiveStepSession(stepIndex: number, handle: SessionHandle, worktreePath: string): void { private registerActiveStepSession(stepIndex: number, handle: SessionHandle, worktreePath: string): void {
this.activeSessions.set(stepIndex, handle); this.activeSessions.set(stepIndex, handle);
@@ -777,10 +800,17 @@ export class StepSessionExecutor {
* FNXC:WorkflowStepSessions 2026-06-29-22:58: * FNXC:WorkflowStepSessions 2026-06-29-22:58:
* Coding (per-step review) still needs the StepSessionExecutor boundary so the graph can run `step-review` between steps, but the operator's "Each step in a new session" switch must control session freshness. Reuse only the primary sequential worktree when `runStepsInNewSessions` is false; parallel/isolated worktrees always need their own sessions because their cwd differs and they may run concurrently. * Coding (per-step review) still needs the StepSessionExecutor boundary so the graph can run `step-review` between steps, but the operator's "Each step in a new session" switch must control session freshness. Reuse only the primary sequential worktree when `runStepsInNewSessions` is false; parallel/isolated worktrees always need their own sessions because their cwd differs and they may run concurrently.
*/ */
private shouldReusePrimarySession(worktreePath: string): boolean { private async shouldReusePrimarySession(worktreePath: string): Promise<boolean> {
await this.drainReusablePrimaryRetarget();
return this.options.settings.runStepsInNewSessions === false && worktreePath === this.options.worktreePath; return this.options.settings.runStepsInNewSessions === false && worktreePath === this.options.worktreePath;
} }
private async drainReusablePrimaryRetarget(): Promise<void> {
if (!this.reusablePrimaryRetargetPending || this.reusablePrimaryAttemptActive) return;
this.reusablePrimaryRetargetPending = false;
await this.disposeReusablePrimarySession();
}
private selectReusableTelemetry(fallback: { agentLogger: AgentLogger; trackingKey: string }): { agentLogger: AgentLogger; trackingKey: string } { private selectReusableTelemetry(fallback: { agentLogger: AgentLogger; trackingKey: string }): { agentLogger: AgentLogger; trackingKey: string } {
return this.reusableStepTelemetry ?? fallback; return this.reusableStepTelemetry ?? fallback;
} }
@@ -807,10 +837,34 @@ export class StepSessionExecutor {
constructor(options: StepSessionExecutorOptions) { constructor(options: StepSessionExecutorOptions) {
this.options = options; this.options = options;
this.store = options.store ?? (NOOP_TASK_STORE as TaskStore); this.store = options.store ?? (NOOP_TASK_STORE as TaskStore);
this.credentialInstanceId = options.credentialInstanceId;
// Clamp maxParallelSteps to 1–4 range // Clamp maxParallelSteps to 1–4 range
this.maxParallel = Math.max(1, Math.min(4, options.settings.maxParallelSteps ?? 2)); this.maxParallel = Math.max(1, Math.min(4, options.settings.maxParallelSteps ?? 2));
} }
/*
* FNXC:CredentialInstanceRotation 2026-08-01-09:58:
* FN-8654 may retarget future executor-step sessions after a usage-limit error.
* disposeReusablePrimarySession aborts bash and disposes its session, so an
* active primary attempt must drain normally before disposal; eager disposal
* would change its result and retry lifecycle. Parallel sessions are untouched.
*/
async retargetCredentialInstance(ref: ProviderInstanceRef | undefined): Promise<void> {
if (ref && !isValidProviderInstanceId(ref.instanceId)) {
stepExecLog.warn(`Ignoring invalid credential instance id for task ${this.options.taskDetail.id}`);
return;
}
const nextCredentialInstanceId = ref?.instanceId;
if (nextCredentialInstanceId === this.credentialInstanceId) return;
this.credentialInstanceId = nextCredentialInstanceId;
if (this.reusablePrimaryAttemptActive) {
this.reusablePrimaryRetargetPending = true;
return;
}
await this.disposeReusablePrimarySession();
}
/** /**
* Execute all steps in the task, respecting conflict-based parallel waves. * Execute all steps in the task, respecting conflict-based parallel waves.
* *
@@ -998,6 +1052,8 @@ export class StepSessionExecutor {
activeSessionRegistry.unregisterPath(worktreePath); activeSessionRegistry.unregisterPath(worktreePath);
} }
this.activeSessions.clear(); this.activeSessions.clear();
this.reusablePrimaryRetargetPending = false;
this.reusablePrimaryAttemptActive = false;
await this.disposeReusablePrimarySession(); await this.disposeReusablePrimarySession();
} }
@@ -1012,6 +1068,8 @@ export class StepSessionExecutor {
if (this.activeSessions.size > 0) { if (this.activeSessions.size > 0) {
await this.terminateAllSessions(); await this.terminateAllSessions();
} }
this.reusablePrimaryRetargetPending = false;
this.reusablePrimaryAttemptActive = false;
await this.disposeReusablePrimarySession(); await this.disposeReusablePrimarySession();
// Remove parallel worktrees // Remove parallel worktrees
@@ -1257,7 +1315,7 @@ export class StepSessionExecutor {
// Build reduced step prompt for context-limit recovery (simpler, shorter) // Build reduced step prompt for context-limit recovery (simpler, shorter)
const reducedStepPrompt = buildReducedStepPrompt(promptTaskDetail, stepIndex, this.options.rootDir); const reducedStepPrompt = buildReducedStepPrompt(promptTaskDetail, stepIndex, this.options.rootDir);
const reusePrimarySession = this.shouldReusePrimarySession(worktreePath); const reusePrimarySession = await this.shouldReusePrimarySession(worktreePath);
// Acquire semaphore if provided // Acquire semaphore if provided
if (semaphore) { if (semaphore) {
@@ -1379,6 +1437,7 @@ export class StepSessionExecutor {
taskDetail.modelId, taskDetail.modelId,
settings, settings,
this.options.assignedAgentRuntimeConfig, this.options.assignedAgentRuntimeConfig,
this.credentialInstanceId,
); );
if (reusePrimarySession && this.reusablePrimarySession) { if (reusePrimarySession && this.reusablePrimarySession) {
@@ -1456,6 +1515,13 @@ Follow instructions precisely and avoid unrelated changes.`,
telemetry.agentLogger.onToolEnd(name, isError, result); telemetry.agentLogger.onToolEnd(name, isError, result);
stuckTaskDetector?.recordActivity(telemetry.trackingKey); stuckTaskDetector?.recordActivity(telemetry.trackingKey);
}, },
/*
* FNXC:CredentialInstanceRotation 2026-08-01-09:58:
* The explicit executor target wins over any model-derived instance
* because FN-8654 deliberately retargeted after a usage-limit error.
* Omit the key entirely when unset to preserve legacy resolution.
*/
...(this.credentialInstanceId ? { credentialInstanceId: this.credentialInstanceId } : {}),
// FNXC:PluginSkills 2026-07-12-00:00: Step-session createFnAgent must receive plugin skill body dirs from TaskExecutor; names alone do not make plugin-package SKILL.md files discoverable. // FNXC:PluginSkills 2026-07-12-00:00: Step-session createFnAgent must receive plugin skill body dirs from TaskExecutor; names alone do not make plugin-package SKILL.md files discoverable.
...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}), ...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}),
...(this.options.additionalSkillPaths && this.options.additionalSkillPaths.length > 0 ? { additionalSkillPaths: this.options.additionalSkillPaths } : {}), ...(this.options.additionalSkillPaths && this.options.additionalSkillPaths.length > 0 ? { additionalSkillPaths: this.options.additionalSkillPaths } : {}),
@@ -1499,6 +1565,10 @@ Follow instructions precisely and avoid unrelated changes.`,
this.reusableStepTelemetry = localTelemetry; this.reusableStepTelemetry = localTelemetry;
} }
this.registerActiveStepSession(stepIndex, handle, worktreePath); this.registerActiveStepSession(stepIndex, handle, worktreePath);
// FNXC:CredentialInstanceRotation 2026-08-01-09:58: This marker covers
// the whole registered reused-primary attempt so retargeting cannot call
// destructive disposal between registration and prompt completion.
if (reusePrimarySession) this.reusablePrimaryAttemptActive = true;
stuckTaskDetector?.trackTask(trackingKey, { dispose: () => session?.dispose() }, taskDetail.id); stuckTaskDetector?.trackTask(trackingKey, { dispose: () => session?.dispose() }, taskDetail.id);
const sessionModel = await describeAgentModel(session); const sessionModel = await describeAgentModel(session);
@@ -1581,6 +1651,18 @@ Follow instructions precisely and avoid unrelated changes.`,
} }
} }
/*
* FNXC:CredentialInstanceRotation 2026-08-01-10:41:
* A limited account may be replaced while this workflow is running.
* Ask TaskExecutor for the live, runtime-config-aware target before
* retrying, so the next session does not recreate the limited account.
*/
if (isUsageLimitError(errorMessage) && this.options.resolveCredentialInstanceRetarget) {
await this.retargetCredentialInstance(
await this.options.resolveCredentialInstanceRetarget(),
);
}
// If this was the last attempt, return failure // If this was the last attempt, return failure
if (attempt === MAX_STEP_RETRIES) { if (attempt === MAX_STEP_RETRIES) {
const result: StepResult = { const result: StepResult = {
@@ -1609,6 +1691,8 @@ Follow instructions precisely and avoid unrelated changes.`,
this.unregisterActiveStepSession(stepIndex, worktreePath); this.unregisterActiveStepSession(stepIndex, worktreePath);
stuckTaskDetector?.untrackTask(trackingKey); stuckTaskDetector?.untrackTask(trackingKey);
if (reusePrimarySession) { if (reusePrimarySession) {
this.reusablePrimaryAttemptActive = false;
await this.drainReusablePrimaryRetarget();
this.reusableStepTelemetry = null; this.reusableStepTelemetry = null;
} else { } else {
try { try {