feat(FN-7039): post-merge workflow steps run graph-native (flag-gated)

Adds graph-native post-merge step execution behind experimentalFeatures.graphNativePostMerge
(default OFF — byte-identical behavior until enabled). After a successful merge-attempt
(the merge seam awaits the merge Promise), the graph runs post-merge optional-group nodes
and records phase:"post-merge" results, non-blocking. The merge-region traversal hop is
inert when the flag is off, and empty for builtin:coding even flag-on (its merge exits only
reach merge-region nodes or end), so the parity oracle holds. Optional-group recording now
derives phase + log prefix from config.phase (defaults pre-merge). Adds postMergeOptionalGroupNode
factory for migration/custom workflows. Legacy merger post-merge path untouched (U7b cutover).

Plan U7a.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-26 01:09:45 -07:00
parent 142bce0b93
commit 194878dac9
7 changed files with 335 additions and 11 deletions

View File

@@ -0,0 +1,73 @@
import type { WorkflowIrNode } from "./workflow-ir-types.js";
/*
FNXC:WorkflowPostMerge 2026-06-26-09:00:
Factory for a POST-MERGE optional-group node — the graph-native execution mechanism
for post-merge workflow steps (U7 spike). Mirrors `codeReviewOptionalGroupNode` /
`browserVerificationOptionalGroupNode`, but the produced node carries
`config.phase: "post-merge"` so the graph executor:
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).
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).
*/
export interface PostMergeOptionalGroupSpec {
/** Stable per-task enable key + group node id. */
id: string;
/** Display name (toggle/editor surfaces + recorded `workflowStepName`). */
name: string;
/** Column the group node sits in (typically a post-merge/`done` column). */
column: string;
/** Agent prompt for the inner post-merge step. */
prompt: string;
/** Optional short description for the inner node. */
description?: string;
/** Inner step tool access; defaults to "readonly". */
toolMode?: "readonly" | "coding";
/** Gate semantics; defaults to "advisory" (post-merge failures are non-blocking). */
gateMode?: "advisory" | "gate";
/** Seed the per-task enable toggle for new tasks; defaults to false (opt-in). */
defaultOn?: boolean;
}
/**
* Build a post-merge `optional-group` node. The node config is marked
* `phase: "post-merge"` so the graph executor's optional-group recording path keys
* the result phase + log prefix off it.
*/
export function postMergeOptionalGroupNode(spec: PostMergeOptionalGroupSpec): WorkflowIrNode {
return {
id: spec.id,
kind: "optional-group",
column: spec.column,
config: {
name: spec.name,
phase: "post-merge",
defaultOn: spec.defaultOn ?? false,
template: {
nodes: [
{
id: `${spec.id}-step`,
kind: "prompt",
config: {
name: spec.name,
...(spec.description !== undefined ? { description: spec.description } : {}),
prompt: spec.prompt,
toolMode: spec.toolMode ?? "readonly",
gateMode: spec.gateMode ?? "advisory",
},
},
],
edges: [],
},
},
};
}

View File

