diff --git a/packages/core/src/builtin-post-merge-group.ts b/packages/core/src/builtin-post-merge-group.ts new file mode 100644 index 0000000000..7f6d7997fc --- /dev/null +++ b/packages/core/src/builtin-post-merge-group.ts @@ -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: [], + }, + }, + }; +} diff --git a/packages/core/src/experimental-features.ts b/packages/core/src/experimental-features.ts index d380c32950..c945ca502a 100644 --- a/packages/core/src/experimental-features.ts +++ b/packages/core/src/experimental-features.ts @@ -16,6 +16,22 @@ const RETIRED_EXPERIMENTAL_FEATURES = new Set([ "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 | undefined, key: string, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d5fc89398..8465db59dd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 2bc146afaa..a8c0c64e06 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -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[]; diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 0e721db487..cc45b90b93 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -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)); diff --git a/packages/engine/src/__tests__/workflow-graph-post-merge.test.ts b/packages/engine/src/__tests__/workflow-graph-post-merge.test.ts new file mode 100644 index 0000000000..84d222e3d3 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-graph-post-merge.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index ef1998e97e..072b27a4bd 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -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(); + 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(); 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);