fix(FN-7265): align per-step review workflow

This commit is contained in:
gsxdsm
2026-06-29 23:25:17 -07:00
parent ea0707c573
commit eb83c81ffb
6 changed files with 289 additions and 104 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Align per-step review coding with default coding gates and session settings.
category: fix
dev: Removes the extra generic review seam from Coding (per-step review) and makes StepSessionExecutor honor runStepsInNewSessions=false by reusing the primary sequential session while keeping graph step-review boundaries.

View File

@@ -220,7 +220,8 @@ describe("built-in workflows", () => {
expect(ir.edges.some((edge) => edge.from === "steps" && edge.to === "browser-verification" && edge.condition === "success")).toBe(true); expect(ir.edges.some((edge) => edge.from === "steps" && edge.to === "browser-verification" && edge.condition === "success")).toBe(true);
expect(ir.edges.some((edge) => edge.from === "browser-verification" && edge.to === "code-review" && edge.condition === "success")).toBe(true); expect(ir.edges.some((edge) => edge.from === "browser-verification" && edge.to === "code-review" && edge.condition === "success")).toBe(true);
expect(ir.edges.some((edge) => edge.from === "code-review" && edge.to === "completion-summary" && edge.condition === "success")).toBe(true); expect(ir.edges.some((edge) => edge.from === "code-review" && edge.to === "completion-summary" && edge.condition === "success")).toBe(true);
expect(ir.edges.some((edge) => edge.from === "completion-summary" && edge.to === "review" && edge.condition === "success")).toBe(true); expect(ir.edges.some((edge) => edge.from === "completion-summary" && edge.to === "merge-gate" && edge.condition === "success")).toBe(true);
expect(ir.nodes.some((node) => node.id === "review")).toBe(false);
const foreach = ir.nodes.find((n) => n.kind === "foreach"); const foreach = ir.nodes.find((n) => n.kind === "foreach");
expect(foreach).toBeDefined(); expect(foreach).toBeDefined();
const template = ( const template = (
@@ -883,7 +884,7 @@ describe("built-in workflows", () => {
expect(ce.ir.edges.some((edge) => edge.from === "manual-pr-review" && edge.to === "completion-summary")).toBe(true); expect(ce.ir.edges.some((edge) => edge.from === "manual-pr-review" && edge.to === "completion-summary")).toBe(true);
}); });
it("non-default coding built-ins retain their generic review nodes", () => { it("coding variants only retain generic review nodes where the workflow requires them", () => {
const coding = getBuiltinWorkflow("builtin:coding")!; const coding = getBuiltinWorkflow("builtin:coding")!;
const legacy = getBuiltinWorkflow("builtin:legacy-coding")!; const legacy = getBuiltinWorkflow("builtin:legacy-coding")!;
const stepwise = getBuiltinWorkflow("builtin:stepwise-coding")!; const stepwise = getBuiltinWorkflow("builtin:stepwise-coding")!;
@@ -891,7 +892,7 @@ describe("built-in workflows", () => {
expect(coding.ir.nodes.some((node) => node.id === "review" && node.config?.seam === "review")).toBe(false); expect(coding.ir.nodes.some((node) => node.id === "review" && node.config?.seam === "review")).toBe(false);
expect(legacy.ir.nodes.some((node) => node.id === "review" && node.config?.seam === "review")).toBe(true); expect(legacy.ir.nodes.some((node) => node.id === "review" && node.config?.seam === "review")).toBe(true);
expect(stepwise.ir.nodes.some((node) => node.id === "review" && node.config?.seam === "review")).toBe(true); expect(stepwise.ir.nodes.some((node) => node.id === "review" && node.config?.seam === "review")).toBe(false);
expect(reviewHeavy.ir.nodes.some((node) => node.id === "review" && node.config?.seam === "review")).toBe(true); expect(reviewHeavy.ir.nodes.some((node) => node.id === "review" && node.config?.seam === "review")).toBe(true);
}); });

View File

@@ -50,6 +50,11 @@ import {
* Coding (per-step review) also needs the default-on optional Plan Review gate before * Coding (per-step review) also needs the default-on optional Plan Review gate before
* execution. The group sits between `plan` and `parse` so operators can toggle plan * execution. The group sits between `plan` and `parse` so operators can toggle plan
* review independently while preserving the per-step code review/rework loop. * review independently while preserving the per-step code review/rework loop.
*
* FNXC:WorkflowReviewGates 2026-06-29-23:27:
* Per-step review should inherit the regular Coding workflow's graph-native suffix:
* completion summary flows directly to the merge gate, with the final optional Code
* Review group providing the only end-of-task automated review gate.
*/ */
const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
version: "v2", version: "v2",
@@ -161,7 +166,6 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
codeReviewOptionalGroupNode("in-progress"), codeReviewOptionalGroupNode("in-progress"),
codeReviewRemediationNode("in-progress"), codeReviewRemediationNode("in-progress"),
completionSummaryNode("in-review"), completionSummaryNode("in-review"),
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") },
{ id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } }, { id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } },
{ id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } }, { id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } },
{ id: "merge-manual-hold", kind: "manual-merge-hold", column: "in-review", config: { release: "manual" } }, { id: "merge-manual-hold", kind: "manual-merge-hold", column: "in-review", config: { release: "manual" } },
@@ -203,18 +207,17 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
// KTD-5: bounded rework exhaustion → manual hold; release re-enters the group. // KTD-5: bounded rework exhaustion → manual hold; release re-enters the group.
{ from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" }, { from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" },
{ from: "rework-hold", to: "browser-verification", condition: "success" }, { from: "rework-hold", to: "browser-verification", condition: "success" },
// browser-verification → code-review → review; each optional-group passes through // browser-verification → code-review → completion-summary → merge-gate; each
// (outcome=success) when disabled, so a task with both off routes straight to review. // optional-group passes through (outcome=success) when disabled, so a task with
// both off routes straight to completion summary and merge policy.
{ from: "browser-verification", to: "code-review", condition: "success" }, { from: "browser-verification", to: "code-review", condition: "success" },
{ from: "code-review", to: "completion-summary", condition: "success" }, { from: "code-review", to: "completion-summary", condition: "success" },
{ from: "completion-summary", to: "review", condition: "success" }, { from: "completion-summary", to: "merge-gate", condition: "success" },
{ from: "browser-verification", to: "browser-verification-remediation", condition: "failure" }, { from: "browser-verification", to: "browser-verification-remediation", condition: "failure" },
{ from: "browser-verification-remediation", to: "browser-verification", condition: "success", kind: "rework" }, { from: "browser-verification-remediation", to: "browser-verification", condition: "success", kind: "rework" },
{ from: "code-review", to: "code-review-remediation", condition: "failure" }, { from: "code-review", to: "code-review-remediation", condition: "failure" },
{ from: "code-review-remediation", to: "code-review", condition: "success", kind: "rework" }, { from: "code-review-remediation", to: "code-review", condition: "success", kind: "rework" },
{ from: "steps", to: "end", condition: "failure" }, { from: "steps", to: "end", condition: "failure" },
{ from: "review", to: "merge-gate", condition: "success" },
{ from: "review", to: "end", condition: "failure" },
{ from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" }, { from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" },
{ from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" }, { from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" },
{ from: "merge-retry", to: "merge-attempt", condition: "success", kind: "rework" }, { from: "merge-retry", to: "merge-attempt", condition: "success", kind: "rework" },

View File

@@ -566,16 +566,15 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
"code-review": { x: 1080, y: 160 }, "code-review": { x: 1080, y: 160 },
"code-review-remediation": { x: 1080, y: 320 }, "code-review-remediation": { x: 1080, y: 320 },
"completion-summary": { x: 1250, y: 160 }, "completion-summary": { x: 1250, y: 160 },
review: { x: 1420, y: 160 }, "merge-gate": { x: 1420, y: 160 },
"merge-gate": { x: 1590, y: 160 }, "branch-group-member-integration": { x: 1590, y: 80 },
"branch-group-member-integration": { x: 1760, y: 80 }, "branch-group-promotion": { x: 1760, y: 80 },
"branch-group-promotion": { x: 1930, y: 80 }, "merge-attempt": { x: 1930, y: 160 },
"merge-attempt": { x: 2100, y: 160 }, "merge-retry": { x: 2100, y: 80 },
"merge-retry": { x: 2270, y: 80 }, "recovery-router": { x: 2100, y: 240 },
"recovery-router": { x: 2270, y: 240 }, "merge-manual-hold": { x: 1590, y: 240 },
"merge-manual-hold": { x: 1760, y: 240 }, "post-merge-verification": { x: 2270, y: 160 },
"post-merge-verification": { x: 2440, y: 160 }, end: { x: 2440, y: 160 },
end: { x: 2610, y: 160 },
}, },
createdAt: BUILTIN_TS, createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS, updatedAt: BUILTIN_TS,

