fix(FN-7261): prevent premature fast workflow merges
Block the merge requester when fast workflow traversal reaches merge before implementation steps are complete, and recover stale no-op merge proof by requeuing executable work instead of looping in blocked finalization. Fusion-Task-Id: FN-7261
This commit is contained in:
7
.changeset/fn-7261-fast-merge-guard.md
Normal file
7
.changeset/fn-7261-fast-merge-guard.md
Normal file
@@ -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.
|
||||
@@ -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<string, unknown>) => {
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof setTimeout> | 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`
|
||||
|
||||
Reference in New Issue
Block a user