fix(FN-7233): route workflow reviews through remediation nodes

This commit is contained in:
gsxdsm
2026-06-29 11:24:41 -07:00
parent b169072224
commit 24d78816a0
11 changed files with 209 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show workflow review failures as explicit replan and remediation nodes.
category: fix
dev: Built-in review gate failures now route to graph-owned remediation nodes before executor scheduling fallback.

View File

@@ -76,12 +76,12 @@ describe("built-in coding + stepwise workflows wire code-review as a default-ON
expect(group?.config?.defaultOn).toBe(true);
expect(group?.column).toBe("in-progress");
// Pre-merge wiring: ... → browser-verification → code-review → completion-summary; failure → end.
// Pre-merge wiring: ... → browser-verification → code-review → completion-summary; failure → remediation node.
expect(ir.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({ from: "browser-verification", to: "code-review", condition: "success" }),
expect.objectContaining({ from: "code-review", to: "completion-summary", condition: "success" }),
expect.objectContaining({ from: "code-review", to: "end", condition: "failure" }),
expect.objectContaining({ from: "code-review", to: "code-review-remediation", condition: "failure" }),
]),
);

View File

@@ -84,14 +84,15 @@ describe("builtin coding workflow ir", () => {
gateMode: "gate",
});
// execute → browser-verification → code-review → completion-summary → review on the success path; the
// pre-merge code-review optional-group sits next to browser-verification. failure → end.
// pre-merge code-review optional-group sits next to browser-verification. failures route to remediation nodes.
expect(BUILTIN_CODING_WORKFLOW_IR.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({ from: "execute", to: "browser-verification", condition: "success" }),
expect.objectContaining({ from: "browser-verification", to: "code-review", condition: "success" }),
expect.objectContaining({ from: "code-review", to: "completion-summary", condition: "success" }),
expect.objectContaining({ from: "completion-summary", to: "review", condition: "success" }),
expect.objectContaining({ from: "browser-verification", to: "end", condition: "failure" }),
expect.objectContaining({ from: "browser-verification", to: "browser-verification-remediation", condition: "failure" }),
expect.objectContaining({ from: "code-review", to: "code-review-remediation", condition: "failure" }),
]),
);
// The legacy optionalSteps declaration is gone (the group replaces it).

View File

