FN-9166: preserve implementation-incomplete merge failures
Keep structured incomplete-implementation failures intact so graph recovery can resume or fail closed without retrying a no-op merge. - Preserve normalized implementation-incomplete reasons during direct merge-attempt classification. - Cover primitive, legacy seam, resumable, fail-closed, and cancellation paths. - Document the merge-boundary invariant and add a patch changeset. Files changed: .../fn-9166-preserve-implementation-incomplete.md | 7 ++ docs/architecture.md | 2 +- .../merge-node-paused-abort-retryable.test.ts | 76 ++++++++++++++++++++++ .../__tests__/workflow-merge-cancellation.test.ts | 15 ++++- .../src/__tests__/workflow-merge-nodes.test.ts | 46 +++++++++++++ .../engine/src/workflows/workflow-merge-nodes.ts | 13 +++- 6 files changed, 155 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-9166 Fusion-Task-Lineage: 1b7946bf-62e3-42f0-8c8c-09230fef22bb Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9166-preserve-implementation-incomplete.md
Normal file
7
.changeset/fn-9166-preserve-implementation-incomplete.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Preserve incomplete implementation failures through workflow merge handling.
|
||||||
|
category: fix
|
||||||
|
dev: Keeps the implementation-incomplete merge-node value intact for graph recovery.
|
||||||
@@ -2427,4 +2427,4 @@ Scheduler and autopilot mission reconciliation persist the evaluated alignment o
|
|||||||
|
|
||||||
## Workflow merge-boundary invariant
|
## Workflow merge-boundary invariant
|
||||||
|
|
||||||
The bounded auto-merge retry must never repeat a merge-boundary check that has already reported missing proof. `merge-boundary-unproven` is terminal: the engine parks the task as failed, and `shouldHoldActiveFileScopeLease` releases its active file-scope lease because failed rows are not live work. Terminal merge values must survive `classifyMergePrimitiveResult` on both the collapsed synthetic `merge` seam and direct `merge-attempt` runner; do not encode a new terminal value solely as `data.status:"failed"` with an unrecognized reason, because that classifier collapses it to non-terminal `merge-failed`.
|
The bounded auto-merge retry must never repeat a merge-boundary check that has already reported missing proof. `merge-boundary-unproven` is terminal: the engine parks the task as failed, and `shouldHoldActiveFileScopeLease` releases its active file-scope lease because failed rows are not live work. `implementation-incomplete` is likewise a preserved structured failed reason, allowing its dedicated resumable/fail-closed graph route to run rather than repeating a merge request. Terminal merge values must survive `classifyMergePrimitiveResult` on both the collapsed synthetic `merge` seam and direct `merge-attempt` runner; do not encode a new terminal value solely as `data.status:"failed"` with an unrecognized reason, because that classifier collapses it to non-terminal `merge-failed`.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import "../executor-test-helpers.js";
|
import "../executor-test-helpers.js";
|
||||||
import { TaskExecutor } from "../../executor.js";
|
import { TaskExecutor } from "../../executor.js";
|
||||||
|
import { runWorkflowMergeAttemptNode } from "../../workflows/workflow-merge-nodes.js";
|
||||||
import { createMockStore, resetExecutorMocks } from "../executor-test-helpers.js";
|
import { createMockStore, resetExecutorMocks } from "../executor-test-helpers.js";
|
||||||
import type { TaskDetail } from "@fusion/core";
|
import type { TaskDetail } from "@fusion/core";
|
||||||
|
|
||||||
@@ -75,6 +76,28 @@ function logText(store: ReturnType<typeof createMockStore>): string {
|
|||||||
return store.logEntry.mock.calls.map((call: unknown[]) => call[1]).join("\n");
|
return store.logEntry.mock.calls.map((call: unknown[]) => call[1]).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function produceImplementationIncompleteMergeNodeValue(task: TaskDetail): Promise<string> {
|
||||||
|
const requestMerge = vi.fn().mockResolvedValue({
|
||||||
|
outcome: "failure",
|
||||||
|
value: "implementation-incomplete",
|
||||||
|
data: { status: "failed", reason: "implementation-incomplete" },
|
||||||
|
});
|
||||||
|
const result = await runWorkflowMergeAttemptNode({
|
||||||
|
primitives: { requestMerge, audit: vi.fn() },
|
||||||
|
}, {
|
||||||
|
run: { runId: "run-implementation-incomplete", taskId: task.id, workflowId: "builtin:coding" },
|
||||||
|
node: { node: { id: "merge-attempt", kind: "merge-attempt" } },
|
||||||
|
}, task);
|
||||||
|
|
||||||
|
expect(requestMerge).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
outcome: "failure",
|
||||||
|
value: "implementation-incomplete",
|
||||||
|
contextPatch: { "workflow:merge-status": "implementation-incomplete" },
|
||||||
|
});
|
||||||
|
return result.value!;
|
||||||
|
}
|
||||||
|
|
||||||
describe("merge-node paused-abort retry classification (FN-6735)", () => {
|
describe("merge-node paused-abort retry classification (FN-6735)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetExecutorMocks();
|
resetExecutorMocks();
|
||||||
@@ -320,6 +343,59 @@ describe("merge-node paused-abort retry classification (FN-6735)", () => {
|
|||||||
"merge-retry",
|
"merge-retry",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
it.each(["merge-attempt", "merge"] as const)("routes primitive-produced implementation-incomplete no-proof failure 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>);
|
||||||
|
(executor as any).addActiveWorktree(task.id, "/tmp/fusion-fn-9166-fail-closed");
|
||||||
|
|
||||||
|
const value = await produceImplementationIncompleteMergeNodeValue(task);
|
||||||
|
await invokeGraphFailure(executor, task, nodeId, value);
|
||||||
|
|
||||||
|
expect(mergeRequester).not.toHaveBeenCalled();
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(
|
||||||
|
task.id,
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "failed",
|
||||||
|
error: expect.stringContaining("implementation incomplete with no executable proof to resume"),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(logText(store)).toContain(`Workflow graph merge blocked at node '${nodeId}': implementation incomplete with no executable proof to resume — failing instead of retrying merge`);
|
||||||
|
expect((executor as any).activeWorktrees.has(task.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["merge-attempt", "merge"] as const)("routes primitive-produced implementation-incomplete resumable failure at node %s without requesting merge", async (nodeId) => {
|
||||||
|
const worktreePath = "/tmp/fusion-fn-9166-resumable";
|
||||||
|
const { store, task, executor, mergeRequester } = makeHarness({
|
||||||
|
steps: [
|
||||||
|
{ name: "Preflight", status: "done" },
|
||||||
|
{ name: "Implement", status: "pending" },
|
||||||
|
],
|
||||||
|
currentStep: 1,
|
||||||
|
branch: "fusion/fn-9166-resumable",
|
||||||
|
worktree: worktreePath,
|
||||||
|
modifiedFiles: undefined,
|
||||||
|
workflowStepResults: undefined,
|
||||||
|
paused: false,
|
||||||
|
} as Partial<TaskDetail>);
|
||||||
|
(executor as any).addActiveWorktree(task.id, worktreePath);
|
||||||
|
|
||||||
|
const value = await produceImplementationIncompleteMergeNodeValue(task);
|
||||||
|
await invokeGraphFailure(executor, task, nodeId, value);
|
||||||
|
|
||||||
|
expect(mergeRequester).not.toHaveBeenCalled();
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo", expect.objectContaining({ preserveProgress: true }));
|
||||||
|
expect(logText(store)).toContain(`Workflow graph failed at node '${nodeId}' (implementation-incomplete) with incomplete steps — moved back to todo for execution resume`);
|
||||||
|
expect((executor as any).getActiveWorktreePaths(task.id)).toEqual([worktreePath]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each(implementationIncompleteMergeNodes)("fails implementation-incomplete no-proof merge pause abort at node %s without requesting no-op merge", async (nodeId) => {
|
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({
|
const { store, task, executor, mergeRequester } = makeHarness({
|
||||||
steps: [],
|
steps: [],
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ import { createMockStore, mockedExistsSync, resetExecutorMocks } from "./executo
|
|||||||
|
|
||||||
const now = "2026-07-15T00:00:00.000Z";
|
const now = "2026-07-15T00:00:00.000Z";
|
||||||
|
|
||||||
/** A task shaped to clear the merge boundary's implementation-proof gates, so the
|
/**
|
||||||
* cancellation race — not a pre-flight rejection — is what the assertion observes. */
|
* FNXC:WorkflowCancellation 2026-08-20-01:20:
|
||||||
|
* FN-9157 requires terminal pre-merge evidence before a merge attempt reaches the requester.
|
||||||
|
* Keep this fixture merge-ready so cancellation, not boundary admission, is the observed contract.
|
||||||
|
*/
|
||||||
function mergeReadyTask(overrides = {}) {
|
function mergeReadyTask(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
id: "FN-CANCEL",
|
id: "FN-CANCEL",
|
||||||
@@ -38,6 +41,14 @@ function mergeReadyTask(overrides = {}) {
|
|||||||
branch: null,
|
branch: null,
|
||||||
worktree: null,
|
worktree: null,
|
||||||
enabledWorkflowSteps: [],
|
enabledWorkflowSteps: [],
|
||||||
|
workflowStepResults: [{
|
||||||
|
workflowStepId: "execute",
|
||||||
|
workflowStepName: "Execute",
|
||||||
|
source: "node",
|
||||||
|
phase: "pre-merge",
|
||||||
|
status: "passed",
|
||||||
|
completedAt: now,
|
||||||
|
}],
|
||||||
prompt: "# Task\n\n## Steps\n\n### Step 1: Decide\n- [ ] Record no-code decision",
|
prompt: "# Task\n\n## Steps\n\n### Step 1: Decide\n- [ ] Record no-code decision",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { TaskDetail } from "@fusion/core";
|
import type { TaskDetail } from "@fusion/core";
|
||||||
|
import { createMergeAttemptHandler } from "../workflow-node-runners/merge-runner.js";
|
||||||
import { classifyMergePrimitiveResult, runWorkflowMergeAttemptNode } from "../workflows/workflow-merge-nodes.js";
|
import { classifyMergePrimitiveResult, runWorkflowMergeAttemptNode } from "../workflows/workflow-merge-nodes.js";
|
||||||
import type { WorkflowPrimitiveContext } from "../execution/runtime-primitives.js";
|
import type { WorkflowPrimitiveContext } from "../execution/runtime-primitives.js";
|
||||||
|
|
||||||
@@ -31,6 +32,18 @@ describe("workflow merge nodes", () => {
|
|||||||
outcome: "failure",
|
outcome: "failure",
|
||||||
value: "file-scope-violation",
|
value: "file-scope-violation",
|
||||||
});
|
});
|
||||||
|
expect(classifyMergePrimitiveResult({ status: "failed", reason: "implementation-incomplete" }, undefined, "failure")).toEqual({
|
||||||
|
outcome: "failure",
|
||||||
|
value: "implementation-incomplete",
|
||||||
|
});
|
||||||
|
expect(classifyMergePrimitiveResult({ status: "failed", reason: " ImPlEmEnTaTiOn-InCoMpLeTe " }, undefined, "failure")).toEqual({
|
||||||
|
outcome: "failure",
|
||||||
|
value: "implementation-incomplete",
|
||||||
|
});
|
||||||
|
expect(classifyMergePrimitiveResult({ status: "failed", reason: "remote rejected" }, undefined, "failure")).toEqual({
|
||||||
|
outcome: "failure",
|
||||||
|
value: "merge-failed",
|
||||||
|
});
|
||||||
expect(classifyMergePrimitiveResult({ status: "merged-requested" }, undefined, "failure")).toEqual({
|
expect(classifyMergePrimitiveResult({ status: "merged-requested" }, undefined, "failure")).toEqual({
|
||||||
outcome: "success",
|
outcome: "success",
|
||||||
value: "merged-requested",
|
value: "merged-requested",
|
||||||
@@ -47,6 +60,10 @@ describe("workflow merge nodes", () => {
|
|||||||
outcome: "success",
|
outcome: "success",
|
||||||
value: "merged-requested",
|
value: "merged-requested",
|
||||||
});
|
});
|
||||||
|
expect(classifyMergePrimitiveResult(undefined, "implementation-incomplete", "failure")).toEqual({
|
||||||
|
outcome: "failure",
|
||||||
|
value: "implementation-incomplete",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs the existing merge primitive and emits a workflow capability audit event", async () => {
|
it("runs the existing merge primitive and emits a workflow capability audit event", async () => {
|
||||||
@@ -71,6 +88,35 @@ describe("workflow merge nodes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves implementation-incomplete from a failed merge primitive in node context", async () => {
|
||||||
|
const audit = vi.fn();
|
||||||
|
const requestMerge = vi.fn().mockResolvedValue({
|
||||||
|
outcome: "failure",
|
||||||
|
value: "implementation-incomplete",
|
||||||
|
data: { status: "failed", reason: "implementation-incomplete" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(runWorkflowMergeAttemptNode({ primitives: { requestMerge, audit } }, ctx, task)).resolves.toEqual({
|
||||||
|
outcome: "failure",
|
||||||
|
value: "implementation-incomplete",
|
||||||
|
contextPatch: { "workflow:merge-status": "implementation-incomplete" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the legacy merge seam's implementation-incomplete value without primitives", async () => {
|
||||||
|
const merge = vi.fn().mockResolvedValue({ outcome: "failure", value: "implementation-incomplete" });
|
||||||
|
const handler = createMergeAttemptHandler({
|
||||||
|
seams: { merge },
|
||||||
|
buildPrimitiveContext: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(handler({ id: "merge-attempt", kind: "merge-attempt" } as any, {
|
||||||
|
task,
|
||||||
|
settings: {},
|
||||||
|
context: {},
|
||||||
|
} as any)).resolves.toEqual({ outcome: "failure", value: "implementation-incomplete" });
|
||||||
|
});
|
||||||
|
|
||||||
it("does not retry the merge primitive when audit fails after classification", async () => {
|
it("does not retry the merge primitive when audit fails after classification", async () => {
|
||||||
const audit = vi.fn().mockRejectedValue(new Error("audit unavailable"));
|
const audit = vi.fn().mockRejectedValue(new Error("audit unavailable"));
|
||||||
const requestMerge = vi.fn().mockResolvedValue({
|
const requestMerge = vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { WorkflowNodeResult } from "./workflow-graph-executor.js";
|
|||||||
/** A terminal graph value: retrying cannot create missing merge-boundary proof. */
|
/** A terminal graph value: retrying cannot create missing merge-boundary proof. */
|
||||||
export const MERGE_BOUNDARY_UNPROVEN_VALUE = "merge-boundary-unproven";
|
export const MERGE_BOUNDARY_UNPROVEN_VALUE = "merge-boundary-unproven";
|
||||||
|
|
||||||
|
const PRESERVED_MERGE_FAILURE_REASONS = new Set(["implementation-incomplete"]);
|
||||||
|
|
||||||
export interface WorkflowMergeNodeDeps {
|
export interface WorkflowMergeNodeDeps {
|
||||||
primitives: Pick<WorkflowRuntimePrimitives, "requestMerge" | "audit">;
|
primitives: Pick<WorkflowRuntimePrimitives, "requestMerge" | "audit">;
|
||||||
}
|
}
|
||||||
@@ -72,7 +74,16 @@ export function classifyMergePrimitiveResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function classifyMergeFailure(reason: string): WorkflowNodeResult {
|
function classifyMergeFailure(reason: string): WorkflowNodeResult {
|
||||||
const normalized = reason.toLowerCase();
|
const normalized = reason.trim().toLowerCase();
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowMerge 2026-08-20-01:20:
|
||||||
|
implementation-incomplete must survive merge classification because handleGraphFailure,
|
||||||
|
routeGraphMergeFailureToRetry, and isRetryableBenignMergePauseAbort key on this literal.
|
||||||
|
Collapsing it to merge-failed reopens FN-1165's no-op-merge-proof hole through bounded retry.
|
||||||
|
*/
|
||||||
|
if (PRESERVED_MERGE_FAILURE_REASONS.has(normalized)) {
|
||||||
|
return { outcome: "failure", value: normalized };
|
||||||
|
}
|
||||||
if (normalized.includes("file scope") || normalized.includes("filescope")) {
|
if (normalized.includes("file scope") || normalized.includes("filescope")) {
|
||||||
return { outcome: "failure", value: "file-scope-violation" };
|
return { outcome: "failure", value: "file-scope-violation" };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user