fix(engine): implementation-incomplete merge failures fail-closed/resumable (#1991) (#2091)

## What & why

Workflow graph merge failures classified `implementation-incomplete`
(i.e. the merge node reports there is no implementation proof — no
branch / no committed work) could still be routed to the no-op merge
requester and false-complete the task as **done**. This hides genuinely
unlanded work behind a green "merge" and is the merge-side sibling of
the "(no feedback captured)" no-verdict dispatch defect.

Closes the truthfulness gap: an `implementation-incomplete` merge-graph
failure now **fails closed** when there is no executable proof to
resume, or **requeues resumable parsed steps** back to `todo` for
execution — it is never handed to a no-branch no-op merge requester.

Refs #1991 (no-op merge truthfulness). Sibling of #1946 (no-verdict "(no
feedback captured)" dispatch defect).

## Change

- New classifier `routeImplementationIncompleteMergeGraphFailure(live,
failedNode)`:
  - clears paused-aborted state + active worktree,
- requeues resumable parsed steps via the existing execution-resume
router when the task still has non-terminal workflow steps,
- otherwise fails closed (`status: "failed"` with a logged, explicit
reason).
- Defense-in-depth: `isRetryableBenignMergePauseAbort` and the
merge-requester route both short-circuit (`return false`) for
`implementation-incomplete`, so this value can never reach the no-op
merge requester.
- `handleGraphFailure` routes genuine (non-global-pause,
non-completion-finalize, non-user-paused) `implementation-incomplete`
merge-graph failures through the new classifier.
- Resume-eligibility predicate treats an `implementation-incomplete`
merge failure with **no** incomplete steps as fail-closed, and keeps the
premature-merge-with-incomplete-steps requeue path.

Legitimate `noCommitsExpected` no-op merges are explicitly preserved
(regression test included).

## Tests

New regression coverage in:
-
`packages/engine/src/__tests__/reliability-interactions/merge-node-paused-abort-retryable.test.ts`
— parametrized across merge node ids: (a) no-proof
`implementation-incomplete` fails closed without requesting a no-op
merge; (b) resumable parsed steps are requeued to `todo` for execution
resume, not no-op-merged.
- `packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts` —
a legitimate `noCommitsExpected` builtin:coding merge is still allowed
(guard does not over-block).

Verification (engine package):

    pnpm --filter @fusion/engine exec vitest run \
      src/__tests__/executor-fast-mode-workflows.test.ts \

src/__tests__/reliability-interactions/merge-node-paused-abort-retryable.test.ts
    # => 2 files, 69 tests, 0 failures

    pnpm check:changesets            # pass
    pnpm --filter @fusion/engine typecheck   # 0 errors

A `patch` changeset for `@runfusion/fusion` is included.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Prevented “implementation-incomplete” workflow merge failures from
being treated as successful no-op merges.
* Ensured tasks with resumable implementation steps move back to
execution to continue where they left off.
* Ensured tasks without sufficient implementation evidence fail safely
rather than entering misleading retry/no-op paths.
* Improved paused/aborted merge-failure handling to avoid incorrect
completion states.
* **Tests**
* Added/expanded coverage for fast-mode coding merges and
implementation-incomplete pause/abort retry classification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Fusion <noreply@runfusion.ai>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
This commit is contained in:
Elite X
2026-07-15 05:47:21 +02:00
committed by GitHub
parent 8cceee4eb3
commit 51859148a7
4 changed files with 334 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent implementation-incomplete workflow merge failures from false-completing as no-op done.
category: fix
dev: Merge graph failures with missing implementation proof now fail closed or requeue resumable parsed steps before any no-branch no-op merge requester path can run.

View File

