fix(FN-7233): move workflow lifecycle policy into nodes

This commit is contained in:
gsxdsm
2026-06-29 12:23:20 -07:00
parent 24d78816a0
commit e09d45037f
27 changed files with 1100 additions and 90 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Harden workflow lifecycle recovery, post-merge gates, warnings, and notifications.
category: fix
dev: Adds post-merge gate blocking, lifecycle warning analysis, recovery-route audit metadata, and workflow transition notification classification.

View File

@@ -113,6 +113,37 @@ describe("built-in workflows", () => {
}
});
it("engineering built-in review failures loop through graph-owned remediation", () => {
const expectedLoops = [
{ gate: "plan-review", remediation: "plan-replan" },
{ gate: "browser-verification", remediation: "browser-verification-remediation" },
{ gate: "code-review", remediation: "code-review-remediation" },
];
for (const workflow of BUILTIN_WORKFLOWS) {
const nodeIds = new Set(workflow.ir.nodes.map((node) => node.id));
if (!expectedLoops.some(({ gate }) => nodeIds.has(gate))) continue;
for (const { gate, remediation } of expectedLoops) {
if (!nodeIds.has(gate)) continue;
expect(workflow.ir.edges, `${workflow.id}:${gate}:failure`).toEqual(
expect.arrayContaining([
expect.objectContaining({ from: gate, to: remediation, condition: "failure" }),
]),
);
expect(workflow.ir.edges, `${workflow.id}:${remediation}:return`).toEqual(
expect.arrayContaining([
expect.objectContaining({ from: remediation, to: gate, condition: "success", kind: "rework" }),
]),
);
expect(workflow.ir.nodes.find((node) => node.id === gate)?.config, `${workflow.id}:${gate}:reworkRegion`).toMatchObject({
reworkRegion: true,
maxReworkCycles: 3,
});
}
}
});
it("all built-in workflows generate a task completion summary as a graph node", () => {
for (const workflow of BUILTIN_WORKFLOWS) {
if (workflow.kind === "fragment") continue;
@@ -124,6 +155,38 @@ describe("built-in workflows", () => {
}
});
it("merge-capable built-ins expose a default-off post-merge verification node after merge proof", () => {
for (const workflow of BUILTIN_WORKFLOWS) {
if (workflow.kind === "fragment") continue;
const mergeNode = workflow.ir.nodes.find((node) => node.id === "merge-attempt" || node.id === "merge");
if (!mergeNode) continue;
const postMerge = workflow.ir.nodes.find((node) => node.id === "post-merge-verification");
expect(postMerge?.kind, workflow.id).toBe("optional-group");
expect(postMerge?.config, workflow.id).toMatchObject({
phase: "post-merge",
defaultOn: false,
});
const template = postMerge?.config?.template as { nodes?: Array<{ config?: Record<string, unknown> }> } | undefined;
expect(template?.nodes?.[0]?.config?.gateMode, workflow.id).toBe("gate");
expect(workflow.ir.edges, `${workflow.id}:post-merge-entry`).toEqual(
expect.arrayContaining([
expect.objectContaining({ from: mergeNode.id, to: "post-merge-verification", condition: "success" }),
]),
);
if (mergeNode.id === "merge-attempt") {
expect(workflow.ir.edges, `${workflow.id}:no-direct-merge-end`).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ from: "merge-attempt", to: "end", condition: "success" }),
]),
);
}
expect(workflow.ir.nodes.map((node) => node.id).indexOf("post-merge-verification"), workflow.id).toBeGreaterThan(
workflow.ir.nodes.map((node) => node.id).indexOf(mergeNode.id),
);
}
});
it("built-in workflow layouts cover every authored node", () => {
for (const workflow of BUILTIN_WORKFLOWS) {
const missingLayoutNodes = workflow.ir.nodes
@@ -524,6 +587,7 @@ describe("built-in workflows", () => {
"review",
"completion-summary",
"merge",
"post-merge-verification",
"plan-replan",
"browser-verification-remediation",
"code-review-remediation",
@@ -757,6 +821,7 @@ describe("built-in workflows", () => {
"resolve-feedback",
"completion-summary",
"merge",
"post-merge-verification",
"document",
"plan-replan",
"browser-verification-remediation",

View File

@@ -52,6 +52,43 @@ describe("TaskStore workflow definitions (U1)", () => {
expect(userList[0].layout.lint).toEqual({ x: 120, y: 0 });
});
it("returns non-blocking lifecycle warnings for custom full workflows", async () => {
const created = await store.createWorkflowDefinition({
name: "Unsafe terminal",
ir: makeIr({
nodes: [
{ id: "start", kind: "start" },
{ id: "execute", kind: "prompt", config: { seam: "execute" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "execute" },
{ from: "execute", to: "end", condition: "success" },
],
}),
});
expect(created.lifecycleWarnings?.map((warning) => warning.code)).toEqual(expect.arrayContaining([
"missing-completion-summary",
"missing-merge-region",
]));
const reloaded = await store.getWorkflowDefinition(created.id);
expect(reloaded?.lifecycleWarnings?.map((warning) => warning.code)).toEqual(expect.arrayContaining([
"missing-completion-summary",
"missing-merge-region",
]));
});
it("does not add lifecycle warnings to fragment definitions", async () => {
const created = await store.createWorkflowDefinition({
name: "Fragment prompt",
kind: "fragment",
ir: makeIr(),
});
expect(created.lifecycleWarnings).toEqual([]);
});
it("rejects a workflow whose IR is missing start/end", async () => {
const bad = makeIr({ nodes: [{ id: "only", kind: "prompt" }], edges: [] });
await expect(

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { analyzeWorkflowLifecycle, type WorkflowIr } from "../index.js";
function baseIr(nodes: WorkflowIr["nodes"], edges: WorkflowIr["edges"]): WorkflowIr {
return {
version: "v2",
name: "lifecycle-validation-test",
columns: [
{ id: "todo", name: "Todo", traits: [] },
{ id: "in-progress", name: "In progress", traits: [] },
{ id: "in-review", name: "In review", traits: [] },
{ id: "done", name: "Done", traits: [] },
],
nodes,
edges,
};
}
describe("analyzeWorkflowLifecycle", () => {
it("warns when a full custom workflow omits summary and merge lifecycle primitives", () => {
const warnings = analyzeWorkflowLifecycle(baseIr(
[
{ id: "start", kind: "start", column: "todo" },
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute" } },
{ id: "end", kind: "end", column: "done" },
],
[
{ from: "start", to: "execute" },
{ from: "execute", to: "end", condition: "success" },
],
));
expect(warnings.map((warning) => warning.code)).toEqual(expect.arrayContaining([
"missing-completion-summary",
"missing-merge-region",
]));
});
it("warns about terminal success paths that bypass the merge region", () => {
const warnings = analyzeWorkflowLifecycle(baseIr(
[
{ id: "start", kind: "start", column: "todo" },
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute" } },
{ id: "completion-summary", kind: "prompt", column: "in-review", config: { summaryTarget: "task" } },
{ id: "merge-gate", kind: "merge-gate", column: "in-review" },
{ id: "end", kind: "end", column: "done" },
],
[
{ from: "start", to: "execute" },
{ from: "execute", to: "completion-summary", condition: "success" },
{ from: "completion-summary", to: "merge-gate", condition: "success" },
{ from: "merge-gate", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "success" },
],
));
expect(warnings).toEqual(expect.arrayContaining([
expect.objectContaining({ code: "unsafe-terminal-before-merge", nodeId: "execute" }),
]));
});
it("warns when Plan Review is placed after execution and blocking gates lack failure routes", () => {
const warnings = analyzeWorkflowLifecycle(baseIr(
[
{ id: "start", kind: "start", column: "todo" },
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute" } },
{
id: "plan-review",
kind: "optional-group",
column: "in-progress",
config: {
name: "Plan Review",
defaultOn: true,
template: {
nodes: [{ id: "plan-review-step", kind: "prompt", config: { gateMode: "gate" } }],
edges: [],
},
},
},
{ id: "completion-summary", kind: "prompt", column: "in-review", config: { summaryTarget: "task" } },
{ id: "merge-gate", kind: "merge-gate", column: "in-review" },
{ id: "end", kind: "end", column: "done" },
],
[
{ from: "start", to: "execute" },
{ from: "execute", to: "plan-review", condition: "success" },
{ from: "plan-review", to: "completion-summary", condition: "success" },
{ from: "completion-summary", to: "merge-gate", condition: "success" },
{ from: "merge-gate", to: "end", condition: "success" },
],
));
expect(warnings).toEqual(expect.arrayContaining([
expect.objectContaining({ code: "optional-group-after-execution", nodeId: "plan-review" }),
expect.objectContaining({ code: "review-gate-without-failure-route", nodeId: "plan-review" }),
]));
});
it("does not warn for fragment templates", () => {
const warnings = analyzeWorkflowLifecycle(baseIr(
[
{ id: "start", kind: "start", column: "todo" },
{ id: "fragment-node", kind: "prompt", column: "todo", config: { prompt: "Reusable" } },
{ id: "end", kind: "end", column: "todo" },
],
[
{ from: "start", to: "fragment-node" },
{ from: "fragment-node", to: "end" },
],
), { kind: "fragment" });
expect(warnings).toEqual([]);
});
});

View File

@@ -95,6 +95,8 @@ export function browserVerificationOptionalGroupNode(
config: {
name: BROWSER_VERIFICATION_NAME,
defaultOn: options.defaultOn ?? false,
reworkRegion: true,
maxReworkCycles: 3,
template: {
nodes: [
{

View File

@@ -91,6 +91,8 @@ export function codeReviewOptionalGroupNode(
// Default-ON: runs for every coding task by default, but operators can toggle it
// off per task (remove `code-review` from enabledWorkflowSteps).
defaultOn: options.defaultOn ?? true,
reworkRegion: true,
maxReworkCycles: 3,
template: {
nodes: [
{

View File

@@ -5,6 +5,7 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
import { browserVerificationOptionalGroupNode } from "./builtin-browser-verification-group.js";
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
import { postMergeVerificationOptionalGroupNode } from "./builtin-post-merge-group.js";
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.js";
import {
browserVerificationRemediationNode,
@@ -112,6 +113,7 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
config: { capability: "task-merge", reworkRegion: true, maxReworkCycles: 3 },
},
{ id: "recovery-router", kind: "recovery-router", column: "in-review", config: { surfaces: ["merge", "retry"] } },
postMergeVerificationOptionalGroupNode("done"),
{ id: "end", kind: "end", column: "done" },
],
edges: [
@@ -135,15 +137,19 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "branch-group-member-integration", to: "merge-manual-hold", condition: "outcome:manual-required" },
{ from: "branch-group-promotion", to: "merge-attempt", condition: "success" },
{ from: "branch-group-promotion", to: "merge-manual-hold", condition: "outcome:manual-required" },
{ from: "merge-attempt", to: "end", condition: "success" },
{ from: "merge-attempt", to: "post-merge-verification", condition: "success" },
{ from: "post-merge-verification", to: "end", condition: "success" },
{ from: "merge-attempt", to: "merge-retry", condition: "outcome:transient-failure" },
{ 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: "plan-replan", condition: "failure" },
{ from: "plan-replan", to: "plan-review", condition: "success", kind: "rework" },
{ from: "execute", to: "end", condition: "failure" },
{ from: "browser-verification", to: "browser-verification-remediation", condition: "failure" },
{ from: "browser-verification-remediation", to: "browser-verification", condition: "success", kind: "rework" },
{ from: "code-review", to: "code-review-remediation", condition: "failure" },
{ from: "code-review-remediation", to: "code-review", condition: "success", kind: "rework" },
{ from: "review", to: "end", condition: "failure" },
{ from: "merge-attempt", to: "end", condition: "failure" },
],

View File

@@ -2,6 +2,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
import { postMergeVerificationOptionalGroupNode } from "./builtin-post-merge-group.js";
/**
* FNXC:WorkflowMarketing 2026-06-20-00:00:
@@ -91,6 +92,7 @@ const RAW_BUILTIN_MARKETING_WORKFLOW_IR: WorkflowIr = {
config: { capability: "task-merge", reworkRegion: true, maxReworkCycles: 3 },
},
{ id: "recovery-router", kind: "recovery-router", column: "editorial-review", config: { surfaces: ["merge", "retry"] } },
postMergeVerificationOptionalGroupNode("published"),
{ id: "end", kind: "end", column: "published" },
],
edges: [
@@ -107,7 +109,8 @@ const RAW_BUILTIN_MARKETING_WORKFLOW_IR: WorkflowIr = {
{ from: "branch-group-member-integration", to: "merge-manual-hold", condition: "outcome:manual-required" },
{ from: "branch-group-promotion", to: "merge-attempt", condition: "success" },
{ from: "branch-group-promotion", to: "merge-manual-hold", condition: "outcome:manual-required" },
{ from: "merge-attempt", to: "end", condition: "success" },
{ from: "merge-attempt", to: "post-merge-verification", condition: "success" },
{ from: "post-merge-verification", to: "end", condition: "success" },
{ from: "merge-attempt", to: "merge-retry", condition: "outcome:transient-failure" },
{ from: "merge-attempt", to: "merge-manual-hold", condition: "outcome:manual-required" },
{ from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" },

View File

@@ -50,6 +50,12 @@ export function planReviewOptionalGroupNode(
config: {
name: PLAN_REVIEW_NAME,
defaultOn: options.defaultOn ?? true,
/*
* FNXC:WorkflowRemediation 2026-06-29-12:14:
* Plan Review REVISE must loop through graph-owned replan and then return to Plan Review before execution. Mark the optional group as the bounded rework-region head so the top-level remediation edge is legal and cannot spin forever.
*/
reworkRegion: true,
maxReworkCycles: 3,
template: {
nodes: [
{

View File

@@ -9,16 +9,34 @@ for post-merge workflow steps (U7 spike). Mirrors `codeReviewOptionalGroupNode`
1. runs it only AFTER a successful merge (when wired off the merge region and the
`graphNativePostMerge` flag is on), and
2. records its WorkflowStepResult with `phase: "post-merge"` + emits `[post-merge]`
logs (failures are NON-BLOCKING — the merged task still completes).
logs. Advisory post-merge failures are non-blocking; explicit gate-mode
verification failures block final graph success after merge proof.
There are NO built-in post-merge steps today, so this factory is intentionally generic
and is NOT wired into `builtin:coding` (which stays byte-identical, the parity oracle).
It is the reusable builder migrated/custom workflows (and the new test) use to author a
post-merge step. The group node id is the STABLE per-task enable key (`enabledWorkflowSteps`),
and the inner template node carries a DISTINCT id (`${id}-step`) — a template node id may
not collide with the group/top-level node id (optional-group validation).
FNXC:WorkflowPostMerge 2026-06-29-12:22:
Full task built-ins need an explicit default-off post-merge verification node so
post-merge audit/verification policy can live in workflow definitions instead of
merger-only fallback code. The group node id is the STABLE per-task enable key
(`enabledWorkflowSteps`), and the inner template node carries a DISTINCT id
(`${id}-step`) — a template node id may not collide with the group/top-level node id
(optional-group validation).
*/
export const POST_MERGE_VERIFICATION_GROUP_ID = "post-merge-verification";
const POST_MERGE_VERIFICATION_PROMPT = `You are a post-merge verification reviewer. Verify that the task's merged result is safe after integration.
## Review focus
1. Confirm the task has merge proof or already-on-main proof before treating the workflow as complete.
2. Check the final merged diff and task summary for obvious mismatches, missing verification evidence, or integration-only regressions.
3. If configured test/build commands are available in the task context, inspect their latest result or explain why no post-merge command was applicable.
## Output Requirements
- APPROVE: post-merge verification is acceptable.
- APPROVE_WITH_NOTES: completion may proceed with non-blocking notes.
- REVISE: completion should be blocked; include the concrete post-merge issue and the needed follow-up.
- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose):
{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}`;
export interface PostMergeOptionalGroupSpec {
/** Stable per-task enable key + group node id. */
id: string;
@@ -71,3 +89,15 @@ export function postMergeOptionalGroupNode(spec: PostMergeOptionalGroupSpec): Wo
},
};
}
export function postMergeVerificationOptionalGroupNode(column = "done"): WorkflowIrNode {
return postMergeOptionalGroupNode({
id: POST_MERGE_VERIFICATION_GROUP_ID,
name: "Post-merge verification",
column,
prompt: POST_MERGE_VERIFICATION_PROMPT,
description: "Verify the integrated result after merge proof before final completion",
gateMode: "gate",
defaultOn: false,
});
}

View File

@@ -5,6 +5,7 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
import { browserVerificationOptionalGroupNode } from "./builtin-browser-verification-group.js";
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
import { postMergeVerificationOptionalGroupNode } from "./builtin-post-merge-group.js";
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.js";
import {
browserVerificationRemediationNode,
@@ -178,6 +179,7 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
config: { capability: "task-merge", reworkRegion: true, maxReworkCycles: 3 },
},
{ id: "recovery-router", kind: "recovery-router", column: "in-review", config: { surfaces: ["merge", "retry"] } },
postMergeVerificationOptionalGroupNode("done"),
{ id: "end", kind: "end", column: "done" },
],
edges: [
@@ -186,6 +188,7 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "plan", to: "end", condition: "failure" },
{ from: "plan-review", to: "parse", condition: "success" },
{ from: "plan-review", to: "plan-replan", condition: "failure" },
{ from: "plan-replan", to: "plan-review", condition: "success", kind: "rework" },
{ 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).
@@ -206,7 +209,9 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "code-review", to: "completion-summary", condition: "success" },
{ from: "completion-summary", to: "review", condition: "success" },
{ from: "browser-verification", to: "browser-verification-remediation", condition: "failure" },
{ from: "browser-verification-remediation", to: "browser-verification", condition: "success", kind: "rework" },
{ from: "code-review", to: "code-review-remediation", condition: "failure" },
{ from: "code-review-remediation", to: "code-review", condition: "success", kind: "rework" },
{ from: "steps", to: "end", condition: "failure" },
{ from: "review", to: "merge-gate", condition: "success" },
{ from: "review", to: "end", condition: "failure" },
@@ -218,7 +223,8 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "branch-group-member-integration", to: "merge-manual-hold", condition: "outcome:manual-required" },
{ from: "branch-group-promotion", to: "merge-attempt", condition: "success" },
{ from: "branch-group-promotion", to: "merge-manual-hold", condition: "outcome:manual-required" },
{ from: "merge-attempt", to: "end", condition: "success" },
{ from: "merge-attempt", to: "post-merge-verification", condition: "success" },
{ from: "post-merge-verification", to: "end", condition: "success" },
{ from: "merge-attempt", to: "merge-retry", condition: "outcome:transient-failure" },
{ from: "merge-attempt", to: "merge-manual-hold", condition: "outcome:manual-required" },
{ from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" },

View File

@@ -2,6 +2,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.js";
import { planReplanNode } from "./builtin-workflow-remediation-nodes.js";
function cloneWorkflowIr(ir: WorkflowIr): WorkflowIr {
return JSON.parse(JSON.stringify(ir)) as WorkflowIr;
@@ -39,6 +40,9 @@ const RAW_BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR: WorkflowIr = (() =>
if (!ir.nodes.some((node) => node.id === "plan-review")) {
ir.nodes.splice(planIndex + 1, 0, planReviewOptionalGroupNode("in-progress"));
}
if (!ir.nodes.some((node) => node.id === "plan-replan")) {
ir.nodes.splice(planIndex + 2, 0, planReplanNode("triage"));
}
template.nodes = template.nodes.filter((node) => node.id !== "step-review");
template.edges = [
@@ -57,8 +61,12 @@ const RAW_BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR: WorkflowIr = (() =>
if (!ir.edges.some((edge) => edge.from === "plan-review" && edge.to === "parse")) {
ir.edges.push({ from: "plan-review", to: "parse", condition: "success" });
}
if (!ir.edges.some((edge) => edge.from === "plan-review" && edge.to === "end" && edge.condition === "failure")) {
ir.edges.push({ from: "plan-review", to: "end", condition: "failure" });
ir.edges = ir.edges.filter((edge) => !(edge.from === "plan-review" && edge.to === "end" && edge.condition === "failure"));
if (!ir.edges.some((edge) => edge.from === "plan-review" && edge.to === "plan-replan" && edge.condition === "failure")) {
ir.edges.push({ from: "plan-review", to: "plan-replan", condition: "failure" });
}
if (!ir.edges.some((edge) => edge.from === "plan-replan" && edge.to === "plan-review" && edge.condition === "success")) {
ir.edges.push({ from: "plan-replan", to: "plan-review", condition: "success", kind: "rework" });
}
if (!ir.edges.some((edge) => edge.from === "code-review" && edge.to === "completion-summary" && edge.condition === "success")) {
ir.edges.push({ from: "code-review", to: "completion-summary", condition: "success" });

View File

@@ -9,6 +9,7 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
import { browserVerificationOptionalGroupNode } from "./builtin-browser-verification-group.js";
import { codeReviewOptionalGroupNode } from "./builtin-code-review-group.js";
import { completionSummaryNode } from "./builtin-completion-summary-node.js";
import { postMergeVerificationOptionalGroupNode } from "./builtin-post-merge-group.js";
import { planReviewOptionalGroupNode } from "./builtin-plan-review-group.js";
import {
browserVerificationRemediationNode,
@@ -56,6 +57,8 @@ function ceCodeReviewOptionalGroupNode(column: string): WorkflowIrNode {
*/
name: "Code Review",
defaultOn: true,
reworkRegion: true,
maxReworkCycles: 3,
template: {
nodes: [
{
@@ -125,7 +128,7 @@ function linear(spec: BuiltinSpec): WorkflowDefinition {
const specNodes = spec.engineeringOptionalGroups
? withEngineeringOptionalGroups(spec.nodes, spec.engineeringOptionalGroups)
: spec.nodes;
const workflowNodes = withCompletionSummaryNode(specNodes);
const workflowNodes = withPostMergeVerificationNode(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");
@@ -161,6 +164,24 @@ function linear(spec: BuiltinSpec): WorkflowDefinition {
edges.push({ from: node.id, to: failureTarget, condition: "failure" });
}
}
/*
* FNXC:WorkflowRemediation 2026-06-29-12:12:
* Review failures are workflow policy, not terminal executor failures. Linear built-ins that opt into Plan Review, Browser Verification, or Code Review must route remediation success back to the owning gate so retry/restart keeps executing the graph instead of parking at an orphan remediation node or falling through to done.
*/
if (hasPlanReview) {
edges.push({ from: "plan-replan", to: "plan-review", condition: "success", kind: "rework" });
}
if (hasBrowserVerification) {
edges.push({
from: "browser-verification-remediation",
to: "browser-verification",
condition: "success",
kind: "rework",
});
}
if (hasCodeReview) {
edges.push({ from: "code-review-remediation", to: "code-review", condition: "success", kind: "rework" });
}
const layout: Record<string, { x: number; y: number }> = {};
nodes.forEach((node, i) => {
layout[node.id] = { x: 60 + i * 170, y: 160 };
@@ -231,6 +252,17 @@ function withCompletionSummaryNode(nodes: BuiltinSpec["nodes"]): BuiltinSpec["no
];
}
function withPostMergeVerificationNode(nodes: BuiltinSpec["nodes"]): BuiltinSpec["nodes"] {
if (nodes.some((node) => node.id === "post-merge-verification")) return nodes;
const mergeIndex = nodes.findIndex((node) => node.config?.seam === "merge" || node.id === "merge");
if (mergeIndex < 0) return nodes;
return [
...nodes.slice(0, mergeIndex + 1),
postMergeVerificationOptionalGroupNode("done"),
...nodes.slice(mergeIndex + 1),
];
}
/**
* Read-only built-in workflow templates. Selectable like any workflow; they
* cannot be edited or deleted. In compile mode (flag off) only the custom
@@ -263,7 +295,8 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
"merge-retry": { x: 2100, y: 80 },
"recovery-router": { x: 2100, y: 240 },
"merge-manual-hold": { x: 1590, y: 240 },
end: { x: 2270, y: 160 },
"post-merge-verification": { x: 2270, y: 160 },
end: { x: 2440, y: 160 },
},
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,
@@ -297,7 +330,8 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
"merge-retry": { x: 2100, y: 80 },
"recovery-router": { x: 2100, y: 240 },
"merge-manual-hold": { x: 1590, y: 240 },
end: { x: 2270, y: 160 },
"post-merge-verification": { x: 2270, y: 160 },
end: { x: 2440, y: 160 },
},
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,
@@ -355,7 +389,8 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
"merge-retry": { x: 1590, y: 80 },
"recovery-router": { x: 1590, y: 240 },
"merge-manual-hold": { x: 1080, y: 240 },
end: { x: 1760, y: 160 },
"post-merge-verification": { x: 1760, y: 160 },
end: { x: 1930, y: 160 },
},
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,
@@ -511,7 +546,8 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
"merge-retry": { x: 2270, y: 80 },
"recovery-router": { x: 2270, y: 240 },
"merge-manual-hold": { x: 1760, y: 240 },
end: { x: 2440, y: 160 },
"post-merge-verification": { x: 2440, y: 160 },
end: { x: 2610, y: 160 },
},
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,

View File

@@ -100,6 +100,12 @@ export {
WORKFLOW_SETTING_TYPES,
SETTING_RENDER_WIDGETS,
} from "./workflow-ir.js";
export {
analyzeWorkflowLifecycle,
type AnalyzeWorkflowLifecycleOptions,
type WorkflowLifecycleWarning,
type WorkflowLifecycleWarningCode,
} from "./workflow-lifecycle-validation.js";
export type {
WorkflowIr,
WorkflowIrV1,
@@ -1726,7 +1732,11 @@ export type {
} from "./research-types.js";
export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG } from "./experimental-features.js";
export { postMergeOptionalGroupNode } from "./builtin-post-merge-group.js";
export {
POST_MERGE_VERIFICATION_GROUP_ID,
postMergeOptionalGroupNode,
postMergeVerificationOptionalGroupNode,
} from "./builtin-post-merge-group.js";
export type { PostMergeOptionalGroupSpec } from "./builtin-post-merge-group.js";
export {
WORKFLOW_COMPARABLE_AUDIT_MUTATIONS,

View File

@@ -115,6 +115,7 @@ import type {
WorkflowNodeLayout,
} from "./workflow-definition-types.js";
import { compileWorkflowToSteps, isInterpreterDeferredWorkflowCompileError } from "./workflow-compiler.js";
import { analyzeWorkflowLifecycle } from "./workflow-lifecycle-validation.js";
import { resolveDefaultOnOptionalGroupIds } from "./workflow-optional-steps.js";
import {
BUILTIN_WORKFLOWS,
@@ -14738,14 +14739,17 @@ ${stepsSection}`;
createdAt: string;
updatedAt: string;
}): WorkflowDefinition {
const kind = row.kind === "fragment" ? "fragment" : "workflow";
const ir = parseWorkflowIr(row.ir);
return {
id: row.id,
name: row.name,
description: row.description,
// Legacy rows (pre-migration-109) have no kind column; default to "workflow".
kind: row.kind === "fragment" ? "fragment" : "workflow",
ir: parseWorkflowIr(row.ir),
kind,
ir,
layout: this.parseWorkflowLayout(row.layout),
lifecycleWarnings: analyzeWorkflowLifecycle(ir, { kind }),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -14794,15 +14798,23 @@ ${stepsSection}`;
const layout = input.layout ?? {};
const now = new Date().toISOString();
const id = this.nextWorkflowDefinitionId();
const kind = input.kind === "fragment" ? "fragment" : "workflow";
const definition: WorkflowDefinition = {
id,
name,
description: input.description ?? "",
// KTD-1: fragments are pure-v1 IRs and pass through downgradeIrToV1IfPure
// unchanged; default to "workflow" when the caller omits the kind.
kind: input.kind === "fragment" ? "fragment" : "workflow",
kind,
ir,
layout,
/*
FNXC:WorkflowLifecycleValidation 2026-06-29-11:47:
Persisted custom workflow definitions should carry computed lifecycle
warnings back to authoring/API surfaces without blocking advanced graphs.
Hard safety still lives in parser/store/merge proof guards.
*/
lifecycleWarnings: analyzeWorkflowLifecycle(ir, { kind }),
createdAt: now,
updatedAt: now,
};
@@ -15030,6 +15042,7 @@ ${stepsSection}`;
description: updates.description !== undefined ? updates.description : existing.description,
ir,
layout: updates.layout !== undefined ? updates.layout : existing.layout,
lifecycleWarnings: analyzeWorkflowLifecycle(ir, { kind: existing.kind }),
updatedAt: new Date().toISOString(),
};

View File

@@ -1,4 +1,5 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
import type { WorkflowLifecycleWarning } from "./workflow-lifecycle-validation.js";
/** Editor layout position for a single workflow IR node. Persisted separately
* from the IR because the v1 IR contract deliberately excludes node geometry. */
@@ -27,6 +28,8 @@ export interface WorkflowDefinition {
ir: WorkflowIr;
/** Editor node positions keyed by IR node id. May be empty (auto-layout). */
layout: Record<string, WorkflowNodeLayout>;
/** Non-blocking lifecycle guidance for custom workflow authors. */
lifecycleWarnings?: WorkflowLifecycleWarning[];
/** ISO-8601 timestamp of creation. */
createdAt: string;
/** ISO-8601 timestamp of last update. */

View File

@@ -0,0 +1,152 @@
import type { WorkflowDefinitionKind } from "./workflow-definition-types.js";
import type { WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "./workflow-ir-types.js";
import { MERGE_REGION_NODE_KINDS } from "./workflow-compiler.js";
export type WorkflowLifecycleWarningCode =
| "missing-completion-summary"
| "missing-merge-region"
| "unsafe-terminal-before-merge"
| "optional-group-after-execution"
| "review-gate-without-failure-route";
export interface WorkflowLifecycleWarning {
code: WorkflowLifecycleWarningCode;
nodeId?: string;
message: string;
}
export interface AnalyzeWorkflowLifecycleOptions {
kind?: WorkflowDefinitionKind;
}
function isSummaryNode(node: WorkflowIrNode): boolean {
return node.config?.summaryTarget === "task" || node.id === "completion-summary";
}
function isMergeNode(node: WorkflowIrNode): boolean {
return MERGE_REGION_NODE_KINDS.has(node.kind) || node.config?.seam === "merge";
}
function isExecutionNode(node: WorkflowIrNode): boolean {
return node.config?.seam === "execute" || node.kind === "foreach" || node.kind === "parse-steps";
}
function buildOutgoing(edges: readonly WorkflowIrEdge[]): Map<string, WorkflowIrEdge[]> {
const outgoing = new Map<string, WorkflowIrEdge[]>();
for (const edge of edges) {
const list = outgoing.get(edge.from) ?? [];
list.push(edge);
outgoing.set(edge.from, list);
}
return outgoing;
}
function reachableBefore(
startId: string,
targetId: string,
outgoing: Map<string, WorkflowIrEdge[]>,
): Set<string> {
const reachable = new Set<string>();
const queue = [startId];
while (queue.length > 0) {
const id = queue.shift()!;
if (id === targetId || reachable.has(id)) continue;
reachable.add(id);
for (const edge of outgoing.get(id) ?? []) {
if (edge.kind === "rework" || reachable.has(edge.to)) continue;
queue.push(edge.to);
}
}
return reachable;
}
/*
FNXC:WorkflowLifecycleValidation 2026-06-29-11:47:
Custom workflow authors need lifecycle-specific guidance without turning every
advanced graph into a hard parse failure. Emit warnings for missing summary,
missing merge proof regions, unsafe terminal paths, misplaced optional gates, and
review gates with no failure route; engine/store merge-proof guards remain the
hard invariant that prevents unsafe done.
*/
export function analyzeWorkflowLifecycle(
ir: WorkflowIr,
options: AnalyzeWorkflowLifecycleOptions = {},
): WorkflowLifecycleWarning[] {
if (options.kind === "fragment") return [];
const warnings: WorkflowLifecycleWarning[] = [];
const nodes = ir.nodes;
const outgoing = buildOutgoing(ir.edges);
const hasSummary = nodes.some(isSummaryNode);
const mergeNodeIds = new Set(nodes.filter(isMergeNode).map((node) => node.id));
const endNode = nodes.find((node) => node.kind === "end");
const startId = nodes.find((node) => node.kind === "start")?.id ?? "start";
if (!hasSummary) {
warnings.push({
code: "missing-completion-summary",
message: "Full task workflows should include a completion-summary node before review, merge, or done.",
});
}
if (mergeNodeIds.size === 0) {
warnings.push({
code: "missing-merge-region",
message: "Full task workflows should include a merge region so done is backed by merge proof.",
});
}
if (endNode && mergeNodeIds.size > 0) {
const beforeEnd = reachableBefore(startId, endNode.id, outgoing);
const mergeReachableBeforeEnd = [...mergeNodeIds].some((id) => beforeEnd.has(id));
for (const edge of ir.edges) {
if (edge.to !== endNode.id) continue;
if (edge.condition !== undefined && edge.condition !== "success") continue;
if (mergeNodeIds.has(edge.from)) continue;
if (!mergeReachableBeforeEnd || beforeEnd.has(edge.from)) {
warnings.push({
code: "unsafe-terminal-before-merge",
nodeId: edge.from,
message: `Node '${edge.from}' can terminate the workflow before a merge-proof region.`,
});
}
}
}
const executionNodeIds = new Set(nodes.filter(isExecutionNode).map((node) => node.id));
for (const node of nodes) {
if (node.kind !== "optional-group") continue;
const groupName = typeof node.config?.name === "string" ? node.config.name : node.id;
const beforeGroup = reachableBefore(
startId,
node.id,
outgoing,
);
const isPlanReview = node.id === "plan-review" || /plan review/i.test(groupName);
if (isPlanReview && [...executionNodeIds].some((id) => beforeGroup.has(id))) {
warnings.push({
code: "optional-group-after-execution",
nodeId: node.id,
message: "Plan Review should be ordered before parse/execution so rejected plans cannot start work.",
});
}
const template = node.config?.template;
const templateNodes = template && typeof template === "object" && Array.isArray((template as { nodes?: unknown }).nodes)
? (template as { nodes: WorkflowIrNode[] }).nodes
: [];
const hasGateStep = templateNodes.some((inner) => inner.config?.gateMode === "gate");
const isPostMergeGate = node.config?.phase === "post-merge";
const hasFailureRoute = (outgoing.get(node.id) ?? []).some((edge) =>
edge.condition === "failure" || String(edge.condition ?? "").startsWith("outcome:"),
);
if (hasGateStep && !hasFailureRoute && !isPostMergeGate) {
warnings.push({
code: "review-gate-without-failure-route",
nodeId: node.id,
message: `Review gate '${node.id}' should declare a failure/remediation route so blocking findings cannot fall through silently.`,
});
}
}
return warnings;
}

View File

@@ -1510,6 +1510,55 @@ Built-in workflow prompts need visible override state and a reset action without
flex-wrap: wrap;
}
/*
FNXC:WorkflowLifecycleValidation 2026-06-29-12:18:
Workflow authors need store-produced lifecycle warnings visible in the editor before runtime. Keep them inline and non-blocking: parser/store/merge guards still enforce hard safety, while this banner exposes missing summary/merge/review-loop guidance during authoring.
*/
.wf-lifecycle-warnings {
display: grid;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid color-mix(in srgb, var(--ws-warning) 45%, var(--border));
background: color-mix(in srgb, var(--ws-warning) 8%, var(--bg-secondary));
color: var(--text);
}
.wf-lifecycle-warnings-title {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.78rem;
font-weight: 650;
color: var(--ws-warning);
}
.wf-lifecycle-warnings ul {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.wf-lifecycle-warnings li {
display: flex;
align-items: baseline;
gap: var(--space-xs);
flex-wrap: wrap;
font-size: 0.78rem;
line-height: 1.35;
}
.wf-lifecycle-warning-code,
.wf-lifecycle-warning-node {
padding: 1px 5px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 0.7rem;
font-family: var(--font-mono, monospace);
}
.wf-workflow-name,
.wf-workflow-name--readonly {
font-size: 0.95rem;

View File

@@ -927,6 +927,7 @@ function InnerEditor({
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
const lifecycleWarnings = activeWorkflow?.lifecycleWarnings ?? [];
// Live mirror of the active workflow id, readable inside async callbacks that
// captured an earlier value before an await (e.g. the AI-design round-trip).
@@ -2806,6 +2807,23 @@ function InnerEditor({
</button>
)}
</div>
{lifecycleWarnings.length > 0 && (
<div className="wf-lifecycle-warnings" role="status" data-testid="wf-lifecycle-warnings">
<div className="wf-lifecycle-warnings-title">
<Shield size={14} aria-hidden />
<span>{t("workflows.lifecycleWarningsTitle", "Lifecycle warnings")}</span>
</div>
<ul>
{lifecycleWarnings.map((warning, index) => (
<li key={`${warning.code}:${warning.nodeId ?? ""}:${index}`}>
<span className="wf-lifecycle-warning-code">{warning.code}</span>
{warning.nodeId && <span className="wf-lifecycle-warning-node">{warning.nodeId}</span>}
<span>{warning.message}</span>
</li>
))}
</ul>
</div>
)}
{simpleLayoutEnabled && (
<div className="wf-mobile-shell" data-testid="wf-mobile-shell">
<nav className="wf-mobile-tabs" aria-label={t("workflows.mobileEditorNav", "Workflow editor sections")}>

View File

@@ -404,16 +404,18 @@ describe("workflow-flow-mapping", () => {
it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => {
const { edges } = edgeRenderableAssertion(builtinDef());
const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure");
// FNXC:WorkflowOptionalGroup 2026-06-21-15:30: the coding built-in's pre-merge `workflow-step` seam was migrated to a `browser-verification` optional-group (U6), which now carries the failure->end edge in its place.
// FNXC:CodeReviewStep 2026-06-25-00:00: the default-on `code-review` optional-group is also on the pre-merge success path with its own failure->end edge (see builtin-code-review-group.test.ts), so it is an expected failure->end source too. This corrected a stale assertion that predated the code-review group's addition.
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([
"browser-verification",
"code-review",
"execute",
"merge-attempt",
"planning",
"review",
]);
expect(edges).toEqual(expect.arrayContaining([
expect.objectContaining({ source: "browser-verification", target: "browser-verification-remediation" }),
expect.objectContaining({ source: "code-review", target: "code-review-remediation" }),
expect.objectContaining({ source: "browser-verification-remediation", target: "browser-verification" }),
expect.objectContaining({ source: "code-review-remediation", target: "code-review" }),
]));
expect(new Set(failuresToEnd.map((edge) => edge.id)).size).toBe(failuresToEnd.length);
expect(failuresToEnd.every((edge) => edge.interactionWidth === WF_EDGE_INTERACTION_WIDTH)).toBe(true);
});
@@ -454,6 +456,33 @@ describe("WorkflowNodeEditor", () => {
expect(screen.getAllByRole("button", { name: "QA" })[0]).toHaveClass("active");
});
it("renders workflow lifecycle warnings returned by the store", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([
{
...v2Def(),
lifecycleWarnings: [
{
code: "missing-merge-region",
message: "Full task workflows should include a merge region so done is backed by merge proof.",
},
{
code: "optional-group-after-execution",
nodeId: "plan-review",
message: "Plan Review should be ordered before parse/execution.",
},
],
},
]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const banner = await screen.findByTestId("wf-lifecycle-warnings");
expect(banner).toHaveTextContent("Lifecycle warnings");
expect(banner).toHaveTextContent("missing-merge-region");
expect(banner).toHaveTextContent("optional-group-after-execution");
expect(banner).toHaveTextContent("plan-review");
});
it("lets desktop users collapse and restore the workflow sidebar", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);

View File

@@ -96,7 +96,14 @@ describe("recoverPausedAbortFailures", () => {
// not the dead releaseExecutorWorktreeOwnership option (PR #1687 review).
expect(clearBinding).toHaveBeenCalledWith("FN-7000");
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "task:auto-recover-paused-abort-park", target: "FN-7000" }),
expect.objectContaining({
mutationType: "task:auto-recover-paused-abort-park",
target: "FN-7000",
metadata: expect.objectContaining({
recoveryRoute: "node-requeue",
recoveryReason: "pause-abort-active-work",
}),
}),
);
});
@@ -146,7 +153,12 @@ describe("recoverPausedAbortFailures", () => {
expect.objectContaining({
mutationType: "task:auto-recover-paused-abort-park",
target: "FN-7002",
metadata: { fromColumn: "in-review", preservedInReview: true },
metadata: {
fromColumn: "in-review",
preservedInReview: true,
recoveryRoute: "work-item-resume",
recoveryReason: "pause-abort-review-progress",
},
}),
);
});

View File

@@ -466,6 +466,77 @@ describe("WorkflowGraphExecutor optional-group", () => {
]));
});
it("uses an explicit graph replan node for Plan Review REVISE and does not execute before replan completes", async () => {
const requestFix = vi.fn(async () => true);
const calls: string[] = [];
const ir: WorkflowIr = {
version: "v2",
name: "plan-review-explicit-replan-route",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "plan-review",
kind: "optional-group",
config: {
name: "Plan Review",
defaultOn: true,
reworkRegion: true,
maxReworkCycles: 3,
template: {
nodes: [{ id: "plan-review-step", kind: "prompt", config: { prompt: "review plan" } }],
edges: [],
},
},
},
{
id: "plan-replan",
kind: "prompt",
config: {
name: "Plan Replan",
workflowAction: "plan-replan",
forWorkflowStepId: "plan-review",
},
},
{ id: "execute", kind: "prompt", config: { prompt: "execute" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "plan-review" },
{ from: "plan-review", to: "execute", condition: "success" },
{ from: "plan-review", to: "plan-replan", condition: "failure" },
{ from: "plan-replan", to: "plan-review", condition: "success", kind: "rework" },
{ from: "execute", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return node.id === "plan-review-step"
? { outcome: "failure", value: "REVISE", contextPatch: { output: "missing acceptance criteria" } }
: { outcome: "success" };
},
},
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(taskWith(["plan-review"]), settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toContain("plan-review");
expect(result.visitedNodeIds).toContain("plan-replan");
expect(result.visitedNodeIds).not.toContain("execute");
expect(calls).not.toContain("execute");
expect(requestFix).toHaveBeenCalledWith("FN-OG", expect.objectContaining({
nodeId: "plan-review",
feedback: "missing acceptance criteria",
verdict: "REVISE",
}));
expect(result.context["node:plan-review:fixScheduled"]).toBe(true);
expect(result.context["node:plan-replan:value"]).toBe("remediation-scheduled");
});
it("does not synthesize a Plan Review replan from advisory malformed output", async () => {
const requestFix = vi.fn(async () => true);
const calls: string[] = [];

View File

@@ -22,7 +22,7 @@ const POST_MERGE_STEP_ID = `${POST_MERGE_ID}-step`;
/** Minimal IR: start → execute → merge-attempt (collapses to the merge seam) with a
* post-merge optional-group hanging off merge-attempt success → end. */
function postMergeIr(): WorkflowIr {
function postMergeIr(options: { gateMode?: "advisory" | "gate" } = {}): WorkflowIr {
return {
version: "v2",
name: "post-merge-test",
@@ -40,6 +40,7 @@ function postMergeIr(): WorkflowIr {
name: "Post Merge Docs",
column: "done",
prompt: "post-merge doc check",
gateMode: options.gateMode,
defaultOn: false,
}),
{ id: "end", kind: "end", column: "done" },
@@ -80,6 +81,13 @@ function handler(innerValue: string): WorkflowNodeHandler {
: { outcome: "success" };
}
function failureHandler(innerValue: string): WorkflowNodeHandler {
return async (node) =>
node.id === POST_MERGE_STEP_ID
? { outcome: "failure", value: innerValue }
: { outcome: "success" };
}
describe("WorkflowGraphExecutor graph-native post-merge steps", () => {
it("flag ON: runs the post-merge optional group after merge and records phase:'post-merge'", async () => {
const recorder = makeRecorder();
@@ -135,6 +143,34 @@ describe("WorkflowGraphExecutor graph-native post-merge steps", () => {
expect(recorder.results[0].status).toBe("advisory_failure");
});
it("flag ON: a gate-mode post-merge failure records failure and blocks final graph success", async () => {
const recorder = makeRecorder();
const executor = new WorkflowGraphExecutor({
handlers: { prompt: failureHandler("REVISE") },
recordWorkflowStepResult: recorder.record,
});
const result = await executor.run(
taskWith([POST_MERGE_ID]),
{ experimentalFeatures: { graphNativePostMerge: true } },
postMergeIr({ gateMode: "gate" }),
);
/*
* FNXC:WorkflowPostMerge 2026-06-29-11:47:
* Explicit gate-mode post-merge verification must block final workflow success;
* advisory post-merge checks remain non-blocking for legacy parity.
*/
expect(result.outcome).toBe("failure");
expect(recorder.results).toHaveLength(1);
expect(recorder.results[0]).toMatchObject({
workflowStepId: POST_MERGE_ID,
phase: "post-merge",
status: "failed",
verdict: "REVISE",
});
});
it("flag explicitly OFF (opt-out): the post-merge node is NOT run via the graph and records nothing", async () => {
const recorder = makeRecorder();
const executor = new WorkflowGraphExecutor({

View File

@@ -358,3 +358,172 @@ describe("NotificationService manual dispatch dedupe", () => {
await service.stop();
});
});
describe("NotificationService workflow transition notifications", () => {
afterEach(() => {
vi.clearAllMocks();
});
async function setup(settings: Partial<Settings> = {}) {
const store = createStore(settings);
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const provider: NotificationProvider = {
getProviderId: () => "mock",
isEventSupported: () => true,
sendNotification,
};
const service = new NotificationService(store as any);
service.registerProvider(provider);
await service.start();
return { store, service, sendNotification };
}
it("emits a deduped planning-awaiting-input notification for workflow await-input task updates", async () => {
const { store, service, sendNotification } = await setup();
const awaitingInput = task({
id: "FN-7201",
status: "awaiting-user-input",
paused: true,
pausedReason: "workflow-input:planning@1782751605619: Which files should this plan cover?",
log: [{ timestamp: new Date().toISOString(), action: "Workflow paused for user input: Which files should this plan cover?" }],
});
store.emit("task:updated", awaitingInput);
store.emit("task:updated", awaitingInput);
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalledTimes(1);
});
expect(sendNotification).toHaveBeenCalledWith(
"planning-awaiting-input",
expect.objectContaining({
taskId: "FN-7201",
metadata: expect.objectContaining({
notificationDedupeKey: "workflow-transition:FN-7201:awaiting-user-input",
notificationKind: "workflow-awaiting-user-input",
}),
}),
);
await service.stop();
});
it("suppresses generic awaiting-user-input updates that are not workflow waits", async () => {
const { store, service, sendNotification } = await setup();
store.emit("task:updated", task({
id: "FN-7202",
status: "awaiting-user-input",
paused: true,
pausedReason: "waiting-for-review",
log: [{ timestamp: new Date().toISOString(), action: "Paused for an unrelated reason" }],
}));
await Promise.resolve();
expect(sendNotification).not.toHaveBeenCalled();
await service.stop();
});
it("emits and dedupes workflow CLI approval notifications", async () => {
const { store, service, sendNotification } = await setup();
const awaitingCli = task({
id: "FN-7205",
status: "awaiting-cli-approval",
paused: true,
pausedReason: "workflow-cli-approval:code-review: pnpm test",
});
store.emit("task:updated", awaitingCli);
store.emit("task:updated", awaitingCli);
store.emit("task:updated", task({
id: "FN-7206",
status: "awaiting-cli-approval",
paused: true,
pausedReason: "manual-cli-approval: pnpm test",
}));
await Promise.resolve();
expect(sendNotification).toHaveBeenCalledTimes(1);
expect(sendNotification).toHaveBeenCalledWith(
"cli-agent-awaiting-input",
expect.objectContaining({
taskId: "FN-7205",
metadata: expect.objectContaining({
notificationDedupeKey: "workflow-transition:FN-7205:awaiting-cli-approval",
notificationKind: "workflow_cli_approval",
pausedReason: "workflow-cli-approval:code-review: pnpm test",
}),
}),
);
await service.stop();
});
it("emits manual merge hold and later recovery requeue workflow notifications with separate dedupe keys", async () => {
const { store, service, sendNotification } = await setup();
const held = task({
id: "FN-7203",
column: "in-review",
paused: true,
pausedReason: "manual-hold",
log: [{ timestamp: new Date().toISOString(), action: "Workflow merge-manual-hold reached manual-required" }],
});
store.emit("task:updated", held);
store.emit("task:updated", held);
store.emit("task:updated", task({
id: "FN-7203",
column: "todo",
paused: false,
pausedReason: undefined,
status: undefined,
log: [{ timestamp: new Date().toISOString(), action: "Workflow graph failed at node 'review' with incomplete steps - moved back to todo for execution resume" }],
}));
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalledTimes(2);
});
expect(sendNotification).toHaveBeenNthCalledWith(
1,
"workflow-notify",
expect.objectContaining({
taskId: "FN-7203",
metadata: expect.objectContaining({
notificationDedupeKey: "workflow-transition:FN-7203:manual-merge-hold",
notificationKind: "manual_merge_hold",
}),
}),
);
expect(sendNotification).toHaveBeenNthCalledWith(
2,
"workflow-notify",
expect.objectContaining({
taskId: "FN-7203",
metadata: expect.objectContaining({
notificationDedupeKey: "workflow-transition:FN-7203:recovery-requeue",
notificationKind: "workflow_recovery_requeue",
}),
}),
);
await service.stop();
});
it("does not add a manual-hold workflow notification when the failed status already represents the task update", async () => {
const { store, service, sendNotification } = await setup({ failureNotificationMode: "all" });
store.emit("task:updated", task({
id: "FN-7204",
column: "in-review",
status: "failed",
paused: true,
pausedReason: "manual-hold",
log: [{ timestamp: new Date().toISOString(), action: "Workflow merge-manual-hold reached manual-required" }],
}));
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalledTimes(1);
});
expect(sendNotification).toHaveBeenCalledWith("failed", expect.objectContaining({ taskId: "FN-7204" }));
expect(sendNotification).not.toHaveBeenCalledWith("workflow-notify", expect.anything());
await service.stop();
});
});

View File

@@ -274,6 +274,15 @@ export class NotificationService {
this.createTaskPayload(task, "awaiting-user-review"),
);
}
const workflowTransition = this.classifyWorkflowTransitionNotification(task);
if (workflowTransition) {
this.maybeNotify(
task.id,
workflowTransition.event,
this.createTaskPayload(task, workflowTransition.event, workflowTransition.metadata),
);
}
};
private handleTaskMerged = (result: MergeResult): void => {
@@ -697,12 +706,107 @@ export class NotificationService {
typeof task.mergeDetails?.mergedAt === "string";
}
private createTaskPayload(task: Task, event: NotificationEvent): NotificationPayload {
private classifyWorkflowTransitionNotification(task: Task): { event: NotificationEvent; metadata: Record<string, unknown> } | null {
/*
* FNXC:WorkflowNotifications 2026-06-29-11:50:
* Workflow-specific operator waits should notify from the durable task update that already represents the wait, not from a new lifecycle bus. Plan/remediation await-input, workflow CLI approval, manual merge holds, and workflow recovery requeues each use a stable dedupe key so repeated task:updated emissions stay quiet while unrelated task notifications can still fire.
*/
if (task.status === "awaiting-user-input" && this.isWorkflowAwaitingUserInput(task)) {
return {
event: "planning-awaiting-input",
metadata: {
notificationDedupeKey: `workflow-transition:${task.id}:awaiting-user-input`,
notificationKind: "workflow-awaiting-user-input",
workflowStatus: task.status,
pausedReason: task.pausedReason,
},
};
}
if (task.status === "awaiting-cli-approval" && task.pausedReason?.startsWith("workflow-cli-approval:")) {
return {
event: "cli-agent-awaiting-input",
metadata: {
notificationDedupeKey: `workflow-transition:${task.id}:awaiting-cli-approval`,
notificationKind: "workflow_cli_approval",
workflowStatus: task.status,
pausedReason: task.pausedReason,
},
};
}
if (task.status !== "failed" && this.isManualMergeHold(task)) {
return {
event: "workflow-notify",
metadata: {
notificationDedupeKey: `workflow-transition:${task.id}:manual-merge-hold`,
notificationKind: "manual_merge_hold",
title: `Manual merge needed for ${task.id}`,
message: "Workflow is holding for manual merge action.",
pausedReason: task.pausedReason,
},
};
}
if (this.isWorkflowRecoveryRequeue(task)) {
return {
event: "workflow-notify",
metadata: {
notificationDedupeKey: `workflow-transition:${task.id}:recovery-requeue`,
notificationKind: "workflow_recovery_requeue",
title: `Workflow requeued ${task.id}`,
message: "Workflow recovery moved the task back to todo for another execution pass.",
},
};
}
return null;
}
private isWorkflowAwaitingUserInput(task: Task): boolean {
if (!task.paused) {
return false;
}
const pausedReason = task.pausedReason ?? "";
const latest = this.latestLogAction(task);
return pausedReason.startsWith("workflow-input:")
|| latest.startsWith("Workflow paused for user input")
|| (latest.startsWith("Workflow step ") && latest.includes(" is waiting for your input:"));
}
private isManualMergeHold(task: Task): boolean {
if (task.column !== "in-review") {
return false;
}
if (task.pausedReason === "manual-hold") {
return true;
}
const latest = this.latestLogAction(task).toLowerCase();
return latest.includes("manual-required")
|| latest.includes("manual merge required")
|| latest.includes("merge-manual-hold");
}
private isWorkflowRecoveryRequeue(task: Task): boolean {
if (task.column !== "todo" || task.status === "failed") {
return false;
}
const latest = this.latestLogAction(task).toLowerCase();
return latest.includes("workflow")
&& (latest.includes("requeued") || latest.includes("moved back to todo"));
}
private latestLogAction(task: Task): string {
return task.log.at(-1)?.action ?? "";
}
private createTaskPayload(task: Task, event: NotificationEvent, metadata?: Record<string, unknown>): NotificationPayload {
return {
taskId: task.id,
taskTitle: task.title,
taskDescription: task.description,
event,
...(metadata ? { metadata } : {}),
};
}

View File

@@ -108,6 +108,11 @@ export const MAX_COMPLETION_HANDOFF_LIMBO_RECOVERIES = 3;
export const MAX_POST_DONE_NONCONTINUABLE_WEDGE_RECOVERIES = 3;
const MAX_NO_PROGRESS_RESUME_ATTEMPTS = 2;
type WorkflowRecoveryRoute =
| { kind: "node-requeue"; reason: "pause-abort-active-work" }
| { kind: "work-item-resume"; reason: "pause-abort-review-progress" }
| { kind: "no-action"; reason: "not-pause-abort" | "unsafe-or-not-routable" };
function extractTaskIdFromTempMergeDir(dirname: string): string | null {
const match = /^fusion-ai-merge-(fn-\d+)-[a-z0-9]+$/i.exec(dirname);
return match?.[1]?.toUpperCase() ?? null;
@@ -770,6 +775,50 @@ export class SelfHealingManager {
private options: SelfHealingOptions,
) {}
private classifyPausedAbortWorkflowRecovery(
task: Task,
settings: Settings,
isExecuting: boolean,
): WorkflowRecoveryRoute {
const isPausedAbortPark =
task.status === "failed" &&
typeof task.error === "string" &&
task.error.includes(PAUSE_ABORT_PARK_OPERATOR_MARKER) &&
task.error.includes(PAUSE_ABORT_PARK_ERROR_MARKER);
if (!isPausedAbortPark) return { kind: "no-action", reason: "not-pause-abort" };
if (task.paused || task.userPaused || isExecuting) return { kind: "no-action", reason: "unsafe-or-not-routable" };
const errorText = typeof task.error === "string" ? task.error.toLowerCase() : "";
const isTerminalMergePark = errorText.includes("conflict")
|| errorText.includes("contamination")
|| errorText.includes("foreign")
|| errorText.includes("retry-exhausted")
|| errorText.includes("retries exhausted")
|| errorText.includes("max retries");
const hasReviewProgress =
task.column === "in-review"
&& allowsAutoMergeProcessing(task, settings)
&& task.mergeDetails?.mergeConfirmed !== true
&& !isTerminalMergePark
&& task.steps.length > 0
&& task.steps.every((step) => step.status === "done" || step.status === "skipped");
/*
FNXC:WorkflowRecoveryRouter 2026-06-29-11:47:
Pause-abort self-healing should classify recovery intent before mutating task
state. Active work routes to a workflow node requeue in todo; completed review
progress routes to a work-item/review resume in-place. Unsafe rows stay
untouched so invariant repair and human holds remain separate decisions.
*/
if (hasReviewProgress) {
return { kind: "work-item-resume", reason: "pause-abort-review-progress" };
}
if (task.column === "todo" || task.column === "in-progress") {
return { kind: "node-requeue", reason: "pause-abort-active-work" };
}
return { kind: "no-action", reason: "unsafe-or-not-routable" };
}
public getActiveMergeTaskId(): string | null {
return this.options.getActiveMergeTaskId?.() ?? null;
}
@@ -9074,39 +9123,8 @@ export class SelfHealingManager {
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const tasks = await this.store.listTasks({ slim: true });
const isPausedAbortPark = (t: Task): boolean =>
t.status === "failed" &&
typeof t.error === "string" &&
t.error.includes(PAUSE_ABORT_PARK_OPERATOR_MARKER) &&
t.error.includes(PAUSE_ABORT_PARK_ERROR_MARKER);
const isTerminalMergePark = (t: Task): boolean => {
const text = typeof t.error === "string" ? t.error.toLowerCase() : "";
return text.includes("conflict")
|| text.includes("contamination")
|| text.includes("foreign")
|| text.includes("retry-exhausted")
|| text.includes("retries exhausted")
|| text.includes("max retries");
};
const isRecoverableInReviewPauseAbortPark = (t: Task): boolean => {
/*
FNXC:WorkflowLifecycle 2026-06-20-00:00:
FN-6796 defense-in-depth: executor memory that distinguishes benign engine aborts from user hard-cancel is gone after restart, so self-healing may recover only persisted clean `in-review` pause-abort parks: non-paused, not executing, auto-merge eligible, completed steps, no terminal/confirmed merge evidence. User hard-cancel rows rest in `todo`; global/user pauses and autoMerge:false review rows remain operator-controlled.
*/
return t.column === "in-review"
&& allowsAutoMergeProcessing(t, settings)
&& t.mergeDetails?.mergeConfirmed !== true
&& !isTerminalMergePark(t)
&& t.steps.length > 0
&& t.steps.every((step) => step.status === "done" || step.status === "skipped");
};
const parked = tasks.filter((t) =>
isPausedAbortPark(t) &&
!t.paused &&
!t.userPaused &&
!executingIds.has(t.id) &&
(t.column === "todo" || t.column === "in-progress" || isRecoverableInReviewPauseAbortPark(t)),
this.classifyPausedAbortWorkflowRecovery(t, settings, executingIds.has(t.id)).kind !== "no-action",
);
if (parked.length === 0) return 0;
@@ -9124,19 +9142,14 @@ export class SelfHealingManager {
// applied (coderabbit Major + greptile, PR #1687).
const fresh = await this.store.getTask(task.id);
const latestExecutingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
if (
!fresh ||
!isPausedAbortPark(fresh) ||
fresh.paused ||
fresh.userPaused ||
latestExecutingIds.has(fresh.id) ||
!(fresh.column === "todo" || fresh.column === "in-progress" || isRecoverableInReviewPauseAbortPark(fresh))
) {
if (!fresh) continue;
const route = this.classifyPausedAbortWorkflowRecovery(fresh, settings, latestExecutingIds.has(fresh.id));
if (route.kind === "no-action") {
continue;
}
await this.store.updateTask(task.id, { status: null, error: null });
if (fresh.column !== "todo" && fresh.column !== "in-review") {
if (route.kind === "node-requeue" && fresh.column !== "todo") {
await this.store.moveTask(task.id, "todo", {
preserveProgress: true,
moveSource: "engine",
@@ -9168,7 +9181,12 @@ export class SelfHealingManager {
domain: "database",
mutationType: "task:auto-recover-paused-abort-park",
target: task.id,
metadata: { fromColumn: fresh.column, preservedInReview: fresh.column === "in-review" },
metadata: {
fromColumn: fresh.column,
preservedInReview: route.kind === "work-item-resume",
recoveryRoute: route.kind,
recoveryReason: route.reason,
},
});
} catch (auditErr: unknown) {
log.warn(`Pause-abort park audit emission failed for ${task.id}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);

View File

@@ -364,12 +364,12 @@ export class WorkflowGraphExecutor {
* `postMergeEntryNodeIds` = the (deterministic, id-sorted) set of edge targets `t`
* such that an edge leaves a merge-region node to `t`, where `t` is itself NOT a
* merge-region node and NOT `end`, the edge is not a rework back-edge, and the edge
* routes on success (no condition or `condition: "success"`). For `builtin:coding`
* this set is EMPTY (every merge-region exit goes to another merge-region node or
* `end`), so flag-ON is byte-identical to flag-OFF there — the parity oracle holds.
* When the flag is OFF the set is left empty and the post-merge hop is never taken,
* so existing merge routing (transient→retry, manual hold, branch-group
* integration/promotion, recovery-router, failure paths) is wholly unchanged.
* routes on success (no condition or `condition: "success"`). Full built-ins can now
* expose the default-off `post-merge-verification` optional group here, so workflow
* definitions own the post-merge verification policy while the merge seam still
* provides proof before the hop. When the flag is OFF the set is left empty and
* post-merge nodes are skipped for compatibility; normal merge failure/manual/retry
* routing remains unchanged.
*/
const postMergeEnabled = isExperimentalFeatureEnabled(settings, GRAPH_NATIVE_POST_MERGE_FLAG);
const postMergeEntryNodeIds: string[] = (() => {
@@ -935,35 +935,39 @@ export class WorkflowGraphExecutor {
* Flag-gated post-merge hop. The merge already finished (the seam awaited the
* merge Promise), so this runs strictly AFTER a successful merge. Walk each
* post-merge entry node via the normal `walk` path (optional-group recording
* with phase:"post-merge"). Post-merge failures are NON-BLOCKING — they record
* a result but DO NOT mutate `aggregate`, so the merged task still completes
* with the merge-success outcome (matching legacy post-merge semantics). When
* with phase:"post-merge"). Advisory post-merge failures are NON-BLOCKING —
* they record a result but DO NOT mutate `aggregate`, so the merged task still
* completes with the merge-success outcome (matching legacy post-merge
* semantics). Explicit gate-mode post-merge failures do block final graph
* success so configured post-merge verification can prevent final done. When
* the flag is OFF, `postMergeEntryNodeIds` is empty and this loop is inert, so
* the merge region stays exactly as collapsed before.
*/
for (const entryId of postMergeEntryNodeIds) {
/*
* FNXC:WorkflowPostMerge 2026-06-26-15:30:
* Post-merge traversal must NEVER fail an already-merged run (non-blocking
* contract). A malformed post-merge IR or any traversal throw is caught,
* logged via the task log sink, and we CONTINUE to the next entry — the
* merged task still completes with the merge-success `aggregate`. Without
* this guard a throw would propagate out of the executor and flip a merged
* task into an executor failure.
* FNXC:WorkflowPostMerge 2026-06-29-11:47:
* Post-merge verification has two policies: advisory checks keep the
* legacy non-blocking behavior, while explicit gate-mode checks are allowed
* to block final workflow success after merge proof. Traversal errors remain
* logged/non-blocking because a malformed post-merge authoring path should
* not overwrite already-proven merge state.
*/
try {
const postMerge = await walk(entryId);
// A post-merge entry node is never an enclosing rework head, so a
// ReworkSignal here would be malformed IR; ignore it rather than bubble a
// rework loop out of the merge boundary. Result is intentionally discarded
// (non-blocking).
void postMerge;
// rework loop out of the merge boundary.
if (!isReworkSignal(postMerge) && postMerge.outcome === "failure") {
aggregate = postMerge;
break;
}
} catch (err) {
this.deps.logTaskEntry?.(
`[post-merge] traversal error: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (aggregate.outcome === "failure") break;
continue;
}
const child = await walk(edge.to);