From 51859148a711510c5aad8554e2fdb1cae124916f Mon Sep 17 00:00:00 2001 From: Elite X Date: Wed, 15 Jul 2026 05:47:21 +0200 Subject: [PATCH] fix(engine): implementation-incomplete merge failures fail-closed/resumable (#1991) (#2091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. ## 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. --------- Co-authored-by: Fusion Co-authored-by: gsxdsm --- .changeset/fn-1165-noop-merge-truthfulness.md | 7 + .../executor-fast-mode-workflows.test.ts | 59 +++++ .../merge-node-paused-abort-retryable.test.ts | 202 +++++++++++++++++- packages/engine/src/executor.ts | 73 ++++++- 4 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 .changeset/fn-1165-noop-merge-truthfulness.md diff --git a/.changeset/fn-1165-noop-merge-truthfulness.md b/.changeset/fn-1165-noop-merge-truthfulness.md new file mode 100644 index 0000000000..f4adbdb736 --- /dev/null +++ b/.changeset/fn-1165-noop-merge-truthfulness.md @@ -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. diff --git a/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts index 50079509e1..f86fa017c2 100644 --- a/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts +++ b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts @@ -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 diff --git a/packages/engine/src/__tests__/reliability-interactions/merge-node-paused-abort-retryable.test.ts b/packages/engine/src/__tests__/reliability-interactions/merge-node-paused-abort-retryable.test.ts index f59f2f70d7..2b5110bc73 100644 --- a/packages/engine/src/__tests__/reliability-interactions/merge-node-paused-abort-retryable.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/merge-node-paused-abort-retryable.test.ts @@ -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); + 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); + + 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); + (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); + (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); + (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]); + }); + }); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 079bd82185..c7a71e9226 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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 { 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 { + /* + 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 { @@ -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