fix(FN-7233): move workflow lifecycle policy into nodes
This commit is contained in:
7
.changeset/workflow-transition-notifications.md
Normal file
7
.changeset/workflow-transition-notifications.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,8 @@ export function browserVerificationOptionalGroupNode(
|
||||
config: {
|
||||
name: BROWSER_VERIFICATION_NAME,
|
||||
defaultOn: options.defaultOn ?? false,
|
||||
reworkRegion: true,
|
||||
maxReworkCycles: 3,
|
||||
template: {
|
||||
nodes: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
152
packages/core/src/workflow-lifecycle-validation.ts
Normal file
152
packages/core/src/workflow-lifecycle-validation.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user