@@ -16,6 +16,22 @@ const RETIRED_EXPERIMENTAL_FEATURES = new Set<string>([
"workflowInterpreterDualObserve",
]);
/*
FNXC:WorkflowPostMerge 2026-06-26-09:00:
Post-merge workflow steps run GRAPH-NATIVE behind this default-OFF experimental flag
(U7 spike). When OFF (the default — the key is absent from DEFAULT_*_SETTINGS so
`isExperimentalFeatureEnabled` returns false), the merge-region stays collapsed exactly
as before: the graph routes merge-attempt success straight to `end` and the merger still
owns post-merge steps from the legacy table — zero behavior change, byte-identical
builtin:coding traversal. When ON, the graph executor lets traversal continue past a
SUCCESSFUL merge to any post-merge optional-group node reachable from the merge region,
running it via the same optional-group execution+recording path (phase:"post-merge",
non-blocking failures). This unit is additive + reversible: a later unit removes the
legacy merger post-merge path. Mirrors the WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG read
plumbing (named constant + `isExperimentalFeatureEnabled`).
*/
export const GRAPH_NATIVE_POST_MERGE_FLAG = "graphNativePostMerge" as const;
export function isExperimentalFeatureEnabled(
settings: Pick<Settings, "experimentalFeatures"> | undefined,
key: string,

View File

@@ -1665,7 +1665,9 @@ export type {
ResearchCancellationState,
} from "./research-types.js";
export { isExperimentalFeatureEnabled } from "./experimental-features.js";
export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG } from "./experimental-features.js";
export { postMergeOptionalGroupNode } from "./builtin-post-merge-group.js";
export type { PostMergeOptionalGroupSpec } from "./builtin-post-merge-group.js";
export {
WORKFLOW_COMPARABLE_AUDIT_MUTATIONS,
WORKFLOW_PARITY_OBSERVED_MUTATION,

View File

@@ -179,6 +179,15 @@ export interface WorkflowOptionalGroupConfig {
defaultOn?: boolean;
/** Display name for the group (editor + per-task toggle surfaces). */
name?: string;
/*
FNXC:WorkflowPostMerge 2026-06-26-09:00:
Execution phase of the optional-group step. Defaults to "pre-merge" (the prior, only
behavior) when absent, so existing built-in/custom optional groups are byte-identical.
"post-merge" marks a group that the graph executor runs AFTER a successful merge
(gated by the `graphNativePostMerge` experimental flag); the recorded
`WorkflowStepResult.phase` and `[post-merge]` logs follow this value.
*/
phase?: "pre-merge" | "post-merge";
template: {
nodes: WorkflowIrNode[];
edges: WorkflowIrEdge[];

View File

@@ -633,6 +633,11 @@ function validateOptionalGroup(
if (cfg.name !== undefined && typeof cfg.name !== "string") {
throw new WorkflowIrError(`optional-group node '${node.id}' name must be a string`);
}
// FNXC:WorkflowPostMerge 2026-06-26-09:00: `phase` is optional and defaults to
// "pre-merge"; only "pre-merge" | "post-merge" are valid when present.
if (cfg.phase !== undefined && cfg.phase !== "pre-merge" && cfg.phase !== "post-merge") {
throw new WorkflowIrError(`optional-group node '${node.id}' phase must be 'pre-merge' or 'post-merge'`);
}
const templateNodes = template.nodes;
const templateIds = new Set(templateNodes.map((n) => n.id));

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import type { TaskDetail, WorkflowIr, WorkflowStepResult } from "@fusion/core";
import { postMergeOptionalGroupNode } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
/*
FNXC:WorkflowPostMerge 2026-06-26-09:00:
Graph-native post-merge steps (U7 spike). A post-merge optional-group node wired off
`merge-attempt` success must, WITH the `graphNativePostMerge` flag ON, run AFTER the
merge seam and record a WorkflowStepResult with phase:"post-merge". WITH the flag OFF
the merge region stays collapsed and the post-merge node is never reached — it records
nothing via the graph (the legacy merger still owns post-merge). Post-merge failures are
non-blocking: the run still completes with the merge-success outcome.
*/
const POST_MERGE_ID = "post-merge-docs";
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 {
return {
version: "v2",
name: "post-merge-test",
columns: [
{ id: "work", name: "Work", traits: [] },
{ id: "review", name: "Review", traits: [] },
{ id: "done", name: "Done", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "work" },
{ id: "execute", kind: "prompt", column: "work", config: { prompt: "x" } },
{ id: "merge-attempt", kind: "merge-attempt", column: "review", config: { capability: "task-merge" } },
postMergeOptionalGroupNode({
id: POST_MERGE_ID,
name: "Post Merge Docs",
column: "done",
prompt: "post-merge doc check",
defaultOn: false,
}),
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "execute" },
{ from: "execute", to: "merge-attempt", condition: "success" },
// Existing terminal success edge (the merge region collapses; this is the
// flag-OFF terminal). The post-merge entry is the SECOND success edge.
{ from: "merge-attempt", to: "end", condition: "success" },
{ from: "merge-attempt", to: POST_MERGE_ID, condition: "success" },
{ from: POST_MERGE_ID, to: "end", condition: "success" },
],
};
}
function taskWith(enabled: string[] | undefined): TaskDetail {
return { id: "FN-PM", enabledWorkflowSteps: enabled } as TaskDetail;
}
function makeRecorder() {
const results: WorkflowStepResult[] = [];
const record = async (_taskId: string, result: WorkflowStepResult) => {
const idx = results.findIndex((r) => r.workflowStepId === result.workflowStepId);
if (idx >= 0) results[idx] = result;
else results.push(result);
};
return { results, record };
}
/** All seam prompts (id "merge" synthetic node included) succeed; the post-merge inner
* step returns the injected verdict. `handlers.prompt` overrides every prompt node, so
* branch on id. */
function handler(innerValue: string): WorkflowNodeHandler {
return async (node) =>
node.id === POST_MERGE_STEP_ID
? { outcome: "success", 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();
const logs: string[] = [];
const executor = new WorkflowGraphExecutor({
handlers: { prompt: handler("APPROVE") },
recordWorkflowStepResult: recorder.record,
logTaskEntry: (summary: string) => logs.push(summary),
});
const result = await executor.run(
taskWith([POST_MERGE_ID]),
{ experimentalFeatures: { graphNativePostMerge: true } },
postMergeIr(),
);
expect(result.outcome).toBe("success");
// The post-merge node ran AFTER the collapsed merge seam.
expect(result.visitedNodeIds).toContain("merge");
expect(result.visitedNodeIds.indexOf(POST_MERGE_ID)).toBeGreaterThan(
result.visitedNodeIds.indexOf("merge"),
);
expect(recorder.results).toHaveLength(1);
const entry = recorder.results[0];
expect(entry.workflowStepId).toBe(POST_MERGE_ID);
expect(entry.workflowStepName).toBe("Post Merge Docs");
expect(entry.phase).toBe("post-merge");
expect(entry.status).toBe("passed");
expect(entry.verdict).toBe("APPROVE");
expect(logs).toContain("[post-merge] Starting workflow step: Post Merge Docs");
expect(logs).toContain("[post-merge] Workflow step completed: Post Merge Docs");
});
it("flag ON: a post-merge REVISE is recorded advisory_failure and is NON-BLOCKING (run still succeeds)", async () => {
const recorder = makeRecorder();
const executor = new WorkflowGraphExecutor({
handlers: { prompt: handler("REVISE") },
recordWorkflowStepResult: recorder.record,
});
const result = await executor.run(
taskWith([POST_MERGE_ID]),
{ experimentalFeatures: { graphNativePostMerge: true } },
postMergeIr(),
);
// Merge succeeded; post-merge advisory REVISE must NOT flip the run to failure.
expect(result.outcome).toBe("success");
expect(recorder.results).toHaveLength(1);
expect(recorder.results[0].phase).toBe("post-merge");
expect(recorder.results[0].status).toBe("advisory_failure");
});
it("flag OFF (default): the post-merge node is NOT run via the graph and records nothing", async () => {
const recorder = makeRecorder();
const executor = new WorkflowGraphExecutor({
handlers: { prompt: handler("APPROVE") },
recordWorkflowStepResult: recorder.record,
});
// No experimentalFeatures at all → flag defaults OFF.
const result = await executor.run(taskWith([POST_MERGE_ID]), {}, postMergeIr());
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toContain("merge");
// The merge region stays collapsed; the post-merge node is never traversed.
expect(result.visitedNodeIds).not.toContain(POST_MERGE_ID);
expect(recorder.results).toHaveLength(0);
});
});

View File

@@ -9,7 +9,7 @@ import type {
WorkflowNodeExtensionResult,
WorkflowStepResult,
} from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, getWorkflowExtensionRegistry, resolveMaxReworkCycles } from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, getWorkflowExtensionRegistry, resolveMaxReworkCycles, isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG } from "@fusion/core";
import {
createDefaultNodeHandlers,
@@ -303,6 +303,42 @@ export class WorkflowGraphExecutor {
[WORKFLOW_RUN_ID_CONTEXT_KEY]: runId,
[WORKFLOW_ID_CONTEXT_KEY]: ir.name || "unknown",
};
/*
* FNXC:WorkflowPostMerge 2026-06-26-09:00:
* Graph-native post-merge steps, gated by the default-OFF `graphNativePostMerge`
* experimental flag. The merge-policy region is collapsed into ONE legacy merge
* seam (see `runLegacyMergeSeam` + the `isMergeRegionKind` branch in
* `traverseChildren`), so a node wired off `merge-attempt` success is normally
* never traversed. With the flag ON we let traversal continue past a SUCCESSFUL
* merge to those post-merge entry nodes.
*
* `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.
*/
const postMergeEnabled = isExperimentalFeatureEnabled(settings, GRAPH_NATIVE_POST_MERGE_FLAG);
const postMergeEntryNodeIds: string[] = (() => {
if (!postMergeEnabled) return [];
const ids = new Set<string>();
for (const [from, edges] of outgoingMap) {
const fromNode = nodeMap.get(from);
if (!fromNode || !isMergeRegionKind(fromNode.kind)) continue;
for (const edge of edges) {
if (edge.kind === "rework") continue;
if (edge.condition && edge.condition !== "success") continue;
const target = nodeMap.get(edge.to);
if (!target || target.kind === "end" || isMergeRegionKind(target.kind)) continue;
ids.add(edge.to);
}
}
return [...ids].sort();
})();
const visitedNodeIds: string[] = [];
const inStack = new Set<string>();
const syntheticMergeNode: WorkflowIrNode = {
@@ -531,15 +567,28 @@ export class WorkflowGraphExecutor {
const groupName = typeof node.config?.name === "string" && node.config.name.trim()
? node.config.name.trim()
: node.id;
/*
* FNXC:WorkflowPostMerge 2026-06-26-09:00:
* Phase is read from the optional-group node's `config.phase` (defaults to
* "pre-merge", so every existing group is byte-identical). A
* `postMergeOptionalGroupNode` carries `phase: "post-merge"`; recorded
* `WorkflowStepResult.phase` + the `[pre-merge]`/`[post-merge]` log prefix
* both follow it. Post-merge groups only become reachable via the
* flag-gated post-merge hop below; the recording/log shape is otherwise
* identical to the pre-merge path.
*/
const stepPhase: WorkflowStepResult["phase"] =
node.config?.phase === "post-merge" ? "post-merge" : "pre-merge";
const logPrefix = stepPhase === "post-merge" ? "[post-merge]" : "[pre-merge]";
const stepStartedAt = new Date().toISOString();
await this.recordOptionalGroupStepResult(task.id, {
workflowStepId: node.id,
workflowStepName: groupName,
phase: "pre-merge",
phase: stepPhase,
status: "pending",
startedAt: stepStartedAt,
});
this.deps.logTaskEntry?.(`[pre-merge] Starting workflow step: ${groupName}`);
this.deps.logTaskEntry?.(`${logPrefix} Starting workflow step: ${groupName}`);
const groupResult = await runOptionalGroup(node, {
context,
@@ -570,7 +619,7 @@ export class WorkflowGraphExecutor {
await this.recordOptionalGroupStepResult(task.id, {
workflowStepId: node.id,
workflowStepName: groupName,
phase: "pre-merge",
phase: stepPhase,
status: stepStatus,
...(verdict ? { verdict } : {}),
...(stepOutput !== undefined ? { output: stepOutput } : {}),
@@ -578,18 +627,18 @@ export class WorkflowGraphExecutor {
startedAt: stepStartedAt,
completedAt: new Date().toISOString(),
});
// `[pre-merge]` terminal logs at parity with the legacy path
// `[pre-merge]`/`[post-merge]` terminal logs at parity with the legacy path
// (executor.ts runWorkflowSteps: "completed" / "requested revision" /
// "failed" + the advisory variant).
if (stepStatus === "passed") {
this.deps.logTaskEntry?.(`[pre-merge] Workflow step completed: ${groupName}`);
this.deps.logTaskEntry?.(`${logPrefix} Workflow step completed: ${groupName}`);
} else if (stepStatus === "advisory_failure") {
this.deps.logTaskEntry?.(`[pre-merge] Workflow step requested revision: ${groupName}`, stepOutput);
this.deps.logTaskEntry?.(`[pre-merge] Advisory workflow step failed: ${groupName}`);
this.deps.logTaskEntry?.(`${logPrefix} Workflow step requested revision: ${groupName}`, stepOutput);
this.deps.logTaskEntry?.(`${logPrefix} Advisory workflow step failed: ${groupName}`);
} else if (verdict === "REVISE") {
this.deps.logTaskEntry?.(`[pre-merge] Workflow step requested revision: ${groupName}`, stepOutput);
this.deps.logTaskEntry?.(`${logPrefix} Workflow step requested revision: ${groupName}`, stepOutput);
} else {
this.deps.logTaskEntry?.(`[pre-merge] Workflow step failed: ${groupName}`, stepOutput);
this.deps.logTaskEntry?.(`${logPrefix} Workflow step failed: ${groupName}`, stepOutput);
}
visitedNodeIds.push(...groupResult.visitedNodeIds);
const result: WorkflowNodeResult = {
@@ -708,6 +757,25 @@ export class WorkflowGraphExecutor {
if (target && isMergeRegionKind(target.kind)) {
aggregate = await runLegacyMergeSeam();
if (aggregate.outcome === "failure") break;
/*
* FNXC:WorkflowPostMerge 2026-06-26-09:00:
* 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
* 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) {
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;
}
continue;
}
const child = await walk(edge.to);