FN-9157: fix workflow merge boundary proof and retry routing

Make workflow merge admission accept valid foreach completion and terminate retries when proof is unavailable.

- Treat fully terminal live foreach steps as implementation proof for Review Level 0 workflows.
- Preserve merge-boundary-unproven as a terminal graph failure and park blocked retries visibly.
- Add unit, graph-boundary, and PostgreSQL regression coverage plus operator documentation and a patch changeset.

Files changed:
 .changeset/fn-9157-merge-boundary.md               |  7 +++
 docs/architecture.md                               |  4 ++
 docs/workflow-steps.md                             |  6 ++
 .../src/__tests__/workflow-merge-proof.test.ts     | 11 +++-
 packages/core/src/workflow-merge-proof.ts          | 20 +++++--
 .../src/__tests__/executor-graph-boundary.test.ts  | 64 +++++++++++++++++++++-
 ...xecutor-merge-boundary-foreach-proof.pg.test.ts | 10 ++++
 .../__tests__/merge-boundary-unproven-park.test.ts | 48 ++++++++++++++++
 .../create-authoritative-workflow-primitives.ts    | 13 ++++-
 .../create-authoritative-workflow-seams.ts         |  6 +-
 .../executor/evaluate-workflow-merge-boundary.ts   | 18 +++++-
 .../executor/route-graph-merge-failure-to-retry.ts | 26 ++++++++-
 packages/engine/src/executor/task-predicates.ts    |  2 +
 .../engine/src/executor/workflow-merge-boundary.ts | 29 +++++++---
 .../engine/src/workflows/workflow-merge-nodes.ts   | 13 +++++
 15 files changed, 253 insertions(+), 24 deletions(-)

Fusion-Task-Id: FN-9157

Fusion-Task-Lineage: 7c68dfe2-28fb-4353-9ae6-df0ddbaf799a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-19 18:16:49 -07:00
parent 7dfce1cc16
commit 7b55a02e51
15 changed files with 253 additions and 24 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent completed foreach workflow tasks from stalling indefinitely in merge review.
category: fix
dev: Adds evaluateForeachMergeProof.liveStepSatisfiedInstanceIds, the merge-boundary-unproven terminal value, and classifyMergePrimitiveResult passthrough.

View File

@@ -2424,3 +2424,7 @@ Paused-safe housekeeping retains at most 30 days and 50,000 rows per project. Th
Scheduler and autopilot mission reconciliation persist the evaluated alignment on linked mission features, including no-delivery-transition outcomes, so roadmap readers consume a durable projection rather than recomputing task reports in the browser.
**Workspace revert land idempotency (FN-9047).** A clean git-mode workspace revert preserves each sub-repository `landedSha` for commit attribution and session-diff ranges, but records `workspaceWorktrees[repo].revertBoundarySha` at the integration commit created by the revert (or the pre-revert integration HEAD when the repo was already reverted). The canonical workspace landed predicate rejects recorded-SHA and `Fusion-Task-Id` trailer proof at or behind that boundary; a subsequent successful land writes its fresh `landedSha` and clears the boundary. PR-mode, conflicting, unsupported, and human-required reverts do not set boundaries because they do not advance an integration ref.
## Workflow merge-boundary invariant
The bounded auto-merge retry must never repeat a merge-boundary check that has already reported missing proof. `merge-boundary-unproven` is terminal: the engine parks the task as failed, and `shouldHoldActiveFileScopeLease` releases its active file-scope lease because failed rows are not live work. Terminal merge values must survive `classifyMergePrimitiveResult` on both the collapsed synthetic `merge` seam and direct `merge-attempt` runner; do not encode a new terminal value solely as `data.status:"failed"` with an unrecognized reason, because that classifier collapses it to non-terminal `merge-failed`.

View File

@@ -1014,3 +1014,9 @@ Task creation resolves ownership once at the shared pre-insert boundary used by
### Board visibility of pre-release Plan Review
Board hold-lane payloads can expose a transient `releaseGate` verdict. It makes the resolved pre-release Plan Review node, its column/default-on state, and a capacity-boundary continuation observable to Promote controls without duplicating workflow-gate rules in the browser.
## Foreach merge admission
A workflow whose `foreach` template contains `step-execute` may use terminal live task-step coverage as merge implementation proof when it expects at least one instance. Every expected instance must map to a `done` or `skipped` live step. This supports Review Level 0, which intentionally disables optional node-result groups. Zero expected instances still require a relevant `source:"node"` pre-merge result, and pending or failed node results remain blocking.
When the merge boundary cannot be proven, Fusion emits the terminal graph value `merge-boundary-unproven` and parks the task as failed with an actionable error. It does not silently retain the task in the review lane or repeat the same boundary check through bounded auto-merge retry.