@@ -378,6 +378,65 @@ describe("fast mode workflow/runtime invariants", () => {
);
});
it("allows noCommitsExpected builtin:coding merge even when parsed implementation steps are empty", async () => {
const liveTask = task({
id: "FN-1165-NOOP",
executionMode: "fast",
enabledWorkflowSteps: [],
column: "in-progress",
steps: [],
noCommitsExpected: true,
branch: null,
worktree: null,
prompt: "# Task\n\n## Steps\n\n### Step 1: Decide\n- [ ] Record no-code decision",
});
const inReviewTask = { ...liveTask, column: "in-review" } as typeof liveTask;
const doneTask = {
...liveTask,
column: "done",
mergeDetails: {
mergeConfirmed: true,
noOpMerge: true,
noOpReason: "no-commits-expected",
},
} as typeof liveTask;
const store = createMockStore();
store.getTask.mockResolvedValue(liveTask);
store.getTaskWorkflowSelection = vi.fn(() => ({ workflowId: "builtin:coding", stepIds: [] }));
store.getWorkflowDefinition = vi.fn(async (id: string) => getBuiltinWorkflow(id));
store.moveTask
.mockResolvedValueOnce(inReviewTask)
.mockResolvedValueOnce(doneTask);
const executor = new TaskExecutor(store, "/tmp/test") as any;
const mergeRequester = vi.fn(async () => ({
task: inReviewTask,
merged: true,
noOp: true,
mergeConfirmed: true,
reason: "no-commits-expected",
}));
executor.setMergeRequester(mergeRequester);
const result = await executor.createAuthoritativeWorkflowPrimitives({ autoMerge: true }).requestMerge(
{
run: { runId: "FN-1165-NOOP:builtin:coding", taskId: "FN-1165-NOOP", workflowId: "builtin-stepwise-final-review-coding" },
node: { node: { id: "merge" } },
},
liveTask,
);
expect(result).toMatchObject({ outcome: "success", value: "merge-noop" });
expect(mergeRequester).toHaveBeenCalledWith("FN-1165-NOOP", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(store.logEntry).not.toHaveBeenCalledWith(
"FN-1165-NOOP",
expect.stringContaining("implementation did not run"),
undefined,
undefined,
);
expect(store.moveTask).toHaveBeenCalledWith("FN-1165-NOOP", "done", expect.objectContaining({ preserveProgress: true }));
});
it("fast builtin:coding executes plain Steps-section headings from fast triage specs", async () => {
const calls: string[] = [];
const prompt = `# Task

View File

@@ -84,9 +84,10 @@ describe("merge-node paused-abort retry classification (FN-6735)", () => {
Surface Enumeration coverage:
- Merge seam node ids: legacy `merge`, `requestMerge`, primitive merge-region ids, and historical aliases all route through the same classifier.
- Auto-merge paths: autopilot autoMerge:true and shared-branch local integration are both exercised.
- Pause sources: benign hard-cancel/undefined-like generic pause is retried; global/user pause controls remain terminal.
- Pause sources: benign hard-cancel/undefined-like generic pause is retried; global/user pause controls remain terminal; system pause (`paused` without userPaused/global-pause) still classifies implementation-incomplete fail-closed/resumable.
- Retry/data states: retry budget, mergeConfirmed partial landing, conflict, foreign/contamination, and pre-existing failure all avoid retry.
- FN-5147/FN-7749: autoMerge:false human-gated in-review tasks preserve the manual merge hold cleanly without failed parking or requeueing.
- Worktree tracking: resumable implementation-incomplete requeue keeps activeWorktrees registration when a worktree is preserved; fail-closed releases it.
*/
it.each([
"merge",
@@ -275,4 +276,203 @@ describe("merge-node paused-abort retry classification (FN-6735)", () => {
expect.objectContaining({ status: null, error: null, paused: false }),
);
});
const implementationIncompleteMergeNodes = [
"merge",
"requestMerge",
"merge-gate",
"merge-attempt",
"manual-merge-hold",
"merge-manual-hold",
"retry-backoff",
"merge-retry",
] as const;
it.each(implementationIncompleteMergeNodes)("fails implementation-incomplete no-proof merge pause abort at node %s without requesting no-op merge", async (nodeId) => {
const { store, task, executor, mergeRequester } = makeHarness({
steps: [],
currentStep: 0,
branch: null,
worktree: null,
modifiedFiles: undefined,
workflowStepResults: undefined,
paused: false,
} as Partial<TaskDetail>);
mergeRequester.mockImplementation(async () => {
await store.updateTask(task.id, {
mergeDetails: {
mergeConfirmed: true,
noOpMerge: true,
noOpReason: "no-branch",
},
});
return {
task,
branch: null,
merged: true,
noOp: true,
mergeConfirmed: true,
reason: "no-branch",
worktreeRemoved: false,
branchDeleted: false,
} as any;
});
await invokeGraphFailure(executor, task, nodeId, "implementation-incomplete");
expect(mergeRequester).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalledWith(task.id, "done", expect.anything());
expect(store.moveTask).not.toHaveBeenCalledWith(task.id, "todo", expect.anything());
expect(store.updateTask).not.toHaveBeenCalledWith(
task.id,
expect.objectContaining({
mergeDetails: expect.objectContaining({ noOpMerge: true, noOpReason: "no-branch" }),
}),
expect.anything(),
);
expect(store.updateTask).toHaveBeenCalledWith(
task.id,
expect.objectContaining({
status: "failed",
error: expect.stringContaining("implementation incomplete with no executable proof to resume"),
}),
undefined,
);
const messages = logText(store);
expect(messages).toContain(`Workflow graph merge blocked at node '${nodeId}': implementation incomplete with no executable proof to resume — failing instead of retrying merge`);
expect(messages).not.toContain("routed to bounded auto-merge retry after benign pause/resume abort");
});
it.each(implementationIncompleteMergeNodes)("requeues resumable implementation-incomplete parsed steps at node %s without requesting merge", async (nodeId) => {
const { store, task, executor, mergeRequester } = makeHarness({
steps: [
{ name: "Preflight", status: "done" },
{ name: "Implement", status: "pending" },
],
currentStep: 1,
branch: null,
worktree: null,
modifiedFiles: undefined,
workflowStepResults: undefined,
paused: false,
} as Partial<TaskDetail>);
await invokeGraphFailure(executor, task, nodeId, "implementation-incomplete");
expect(mergeRequester).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith(task.id, { status: null, error: null }, undefined);
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo", expect.objectContaining({
preserveProgress: true,
moveSource: "engine",
recoveryRehome: true,
}));
expect(store.moveTask).not.toHaveBeenCalledWith(task.id, "done", expect.anything());
const messages = logText(store);
expect(messages).toContain(`Workflow graph failed at node '${nodeId}' (implementation-incomplete) with incomplete steps — moved back to todo for execution resume`);
expect(messages).not.toContain("routed to bounded auto-merge retry after benign pause/resume abort");
});
/*
FNXC:WorkflowMerge 2026-07-14-18:20:
Greptile P1 regressions for FN-1165: system-paused rows must still classify, and resumable requeue must not drop active worktree tracking while preserving a persisted worktree.
*/
it("classifies system-paused implementation-incomplete merge failures fail-closed instead of pause-abort parking", async () => {
const { store, task, executor, mergeRequester } = makeHarness({
steps: [],
currentStep: 0,
branch: null,
worktree: null,
modifiedFiles: undefined,
workflowStepResults: undefined,
// System pause park (not userPaused / not global-pause provenance).
paused: true,
userPaused: false,
pausedReason: "awaiting-engine-recovery",
} as Partial<TaskDetail>);
(executor as any).addActiveWorktree(task.id, "/tmp/fusion-fn-1165-fail-closed");
await invokeGraphFailure(executor, task, "merge", "implementation-incomplete");
expect(mergeRequester).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalledWith(task.id, "todo", expect.anything());
expect(store.updateTask).toHaveBeenCalledWith(
task.id,
expect.objectContaining({
status: "failed",
error: expect.stringContaining("implementation incomplete with no executable proof to resume"),
}),
undefined,
);
const messages = logText(store);
expect(messages).toContain("Workflow graph merge blocked at node 'merge': implementation incomplete with no executable proof to resume — failing instead of retrying merge");
expect(messages).not.toContain("operator action required");
expect(messages).not.toContain("benign, paused awaiting explicit unpause");
// Fail-closed may release tracking — no second worktree will be allocated for a terminal row.
expect((executor as any).activeWorktrees.has(task.id)).toBe(false);
});
it("requeues system-paused implementation-incomplete incomplete steps and keeps active worktree tracking", async () => {
const worktreePath = "/tmp/fusion-fn-1165-resumable-wt";
const { store, task, executor, mergeRequester } = makeHarness({
steps: [
{ name: "Preflight", status: "done" },
{ name: "Implement", status: "pending" },
],
currentStep: 1,
branch: "fusion/fn-1165-resumable",
worktree: worktreePath,
modifiedFiles: undefined,
workflowStepResults: undefined,
paused: true,
userPaused: false,
pausedReason: "system-pause-park",
} as Partial<TaskDetail>);
(executor as any).addActiveWorktree(task.id, worktreePath);
await invokeGraphFailure(executor, task, "merge", "implementation-incomplete");
expect(mergeRequester).not.toHaveBeenCalled();
// System pause park must be cleared so the requeued todo row is dispatchable.
expect(store.updateTask).toHaveBeenCalledWith(
task.id,
expect.objectContaining({ paused: false, pausedReason: null }),
undefined,
);
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo", expect.objectContaining({
preserveProgress: true,
moveSource: "engine",
recoveryRehome: true,
}));
const messages = logText(store);
expect(messages).toContain("Workflow graph failed at node 'merge' (implementation-incomplete) with incomplete steps — moved back to todo for execution resume");
expect(messages).not.toContain("operator action required");
// Resumable path keeps active registration so the preserved worktree stays counted.
expect((executor as any).activeWorktrees.has(task.id)).toBe(true);
expect((executor as any).getActiveWorktreePaths(task.id)).toEqual([worktreePath]);
});
it("keeps active worktree tracking on non-paused resumable implementation-incomplete requeue", async () => {
const worktreePath = "/tmp/fusion-fn-1165-unpaused-resumable-wt";
const { store, task, executor, mergeRequester } = makeHarness({
steps: [
{ name: "Preflight", status: "done" },
{ name: "Implement", status: "pending" },
],
currentStep: 1,
branch: "fusion/fn-1165-unpaused",
worktree: worktreePath,
modifiedFiles: undefined,
workflowStepResults: undefined,
paused: false,
} as Partial<TaskDetail>);
(executor as any).addActiveWorktree(task.id, worktreePath);
await invokeGraphFailure(executor, task, "merge-gate", "implementation-incomplete");
expect(mergeRequester).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo", expect.objectContaining({ preserveProgress: true }));
expect((executor as any).activeWorktrees.has(task.id)).toBe(true);
expect((executor as any).getActiveWorktreePaths(task.id)).toEqual([worktreePath]);
});
});

View File

@@ -8434,7 +8434,10 @@ export class TaskExecutor {
if (abortProvenance === "completion-finalize") return false;
if (live.column !== "in-review" || !this.isRetryableMergePauseAbortStatus(live.status) || live.error != null) return false;
if (live.mergeDetails?.mergeConfirmed === true) return false;
if (this.isTerminalMergeGraphFailureValue(this.graphFailureValue(result))) return false;
const failureValue = this.graphFailureValue(result);
if (this.isTerminalMergeGraphFailureValue(failureValue)) return false;
/* FNXC:WorkflowMerge 2026-07-12-17:38: FN-1165 / Runfusion#1991 — missing implementation proof is not a transient merge pause. Let the implementation-incomplete classifier fail closed or requeue resumable parsed steps before any requester can mint a no-branch no-op merge proof. */
if (failureValue === "implementation-incomplete") return false;
const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1];
if (!this.isMergeGraphFailure(failedNode)) return false;
let settings: Settings | undefined;
@@ -8826,6 +8829,8 @@ export class TaskExecutor {
abortProvenance: "global-pause" | "merge-seam" | "hard-cancel" | "completion-finalize" | undefined,
): Promise<boolean> {
if (!this.mergeRequester) return false;
/* FNXC:WorkflowMerge 2026-07-12-17:38: FN-1165 defense in depth — implementation-incomplete merge graph failures must never reach the merge requester, because a no-branch task can otherwise be finalized as an intentional no-op. */
if (this.graphFailureValue(result) === "implementation-incomplete") return false;
const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1] ?? "unknown";
const message = `Workflow graph merge failure at node '${failedNode}' routed to bounded auto-merge retry${abortProvenance === "merge-seam" ? " after merge-seam abort" : abortProvenance === "hard-cancel" || abortProvenance === undefined ? " after benign pause/resume abort" : ""}`;
executorLog.warn(`${live.id}: ${message}`);
@@ -8845,6 +8850,40 @@ export class TaskExecutor {
return true;
}
private async routeImplementationIncompleteMergeGraphFailure(live: TaskDetail, failedNode: string): Promise<boolean> {
/*
FNXC:WorkflowMerge 2026-07-14-18:20:
FN-1165 greptile P1s: (1) system-paused implementation-incomplete merge failures must still classify —
clear only non-user pause parks so incomplete steps can requeue; real global/user pauses never enter this method.
(2) Do not drop activeWorktrees until we know the outcome is terminal fail-closed. Resumable requeue preserves
progress (and often the persisted worktree); releasing tracking early leaves that worktree uncounted while a later
dispatch can allocate a second one. Keep the active registration on the resumable path; release only on fail-closed.
*/
this.clearPausedAborted(live.id);
let resumeLive = live;
if (live.paused === true && live.userPaused !== true) {
// FNXC:WorkflowMerge 2026-07-14-18:35: TaskDetail.pausedReason is string|undefined (not null). Persist clear via updateTask (store accepts null); in-memory resume snapshot uses undefined to satisfy the type.
await this.store.updateTask(live.id, {
paused: false,
pausedReason: null,
}, this.getRunContextFor(live.id));
resumeLive = { ...live, paused: false, pausedReason: undefined };
}
if (hasNonTerminalWorkflowSteps(resumeLive) && await this.routeGraphFailureToExecutionResume(resumeLive, failedNode, "implementation-incomplete")) {
return true;
}
// Fail-closed terminal path — release active worktree tracking now that no resume will reuse it.
this.activeWorktrees.delete(live.id);
const message = `Workflow graph merge blocked at node '${failedNode}': implementation incomplete with no executable proof to resume — failing instead of retrying merge`;
executorLog.warn(`${live.id}: ${message}`);
await this.store.logEntry(live.id, message, undefined, this.getRunContextFor(live.id));
if (live.column !== "done" && live.column !== "archived" && live.error == null) {
await this.store.updateTask(live.id, { error: message, status: "failed" }, this.getRunContextFor(live.id));
}
await this.persistTokenUsage(live.id);
return true;
}
/** Terminal failure of a graph run: record the error and park the task in
* review so a human can act — never leave it invisible in in-progress. */
private async handleGraphFailure(task: Task, result: WorkflowGraphTaskRunResult): Promise<void> {
@@ -8930,9 +8969,9 @@ export class TaskExecutor {
|| (live.paused && !mergeSeamAborted && !suppressFinalizedCompletionAbort)
|| (pausedAborted && !mergeSeamAborted && !completionFinalizeAborted && !suppressFinalizedCompletionAbort),
);
const failedNodeForLog = result.visitedNodeIds[result.visitedNodeIds.length - 1] ?? "unknown";
const failureValueForLog = this.graphFailureValue(result) ?? "none";
if (pausedAborted || live.paused || live.userPaused || abortProvenance) {
const failedNodeForLog = result.visitedNodeIds[result.visitedNodeIds.length - 1] ?? "unknown";
const failureValueForLog = this.graphFailureValue(result) ?? "none";
this.safeLogEntry(
task.id,
`Pause abort classified: provenance=${abortProvenance ?? "unknown"}; node=${failedNodeForLog}; interrupted=${result.interruptedNodeId ?? "none"}; abortKind=${result.interruptedAbortKind ?? "none"}; column=${live.column}; status=${live.status ?? "none"}; paused=${live.paused === true}; userPaused=${live.userPaused === true}; value=${failureValueForLog}; genuine=${genuinePauseAbort}; mergeSeam=${mergeSeamAborted}; completionSuppressed=${suppressFinalizedCompletionAbort}`,
@@ -8943,6 +8982,24 @@ export class TaskExecutor {
return;
}
}
/*
FNXC:WorkflowMerge 2026-07-14-18:20:
FN-1165 greptile P1: system pause (`live.paused` without userPaused/global-pause) must still enter the
implementation-incomplete merge classifier. Requiring `live.paused !== true` let pause-abort parking win and
skipped fail-closed/resumable routing for missing implementation proof. User pause and global-pause stay excluded.
*/
if (
genuinePauseAbort
&& abortProvenance !== "global-pause"
&& abortProvenance !== "completion-finalize"
&& live.userPaused !== true
&& this.isMergeGraphFailure(failedNodeForLog)
&& failureValueForLog === "implementation-incomplete"
) {
if (await this.routeImplementationIncompleteMergeGraphFailure(live, failedNodeForLog)) {
return;
}
}
if (genuinePauseAbort && await this.isRetryableBenignMergePauseAbort(live, result, abortProvenance, pausedAborted)) {
if (await this.routeGraphMergeFailureToRetry(live, result, abortProvenance)) {
return;
@@ -9279,8 +9336,10 @@ export class TaskExecutor {
await this.persistTokenUsage(task.id);
return;
}
if (mergeGraphFailure && failureValue === "implementation-incomplete" && await this.routeGraphFailureToExecutionResume(live, failedNode ?? "unknown", failureValue)) {
return;
if (mergeGraphFailure && failureValue === "implementation-incomplete") {
if (await this.routeImplementationIncompleteMergeGraphFailure(live, failedNode ?? "unknown")) {
return;
}
}
if (mergeGraphFailure && !this.isTerminalMergeGraphFailureValue(failureValue) && await this.routeGraphMergeFailureToRetry(live, result, abortProvenance)) {
return;
@@ -9405,7 +9464,9 @@ export class TaskExecutor {
*/
if (failedNode === COMPLETION_SUMMARY_NODE_ID) return false;
const incompleteSteps = hasNonTerminalWorkflowSteps(live);
const prematureMergeWithIncompleteSteps = failedNode === "merge" && failureValue === "implementation-incomplete" && incompleteSteps;
const implementationIncompleteMergeFailure = this.isMergeGraphFailure(failedNode) && failureValue === "implementation-incomplete";
if (implementationIncompleteMergeFailure && !incompleteSteps) return false;
const prematureMergeWithIncompleteSteps = implementationIncompleteMergeFailure && incompleteSteps;
if (live.column !== "in-review" && !(incompleteSteps && live.column === "todo") && !(prematureMergeWithIncompleteSteps && live.column === "in-progress")) return false;
const message = incompleteSteps