fix(FN-7233): route workflow reviews through remediation nodes
This commit is contained in:
7
.changeset/fn-workflow-remediation-nodes.md
Normal file
7
.changeset/fn-workflow-remediation-nodes.md
Normal 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.
|
||||||
@@ -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?.config?.defaultOn).toBe(true);
|
||||||
expect(group?.column).toBe("in-progress");
|
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(ir.edges).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ from: "browser-verification", to: "code-review", 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: "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" }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -84,14 +84,15 @@ describe("builtin coding workflow ir", () => {
|
|||||||
gateMode: "gate",
|
gateMode: "gate",
|
||||||
});
|
});
|
||||||
// execute → browser-verification → code-review → completion-summary → review on the success path; the
|
// 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(BUILTIN_CODING_WORKFLOW_IR.edges).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ from: "execute", to: "browser-verification", condition: "success" }),
|
expect.objectContaining({ from: "execute", to: "browser-verification", condition: "success" }),
|
||||||
expect.objectContaining({ from: "browser-verification", to: "code-review", 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: "code-review", to: "completion-summary", condition: "success" }),
|
||||||
expect.objectContaining({ from: "completion-summary", to: "review", 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).
|
// The legacy optionalSteps declaration is gone (the group replaces it).
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ describe("built-in workflows", () => {
|
|||||||
expect(byId.get("plan-review")?.column).toBe("in-progress");
|
expect(byId.get("plan-review")?.column).toBe("in-progress");
|
||||||
expect(planReviewInnerConfig(ir)).toMatchObject({
|
expect(planReviewInnerConfig(ir)).toMatchObject({
|
||||||
toolMode: "readonly",
|
toolMode: "readonly",
|
||||||
gateMode: "advisory",
|
gateMode: "gate",
|
||||||
});
|
});
|
||||||
expect(byId.get("parse")?.column).toBe("in-progress");
|
expect(byId.get("parse")?.column).toBe("in-progress");
|
||||||
expect(byId.get("steps")?.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();
|
expect(() => parseWorkflowIr(design!.ir)).not.toThrow();
|
||||||
|
|
||||||
const authoredNodeIds = design!.ir.nodes.filter((node) => node.id !== "start" && node.id !== "end").map((node) => node.id);
|
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");
|
const execute = design!.ir.nodes.find((node) => node.id === "execute");
|
||||||
expect(execute?.config?.seam).toBe("execute");
|
expect(execute?.config?.seam).toBe("execute");
|
||||||
@@ -746,6 +758,9 @@ describe("built-in workflows", () => {
|
|||||||
"completion-summary",
|
"completion-summary",
|
||||||
"merge",
|
"merge",
|
||||||
"document",
|
"document",
|
||||||
|
"plan-replan",
|
||||||
|
"browser-verification-remediation",
|
||||||
|
"code-review-remediation",
|
||||||
]);
|
]);
|
||||||
expect(ce.ir.nodes.some((node) => node.config?.seam === "review")).toBe(false);
|
expect(ce.ir.nodes.some((node) => node.config?.seam === "review")).toBe(false);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { browserVerificationOptionalGroupNode } from "./builtin-browser-verifica
|
|||||||
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
|
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
|
||||||
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
|
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
|
||||||
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.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
|
* 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"),
|
config: builtinPromptConfig("planning", "Plan / specify"),
|
||||||
},
|
},
|
||||||
planReviewOptionalGroupNode("in-progress"),
|
planReviewOptionalGroupNode("in-progress"),
|
||||||
|
planReplanNode("triage"),
|
||||||
{
|
{
|
||||||
id: "execute",
|
id: "execute",
|
||||||
kind: "prompt",
|
kind: "prompt",
|
||||||
@@ -79,12 +85,14 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
|||||||
},
|
},
|
||||||
// Pre-merge optional browser-verification (optional-group, default OFF).
|
// Pre-merge optional browser-verification (optional-group, default OFF).
|
||||||
browserVerificationOptionalGroupNode("in-progress"),
|
browserVerificationOptionalGroupNode("in-progress"),
|
||||||
|
browserVerificationRemediationNode("in-progress"),
|
||||||
// FNXC:CodeReviewStep 2026-06-25-15:00:
|
// FNXC:CodeReviewStep 2026-06-25-15:00:
|
||||||
// Pre-merge Code Review as a DEFAULT-ON optional-group (blocking gate), on the success path
|
// Pre-merge Code Review as a DEFAULT-ON optional-group (blocking gate), on the success path
|
||||||
// between browser-verification and review (execute → browser-verification →
|
// between browser-verification and review (execute → browser-verification →
|
||||||
// code-review → review). Runs for every coding task by default (defaultOn:true) but is
|
// code-review → review). Runs for every coding task by default (defaultOn:true) but is
|
||||||
// toggleable off per task; disabled → byte-inert pass-through.
|
// toggleable off per task; disabled → byte-inert pass-through.
|
||||||
codeReviewOptionalGroupNode("in-progress"),
|
codeReviewOptionalGroupNode("in-progress"),
|
||||||
|
codeReviewRemediationNode("in-progress"),
|
||||||
completionSummaryNode("in-review"),
|
completionSummaryNode("in-review"),
|
||||||
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "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" } },
|
||||||
@@ -132,10 +140,10 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
|||||||
{ from: "merge-attempt", to: "merge-manual-hold", condition: "outcome:manual-required" },
|
{ from: "merge-attempt", to: "merge-manual-hold", condition: "outcome:manual-required" },
|
||||||
{ from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" },
|
{ from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" },
|
||||||
{ from: "planning", to: "end", condition: "failure" },
|
{ 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: "execute", to: "end", condition: "failure" },
|
||||||
{ from: "browser-verification", to: "end", condition: "failure" },
|
{ from: "browser-verification", to: "browser-verification-remediation", condition: "failure" },
|
||||||
{ from: "code-review", to: "end", condition: "failure" },
|
{ from: "code-review", to: "code-review-remediation", condition: "failure" },
|
||||||
{ from: "review", to: "end", condition: "failure" },
|
{ from: "review", to: "end", condition: "failure" },
|
||||||
{ from: "merge-attempt", to: "end", condition: "failure" },
|
{ from: "merge-attempt", to: "end", condition: "failure" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function planReviewOptionalGroupNode(
|
|||||||
description: PLAN_REVIEW_DESCRIPTION,
|
description: PLAN_REVIEW_DESCRIPTION,
|
||||||
prompt: PLAN_REVIEW_PROMPT,
|
prompt: PLAN_REVIEW_PROMPT,
|
||||||
toolMode: "readonly",
|
toolMode: "readonly",
|
||||||
gateMode: "advisory",
|
gateMode: "gate",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { browserVerificationOptionalGroupNode } from "./builtin-browser-verifica
|
|||||||
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
|
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
|
||||||
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
|
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
|
||||||
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.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
|
* 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).
|
// Planning seam: produces PROMPT.md (the declared step-source artifact).
|
||||||
{ id: "plan", kind: "prompt", column: "in-progress", config: builtinPromptConfig("planning", "Plan") },
|
{ id: "plan", kind: "prompt", column: "in-progress", config: builtinPromptConfig("planning", "Plan") },
|
||||||
planReviewOptionalGroupNode("in-progress"),
|
planReviewOptionalGroupNode("in-progress"),
|
||||||
|
planReplanNode("triage"),
|
||||||
// KTD-12: parse the planned PROMPT.md into the task step list. This node must
|
// KTD-12: parse the planned PROMPT.md into the task step list. This node must
|
||||||
// dominate the foreach (validator-enforced).
|
// 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
|
// disabled the group passes through inert. Both the normal foreach-success path
|
||||||
// and the rework-exhausted manual-release path flow through this node.
|
// and the rework-exhausted manual-release path flow through this node.
|
||||||
browserVerificationOptionalGroupNode("in-progress"),
|
browserVerificationOptionalGroupNode("in-progress"),
|
||||||
|
browserVerificationRemediationNode("in-progress"),
|
||||||
// FNXC:CodeReviewStep 2026-06-25-15:00:
|
// FNXC:CodeReviewStep 2026-06-25-15:00:
|
||||||
// Pre-merge Code Review as a DEFAULT-ON optional-group (blocking gate), on the post-foreach
|
// 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 →
|
// 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
|
// release paths flow through it. Runs for every task by default (defaultOn:true) but is
|
||||||
// toggleable off per task; disabled → byte-inert pass-through.
|
// toggleable off per task; disabled → byte-inert pass-through.
|
||||||
codeReviewOptionalGroupNode("in-progress"),
|
codeReviewOptionalGroupNode("in-progress"),
|
||||||
|
codeReviewRemediationNode("in-progress"),
|
||||||
completionSummaryNode("in-review"),
|
completionSummaryNode("in-review"),
|
||||||
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "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" } },
|
||||||
@@ -177,7 +185,7 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
|||||||
{ from: "plan", to: "plan-review", condition: "success" },
|
{ from: "plan", to: "plan-review", condition: "success" },
|
||||||
{ from: "plan", to: "end", condition: "failure" },
|
{ from: "plan", to: "end", condition: "failure" },
|
||||||
{ from: "plan-review", to: "parse", condition: "success" },
|
{ 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" },
|
{ from: "parse", to: "steps", condition: "success" },
|
||||||
// parse-steps no-steps defaults to success; route it explicitly to the foreach
|
// 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).
|
// (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: "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: "review", condition: "success" },
|
||||||
{ from: "browser-verification", to: "end", condition: "failure" },
|
{ from: "browser-verification", to: "browser-verification-remediation", condition: "failure" },
|
||||||
{ from: "code-review", to: "end", condition: "failure" },
|
{ from: "code-review", to: "code-review-remediation", condition: "failure" },
|
||||||
{ from: "steps", to: "end", condition: "failure" },
|
{ from: "steps", to: "end", condition: "failure" },
|
||||||
{ from: "review", to: "merge-gate", condition: "success" },
|
{ from: "review", to: "merge-gate", condition: "success" },
|
||||||
{ from: "review", to: "end", condition: "failure" },
|
{ from: "review", to: "end", condition: "failure" },
|
||||||
|
|||||||
52
packages/core/src/builtin-workflow-remediation-nodes.ts
Normal file
52
packages/core/src/builtin-workflow-remediation-nodes.ts
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ import { browserVerificationOptionalGroupNode } from "./builtin-browser-verifica
|
|||||||
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
|
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
|
||||||
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
|
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
|
||||||
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.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 { WorkflowDefinition } from "./workflow-definition-types.js";
|
||||||
import type { WorkflowIr, WorkflowIrColumn, WorkflowIrNode } from "./workflow-ir-types.js";
|
import type { WorkflowIr, WorkflowIrColumn, WorkflowIrNode } from "./workflow-ir-types.js";
|
||||||
import { parseWorkflowIr } from "./workflow-ir.js";
|
import { parseWorkflowIr } from "./workflow-ir.js";
|
||||||
@@ -121,19 +126,39 @@ function linear(spec: BuiltinSpec): WorkflowDefinition {
|
|||||||
? withEngineeringOptionalGroups(spec.nodes, spec.engineeringOptionalGroups)
|
? withEngineeringOptionalGroups(spec.nodes, spec.engineeringOptionalGroups)
|
||||||
: spec.nodes;
|
: spec.nodes;
|
||||||
const workflowNodes = withCompletionSummaryNode(specNodes);
|
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"] = [
|
const nodes: WorkflowIr["nodes"] = [
|
||||||
{ id: "start", kind: "start" },
|
{ id: "start", kind: "start" },
|
||||||
...workflowNodes,
|
...workflowNodes,
|
||||||
|
...remediationNodes,
|
||||||
{ id: "end", kind: "end" },
|
{ id: "end", kind: "end" },
|
||||||
];
|
];
|
||||||
const edges: WorkflowIr["edges"] = [];
|
const edges: WorkflowIr["edges"] = [];
|
||||||
for (let i = 0; i < nodes.length - 1; i += 1) {
|
const successPathNodes: WorkflowIr["nodes"] = [{ id: "start", kind: "start" }, ...workflowNodes, { id: "end", kind: "end" }];
|
||||||
edges.push({ from: nodes[i].id, to: nodes[i + 1].id, condition: "success" });
|
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).
|
// Seam nodes also fail straight to end (mirrors the legacy pipeline).
|
||||||
for (const node of workflowNodes) {
|
for (const node of workflowNodes) {
|
||||||
if ((typeof node.config?.seam === "string" || node.kind === "optional-group") && node.config?.summaryTarget !== "task") {
|
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 }> = {};
|
const layout: Record<string, { x: number; y: number }> = {};
|
||||||
@@ -223,10 +248,13 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
|
|||||||
start: { x: 60, y: 160 },
|
start: { x: 60, y: 160 },
|
||||||
plan: { x: 230, y: 160 },
|
plan: { x: 230, y: 160 },
|
||||||
"plan-review": { x: 400, y: 160 },
|
"plan-review": { x: 400, y: 160 },
|
||||||
|
"plan-replan": { x: 400, y: 320 },
|
||||||
parse: { x: 570, y: 160 },
|
parse: { x: 570, y: 160 },
|
||||||
steps: { x: 740, y: 160 },
|
steps: { x: 740, y: 160 },
|
||||||
"browser-verification": { x: 910, y: 160 },
|
"browser-verification": { x: 910, y: 160 },
|
||||||
|
"browser-verification-remediation": { x: 910, y: 320 },
|
||||||
"code-review": { x: 1080, y: 160 },
|
"code-review": { x: 1080, y: 160 },
|
||||||
|
"code-review-remediation": { x: 1080, y: 320 },
|
||||||
"completion-summary": { x: 1250, y: 160 },
|
"completion-summary": { x: 1250, y: 160 },
|
||||||
"merge-gate": { x: 1420, y: 160 },
|
"merge-gate": { x: 1420, y: 160 },
|
||||||
"branch-group-member-integration": { x: 1590, y: 80 },
|
"branch-group-member-integration": { x: 1590, y: 80 },
|
||||||
@@ -254,9 +282,12 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
|
|||||||
start: { x: 60, y: 160 },
|
start: { x: 60, y: 160 },
|
||||||
planning: { x: 230, y: 160 },
|
planning: { x: 230, y: 160 },
|
||||||
"plan-review": { x: 400, y: 160 },
|
"plan-review": { x: 400, y: 160 },
|
||||||
|
"plan-replan": { x: 400, y: 320 },
|
||||||
execute: { x: 570, y: 160 },
|
execute: { x: 570, y: 160 },
|
||||||
"browser-verification": { x: 740, y: 160 },
|
"browser-verification": { x: 740, y: 160 },
|
||||||
|
"browser-verification-remediation": { x: 740, y: 320 },
|
||||||
"code-review": { x: 910, y: 160 },
|
"code-review": { x: 910, y: 160 },
|
||||||
|
"code-review-remediation": { x: 910, y: 320 },
|
||||||
"completion-summary": { x: 1080, y: 160 },
|
"completion-summary": { x: 1080, y: 160 },
|
||||||
review: { x: 1250, y: 160 },
|
review: { x: 1250, y: 160 },
|
||||||
"merge-gate": { x: 1420, y: 160 },
|
"merge-gate": { x: 1420, y: 160 },
|
||||||
@@ -463,11 +494,14 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
|
|||||||
start: { x: 60, y: 160 },
|
start: { x: 60, y: 160 },
|
||||||
plan: { x: 230, y: 160 },
|
plan: { x: 230, y: 160 },
|
||||||
"plan-review": { x: 400, y: 160 },
|
"plan-review": { x: 400, y: 160 },
|
||||||
|
"plan-replan": { x: 400, y: 320 },
|
||||||
parse: { x: 570, y: 160 },
|
parse: { x: 570, y: 160 },
|
||||||
steps: { x: 740, y: 160 },
|
steps: { x: 740, y: 160 },
|
||||||
"rework-hold": { x: 740, y: 320 },
|
"rework-hold": { x: 740, y: 320 },
|
||||||
"browser-verification": { x: 910, y: 160 },
|
"browser-verification": { x: 910, y: 160 },
|
||||||
|
"browser-verification-remediation": { x: 910, y: 320 },
|
||||||
"code-review": { x: 1080, y: 160 },
|
"code-review": { x: 1080, y: 160 },
|
||||||
|
"code-review-remediation": { x: 1080, y: 320 },
|
||||||
"completion-summary": { x: 1250, y: 160 },
|
"completion-summary": { x: 1250, y: 160 },
|
||||||
review: { x: 1420, y: 160 },
|
review: { x: 1420, y: 160 },
|
||||||
"merge-gate": { x: 1590, y: 160 },
|
"merge-gate": { x: 1590, y: 160 },
|
||||||
|
|||||||
@@ -758,7 +758,11 @@ describe("WorkflowGraphExecutor optional-group", () => {
|
|||||||
expect(node?.config?.phase).toBeUndefined();
|
expect(node?.config?.phase).toBeUndefined();
|
||||||
expect(ir.edges).toEqual(expect.arrayContaining([
|
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: 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",
|
||||||
|
}),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ export interface WorkflowNodeResult {
|
|||||||
contextPatch?: Record<string, unknown>;
|
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 {
|
export interface WorkflowTaskProjection {
|
||||||
modifiedFiles?: string[];
|
modifiedFiles?: string[];
|
||||||
mergeDetails?: {
|
mergeDetails?: {
|
||||||
@@ -233,6 +243,10 @@ function isMergeRegionKind(kind: WorkflowIrNodeKind): boolean {
|
|||||||
return MERGE_REGION_KINDS.has(kind);
|
return MERGE_REGION_KINDS.has(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalStepFailureContextKey(stepId: string): string {
|
||||||
|
return `workflow:optional-step-failure:${stepId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTouchedFile(value: unknown): string | undefined {
|
function normalizeTouchedFile(value: unknown): string | undefined {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const trimmed = value.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
const trimmed = value.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
||||||
@@ -743,7 +757,7 @@ export class WorkflowGraphExecutor {
|
|||||||
|| (node.id === PLAN_REVIEW_GROUP_ID
|
|| (node.id === PLAN_REVIEW_GROUP_ID
|
||||||
? "Plan Review failed before execution. Re-run triage to revise PROMPT.md before implementation continues."
|
? "Plan Review failed before execution. Re-run triage to revise PROMPT.md before implementation continues."
|
||||||
: "(no feedback captured)");
|
: "(no feedback captured)");
|
||||||
const fixScheduled = await this.deps.requestPreMergeOptionalStepFix?.(task.id, {
|
const failureContext: PreMergeOptionalStepFailureContext = {
|
||||||
stepName: groupName,
|
stepName: groupName,
|
||||||
feedback,
|
feedback,
|
||||||
phase: stepPhase,
|
phase: stepPhase,
|
||||||
@@ -751,7 +765,27 @@ export class WorkflowGraphExecutor {
|
|||||||
verdict: verdict ?? (node.id === PLAN_REVIEW_GROUP_ID ? "REVISE" : undefined),
|
verdict: verdict ?? (node.id === PLAN_REVIEW_GROUP_ID ? "REVISE" : undefined),
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
maxRevisions: node.config?.maxRevisions,
|
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) {
|
if (fixScheduled) {
|
||||||
context[`node:${node.id}:fixScheduled`] = true;
|
context[`node:${node.id}:fixScheduled`] = true;
|
||||||
return { outcome: "success", value: "pre-merge-optional-step-fix-scheduled" };
|
return { outcome: "success", value: "pre-merge-optional-step-fix-scheduled" };
|
||||||
@@ -760,6 +794,34 @@ export class WorkflowGraphExecutor {
|
|||||||
return await traverseChildren(node, result);
|
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);
|
const result = await this.executeNodeWithRetries(node, task, settings, context, ir, this.deps.signal);
|
||||||
if (result.contextPatch) Object.assign(context, result.contextPatch);
|
if (result.contextPatch) Object.assign(context, result.contextPatch);
|
||||||
context[`node:${node.id}:outcome`] = result.outcome;
|
context[`node:${node.id}:outcome`] = result.outcome;
|
||||||
|
|||||||
Reference in New Issue
Block a user