View File

@@ -1192,6 +1192,89 @@ describe("StepSessionExecutor", () => {
); );
}); });
it("reuses the primary step session when runStepsInNewSessions is false", async () => {
const prompt = makeStepPrompt("FN-001", 2);
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 1, runStepsInNewSessions: false });
let statsCall = 0;
const session = {
...makeMockSession(),
getSessionStats: vi.fn(() => {
statsCall++;
return {
tokens: {
input: statsCall * 10,
output: statsCall * 20,
cacheRead: statsCall * 3,
cacheWrite: statsCall,
total: statsCall * 34,
},
};
}),
};
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
pluginRunner: undefined,
} as any);
const result = await executor.executeAll();
await executor.cleanup();
expect(result).toHaveLength(2);
expect(result.every((step) => step.success)).toBe(true);
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
expect(session.prompt).toHaveBeenCalledTimes(2);
expect(session.dispose).toHaveBeenCalledTimes(1);
expect(result[0]?.tokenUsage?.inputTokens).toBe(10);
expect(result[1]?.tokenUsage?.inputTokens).toBe(10);
expect(result[1]?.tokenUsage?.totalTokens).toBe(34);
});
it("creates a fresh primary step session for each step when runStepsInNewSessions is true", async () => {
const prompt = makeStepPrompt("FN-001", 2);
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 1, runStepsInNewSessions: true });
const sessions = [makeMockSession(), makeMockSession()];
mockedCreateFnAgent
.mockResolvedValueOnce({ session: sessions[0] } as any)
.mockResolvedValueOnce({ session: sessions[1] } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
pluginRunner: undefined,
} as any);
const result = await executor.executeAll();
expect(result).toHaveLength(2);
expect(result.every((step) => step.success)).toBe(true);
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
expect(sessions[0]?.prompt).toHaveBeenCalledTimes(1);
expect(sessions[1]?.prompt).toHaveBeenCalledTimes(1);
expect(sessions[0]?.dispose).toHaveBeenCalledTimes(1);
expect(sessions[1]?.dispose).toHaveBeenCalledTimes(1);
});
it("delivers pending steering comments in exactly one subsequent step prompt", async () => { it("delivers pending steering comments in exactly one subsequent step prompt", async () => {
const prompt = makeStepPrompt("FN-001", 2); const prompt = makeStepPrompt("FN-001", 2);
const task = makeTaskDetail({ const task = makeTaskDetail({

View File

@@ -1,9 +1,10 @@
/** /**
* StepSessionExecutor — runs each task step in its own fresh agent session. * StepSessionExecutor — runs task steps with graph-controlled step boundaries.
* *
* This module enables per-step error recovery with retry semantics, optional * This module enables per-step error recovery with retry semantics, optional
* parallel execution for non-conflicting steps (via git worktree isolation), * parallel execution for non-conflicting steps (via git worktree isolation),
* and clean lifecycle management (pause, cleanup). * optional primary-worktree session reuse, and clean lifecycle management
* (pause, cleanup).
* *
* The class is a standalone engine subsystem with minimal integration surface. * The class is a standalone engine subsystem with minimal integration surface.
* It receives TaskDetail (read-only), a TaskStore for agent logs, and emits * It receives TaskDetail (read-only), a TaskStore for agent logs, and emits
@@ -669,10 +670,12 @@ const NOOP_TASK_STORE: Pick<TaskStore, "appendAgentLog"> = {
}; };
/** /**
* StepSessionExecutor — runs each task step in its own fresh agent session. * StepSessionExecutor — runs each task step behind a deterministic boundary.
* *
* This class orchestrates per-step agent sessions with: * This class orchestrates per-step agent sessions with:
* - **Sequential execution** (default): steps run one at a time * - **Sequential execution** (default): steps run one at a time
* - **Session policy**: `runStepsInNewSessions=false` reuses one primary-worktree
* session across sequential steps; `true` creates a fresh session per step
* - **Parallel execution**: non-conflicting steps run simultaneously in * - **Parallel execution**: non-conflicting steps run simultaneously in
* separate git worktrees (when `maxParallelSteps > 1`) * separate git worktrees (when `maxParallelSteps > 1`)
* - **Per-step retry**: failed steps retry up to 3 times with exponential backoff * - **Per-step retry**: failed steps retry up to 3 times with exponential backoff
@@ -712,6 +715,10 @@ export class StepSessionExecutor {
private aborted = false; private aborted = false;
private maxParallel: number; private maxParallel: number;
private deliveredSteeringCommentIds = new Set<string>(); private deliveredSteeringCommentIds = new Set<string>();
private reusablePrimarySession: AgentSession | null = null;
private reusablePrimaryHandle: SessionHandle | null = null;
private reusableStepTelemetry: { agentLogger: AgentLogger; trackingKey: string } | null = null;
private reusablePrimaryLastTokenUsage: StepResult["tokenUsage"] | undefined;
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);
@@ -744,6 +751,37 @@ export class StepSessionExecutor {
this.parallelWorktrees.delete(stepIndex); this.parallelWorktrees.delete(stepIndex);
} }
/*
* 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.
*/
private shouldReusePrimarySession(worktreePath: string): boolean {
return this.options.settings.runStepsInNewSessions === false && worktreePath === this.options.worktreePath;
}
private selectReusableTelemetry(fallback: { agentLogger: AgentLogger; trackingKey: string }): { agentLogger: AgentLogger; trackingKey: string } {
return this.reusableStepTelemetry ?? fallback;
}
private async disposeReusablePrimarySession(): Promise<void> {
if (!this.reusablePrimarySession) return;
try {
this.reusablePrimaryHandle?.abortBash();
} catch (err) {
stepExecLog.warn(`Failed to abort reusable primary step session: ${err}`);
}
try {
this.reusablePrimarySession.dispose();
} catch (err) {
stepExecLog.warn(`Failed to dispose reusable primary step session: ${err}`);
} finally {
this.reusablePrimarySession = null;
this.reusablePrimaryHandle = null;
this.reusableStepTelemetry = null;
this.reusablePrimaryLastTokenUsage = undefined;
}
}
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);
@@ -938,6 +976,7 @@ export class StepSessionExecutor {
activeSessionRegistry.unregisterPath(worktreePath); activeSessionRegistry.unregisterPath(worktreePath);
} }
this.activeSessions.clear(); this.activeSessions.clear();
await this.disposeReusablePrimarySession();
} }
/** /**
@@ -951,6 +990,7 @@ export class StepSessionExecutor {
if (this.activeSessions.size > 0) { if (this.activeSessions.size > 0) {
await this.terminateAllSessions(); await this.terminateAllSessions();
} }
await this.disposeReusablePrimarySession();
// Remove parallel worktrees // Remove parallel worktrees
for (const [stepIdx, worktreePath] of this.parallelWorktrees) { for (const [stepIdx, worktreePath] of this.parallelWorktrees) {
@@ -1043,6 +1083,29 @@ export class StepSessionExecutor {
} }
} }
private async extractStepTokenUsage(session: AgentSession | null | undefined, reusePrimarySession: boolean): Promise<StepResult["tokenUsage"] | undefined> {
const current = await this.extractTokenUsageFromSession(session);
if (!reusePrimarySession || !current) {
return current;
}
const previous = this.reusablePrimaryLastTokenUsage;
this.reusablePrimaryLastTokenUsage = current;
if (!previous) {
return current;
}
return {
inputTokens: Math.max(0, current.inputTokens - previous.inputTokens),
outputTokens: Math.max(0, current.outputTokens - previous.outputTokens),
cachedTokens: Math.max(0, current.cachedTokens - previous.cachedTokens),
cacheWriteTokens: Math.max(0, current.cacheWriteTokens - previous.cacheWriteTokens),
totalTokens: Math.max(0, current.totalTokens - previous.totalTokens),
modelProvider: current.modelProvider,
modelId: current.modelId,
};
}
// ── Internal: Step Execution ──────────────────────────────────────── // ── Internal: Step Execution ────────────────────────────────────────
/** /**
@@ -1073,6 +1136,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);
// Acquire semaphore if provided // Acquire semaphore if provided
if (semaphore) { if (semaphore) {
@@ -1110,6 +1174,7 @@ export class StepSessionExecutor {
persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }),
}); });
let session: AgentSession | null = null; let session: AgentSession | null = null;
const localTelemetry = { agentLogger, trackingKey };
try { try {
// Get plugin tools from plugin runner if available // Get plugin tools from plugin runner if available
@@ -1150,7 +1215,7 @@ export class StepSessionExecutor {
] ]
: []; : [];
// Create fresh agent session for this attempt // Create or reuse the agent session for this attempt
// Resolve executor model using canonical lane hierarchy: // Resolve executor model using canonical lane hierarchy:
// 1. Task override pair (taskDetail.modelProvider + taskDetail.modelId) // 1. Task override pair (taskDetail.modelProvider + taskDetail.modelId)
// 2. Project execution lane pair (settings.executionProvider + settings.executionModelId) // 2. Project execution lane pair (settings.executionProvider + settings.executionModelId)
@@ -1164,94 +1229,113 @@ export class StepSessionExecutor {
this.options.assignedAgentRuntimeConfig, this.options.assignedAgentRuntimeConfig,
); );
const createResult = await createResolvedAgentSession({ if (reusePrimarySession && this.reusablePrimarySession) {
sessionPurpose: "executor", session = this.reusablePrimarySession;
runtimeHint: this.options.runtimeHint, } else {
pluginRunner: this.options.pluginRunner, const createResult = await createResolvedAgentSession({
cwd: worktreePath, sessionPurpose: "executor",
systemPrompt: `You are an AI agent executing step ${stepIndex} of task ${taskDetail.id}. runtimeHint: this.options.runtimeHint,
pluginRunner: this.options.pluginRunner,
cwd: worktreePath,
systemPrompt: `You are an AI agent executing steps for task ${taskDetail.id}.
Your role: Your role:
- Complete only this step's scoped outcomes. - Complete only the current step's scoped outcomes.
- Read step context before editing. - Read step context before editing.
- Reuse existing patterns in nearby code. - Reuse existing patterns in nearby code.
- Run relevant tests for changes made in this step. - Run relevant tests for changes made in this step.
- Report blockers clearly instead of guessing. - Report blockers clearly instead of guessing.
Follow instructions precisely and avoid unrelated changes.`, Follow instructions precisely and avoid unrelated changes.`,
defaultProvider: executorProvider, defaultProvider: executorProvider,
defaultModelId: executorModelId, defaultModelId: executorModelId,
fallbackProvider: settings.fallbackProvider, fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId, fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: taskDetail.thinkingLevel ?? settings.defaultThinkingLevel, defaultThinkingLevel: taskDetail.thinkingLevel ?? settings.defaultThinkingLevel,
runAuditor: createRunAuditor(this.store, { runAuditor: createRunAuditor(this.store, {
runId: generateSyntheticRunId("workflow-step", taskDetail.id), runId: generateSyntheticRunId("workflow-step", taskDetail.id),
// Column-agent attribution (U4): the effective column agent is the // Column-agent attribution (U4): the effective column agent is the
// principal that actually ran when the seam node's column governs; // principal that actually ran when the seam node's column governs;
// fall back to the task's assigned agent (legacy, byte-identical). // fall back to the task's assigned agent (legacy, byte-identical).
agentId: this.options.effectiveAgentId ?? taskDetail.assignedAgentId ?? "executor", agentId: this.options.effectiveAgentId ?? taskDetail.assignedAgentId ?? "executor",
taskId: taskDetail.id, taskId: taskDetail.id,
taskLineageId: taskDetail.lineageId, taskLineageId: taskDetail.lineageId,
phase: "execute", phase: "execute",
source: "step-session-executor", source: "step-session-executor",
}), }),
settings, settings,
// FNXC:McpConfig 2026-06-25-23:02: Workflow model-node step sessions receive the same resolved, secret-materialized MCP server set as the parent executor; runtime support is still enforced inside the pi session seam without logging server contents. // FNXC:McpConfig 2026-06-25-23:02: Workflow model-node step sessions receive the same resolved, secret-materialized MCP server set as the parent executor; runtime support is still enforced inside the pi session seam without logging server contents.
mcpServers: this.options.mcpServers, mcpServers: this.options.mcpServers,
customTools: [ customTools: [
...pluginTools, ...pluginTools,
...documentTools, ...documentTools,
webFetchTool, webFetchTool,
...memoryTools, ...memoryTools,
...taskLogTool, ...taskLogTool,
...taskCreateTool, ...taskCreateTool,
...delegationTools, ...delegationTools,
...messagingTools, ...messagingTools,
], ],
onText: (delta) => { onText: (delta) => {
agentLogger.onText(delta); const telemetry = reusePrimarySession ? this.selectReusableTelemetry(localTelemetry) : localTelemetry;
stuckTaskDetector?.recordActivity(trackingKey); telemetry.agentLogger.onText(delta);
}, stuckTaskDetector?.recordActivity(telemetry.trackingKey);
onThinking: (delta) => { },
agentLogger.onThinking(delta); onThinking: (delta) => {
}, const telemetry = reusePrimarySession ? this.selectReusableTelemetry(localTelemetry) : localTelemetry;
onToolStart: (name, args) => { telemetry.agentLogger.onThinking(delta);
agentLogger.onToolStart(name, args); },
stuckTaskDetector?.recordActivity(trackingKey); onToolStart: (name, args) => {
}, const telemetry = reusePrimarySession ? this.selectReusableTelemetry(localTelemetry) : localTelemetry;
onToolEnd: (name, isError, result) => { telemetry.agentLogger.onToolStart(name, args);
agentLogger.onToolEnd(name, isError, result); stuckTaskDetector?.recordActivity(telemetry.trackingKey);
stuckTaskDetector?.recordActivity(trackingKey); },
}, onToolEnd: (name, isError, result) => {
// Skill selection from step-session executor options const telemetry = reusePrimarySession ? this.selectReusableTelemetry(localTelemetry) : localTelemetry;
...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}), telemetry.agentLogger.onToolEnd(name, isError, result);
actionGateContext: this.options.actionGateContext, stuckTaskDetector?.recordActivity(telemetry.trackingKey);
permanentAgentGating: this.options.permanentAgentGating, },
taskId: taskDetail.id, // Skill selection from step-session executor options
taskTitle: taskDetail.title, ...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}),
onFallbackModelUsed: createFallbackModelObserver({ actionGateContext: this.options.actionGateContext,
agent: "executor", permanentAgentGating: this.options.permanentAgentGating,
label: "workflow step agent",
store: this.store,
taskId: taskDetail.id, taskId: taskDetail.id,
taskTitle: taskDetail.title, taskTitle: taskDetail.title,
}), onFallbackModelUsed: createFallbackModelObserver({
taskEnv: this.options.taskEnv, agent: "executor",
}); label: "workflow step agent",
session = createResult.session; store: this.store,
taskId: taskDetail.id,
taskTitle: taskDetail.title,
}),
taskEnv: this.options.taskEnv,
});
session = createResult.session;
if (reusePrimarySession) {
this.reusablePrimarySession = session;
}
}
// Track session for termination and stuck-task detection. // Track session for termination and stuck-task detection.
// Pass the canonical task ID (e.g. "FN-1452") as the third argument so // Pass the canonical task ID (e.g. "FN-1452") as the third argument so
// that stuck-kill callbacks (beforeRequeue, onStuck) operate on the real // that stuck-kill callbacks (beforeRequeue, onStuck) operate on the real
// task rather than the compound step key ("FN-1452-step-1"). // task rather than the compound step key ("FN-1452-step-1").
const handle: SessionHandle = { const handle: SessionHandle = reusePrimarySession && this.reusablePrimaryHandle
dispose: () => session?.dispose(), ? this.reusablePrimaryHandle
abortBash: () => session?.abortBash(), : {
steer: async (message) => { dispose: () => session?.dispose(),
if (!session) return; abortBash: () => session?.abortBash(),
await session.steer(message); steer: async (message) => {
}, if (!session) return;
}; await session.steer(message);
},
};
if (reusePrimarySession && !this.reusablePrimaryHandle) {
this.reusablePrimaryHandle = handle;
}
if (reusePrimarySession) {
this.reusableStepTelemetry = localTelemetry;
}
this.registerActiveStepSession(stepIndex, handle, worktreePath); this.registerActiveStepSession(stepIndex, handle, worktreePath);
stuckTaskDetector?.trackTask(trackingKey, { dispose: () => session?.dispose() }, taskDetail.id); stuckTaskDetector?.trackTask(trackingKey, { dispose: () => session?.dispose() }, taskDetail.id);
@@ -1273,7 +1357,7 @@ Follow instructions precisely and avoid unrelated changes.`,
stepIndex, stepIndex,
success: true, success: true,
retries, retries,
tokenUsage: await this.extractTokenUsageFromSession(session), tokenUsage: await this.extractStepTokenUsage(session, reusePrimarySession),
}; };
this.options.onStepComplete?.(stepIndex, result); this.options.onStepComplete?.(stepIndex, result);
return result; return result;
@@ -1313,7 +1397,7 @@ Follow instructions precisely and avoid unrelated changes.`,
stepIndex, stepIndex,
success: true, success: true,
retries, retries,
tokenUsage: await this.extractTokenUsageFromSession(session), tokenUsage: await this.extractStepTokenUsage(session, reusePrimarySession),
}; };
this.options.onStepComplete?.(stepIndex, result); this.options.onStepComplete?.(stepIndex, result);
return result; return result;
@@ -1340,11 +1424,15 @@ Follow instructions precisely and avoid unrelated changes.`,
success: false, success: false,
error: errorMessage, error: errorMessage,
retries, retries,
tokenUsage: await this.extractTokenUsageFromSession(session), tokenUsage: await this.extractStepTokenUsage(session, reusePrimarySession),
}; };
this.options.onStepComplete?.(stepIndex, result); this.options.onStepComplete?.(stepIndex, result);
return result; return result;
} }
if (reusePrimarySession) {
await this.disposeReusablePrimarySession();
session = null;
}
} finally { } finally {
try { try {
await agentLogger.flush(); await agentLogger.flush();
@@ -1355,11 +1443,15 @@ Follow instructions precisely and avoid unrelated changes.`,
this.unregisterActiveStepSession(stepIndex, worktreePath); this.unregisterActiveStepSession(stepIndex, worktreePath);
stuckTaskDetector?.untrackTask(trackingKey); stuckTaskDetector?.untrackTask(trackingKey);
try { if (reusePrimarySession) {
session?.dispose(); this.reusableStepTelemetry = null;
} catch (err: unknown) { } else {
const msg = err instanceof Error ? err.message : String(err); try {
stepExecLog.warn(`Failed to dispose session for step ${stepIndex}: ${msg}`); session?.dispose();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
stepExecLog.warn(`Failed to dispose session for step ${stepIndex}: ${msg}`);
}
} }
} }
} }