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
|
||||
|
||||
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 "../executor-test-helpers.js";
|
||||
import { TaskExecutor } from "../../executor.js";
|
||||
import { runWorkflowMergeAttemptNode } from "../../workflows/workflow-merge-nodes.js";
|
||||
import { createMockStore, resetExecutorMocks } from "../executor-test-helpers.js";
|
||||
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");
|
||||
}
|
||||
|
||||
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)", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
@@ -320,6 +343,59 @@ describe("merge-node paused-abort retry classification (FN-6735)", () => {
|
||||
"merge-retry",
|
||||
] 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) => {
|
||||
const { store, task, executor, mergeRequester } = makeHarness({
|
||||
steps: [],
|
||||
|
||||
@@ -22,8 +22,11 @@ import { createMockStore, mockedExistsSync, resetExecutorMocks } from "./executo
|
||||
|
||||
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 = {}) {
|
||||
return {
|
||||
id: "FN-CANCEL",
|
||||
@@ -38,6 +41,14 @@ function mergeReadyTask(overrides = {}) {
|
||||
branch: null,
|
||||
worktree: null,
|
||||
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",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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 type { WorkflowPrimitiveContext } from "../execution/runtime-primitives.js";
|
||||
|
||||
@@ -31,6 +32,18 @@ describe("workflow merge nodes", () => {
|
||||
outcome: "failure",
|
||||
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({
|
||||
outcome: "success",
|
||||
value: "merged-requested",
|
||||
@@ -47,6 +60,10 @@ describe("workflow merge nodes", () => {
|
||||
outcome: "success",
|
||||
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 () => {
|
||||
@@ -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 () => {
|
||||
const audit = vi.fn().mockRejectedValue(new Error("audit unavailable"));
|
||||
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. */
|
||||
export const MERGE_BOUNDARY_UNPROVEN_VALUE = "merge-boundary-unproven";
|
||||
|
||||
const PRESERVED_MERGE_FAILURE_REASONS = new Set(["implementation-incomplete"]);
|
||||
|
||||
export interface WorkflowMergeNodeDeps {
|
||||
primitives: Pick<WorkflowRuntimePrimitives, "requestMerge" | "audit">;
|
||||
}
|
||||
@@ -72,7 +74,16 @@ export function classifyMergePrimitiveResult(
|
||||
}
|
||||
|
||||
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")) {
|
||||
return { outcome: "failure", value: "file-scope-violation" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user