diff --git a/.changeset/fn-9157-merge-boundary.md b/.changeset/fn-9157-merge-boundary.md new file mode 100644 index 0000000000..2669cde3cd --- /dev/null +++ b/.changeset/fn-9157-merge-boundary.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index be5e9c9831..e58734a08f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index c1b63f8db2..79c1774b22 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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. diff --git a/packages/core/src/__tests__/workflow-merge-proof.test.ts b/packages/core/src/__tests__/workflow-merge-proof.test.ts index 74244dd9cf..97351ce9b6 100644 --- a/packages/core/src/__tests__/workflow-merge-proof.test.ts +++ b/packages/core/src/__tests__/workflow-merge-proof.test.ts @@ -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]); }); diff --git a/packages/core/src/workflow-merge-proof.ts b/packages/core/src/workflow-merge-proof.ts index b2704c5756..0e6bb6b755 100644 --- a/packages/core/src/workflow-merge-proof.ts +++ b/packages/core/src/workflow-merge-proof.ts @@ -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, }; diff --git a/packages/engine/src/__tests__/executor-graph-boundary.test.ts b/packages/engine/src/__tests__/executor-graph-boundary.test.ts index dee50bb853..12718354aa 100644 --- a/packages/engine/src/__tests__/executor-graph-boundary.test.ts +++ b/packages/engine/src/__tests__/executor-graph-boundary.test.ts @@ -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"); + } + }); }); diff --git a/packages/engine/src/__tests__/executor-merge-boundary-foreach-proof.pg.test.ts b/packages/engine/src/__tests__/executor-merge-boundary-foreach-proof.pg.test.ts index e1dd70802a..14eb477410 100644 --- a/packages/engine/src/__tests__/executor-merge-boundary-foreach-proof.pg.test.ts +++ b/packages/engine/src/__tests__/executor-merge-boundary-foreach-proof.pg.test.ts @@ -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")] }); diff --git a/packages/engine/src/__tests__/merge-boundary-unproven-park.test.ts b/packages/engine/src/__tests__/merge-boundary-unproven-park.test.ts new file mode 100644 index 0000000000..27f4337c18 --- /dev/null +++ b/packages/engine/src/__tests__/merge-boundary-unproven-park.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/executor/create-authoritative-workflow-primitives.ts b/packages/engine/src/executor/create-authoritative-workflow-primitives.ts index 28de08a3ba..78c2d57015 100644 --- a/packages/engine/src/executor/create-authoritative-workflow-primitives.ts +++ b/packages/engine/src/executor/create-authoritative-workflow-primitives.ts @@ -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. diff --git a/packages/engine/src/executor/create-authoritative-workflow-seams.ts b/packages/engine/src/executor/create-authoritative-workflow-seams.ts index 1de3cef423..fd3d1515ae 100644 --- a/packages/engine/src/executor/create-authoritative-workflow-seams.ts +++ b/packages/engine/src/executor/create-authoritative-workflow-seams.ts @@ -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( diff --git a/packages/engine/src/executor/evaluate-workflow-merge-boundary.ts b/packages/engine/src/executor/evaluate-workflow-merge-boundary.ts index d1b5f997c4..9b374907cf 100644 --- a/packages/engine/src/executor/evaluate-workflow-merge-boundary.ts +++ b/packages/engine/src/executor/evaluate-workflow-merge-boundary.ts @@ -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 = { diff --git a/packages/engine/src/executor/route-graph-merge-failure-to-retry.ts b/packages/engine/src/executor/route-graph-merge-failure-to-retry.ts index 871c176fed..2eec63ba85 100644 --- a/packages/engine/src/executor/route-graph-merge-failure-to-retry.ts +++ b/packages/engine/src/executor/route-graph-merge-failure-to-retry.ts @@ -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; + ) => Promise<{ task: TaskDetail; blocked?: { reason: string } }>; persistTokenUsage: (taskId: string) => Promise; }; @@ -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)}`); } diff --git a/packages/engine/src/executor/task-predicates.ts b/packages/engine/src/executor/task-predicates.ts index ea7c14a511..0e8153a08b 100644 --- a/packages/engine/src/executor/task-predicates.ts +++ b/packages/engine/src/executor/task-predicates.ts @@ -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") diff --git a/packages/engine/src/executor/workflow-merge-boundary.ts b/packages/engine/src/executor/workflow-merge-boundary.ts index e3442998ca..50048d8eea 100644 --- a/packages/engine/src/executor/workflow-merge-boundary.ts +++ b/packages/engine/src/executor/workflow-merge-boundary.ts @@ -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 { +): Promise { 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, 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 } }; } diff --git a/packages/engine/src/workflows/workflow-merge-nodes.ts b/packages/engine/src/workflows/workflow-merge-nodes.ts index b42a6e4700..0e1ce6b465 100644 --- a/packages/engine/src/workflows/workflow-merge-nodes.ts +++ b/packages/engine/src/workflows/workflow-merge-nodes.ts @@ -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; } @@ -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" }; }