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:
7
.changeset/fn-9157-merge-boundary.md
Normal file
7
.changeset/fn-9157-merge-boundary.md
Normal 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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")] });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user