View File

@@ -27,11 +27,20 @@ describe("evaluateForeachMergeProof", () => {
expect(evaluateForeachMergeProof({ ir: foreachIr(), steps: steps(["pending"]), workflowStepResults: [result(ids[0]), result(ids[0])] }).missingInstanceIds).toEqual([]);
});
it("uses persisted rows only to widen expected identities, never as satisfaction", () => {
it("reports only terminal live steps as live-step implementation evidence", () => {
const ids = [0, 1, 2].map((index) => instanceNodeId("steps", index, "step-execute"));
expect(evaluateForeachMergeProof({ ir: foreachIr(), steps: steps(["pending", "pending", "pending"]), workflowStepResults: ids.map((id) => result(id)) }).liveStepSatisfiedInstanceIds).toEqual([]);
expect(evaluateForeachMergeProof({ ir: foreachIr(), steps: steps(["done", "done", "done"]), workflowStepResults: [] }).liveStepSatisfiedInstanceIds).toEqual(ids);
expect(evaluateForeachMergeProof({ ir: foreachIr(), steps: steps(["skipped", "done", "skipped"]), workflowStepResults: [] }).liveStepSatisfiedInstanceIds).toEqual(ids);
expect(evaluateForeachMergeProof({ ir: foreachIr(), steps: [], workflowStepResults: [] }).liveStepSatisfiedInstanceIds).toEqual([]);
});
it("uses persisted rows only to widen expected identities, never as live-step satisfaction", () => {
const id0 = instanceNodeId("steps", 0, "step-execute");
const id1 = instanceNodeId("steps", 1, "step-execute");
const proof = evaluateForeachMergeProof({ ir: foreachIr(), steps: steps(["pending"]), workflowStepResults: [result(id0)], persistedInstances: [{ foreachNodeId: "steps", stepIndex: 1, pinnedStepCount: 2 }] });
expect(proof.expectedInstanceIds).toEqual([id0, id1]);
expect(proof.liveStepSatisfiedInstanceIds).toEqual([]);
expect(proof.missingInstanceIds).toEqual([id1]);
});

View File

@@ -12,6 +12,8 @@ export interface ForeachMergeProofInput {
export interface ForeachMergeProof {
expectedInstanceIds: string[];
satisfiedInstanceIds: string[];
/** Expected identities satisfied specifically by terminal live task steps. */
liveStepSatisfiedInstanceIds: string[];
missingInstanceIds: string[];
hasForeachStepExecute: boolean;
}
@@ -20,10 +22,18 @@ export interface ForeachMergeProof {
* FNXC:WorkflowMerge 2026-07-27-12:00:
* FN-8601 requires every expanded step-execute identity to have a terminal-success
* workflowStepResult, unless its live task step is already done/skipped. Callers
* must independently require a relevant pre-merge node result and that every such
* result is passed/skipped: coverage is vacuously complete on non-foreach/no-seam
* shapes and is never sole proof. Persisted rows only widen expectation; partial
* projections cannot complete a checklist, prove implementation, or enter merge.
* must independently require relevant implementation proof: coverage is vacuously
* complete on non-foreach/no-seam shapes and is never sole proof without expected
* terminal live steps or a relevant node result. Persisted rows only widen
* expectation; partial projections cannot complete a checklist, prove
* implementation, or enter merge.
*
* FNXC:WorkflowMerge 2026-08-20-00:50:
* FN-9157 / issue #3490 makes Review Level 0's explicit optional-group opt-out
* mergeable. On builtin:coding, only reviewKind/skillName nodes record node
* progress, so enabledWorkflowSteps: [] leaves terminal step-execute coverage as
* the only implementation evidence. Expose that subset without letting persisted
* rows or node results satisfy a live-step identity.
*/
export function evaluateForeachMergeProof(input: ForeachMergeProofInput): ForeachMergeProof {
const steps = input.steps ?? [];
@@ -64,10 +74,12 @@ export function evaluateForeachMergeProof(input: ForeachMergeProofInput): Foreac
.map((result) => result.workflowStepId),
);
const expectedInstanceIds = [...expected].sort();
const liveStepSatisfiedInstanceIds = expectedInstanceIds.filter((id) => terminalLiveSteps.has(id));
const satisfiedInstanceIds = expectedInstanceIds.filter((id) => terminalLiveSteps.has(id) || successfulResults.has(id));
return {
expectedInstanceIds,
satisfiedInstanceIds,
liveStepSatisfiedInstanceIds,
missingInstanceIds: expectedInstanceIds.filter((id) => !terminalLiveSteps.has(id) && !successfulResults.has(id)),
hasForeachStepExecute,
};

View File

@@ -36,6 +36,22 @@ function benchmarkIr(): WorkflowIr {
} as WorkflowIr;
}
function foreachIr(): WorkflowIr {
return {
version: "v2",
columns: [{ id: "in-review", name: "In review", traits: [{ trait: "merge" }] }],
nodes: [{
id: "steps",
kind: "foreach",
config: {
source: "task-steps",
template: { nodes: [{ id: "step-execute", kind: "prompt", config: { seam: "step-execute" } }], edges: [] },
},
}],
edges: [],
} as WorkflowIr;
}
function executeIr(): WorkflowIr {
return {
version: "v2",
@@ -56,13 +72,13 @@ function makeExecutor(opts: {
selection?: { workflowId: string; stepIds: string[] };
ir?: WorkflowIr;
taskColumn?: string;
steps?: Array<{ id: string; title: string; status: "pending" | "done" }>;
steps?: Array<{ id: string; title: string; status: "pending" | "done" | "skipped" }>;
workflowStepResults?: Array<{
workflowStepId: string;
workflowStepName: string;
source: "node";
phase: "pre-merge";
status: "passed";
status: "passed" | "pending";
completedAt: string;
}>;
}) {
@@ -74,7 +90,7 @@ function makeExecutor(opts: {
column: opts.taskColumn ?? "in-review",
dependencies: [],
steps: opts.steps ?? [],
workflowStepResults: opts.workflowStepResults ?? [],
workflowStepResults: opts.workflowStepResults,
currentStep: 0,
log: [],
prompt: "# t",
@@ -179,4 +195,46 @@ describe("U5a — IR-driven merge boundary (scenario 1)", () => {
);
expect(store.moveTask).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowMerge 2026-08-20-00:50:
FN-9157 regression: Review Level 0 deliberately supplies no optional node
results, so terminal step-execute coverage must admit the same merge handoff.
*/
it.each([[], undefined] as const)("admits fully terminal foreach coverage without node results (%j)", async (workflowStepResults) => {
const doneSteps = [
{ id: "0", title: "Preflight", status: "done" as const },
{ id: "1", title: "Implement", status: "done" as const },
];
const { executor, store, liveTask } = makeExecutor({
selection: { workflowId: "custom:foreach", stepIds: [] },
ir: foreachIr(),
taskColumn: "in-progress",
steps: doneSteps,
workflowStepResults: workflowStepResults as never,
});
const result = await executor.ensureWorkflowMergeBoundaryTask(
liveTask,
{ reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" },
) as { task: { column: string }; blocked?: { reason: string } };
expect(result.blocked).toBeUndefined();
expect(store.logEntry).not.toHaveBeenCalledWith("FN-B1", expect.stringContaining("Workflow merge boundary blocked:"), expect.anything(), expect.anything());
expect(store.moveTask).toHaveBeenCalledWith("FN-B1", "in-review", expect.anything());
});
it("keeps incomplete foreach coverage and zero expected steps blocked", async () => {
for (const steps of [
[{ id: "0", title: "Implement", status: "pending" as const }],
[],
]) {
const { executor, liveTask } = makeExecutor({
selection: { workflowId: "custom:foreach", stepIds: [] }, ir: foreachIr(), steps, workflowStepResults: [],
});
const result = await executor.ensureWorkflowMergeBoundaryTask(
liveTask,
{ reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" },
) as { blocked?: { reason: string } };
expect(result.blocked?.reason).toBe("no pre-merge node result recorded");
}
});
});

View File

@@ -72,6 +72,16 @@ pgDescribe("FN-8601 — PostgreSQL foreach merge proof", () => {
expect(live?.steps.every((step) => step.status === "done")).toBe(true);
});
it("admits builtin-coding-shaped terminal steps with Review Level 0 results disabled", async () => {
const doneSteps = pendingSteps().map((step) => ({ ...step, status: "done" as const }));
const { store, task, executor } = await setup({ results: [], steps: doneSteps });
await store.updateTask(task.id, { enabledWorkflowSteps: [] });
const live = await store.getTask(task.id);
const result = await merge(executor, live!);
expect((result as { blocked?: unknown }).blocked).toBeUndefined();
expect((await store.getTask(task.id))?.column).toBe("in-review");
});
it("blocks complete coverage when an unrelated pre-merge node result failed", async () => {
const ids = [0, 1, 2].map((index) => instanceNodeId("steps", index, "step-execute"));
const { store, task, executor } = await setup({ results: [...ids.map((id) => nodeResult(id)), nodeResult("unrelated", "failed")] });

View File

@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { MERGE_BOUNDARY_UNPROVEN_VALUE, classifyMergePrimitiveResult, runWorkflowMergeAttemptNode } from "../workflows/workflow-merge-nodes.js";
import { graphFailureValue, isMergeGraphFailure } from "../executor/graph-failure-pure.js";
import { isTerminalMergeGraphFailureValue } from "../executor/task-predicates.js";
import { routeGraphMergeFailureToRetry } from "../executor/route-graph-merge-failure-to-retry.js";
import { shouldHoldActiveFileScopeLease } from "../scheduler.js";
const task = { id: "FN-9157", column: "in-review", steps: [], dependencies: [], log: [], createdAt: "2026-08-20T00:00:00.000Z", updatedAt: "2026-08-20T00:00:00.000Z", title: "t", description: "", prompt: "# t" } as any;
const graphResult = (nodeId = "merge") => ({ visitedNodeIds: [nodeId], context: { [`node:${nodeId}:value`]: MERGE_BOUNDARY_UNPROVEN_VALUE } }) as any;
describe("FN-9157 merge-boundary-unproven terminal routing", () => {
it("preserves the explicit terminal value before failed-data classification", () => {
expect(classifyMergePrimitiveResult(undefined, MERGE_BOUNDARY_UNPROVEN_VALUE, "failure")).toEqual({ outcome: "failure", value: MERGE_BOUNDARY_UNPROVEN_VALUE });
expect(classifyMergePrimitiveResult({ status: "failed", reason: MERGE_BOUNDARY_UNPROVEN_VALUE } as any, MERGE_BOUNDARY_UNPROVEN_VALUE, "failure")).toEqual({ outcome: "failure", value: MERGE_BOUNDARY_UNPROVEN_VALUE });
});
it("keeps the value on direct merge-attempt dispatch and both graph context ids", async () => {
const output = await runWorkflowMergeAttemptNode({ primitives: {
requestMerge: vi.fn().mockResolvedValue({ outcome: "failure", value: MERGE_BOUNDARY_UNPROVEN_VALUE }),
audit: vi.fn(),
} }, {} as any, task);
expect(output).toMatchObject({ outcome: "failure", value: MERGE_BOUNDARY_UNPROVEN_VALUE });
expect(graphFailureValue(graphResult("merge"))).toBe(MERGE_BOUNDARY_UNPROVEN_VALUE);
expect(graphFailureValue(graphResult("merge-attempt"))).toBe(MERGE_BOUNDARY_UNPROVEN_VALUE);
expect(isMergeGraphFailure("merge")).toBe(true);
expect(isMergeGraphFailure("merge-attempt")).toBe(true);
expect(isTerminalMergeGraphFailureValue(MERGE_BOUNDARY_UNPROVEN_VALUE)).toBe(true);
});
it("parks an unprovable retry once without requesting merge and releases its lease", async () => {
const live = { ...task, worktree: "/worktree", status: undefined };
const updateTask = vi.fn(async (_id, patch) => ({ ...live, ...patch }));
const logEntry = vi.fn();
const mergeRequester = vi.fn();
const handled = await routeGraphMergeFailureToRetry({
store: { updateTask, logEntry } as any,
getRunContextFor: () => undefined,
mergeRequester,
ensureWorkflowMergeBoundaryTask: vi.fn().mockResolvedValue({ task: live, blocked: { reason: "no pre-merge node result recorded" } }),
persistTokenUsage: vi.fn(),
}, live, graphResult(), undefined);
expect(handled).toBe(true);
expect(mergeRequester).not.toHaveBeenCalled();
expect(updateTask).toHaveBeenCalledWith("FN-9157", expect.objectContaining({ status: "failed", error: expect.stringContaining("MERGE_BOUNDARY_UNPROVEN:") }), undefined);
expect(logEntry).toHaveBeenCalledWith("FN-9157", expect.stringContaining("retry parked task"), undefined, undefined);
expect(shouldHoldActiveFileScopeLease({ ...live, status: "failed" }, [])).toBe(false);
});
});

View File

@@ -18,6 +18,7 @@ import type {
WorkflowRuntimePrimitives,
} from "../execution/runtime-primitives.js";
import { WorkflowPlanningService } from "../workflows/workflow-planning-service.js";
import { MERGE_BOUNDARY_UNPROVEN_VALUE } from "../workflows/workflow-merge-nodes.js";
import {
FOREACH_ACTIVE_CONTEXT_KEY,
SEAM_GOVERNING_NODE_CONTEXT_KEY,
@@ -359,13 +360,23 @@ export function createAuthoritativeWorkflowPrimitivesFromExecutor(
if (ctx.signal?.aborted) {
return { outcome: "failure", value: "merge-cancelled" };
}
const mergeTask = await deps.ensureWorkflowMergeBoundaryTask(task, {
const mergeBoundary = await deps.ensureWorkflowMergeBoundaryTask(task, {
reason: "workflow-merge-boundary",
nodeId: ctx.node.node.id,
workflowId: ctx.run.workflowId,
runId: ctx.run.runId,
});
/*
FNXC:WorkflowMerge 2026-08-20-00:50:
FN-9157 turns an unprovable boundary into a terminal graph failure. Do not
attach data.status:failed: direct merge-attempt classification rewrites an
unknown reason to merge-failed and re-enters the bounded retry.
*/
if (mergeBoundary.blocked) {
return { outcome: "failure", value: MERGE_BOUNDARY_UNPROVEN_VALUE };
}
const mergeTask = mergeBoundary.task;
/*
FNXC:WorkflowMerge 2026-06-29-23:18:
FN-7261 reached the merge node in fast mode with every legacy implementation step still pending, producing a no-op merge proof for work that never ran. A graph-native workflow may project its checklist at the merge boundary only when node workflow results prove implementation completed; otherwise incomplete legacy steps are authoritative and merge must fail before the merger can create stale no-op proof.

View File

@@ -20,6 +20,7 @@ import {
} from "../workflows/workflow-node-handlers.js";
import { graphActiveContextKey } from "./task-predicates.js";
import { WorkflowReviewService } from "../workflows/workflow-review-service.js";
import { MERGE_BOUNDARY_UNPROVEN_VALUE } from "../workflows/workflow-merge-nodes.js";
import { mergeEffectiveSettings } from "../project/effective-settings.js";
import { resolveReviewCheckoutCwd } from "../execution/review-checkout.js";
import { logReviewCheckoutRouting } from "./review-checkout-routing.js";
@@ -202,12 +203,15 @@ export function createAuthoritativeWorkflowSeams(
if (signal?.aborted) {
return { outcome: "failure", value: "merge-cancelled" };
}
const mergeTask = await deps.ensureWorkflowMergeBoundaryTask(seamTask, {
const mergeBoundary = await deps.ensureWorkflowMergeBoundaryTask(seamTask, {
reason: "workflow-merge-boundary",
nodeId: "legacy-merge-seam",
workflowId: "legacy-seams",
runId: deps.getRunContextFor(seamTask.id)?.runId ?? "legacy-seam",
});
/* FNXC:WorkflowMerge 2026-08-20-00:50: FN-9157 keeps the legacy seam terminal too; a retry cannot create missing boundary proof. */
if (mergeBoundary.blocked) return { outcome: "failure", value: MERGE_BOUNDARY_UNPROVEN_VALUE };
const mergeTask = mergeBoundary.task;
const missingImplementationProof = await deps.getWorkflowMergeImplementationProofFailure(mergeTask);
if (missingImplementationProof) {
await deps.store.logEntry(

View File

@@ -18,6 +18,7 @@ export type WorkflowMergeBoundaryProof = {
hasRelevantNodeResult: boolean;
allResultsTerminal: boolean;
coverageComplete: boolean;
hasLiveStepImplementationProof: boolean;
hasForeachStepExecute: boolean;
missingInstanceIds: string[];
nonTerminalResult?: CoreWorkflowStepResult;
@@ -40,13 +41,24 @@ export async function evaluateWorkflowMergeBoundary(
const allResultsTerminal = nonTerminalResult === undefined;
let ir: WorkflowIr | undefined;
try { ir = await resolveWorkflowIrForTask(deps.store, task.id); } catch { /* preserve legacy behavior for unresolved IRs */ }
if (!ir) return { resolved: false, hasRelevantNodeResult, allResultsTerminal, coverageComplete: true, hasForeachStepExecute: false, missingInstanceIds: [], nonTerminalResult, complete: false };
if (!ir) return { resolved: false, hasRelevantNodeResult, allResultsTerminal, coverageComplete: true, hasLiveStepImplementationProof: false, hasForeachStepExecute: false, missingInstanceIds: [], nonTerminalResult, complete: false };
let persistedInstances: Array<{ foreachNodeId: string; stepIndex: number; pinnedStepCount: number }> = [];
try { persistedInstances = await deps.loadMergeBoundaryInstances(task.id, runId); } catch { /* persistence is additive */ }
const coverage = evaluateForeachMergeProof({ ir, steps: task.steps, workflowStepResults: task.workflowStepResults, persistedInstances });
const complete = hasRelevantNodeResult && allResultsTerminal && coverage.missingInstanceIds.length === 0;
return { resolved: true, hasRelevantNodeResult, allResultsTerminal, coverageComplete: coverage.missingInstanceIds.length === 0, hasForeachStepExecute: coverage.hasForeachStepExecute, missingInstanceIds: coverage.missingInstanceIds, nonTerminalResult, complete };
const coverageComplete = coverage.missingInstanceIds.length === 0;
/*
FNXC:WorkflowMerge 2026-08-20-00:50:
FN-9157 accepts terminal live step-execute coverage as implementation proof for
Review Level 0, whose explicit optional-group opt-out creates no node results.
Require at least one expected instance and every identity to be live-step
satisfied: zero parsed steps still need a node result, and any pending step
remains incomplete.
*/
const hasLiveStepImplementationProof = coverage.expectedInstanceIds.length > 0
&& coverage.expectedInstanceIds.every((id) => coverage.liveStepSatisfiedInstanceIds.includes(id));
const complete = allResultsTerminal && coverageComplete && (hasRelevantNodeResult || hasLiveStepImplementationProof);
return { resolved: true, hasRelevantNodeResult, allResultsTerminal, coverageComplete, hasLiveStepImplementationProof, hasForeachStepExecute: coverage.hasForeachStepExecute, missingInstanceIds: coverage.missingInstanceIds, nonTerminalResult, complete };
}
export type GetWorkflowMergeImplementationProofFailureDeps = {

View File

@@ -12,6 +12,7 @@ import { isGenericAbortProvenance } from "./paused-abort-provenance.js";
import { graphFailureValue } from "./graph-failure-pure.js";
import type { EngineRunContext } from "../util/run-audit.js";
import { executorLog } from "../logger.js";
import { MERGE_BOUNDARY_UNPROVEN_VALUE } from "../workflows/workflow-merge-nodes.js";
export type RouteGraphMergeFailureToRetryDeps = {
store: TaskStore;
@@ -20,7 +21,7 @@ export type RouteGraphMergeFailureToRetryDeps = {
ensureWorkflowMergeBoundaryTask: (
live: TaskDetail,
opts: { reason: string; nodeId: string; workflowId: string; runId: string },
) => Promise<TaskDetail>;
) => Promise<{ task: TaskDetail; blocked?: { reason: string } }>;
persistTokenUsage: (taskId: string) => Promise<void>;
};
@@ -38,13 +39,32 @@ export async function routeGraphMergeFailureToRetry(
executorLog.warn(`${live.id}: ${message}`);
await deps.store.logEntry(live.id, message, undefined, deps.getRunContextFor(live.id));
try {
const mergeTask = await deps.ensureWorkflowMergeBoundaryTask(live, {
const mergeBoundary = await deps.ensureWorkflowMergeBoundaryTask(live, {
reason: "workflow-merge-retry-boundary",
nodeId: failedNode,
workflowId: result.context?.["workflow:id"] as string | undefined ?? "workflow-graph",
runId: deps.getRunContextFor(live.id)?.runId ?? "graph-merge-retry",
});
await deps.mergeRequester(mergeTask.id);
/*
FNXC:WorkflowMerge 2026-08-20-00:50:
FN-9157 forbids a bounded retry from repeating an unprovable boundary check.
Park visibly so the existing failed-status lease rule releases overlapping
work, rather than silently retaining an in-review blocker.
*/
if (mergeBoundary.blocked) {
const reason = mergeBoundary.blocked.reason;
await deps.store.logEntry(live.id, `Workflow merge boundary retry parked task: ${reason}`, undefined, deps.getRunContextFor(live.id));
if (mergeBoundary.task.status !== "failed" || !mergeBoundary.task.error) {
await deps.store.updateTask(
live.id,
{ status: "failed", error: `${MERGE_BOUNDARY_UNPROVEN_VALUE.toUpperCase().replaceAll("-", "_")}: ${reason}` },
deps.getRunContextFor(live.id),
);
}
await deps.persistTokenUsage(live.id);
return true;
}
await deps.mergeRequester(mergeBoundary.task.id);
} catch (error) {
executorLog.warn(`${live.id}: bounded auto-merge retry request failed after graph merge failure: ${error instanceof Error ? error.message : String(error)}`);
}

View File

@@ -4,6 +4,7 @@
* No instance state; re-exported from the facade for call-site stability.
*/
import type { Task } from "@fusion/core";
import { MERGE_BOUNDARY_UNPROVEN_VALUE } from "../workflows/workflow-merge-nodes.js";
/** True when every step is done or skipped (and at least one step exists). */
export function isTaskWorkComplete(task: Task): boolean {
@@ -50,6 +51,7 @@ export function isRetryableMergePauseAbortStatus(status: string | null | undefin
export function isTerminalMergeGraphFailureValue(value: string | undefined): boolean {
if (!value) return false;
if (value === MERGE_BOUNDARY_UNPROVEN_VALUE) return true;
const normalized = value.toLowerCase();
return normalized.includes("conflict")
|| normalized.includes("contamination")

View File

@@ -12,10 +12,16 @@ export type WorkflowMergeBoundaryProof = {
complete: boolean;
hasRelevantNodeResult: boolean;
allResultsTerminal: boolean;
hasLiveStepImplementationProof: boolean;
nonTerminalResult?: { workflowStepId?: string; status?: string } | null;
missingInstanceIds: string[];
};
export type WorkflowMergeBoundaryResult = {
task: TaskDetail;
blocked?: { reason: string };
};
export type WorkflowMergeBoundaryDeps = {
store: TaskStore;
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
@@ -34,9 +40,9 @@ export async function ensureWorkflowMergeBoundaryTask(
deps: WorkflowMergeBoundaryDeps,
task: TaskDetail,
metadata: { reason: string; nodeId: string; workflowId: string; runId: string },
): Promise<TaskDetail> {
): Promise<WorkflowMergeBoundaryResult> {
let live = await deps.store.getTask(task.id);
if (!live) return task;
if (!live) return { task };
/*
FNXC:WorkflowMerge 2026-07-19-04:10 (U5a / R1 / KTD-7):
@@ -56,8 +62,8 @@ export async function ensureWorkflowMergeBoundaryTask(
A prior review handoff can move a graph-native workflow into its merge column before this boundary projects successful node results onto the legacy checklist. Preserve the no-move behavior, but do not return until the projection has run.
*/
const alreadyAtMergeColumn = live.column === targetColumn;
if (live.column === await resolveCompleteColumnFor(deps.store, live.id)) return live;
if (live.paused || live.userPaused) return live;
if (live.column === await resolveCompleteColumnFor(deps.store, live.id)) return { task: live };
if (live.paused || live.userPaused) return { task: live };
/*
FNXC:WorkflowMerge 2026-06-29-10:15:
@@ -66,6 +72,13 @@ export async function ensureWorkflowMergeBoundaryTask(
FNXC:WorkflowMerge 2026-06-29-15:28:
Compound Engineering and similar graph-native workflows execute skill nodes instead of legacy parsed task steps. The graph records those nodes as `workflowStepResults.source = "node"`; at the merge boundary, project a successful graph-native run onto the legacy checklist so `task has incomplete steps` cannot block a workflow that already completed its authoritative nodes.
*/
/*
FNXC:WorkflowMerge 2026-08-20-00:50:
FN-9157 permits merge admission from complete terminal live foreach coverage
when Review Level 0 has deliberately disabled optional node-recording groups.
The proof still excludes zero expected instances and any pending step; checklist
projection continues to depend only on proof.complete.
*/
const mergeProof = await deps.evaluateWorkflowMergeBoundary(live, metadata.runId);
if (mergeProof.hasForeachStepExecute && !mergeProof.complete) {
const reason = !mergeProof.hasRelevantNodeResult
@@ -74,7 +87,7 @@ export async function ensureWorkflowMergeBoundaryTask(
? `non-terminal pre-merge node result ${mergeProof.nonTerminalResult?.workflowStepId ?? "unknown"} (${mergeProof.nonTerminalResult?.status ?? "unknown"})`
: `foreach step instances incomplete at merge boundary: missing ${mergeProof.missingInstanceIds.join(", ")}`;
await deps.store.logEntry(live.id, `Workflow merge boundary blocked: ${reason}`, undefined, deps.getRunContextFor(live.id));
return live;
return { task: live, blocked: { reason } };
}
if (deps.shouldCompleteChecklistAtWorkflowMerge(live, mergeProof)) {
@@ -99,7 +112,7 @@ export async function ensureWorkflowMergeBoundaryTask(
deps.getRunContextFor(live.id),
);
}
if (alreadyAtMergeColumn) return live;
if (alreadyAtMergeColumn) return { task: live };
const moveOptions = {
preserveProgress: true,
moveSource: "engine" as const,
@@ -112,9 +125,9 @@ export async function ensureWorkflowMergeBoundaryTask(
if (typeof storeWithMove.moveTask === "function") {
const moved = await storeWithMove.moveTask(live.id, targetColumn, moveOptions);
await deps.store.logEntry(live.id, `Workflow merge boundary moved task to ${targetColumn} before requesting merge`, undefined, deps.getRunContextFor(live.id));
return moved ?? { ...live, column: targetColumn };
return { task: moved ?? { ...live, column: targetColumn } };
}
await deps.store.updateTask(live.id, { column: targetColumn } as Partial<TaskDetail>, deps.getRunContextFor(live.id));
await deps.store.logEntry(live.id, `Workflow merge boundary moved task to ${targetColumn} before requesting merge`, undefined, deps.getRunContextFor(live.id));
return { ...live, column: targetColumn };
return { task: { ...live, column: targetColumn } };
}

View File

@@ -2,6 +2,9 @@ import type { TaskDetail } from "@fusion/core";
import type { MergePrimitiveResult, WorkflowPrimitiveContext, WorkflowRuntimePrimitives } from "../execution/runtime-primitives.js";
import type { WorkflowNodeResult } from "./workflow-graph-executor.js";
/** A terminal graph value: retrying cannot create missing merge-boundary proof. */
export const MERGE_BOUNDARY_UNPROVEN_VALUE = "merge-boundary-unproven";
export interface WorkflowMergeNodeDeps {
primitives: Pick<WorkflowRuntimePrimitives, "requestMerge" | "audit">;
}
@@ -34,6 +37,16 @@ export function classifyMergePrimitiveResult(
value: string | undefined,
primitiveOutcome: WorkflowNodeResult["outcome"],
): WorkflowNodeResult {
/*
FNXC:WorkflowMerge 2026-08-20-00:50:
FN-9157 requires an unprovable merge boundary to remain terminal on direct
merge-attempt dispatch. Preserve this explicit value before failed-data
classification, whose unknown-reason fallback is merge-failed and would repeat
the boundary retry.
*/
if (value === MERGE_BOUNDARY_UNPROVEN_VALUE) {
return { outcome: "failure", value: MERGE_BOUNDARY_UNPROVEN_VALUE };
}
if (data?.status === "merged") {
return { outcome: "success", value: data.noOp ? "already-landed" : "merged" };
}