@@ -431,7 +431,7 @@ describe("built-in workflows", () => {
expect(byId.get("plan-review")?.column).toBe("in-progress");
expect(planReviewInnerConfig(ir)).toMatchObject({
toolMode: "readonly",
gateMode: "advisory",
gateMode: "gate",
});
expect(byId.get("parse")?.column).toBe("in-progress");
expect(byId.get("steps")?.column).toBe("in-progress");
@@ -515,7 +515,19 @@ describe("built-in workflows", () => {
expect(() => parseWorkflowIr(design!.ir)).not.toThrow();
const authoredNodeIds = design!.ir.nodes.filter((node) => node.id !== "start" && node.id !== "end").map((node) => node.id);
expect(authoredNodeIds).toEqual(["plan-review", "execute", "browser-verification", "code-review", "design-review", "review", "completion-summary", "merge"]);
expect(authoredNodeIds).toEqual([
"plan-review",
"execute",
"browser-verification",
"code-review",
"design-review",
"review",
"completion-summary",
"merge",
"plan-replan",
"browser-verification-remediation",
"code-review-remediation",
]);
const execute = design!.ir.nodes.find((node) => node.id === "execute");
expect(execute?.config?.seam).toBe("execute");
@@ -746,6 +758,9 @@ describe("built-in workflows", () => {
"completion-summary",
"merge",
"document",
"plan-replan",
"browser-verification-remediation",
"code-review-remediation",
]);
expect(ce.ir.nodes.some((node) => node.config?.seam === "review")).toBe(false);

View File

@@ -6,6 +6,11 @@ import { browserVerificationOptionalGroupNode } from "./builtin-browser-verifica
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.js";
import {
browserVerificationRemediationNode,
codeReviewRemediationNode,
planReplanNode,
} from "./builtin-workflow-remediation-nodes.js";
/**
* The built-in default workflow as a v2 IR. Its six columns have ids that are
@@ -71,6 +76,7 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
config: builtinPromptConfig("planning", "Plan / specify"),
},
planReviewOptionalGroupNode("in-progress"),
planReplanNode("triage"),
{
id: "execute",
kind: "prompt",
@@ -79,12 +85,14 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
},
// Pre-merge optional browser-verification (optional-group, default OFF).
browserVerificationOptionalGroupNode("in-progress"),
browserVerificationRemediationNode("in-progress"),
// FNXC:CodeReviewStep 2026-06-25-15:00:
// Pre-merge Code Review as a DEFAULT-ON optional-group (blocking gate), on the success path
// between browser-verification and review (execute → browser-verification →
// code-review → review). Runs for every coding task by default (defaultOn:true) but is
// toggleable off per task; disabled → byte-inert pass-through.
codeReviewOptionalGroupNode("in-progress"),
codeReviewRemediationNode("in-progress"),
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" } },
@@ -132,10 +140,10 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "merge-attempt", to: "merge-manual-hold", condition: "outcome:manual-required" },
{ from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" },
{ from: "planning", to: "end", condition: "failure" },
{ from: "plan-review", to: "end", condition: "failure" },
{ from: "plan-review", to: "plan-replan", condition: "failure" },
{ from: "execute", to: "end", condition: "failure" },
{ from: "browser-verification", to: "end", condition: "failure" },
{ from: "code-review", to: "end", condition: "failure" },
{ from: "browser-verification", to: "browser-verification-remediation", condition: "failure" },
{ from: "code-review", to: "code-review-remediation", condition: "failure" },
{ from: "review", to: "end", condition: "failure" },
{ from: "merge-attempt", to: "end", condition: "failure" },
],

View File

@@ -60,7 +60,7 @@ export function planReviewOptionalGroupNode(
description: PLAN_REVIEW_DESCRIPTION,
prompt: PLAN_REVIEW_PROMPT,
toolMode: "readonly",
gateMode: "advisory",
gateMode: "gate",
},
},
],

View File

@@ -6,6 +6,11 @@ import { browserVerificationOptionalGroupNode } from "./builtin-browser-verifica
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.js";
import {
browserVerificationRemediationNode,
codeReviewRemediationNode,
planReplanNode,
} from "./builtin-workflow-remediation-nodes.js";
/**
* The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step
@@ -76,6 +81,7 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
// Planning seam: produces PROMPT.md (the declared step-source artifact).
{ id: "plan", kind: "prompt", column: "in-progress", config: builtinPromptConfig("planning", "Plan") },
planReviewOptionalGroupNode("in-progress"),
planReplanNode("triage"),
// KTD-12: parse the planned PROMPT.md into the task step list. This node must
// dominate the foreach (validator-enforced).
{
@@ -143,6 +149,7 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
// disabled the group passes through inert. Both the normal foreach-success path
// and the rework-exhausted manual-release path flow through this node.
browserVerificationOptionalGroupNode("in-progress"),
browserVerificationRemediationNode("in-progress"),
// FNXC:CodeReviewStep 2026-06-25-15:00:
// Pre-merge Code Review as a DEFAULT-ON optional-group (blocking gate), on the post-foreach
// success path between browser-verification and review (steps → browser-verification →
@@ -151,6 +158,7 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
// release paths flow through it. Runs for every task by default (defaultOn:true) but is
// toggleable off per task; disabled → byte-inert pass-through.
codeReviewOptionalGroupNode("in-progress"),
codeReviewRemediationNode("in-progress"),
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" } },
@@ -177,7 +185,7 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "plan", to: "plan-review", condition: "success" },
{ from: "plan", to: "end", condition: "failure" },
{ from: "plan-review", to: "parse", condition: "success" },
{ from: "plan-review", to: "end", condition: "failure" },
{ from: "plan-review", to: "plan-replan", condition: "failure" },
{ from: "parse", to: "steps", condition: "success" },
// parse-steps no-steps defaults to success; route it explicitly to the foreach
// (zero steps → foreach no-ops through its success edge, KTD-8/R8).
@@ -197,8 +205,8 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "browser-verification", to: "code-review", condition: "success" },
{ from: "code-review", to: "completion-summary", condition: "success" },
{ from: "completion-summary", to: "review", condition: "success" },
{ from: "browser-verification", to: "end", condition: "failure" },
{ from: "code-review", to: "end", condition: "failure" },
{ from: "browser-verification", to: "browser-verification-remediation", condition: "failure" },
{ from: "code-review", to: "code-review-remediation", condition: "failure" },
{ from: "steps", to: "end", condition: "failure" },
{ from: "review", to: "merge-gate", condition: "success" },
{ from: "review", to: "end", condition: "failure" },

View File

@@ -0,0 +1,52 @@
import type { WorkflowIrNode } from "./workflow-ir-types.js";
/*
FNXC:WorkflowRemediation 2026-06-29-16:18:
Review-gate remediation must be visible in the workflow graph instead of living only in executor fallback branches. These prompt nodes are graph-owned lifecycle policy: Plan Review failure routes to an automatic replan handoff, while Code Review and Browser Verification failures route to implementation remediation handoffs. The engine still enforces the handoff mechanics and budgets.
*/
export const PLAN_REPLAN_NODE_ID = "plan-replan";
export const BROWSER_VERIFICATION_REMEDIATION_NODE_ID = "browser-verification-remediation";
export const CODE_REVIEW_REMEDIATION_NODE_ID = "code-review-remediation";
export function planReplanNode(column = "triage"): WorkflowIrNode {
return {
id: PLAN_REPLAN_NODE_ID,
kind: "prompt",
column,
config: {
name: "Plan Replan",
workflowAction: "plan-replan",
forWorkflowStepId: "plan-review",
toolMode: "readonly",
},
};
}
export function browserVerificationRemediationNode(column = "in-progress"): WorkflowIrNode {
return {
id: BROWSER_VERIFICATION_REMEDIATION_NODE_ID,
kind: "prompt",
column,
config: {
name: "Browser Verification Remediation",
workflowAction: "pre-merge-remediation",
forWorkflowStepId: "browser-verification",
toolMode: "coding",
},
};
}
export function codeReviewRemediationNode(column = "in-progress"): WorkflowIrNode {
return {
id: CODE_REVIEW_REMEDIATION_NODE_ID,
kind: "prompt",
column,
config: {
name: "Code Review Remediation",
workflowAction: "pre-merge-remediation",
forWorkflowStepId: "code-review",
toolMode: "coding",
},
};
}

View File

@@ -10,6 +10,11 @@ import { browserVerificationOptionalGroupNode } from "./builtin-browser-verifica
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.js";
import {
browserVerificationRemediationNode,
codeReviewRemediationNode,
planReplanNode,
} from "./builtin-workflow-remediation-nodes.js";
import type { WorkflowDefinition } from "./workflow-definition-types.js";
import type { WorkflowIr, WorkflowIrColumn, WorkflowIrNode } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
@@ -121,19 +126,39 @@ function linear(spec: BuiltinSpec): WorkflowDefinition {
? withEngineeringOptionalGroups(spec.nodes, spec.engineeringOptionalGroups)
: spec.nodes;
const workflowNodes = withCompletionSummaryNode(specNodes);
const hasPlanReview = workflowNodes.some((node) => node.id === "plan-review");
const hasBrowserVerification = workflowNodes.some((node) => node.id === "browser-verification");
const hasCodeReview = workflowNodes.some((node) => node.id === "code-review");
const remediationNodes = hasPlanReview || hasBrowserVerification || hasCodeReview
? [
...(hasPlanReview ? [planReplanNode("triage")] : []),
...(hasBrowserVerification ? [browserVerificationRemediationNode("in-progress")] : []),
...(hasCodeReview ? [codeReviewRemediationNode("in-progress")] : []),
]
: [];
const nodes: WorkflowIr["nodes"] = [
{ id: "start", kind: "start" },
...workflowNodes,
...remediationNodes,
{ id: "end", kind: "end" },
];
const edges: WorkflowIr["edges"] = [];
for (let i = 0; i < nodes.length - 1; i += 1) {
edges.push({ from: nodes[i].id, to: nodes[i + 1].id, condition: "success" });
const successPathNodes: WorkflowIr["nodes"] = [{ id: "start", kind: "start" }, ...workflowNodes, { id: "end", kind: "end" }];
for (let i = 0; i < successPathNodes.length - 1; i += 1) {
edges.push({ from: successPathNodes[i].id, to: successPathNodes[i + 1].id, condition: "success" });
}
// Seam nodes also fail straight to end (mirrors the legacy pipeline).
for (const node of workflowNodes) {
if ((typeof node.config?.seam === "string" || node.kind === "optional-group") && node.config?.summaryTarget !== "task") {
edges.push({ from: node.id, to: "end", condition: "failure" });
const failureTarget =
node.id === "plan-review"
? "plan-replan"
: node.id === "browser-verification"
? "browser-verification-remediation"
: node.id === "code-review"
? "code-review-remediation"
: "end";
edges.push({ from: node.id, to: failureTarget, condition: "failure" });
}
}
const layout: Record<string, { x: number; y: number }> = {};
@@ -223,10 +248,13 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
start: { x: 60, y: 160 },
plan: { x: 230, y: 160 },
"plan-review": { x: 400, y: 160 },
"plan-replan": { x: 400, y: 320 },
parse: { x: 570, y: 160 },
steps: { x: 740, y: 160 },
"browser-verification": { x: 910, y: 160 },
"browser-verification-remediation": { x: 910, y: 320 },
"code-review": { x: 1080, y: 160 },
"code-review-remediation": { x: 1080, y: 320 },
"completion-summary": { x: 1250, y: 160 },
"merge-gate": { x: 1420, y: 160 },
"branch-group-member-integration": { x: 1590, y: 80 },
@@ -254,9 +282,12 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
start: { x: 60, y: 160 },
planning: { x: 230, y: 160 },
"plan-review": { x: 400, y: 160 },
"plan-replan": { x: 400, y: 320 },
execute: { x: 570, y: 160 },
"browser-verification": { x: 740, y: 160 },
"browser-verification-remediation": { x: 740, y: 320 },
"code-review": { x: 910, y: 160 },
"code-review-remediation": { x: 910, y: 320 },
"completion-summary": { x: 1080, y: 160 },
review: { x: 1250, y: 160 },
"merge-gate": { x: 1420, y: 160 },
@@ -463,11 +494,14 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
start: { x: 60, y: 160 },
plan: { x: 230, y: 160 },
"plan-review": { x: 400, y: 160 },
"plan-replan": { x: 400, y: 320 },
parse: { x: 570, y: 160 },
steps: { x: 740, y: 160 },
"rework-hold": { x: 740, y: 320 },
"browser-verification": { x: 910, y: 160 },
"browser-verification-remediation": { x: 910, y: 320 },
"code-review": { x: 1080, y: 160 },
"code-review-remediation": { x: 1080, y: 320 },
"completion-summary": { x: 1250, y: 160 },
review: { x: 1420, y: 160 },
"merge-gate": { x: 1590, y: 160 },

View File

@@ -758,7 +758,11 @@ describe("WorkflowGraphExecutor optional-group", () => {
expect(node?.config?.phase).toBeUndefined();
expect(ir.edges).toEqual(expect.arrayContaining([
expect.objectContaining({ from: groupId, to: groupId === "browser-verification" ? "code-review" : "completion-summary", condition: "success" }),
expect.objectContaining({ from: groupId, to: "end", condition: "failure" }),
expect.objectContaining({
from: groupId,
to: groupId === "browser-verification" ? "browser-verification-remediation" : "code-review-remediation",
condition: "failure",
}),
]));
}
}

View File

@@ -55,6 +55,16 @@ export interface WorkflowNodeResult {
contextPatch?: Record<string, unknown>;
}
interface PreMergeOptionalStepFailureContext {
stepName: string;
feedback: string;
phase: WorkflowStepResult["phase"];
status: WorkflowStepResult["status"];
verdict?: string;
nodeId?: string;
maxRevisions?: unknown;
}
export interface WorkflowTaskProjection {
modifiedFiles?: string[];
mergeDetails?: {
@@ -233,6 +243,10 @@ function isMergeRegionKind(kind: WorkflowIrNodeKind): boolean {
return MERGE_REGION_KINDS.has(kind);
}
function optionalStepFailureContextKey(stepId: string): string {
return `workflow:optional-step-failure:${stepId}`;
}
function normalizeTouchedFile(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim().replaceAll("\\", "/").replace(/^\.\//, "");
@@ -743,7 +757,7 @@ export class WorkflowGraphExecutor {
|| (node.id === PLAN_REVIEW_GROUP_ID
? "Plan Review failed before execution. Re-run triage to revise PROMPT.md before implementation continues."
: "(no feedback captured)");
const fixScheduled = await this.deps.requestPreMergeOptionalStepFix?.(task.id, {
const failureContext: PreMergeOptionalStepFailureContext = {
stepName: groupName,
feedback,
phase: stepPhase,
@@ -751,7 +765,27 @@ export class WorkflowGraphExecutor {
verdict: verdict ?? (node.id === PLAN_REVIEW_GROUP_ID ? "REVISE" : undefined),
nodeId: node.id,
maxRevisions: node.config?.maxRevisions,
};
context[optionalStepFailureContextKey(node.id)] = failureContext;
/*
* FNXC:WorkflowRemediation 2026-06-29-16:22:
* New built-in and custom workflows can author an explicit failure edge
* from an optional review gate to a remediation/replan node. When such a
* node exists, traversal owns the handoff so the workflow definition shows
* the lifecycle policy. Older stored specs without that node keep the
* compatibility scheduler here.
*/
const remediationRouteSource: WorkflowNodeResult = { outcome: "failure", value: result.value };
const explicitWorkflowRemediationRoute = (outgoingMap.get(node.id) ?? []).some((edge) => {
if (!this.shouldTraverseEdge(edge, remediationRouteSource)) return false;
const target = nodeMap.get(edge.to);
const action = target?.config?.workflowAction;
return action === "plan-replan" || action === "pre-merge-remediation";
});
if (explicitWorkflowRemediationRoute) {
return await traverseChildren(node, remediationRouteSource);
}
const fixScheduled = await this.deps.requestPreMergeOptionalStepFix?.(task.id, failureContext);
if (fixScheduled) {
context[`node:${node.id}:fixScheduled`] = true;
return { outcome: "success", value: "pre-merge-optional-step-fix-scheduled" };
@@ -760,6 +794,34 @@ export class WorkflowGraphExecutor {
return await traverseChildren(node, result);
}
const workflowAction = node.config?.workflowAction;
if (workflowAction === "plan-replan" || workflowAction === "pre-merge-remediation") {
const stepId = typeof node.config?.forWorkflowStepId === "string"
? node.config.forWorkflowStepId
: undefined;
const failureContext = stepId
? context[optionalStepFailureContextKey(stepId)] as PreMergeOptionalStepFailureContext | undefined
: undefined;
if (!failureContext) {
return { outcome: "failure", value: "missing-remediation-context" };
}
const scheduled = await this.deps.requestPreMergeOptionalStepFix?.(task.id, failureContext);
if (!scheduled) {
return { outcome: "failure", value: "remediation-not-scheduled" };
}
/*
* FNXC:WorkflowRemediation 2026-06-29-16:27:
* A remediation/replan node schedules asynchronous task work rather than
* fixing the branch inside this graph call. Stop traversal after a successful
* handoff so the rerun starts from fresh task state instead of immediately
* re-reviewing unchanged PROMPT.md or unchanged code.
*/
if (failureContext.nodeId) context[`node:${failureContext.nodeId}:fixScheduled`] = true;
context[`node:${node.id}:outcome`] = "success";
context[`node:${node.id}:value`] = "remediation-scheduled";
return { outcome: "success", value: "remediation-scheduled" };
}
const result = await this.executeNodeWithRetries(node, task, settings, context, ir, this.deps.signal);
if (result.contextPatch) Object.assign(context, result.contextPatch);
context[`node:${node.id}:outcome`] = result.outcome;