fix(FN-7265): align per-step review workflow
This commit is contained in:
7
.changeset/per-step-review-session-switch.md
Normal file
7
.changeset/per-step-review-session-switch.md
Normal 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.
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user