diff --git a/.changeset/fn-7261-fast-merge-guard.md b/.changeset/fn-7261-fast-merge-guard.md new file mode 100644 index 0000000000..6ab09ea39c --- /dev/null +++ b/.changeset/fn-7261-fast-merge-guard.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent fast workflow merges from completing before implementation steps run. +category: fix +dev: Blocks stale no-op merge proof from trapping unfinished workflow tasks and requeues premature merge-node failures. diff --git a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts index 774bf354b9..7c2d012ef8 100644 --- a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts +++ b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts @@ -388,6 +388,88 @@ describe("CE workflow-step executor integration", () => { expect(live.mergeDetails?.mergeConfirmed).toBe(true); }); + it("lets stale no-op merge proof fall through when implementation steps are incomplete", async () => { + const store = createMockStore(); + const live = baseStepTask({ + column: "in-progress", + status: "failed", + error: "Merge confirmed but finalization blocked: task has incomplete steps", + mergeDetails: { mergeConfirmed: true, noOpMerge: true, noOpReason: "already-merged" }, + steps: [ + { name: "Preflight", status: "in-progress" }, + { name: "Implement", status: "pending" }, + ], + }); + store.getTask.mockResolvedValue(live as any); + const { executor } = makeExecutor(store); + + /* + * FNXC:WorkflowMerge 2026-06-29-23:12: + * A no-op merge confirmation without a landed commit is not implementation proof. When reopened work still has incomplete legacy steps, execute() must continue to stale-merge cleanup/reverification instead of consuming the run in merge-confirmed finalization. + */ + const handled = await (executor as any).finalizeMergeConfirmedWorkflowGraphTask("FN-CE-1", "test"); + + expect(handled).toBe(false); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-CE-1", "done", expect.anything()); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-CE-1", + expect.stringContaining("merge-confirmed finalization blocked"), + undefined, + undefined, + ); + }); + + it("blocks the merge requester when graph traversal reaches merge before implementation steps finish", async () => { + const store = createMockStore(); + let live = baseStepTask({ + column: "in-progress", + status: null, + error: null, + steps: [ + { name: "Preflight", status: "in-progress" }, + { name: "Implement", status: "pending" }, + ], + }); + store.getTask.mockImplementation(async () => live as any); + store.updateTask.mockImplementation(async (_id: string, patch: Record) => { + live = { ...live, ...patch }; + return live as any; + }); + store.moveTask.mockImplementation(async (_id: string, column: string) => { + live = { ...live, column }; + return live as any; + }); + const { executor } = makeExecutor(store); + const mergeRequester = vi.fn(async () => ({ ok: true, merged: false, noOp: true, mergeConfirmed: true })); + executor.setMergeRequester(mergeRequester as any); + const settings = await store.getSettings(); + const primitives = (executor as any).createAuthoritativeWorkflowPrimitives(settings); + + /* + * FNXC:WorkflowMerge 2026-06-29-23:18: + * Reaching the merge node is not itself proof that implementation ran. The requester must not create a no-op merge for an unfinished legacy checklist; the graph failure path will route the task back to executable work. + */ + const result = await primitives.requestMerge( + { + run: { runId: "run-1", taskId: "FN-CE-1", workflowId: "builtin:coding" }, + node: { node: { id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } }, context: {} }, + }, + live, + ); + + expect(result).toEqual(expect.objectContaining({ + outcome: "failure", + value: "implementation-incomplete", + })); + expect(mergeRequester).not.toHaveBeenCalled(); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-CE-1", + "Workflow merge blocked before requester: implementation steps are incomplete", + undefined, + undefined, + ); + }); + it("uses moveTask for workflow graph column transitions so lifecycle notifications fire", async () => { const store = createMockStore(); store.getTask.mockResolvedValue(baseStepTask({ column: "todo" }) as any); diff --git a/packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts b/packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts index ed151d9d0d..c5e849f8dc 100644 --- a/packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts +++ b/packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts @@ -121,6 +121,50 @@ describe("executor graph execute self-requeue gate", () => { expect(store.handoffToReview).not.toHaveBeenCalled(); }); + it("moves premature merge failures with incomplete in-progress steps back to todo", async () => { + resetExecutorMocks(); + const store = createMockStore(); + const live = task({ + id: "FN-7261", + column: "in-progress", + status: null, + error: null, + steps: [ + { name: "Preflight", status: "in-progress" }, + { name: "Implement", status: "pending" }, + ], + }); + store.getTask.mockResolvedValue(live); + const executor = new TaskExecutor(store, "/tmp/test"); + + /* + * FNXC:WorkflowMerge 2026-06-29-23:18: + * Fast-mode graph traversal must not turn an unfinished legacy checklist into a no-op merge. If the merge node is reached before implementation proof exists, recover by requeueing executable work instead of parking the task failed in-progress. + */ + await (executor as any).handleGraphFailure(live, { + disposition: "failed", + outcome: "failure", + visitedNodeIds: ["merge"], + context: { "node:merge:value": "implementation-incomplete" }, + }); + + expect(store.updateTask).toHaveBeenCalledWith( + live.id, + expect.objectContaining({ status: null, error: null }), + undefined, + ); + expect(store.moveTask).toHaveBeenCalledWith( + live.id, + "todo", + expect.objectContaining({ preserveProgress: true, moveSource: "engine", recoveryRehome: true }), + ); + expect(store.updateTask).not.toHaveBeenCalledWith( + live.id, + expect.objectContaining({ status: "failed" }), + expect.anything(), + ); + }); + it("does not hand generic graph failures to review", async () => { resetExecutorMocks(); const store = createMockStore(); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 6b32d7bc9f..96d5fc4d2e 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -3169,6 +3169,8 @@ export class TaskExecutor { await this.store.updateTask(task.id, { mergeDetails: null, mergeRetries: 0, + status: null, + error: null, verificationFailureCount: options?.preserveVerificationFailureCount ? task.verificationFailureCount ?? 0 : 0, workflowStepResults: preservedWorkflowStepResults, }); @@ -5806,6 +5808,23 @@ export class TaskExecutor { workflowId: ctx.run.workflowId, runId: ctx.run.runId, }); + /* + 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. + */ + if (hasNonTerminalWorkflowSteps(mergeTask)) { + await this.store.logEntry( + mergeTask.id, + "Workflow merge blocked before requester: implementation steps are incomplete", + undefined, + this.getRunContextFor(mergeTask.id), + ); + return { + outcome: "failure", + value: "implementation-incomplete", + data: { status: "failed", reason: "implementation-incomplete" }, + }; + } const GRAPH_MERGE_TIMEOUT_MS = 30 * 60 * 1000; const controller = new AbortController(); let timeoutHandle: ReturnType | undefined; @@ -6783,6 +6802,13 @@ export class TaskExecutor { undefined, this.getRunContextFor(taskId), ); + if (finalization.reason === "task has incomplete steps" && live.mergeDetails?.noOpMerge === true && !live.mergeDetails?.commitSha) { + /* + FNXC:WorkflowMerge 2026-06-29-23:12: + FN-7261 exposed stale no-op proof as a re-execution blocker: a reopened task with incomplete implementation steps and only no-op merge proof must fall through to merge-state cleanup/reverification, not consume execute() by repeatedly trying blocked finalization. + */ + return false; + } return true; } executorLog.log(`${taskId}: workflow graph merge-confirmed task finalized (${finalization.outcome})`); @@ -8069,6 +8095,9 @@ export class TaskExecutor { await this.persistTokenUsage(task.id); return; } + if (mergeGraphFailure && failureValue === "implementation-incomplete" && await this.routeGraphFailureToExecutionResume(live, failedNode ?? "unknown", failureValue)) { + return; + } if (mergeGraphFailure && !this.isTerminalMergeGraphFailureValue(failureValue) && await this.routeGraphMergeFailureToRetry(live, result, abortProvenance)) { return; } @@ -8167,7 +8196,8 @@ export class TaskExecutor { if (live.paused || live.userPaused === true) return false; if (live.column === "done" || live.column === "archived") return false; const incompleteSteps = hasNonTerminalWorkflowSteps(live); - if (live.column !== "in-review" && !(incompleteSteps && live.column === "todo")) return false; + const prematureMergeWithIncompleteSteps = failedNode === "merge" && failureValue === "implementation-incomplete" && incompleteSteps; + if (live.column !== "in-review" && !(incompleteSteps && live.column === "todo") && !(prematureMergeWithIncompleteSteps && live.column === "in-progress")) return false; const message = incompleteSteps ? `Workflow graph failed at node '${failedNode}'${failureValue ? ` (${failureValue})` : ""} with incomplete steps — moved back to todo for execution resume`