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:
73
packages/core/src/builtin-post-merge-group.ts
Normal file
73
packages/core/src/builtin-post-merge-group.ts
Normal 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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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));
|
||||
|
||||
151
packages/engine/src/__tests__/workflow-graph-post-merge.test.ts
Normal file
151
packages/engine/src/__tests__/workflow-graph-post-merge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user