## 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:
7
.changeset/fn-1165-noop-merge-truthfulness.md
Normal file
7
.changeset/fn-1165-noop-merge-truthfulness.md
Normal 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.
|
||||||
@@ -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 () => {
|
it("fast builtin:coding executes plain Steps-section headings from fast triage specs", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const prompt = `# Task
|
const prompt = `# Task
|
||||||
|
|||||||
@@ -84,9 +84,10 @@ describe("merge-node paused-abort retry classification (FN-6735)", () => {
|
|||||||
Surface Enumeration coverage:
|
Surface Enumeration coverage:
|
||||||
- Merge seam node ids: legacy `merge`, `requestMerge`, primitive merge-region ids, and historical aliases all route through the same classifier.
|
- 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.
|
- 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.
|
- 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.
|
- 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([
|
it.each([
|
||||||
"merge",
|
"merge",
|
||||||
@@ -275,4 +276,203 @@ describe("merge-node paused-abort retry classification (FN-6735)", () => {
|
|||||||
expect.objectContaining({ status: null, error: null, paused: false }),
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8434,7 +8434,10 @@ export class TaskExecutor {
|
|||||||
if (abortProvenance === "completion-finalize") return false;
|
if (abortProvenance === "completion-finalize") return false;
|
||||||
if (live.column !== "in-review" || !this.isRetryableMergePauseAbortStatus(live.status) || live.error != null) return false;
|
if (live.column !== "in-review" || !this.isRetryableMergePauseAbortStatus(live.status) || live.error != null) return false;
|
||||||
if (live.mergeDetails?.mergeConfirmed === true) 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];
|
const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1];
|
||||||
if (!this.isMergeGraphFailure(failedNode)) return false;
|
if (!this.isMergeGraphFailure(failedNode)) return false;
|
||||||
let settings: Settings | undefined;
|
let settings: Settings | undefined;
|
||||||
@@ -8826,6 +8829,8 @@ export class TaskExecutor {
|
|||||||
abortProvenance: "global-pause" | "merge-seam" | "hard-cancel" | "completion-finalize" | undefined,
|
abortProvenance: "global-pause" | "merge-seam" | "hard-cancel" | "completion-finalize" | undefined,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!this.mergeRequester) return false;
|
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 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" : ""}`;
|
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}`);
|
executorLog.warn(`${live.id}: ${message}`);
|
||||||
@@ -8845,6 +8850,40 @@ export class TaskExecutor {
|
|||||||
return true;
|
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
|
/** 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. */
|
* review so a human can act — never leave it invisible in in-progress. */
|
||||||
private async handleGraphFailure(task: Task, result: WorkflowGraphTaskRunResult): Promise<void> {
|
private async handleGraphFailure(task: Task, result: WorkflowGraphTaskRunResult): Promise<void> {
|
||||||
@@ -8930,9 +8969,9 @@ export class TaskExecutor {
|
|||||||
|| (live.paused && !mergeSeamAborted && !suppressFinalizedCompletionAbort)
|
|| (live.paused && !mergeSeamAborted && !suppressFinalizedCompletionAbort)
|
||||||
|| (pausedAborted && !mergeSeamAborted && !completionFinalizeAborted && !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) {
|
if (pausedAborted || live.paused || live.userPaused || abortProvenance) {
|
||||||
const failedNodeForLog = result.visitedNodeIds[result.visitedNodeIds.length - 1] ?? "unknown";
|
|
||||||
const failureValueForLog = this.graphFailureValue(result) ?? "none";
|
|
||||||
this.safeLogEntry(
|
this.safeLogEntry(
|
||||||
task.id,
|
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}`,
|
`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;
|
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 (genuinePauseAbort && await this.isRetryableBenignMergePauseAbort(live, result, abortProvenance, pausedAborted)) {
|
||||||
if (await this.routeGraphMergeFailureToRetry(live, result, abortProvenance)) {
|
if (await this.routeGraphMergeFailureToRetry(live, result, abortProvenance)) {
|
||||||
return;
|
return;
|
||||||
@@ -9279,8 +9336,10 @@ export class TaskExecutor {
|
|||||||
await this.persistTokenUsage(task.id);
|
await this.persistTokenUsage(task.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mergeGraphFailure && failureValue === "implementation-incomplete" && await this.routeGraphFailureToExecutionResume(live, failedNode ?? "unknown", failureValue)) {
|
if (mergeGraphFailure && failureValue === "implementation-incomplete") {
|
||||||
return;
|
if (await this.routeImplementationIncompleteMergeGraphFailure(live, failedNode ?? "unknown")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (mergeGraphFailure && !this.isTerminalMergeGraphFailureValue(failureValue) && await this.routeGraphMergeFailureToRetry(live, result, abortProvenance)) {
|
if (mergeGraphFailure && !this.isTerminalMergeGraphFailureValue(failureValue) && await this.routeGraphMergeFailureToRetry(live, result, abortProvenance)) {
|
||||||
return;
|
return;
|
||||||
@@ -9405,7 +9464,9 @@ export class TaskExecutor {
|
|||||||
*/
|
*/
|
||||||
if (failedNode === COMPLETION_SUMMARY_NODE_ID) return false;
|
if (failedNode === COMPLETION_SUMMARY_NODE_ID) return false;
|
||||||
const incompleteSteps = hasNonTerminalWorkflowSteps(live);
|
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;
|
if (live.column !== "in-review" && !(incompleteSteps && live.column === "todo") && !(prematureMergeWithIncompleteSteps && live.column === "in-progress")) return false;
|
||||||
|
|
||||||
const message = incompleteSteps
|
const message = incompleteSteps
|
||||||
|
|||||||
Reference in New Issue
Block a user