FN-6949: fix auto-merge lifecycle finalization

Make auto-merge completion idempotent when landed tasks are left outside done.

- Add shared auto-merge finalization logic that refreshes task rows, clears stale queue/blocker state, and recovery-rehomes proven landed tasks to done.
- Route direct AI merge, merge-confirmed fast paths, and self-healing recovery through the shared finalizer with run-audit events for reconciled or blocked column mismatches.
- Extend lifecycle and recovery tests to cover todo/in-review mismatches, finalization failures, stale blockers, and cleanup idempotency.
- Add the required patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-6949-auto-merge-lifecycle.md         |   5 +
 .../src/__tests__/merge-error-recovery.test.ts     |  50 ++++-
 packages/engine/src/__tests__/merger-ai.test.ts    |   8 +-
 .../src/__tests__/merger-merge-lifecycle.test.ts   | 155 +++++++++++++++
 .../ai-merge-cleanup-enoent-idempotent.test.ts     |   2 +-
 packages/engine/src/__tests__/self-healing.test.ts | 194 +++++++++++--------
 packages/engine/src/auto-merge-finalization.ts     | 208 +++++++++++++++++++++
 packages/engine/src/merger-ai.ts                   |  47 +++--
 packages/engine/src/project-engine.ts              |  87 +++++----
 packages/engine/src/run-audit.ts                   |   4 +
 packages/engine/src/self-healing.ts                |  92 ++++-----
 11 files changed, 663 insertions(+), 189 deletions(-)

Fusion-Task-Id: FN-6949

Fusion-Task-Lineage: db784ec0-68e9-4a37-9728-daa6f133e294
This commit is contained in:
gsxdsm
2026-06-22 23:18:28 -07:00
parent 0b8f299444
commit 9218613552
11 changed files with 663 additions and 189 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix auto-merge lifecycle finalization so successful squash commits reliably leave tasks done, clear transient auto-merge state, and preserve actionable failure state when lifecycle updates fail.

View File

@@ -52,6 +52,8 @@ type MockTask = {
status: string | null;
error: string | null;
paused?: boolean;
blockedBy?: string | null;
overlapBlockedBy?: string | null;
steps?: Array<{ status: string }>;
mergeDetails?: { mergeConfirmed?: boolean; commitSha?: string; mergedAt?: string } | null;
verificationFailureCount?: number;
@@ -708,8 +710,11 @@ describe("ProjectEngine merge error recovery", () => {
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, { paused: false, status: null, error: null });
expect(store.moveTask).toHaveBeenCalledWith(TASK_ID, "done");
expect(store.updateTask).toHaveBeenCalledWith(
TASK_ID,
expect.objectContaining({ paused: false, status: null, error: null }),
);
expect(store.moveTask).toHaveBeenCalledWith(TASK_ID, "done", expect.objectContaining({ moveSource: "engine" }));
});
it("auto-finalizes merge-confirmed tasks with stale transient merging status", async () => {
@@ -726,8 +731,11 @@ describe("ProjectEngine merge error recovery", () => {
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, { paused: false, status: null, error: null });
expect(store.moveTask).toHaveBeenCalledWith(TASK_ID, "done");
expect(store.updateTask).toHaveBeenCalledWith(
TASK_ID,
expect.objectContaining({ paused: false, status: null, error: null }),
);
expect(store.moveTask).toHaveBeenCalledWith(TASK_ID, "done", expect.objectContaining({ moveSource: "engine" }));
expect(store.updateTask).not.toHaveBeenCalledWith(
TASK_ID,
expect.objectContaining({
@@ -737,18 +745,20 @@ describe("ProjectEngine merge error recovery", () => {
);
});
it("does not park merge-confirmed tasks as failed when finalize loses in-review ownership", async () => {
it("reconciles merge-confirmed tasks when finalize refresh finds todo ownership", async () => {
const store = makeStore({
tasks: [
makeTask({
mergeDetails: { mergeConfirmed: true },
}),
makeTask({ column: "todo" }),
makeTask({
column: "todo",
status: "queued",
overlapBlockedBy: "FN-9999",
mergeDetails: { mergeConfirmed: true },
}),
],
});
store.moveTask.mockRejectedValueOnce(
new Error("Invalid transition: 'todo' → 'done'. Valid targets: in-progress, triage"),
);
const engine = createEngine(store);
await runMergeCycle(engine);
@@ -758,10 +768,30 @@ describe("ProjectEngine merge error recovery", () => {
mergeRetries: 3,
error: expect.stringContaining("Invalid transition"),
});
expect(store.updateTask).toHaveBeenCalledWith(
TASK_ID,
expect.objectContaining({ status: null, error: null, blockedBy: null, overlapBlockedBy: null }),
);
expect(store.moveTask).toHaveBeenCalledWith(
TASK_ID,
"done",
expect.objectContaining({ moveSource: "engine", recoveryRehome: true }),
);
expect(store.logEntry).toHaveBeenCalledWith(
TASK_ID,
expect.stringContaining("finalize skipped"),
expect.stringContaining("Auto-merge finalization repaired column mismatch"),
);
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
domain: "database",
mutationType: "task:auto-merge-finalize-column-mismatch-reconciled",
target: TASK_ID,
metadata: expect.objectContaining({
previousColumn: "todo",
targetColumn: "done",
status: "queued",
overlapBlockedBy: "FN-9999",
}),
}));
});
it("logs when non-conflict direct merge error recovery update fails", async () => {

View File

@@ -255,7 +255,7 @@ describe("runAiMerge", () => {
mergeDetails: expect.objectContaining({ mergeConfirmed: true }),
}),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true }));
expect(emitted.some((e) => e.event === "task:merged")).toBe(true);
});
@@ -418,7 +418,7 @@ describe("runAiMerge", () => {
expect(result.noOp).toBe(true);
expect(result.ok).toBe(true);
expect(task.column).toBe("done");
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true }));
});
it("fails loudly when an executed, never-merged task has no branch (possible lost work)", async () => {
@@ -440,7 +440,7 @@ describe("runAiMerge", () => {
mergeAgent: vi.fn(), reviewAgent: vi.fn(),
});
expect(result.noOp).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true }));
});
it("finalizes as a no-op when a never-executed task has no branch", async () => {
@@ -451,7 +451,7 @@ describe("runAiMerge", () => {
mergeAgent: vi.fn(), reviewAgent: vi.fn(),
});
expect(result.noOp).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true }));
});
it("throws a clear error when the task's target branch has no local ref", async () => {

View File

@@ -178,6 +178,7 @@ import {
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.js";
import { auditSquashMerge } from "../merger-squash-audit.js";
import { finalizeProvenAutoMergeTask } from "../auto-merge-finalization.js";
import { detectMergeOverlap, restoreBranchWinsFiles } from "../merger-overlap-guard.js";
import { execSync, exec } from "node:child_process";
import * as core from "@fusion/core";
@@ -235,6 +236,160 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
} as unknown as TaskStore;
}
describe("auto-merge proven finalization helper", () => {
it("reconciles a landed merge-confirmed todo row without invalid todo-to-done transition", async () => {
const strandedTask = {
id: "FN-6897",
title: "Stranded landed merge",
description: "Test",
column: "todo",
status: "queued",
error: "Invalid transition: 'todo' → 'done'. Valid targets: in-progress, triage",
blockedBy: "FN-BLOCKER",
overlapBlockedBy: "FN-OVERLAP",
dependencies: [],
steps: [{ status: "done" }],
currentStep: 0,
log: [{ action: "AI merge: landed f528cd06, task → done" }],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
mergeDetails: {
mergeConfirmed: true,
commitSha: "f528cd06",
mergedAt: "2026-06-22T19:00:00.000Z",
landedFiles: ["packages/engine/src/merger-ai.ts"],
},
} as Task;
const doneTask = { ...strandedTask, column: "done", status: null, error: null, blockedBy: null, overlapBlockedBy: null } as Task;
const store = createMockStore(strandedTask) as unknown as TaskStore & {
getTask: ReturnType<typeof vi.fn>;
updateTask: ReturnType<typeof vi.fn>;
moveTask: ReturnType<typeof vi.fn>;
logEntry: ReturnType<typeof vi.fn>;
recordRunAuditEvent: ReturnType<typeof vi.fn>;
};
store.getTask.mockResolvedValue(strandedTask);
store.moveTask.mockResolvedValue(doneTask);
const result = await finalizeProvenAutoMergeTask({
store,
taskId: "FN-6897",
result: { task: strandedTask, ok: true, merged: true, commitSha: "f528cd06", mergeConfirmed: true } as MergeResult,
source: "direct-ai-merge",
auditAgentId: "merger",
auditPhase: "direct-ai-merge-finalize",
});
expect(result.outcome).toBe("done");
expect(store.updateTask).toHaveBeenCalledWith(
"FN-6897",
expect.objectContaining({
status: null,
error: null,
blockedBy: null,
overlapBlockedBy: null,
mergeRetries: 0,
mergeDetails: expect.objectContaining({ commitSha: "f528cd06", mergeConfirmed: true, landedFiles: ["packages/engine/src/merger-ai.ts"] }),
}),
);
expect(store.moveTask).toHaveBeenCalledWith(
"FN-6897",
"done",
expect.objectContaining({ moveSource: "engine", recoveryRehome: true, preserveProgress: true }),
);
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:auto-merge-finalize-column-mismatch-reconciled",
metadata: expect.objectContaining({
taskId: "FN-6897",
previousColumn: "todo",
targetColumn: "done",
commitSha: "f528cd06",
status: "queued",
blockedBy: "FN-BLOCKER",
overlapBlockedBy: "FN-OVERLAP",
}),
}));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-6897",
expect.stringContaining("Auto-merge finalization repaired column mismatch"),
);
});
it("treats already-done landed rows as idempotent success", async () => {
const doneTask = {
id: "FN-DONE",
title: "Already done",
description: "Test",
column: "done",
dependencies: [],
steps: [{ status: "done" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
mergeDetails: { mergeConfirmed: true, commitSha: "abc123" },
} as Task;
const store = createMockStore(doneTask) as unknown as TaskStore & {
getTask: ReturnType<typeof vi.fn>;
updateTask: ReturnType<typeof vi.fn>;
moveTask: ReturnType<typeof vi.fn>;
recordRunAuditEvent: ReturnType<typeof vi.fn>;
};
store.getTask.mockResolvedValue(doneTask);
const mergeResult = { task: doneTask, ok: true, merged: true, commitSha: "abc123", mergeConfirmed: true } as MergeResult;
const result = await finalizeProvenAutoMergeTask({
store,
taskId: "FN-DONE",
result: mergeResult,
source: "merge-confirmed-fast-path",
});
expect(result.outcome).toBe("already-done");
expect(mergeResult.task).toBe(doneTask);
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.recordRunAuditEvent).not.toHaveBeenCalled();
});
it("diagnoses rows without merge proof instead of finalizing them", async () => {
const unprovenTask = {
id: "FN-NOPROOF",
title: "No proof",
description: "Test",
column: "todo",
dependencies: [],
steps: [{ status: "done" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
mergeDetails: undefined,
} as Task;
const store = createMockStore(unprovenTask) as unknown as TaskStore & {
getTask: ReturnType<typeof vi.fn>;
updateTask: ReturnType<typeof vi.fn>;
moveTask: ReturnType<typeof vi.fn>;
recordRunAuditEvent: ReturnType<typeof vi.fn>;
};
store.getTask.mockResolvedValue(unprovenTask);
const result = await finalizeProvenAutoMergeTask({
store,
taskId: "FN-NOPROOF",
source: "self-healing",
});
expect(result).toEqual(expect.objectContaining({ outcome: "blocked", reason: "missing-merge-confirmation" }));
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:auto-merge-finalize-column-mismatch-no-action",
metadata: expect.objectContaining({ previousColumn: "todo", reason: "missing-merge-confirmation" }),
}));
});
});
/**
* Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),

View File

@@ -111,7 +111,7 @@ describe("FN-6257 AI-merge cleanup ENOENT idempotency (real git)", () => {
expect(git(rootDir, "rev-parse main")).not.toBe(mainBefore);
expect(task.column).toBe("done");
expect(task.status ?? null).toBeNull();
expect(task.error).toBeUndefined();
expect(task.error ?? null).toBeNull();
expect(task.mergeRetries ?? 0).not.toBeGreaterThanOrEqual(3);
expect(task.mergeDetails).toEqual(expect.objectContaining({
commitSha: result.commitSha,

View File

@@ -4405,34 +4405,32 @@ describe("SelfHealingManager", () => {
rootDir: "/tmp/test-project",
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-350",
column: "in-review",
status: "failed",
error: "Invalid transition: 'todo' → 'done'. Valid targets: in-progress, triage",
mergeRetries: 3,
mergeDetails: {
mergeConfirmed: true,
mergedAt: "2026-01-01T00:00:00.000Z",
},
log: [],
const task = {
id: "FN-350",
column: "in-review",
status: "failed",
error: "Invalid transition: 'todo' → 'done'. Valid targets: in-progress, triage",
mergeRetries: 3,
mergeDetails: {
mergeConfirmed: true,
mergedAt: "2026-01-01T00:00:00.000Z",
},
]);
log: [],
};
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([task]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
const result = await managerWithRecovery.recoverMergedReviewTasks();
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-350", {
paused: false,
status: null,
error: null,
mergeRetries: 0,
});
expect(store.moveTask).toHaveBeenCalledWith("FN-350", "done");
expect(store.updateTask).toHaveBeenCalledWith(
"FN-350",
expect.objectContaining({ paused: false, status: null, error: null, mergeRetries: 0 }),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-350", "done", expect.objectContaining({ moveSource: "engine" }));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-350",
expect.stringContaining("Auto-finalized from in-review/paused: content proven"),
expect.stringContaining("Auto-finalized from in-review: content proven"),
);
managerWithRecovery.stop();
@@ -4468,29 +4466,27 @@ describe("SelfHealingManager", () => {
rootDir: "/tmp/test-project",
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-352",
column: "in-review",
paused: true,
mergeDetails: {
mergeConfirmed: true,
mergedAt: "2026-01-01T00:00:00.000Z",
},
log: [],
const task = {
id: "FN-352",
column: "in-review",
paused: true,
mergeDetails: {
mergeConfirmed: true,
mergedAt: "2026-01-01T00:00:00.000Z",
},
]);
log: [],
};
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([task]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
const result = await managerWithRecovery.recoverMergedReviewTasks();
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-352", {
paused: false,
status: null,
error: null,
mergeRetries: 0,
});
expect(store.moveTask).toHaveBeenCalledWith("FN-352", "done");
expect(store.updateTask).toHaveBeenCalledWith(
"FN-352",
expect.objectContaining({ paused: false, status: null, error: null, mergeRetries: 0 }),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-352", "done", expect.objectContaining({ moveSource: "engine" }));
managerWithRecovery.stop();
});
@@ -4500,21 +4496,21 @@ describe("SelfHealingManager", () => {
rootDir: "/tmp/test-project",
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-353",
column: "in-review",
paused: false,
status: null,
error: null,
mergeDetails: {
mergeConfirmed: true,
mergedAt: "2026-01-01T00:00:00.000Z",
},
steps: [{ status: "in-progress" }],
log: [],
const task = {
id: "FN-353",
column: "in-review",
paused: false,
status: null,
error: null,
mergeDetails: {
mergeConfirmed: true,
mergedAt: "2026-01-01T00:00:00.000Z",
},
]);
steps: [{ status: "in-progress" }],
log: [],
};
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([task]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
const result = await managerWithRecovery.recoverMergedReviewTasks();
@@ -4537,33 +4533,85 @@ describe("SelfHealingManager", () => {
rootDir: "/tmp/test-project",
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-354",
column: "in-review",
paused: false,
status: "merging",
error: "stale transient merge state",
mergeDetails: {
mergeConfirmed: true,
mergedAt: "2026-01-01T00:00:00.000Z",
},
steps: [{ status: "done" }],
workflowStepResults: [],
log: [],
const task = {
id: "FN-354",
column: "in-review",
paused: false,
status: "merging",
error: "stale transient merge state",
mergeDetails: {
mergeConfirmed: true,
mergedAt: "2026-01-01T00:00:00.000Z",
},
]);
steps: [{ status: "done" }],
workflowStepResults: [],
log: [],
};
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([task]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
const result = await managerWithRecovery.recoverMergedReviewTasks();
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-354", {
paused: false,
status: null,
error: null,
mergeRetries: 0,
expect(store.updateTask).toHaveBeenCalledWith(
"FN-354",
expect.objectContaining({ paused: false, status: null, error: null, mergeRetries: 0 }),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-354", "done", expect.objectContaining({ moveSource: "engine" }));
managerWithRecovery.stop();
});
it("finalizes landed merge-confirmed tasks stranded in todo with stale queued overlap", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
});
expect(store.moveTask).toHaveBeenCalledWith("FN-354", "done");
const task = {
id: "FN-6897",
column: "todo",
status: "queued",
error: "Invalid transition: 'todo' → 'done'. Valid targets: in-progress, triage",
blockedBy: null,
overlapBlockedBy: "FN-ACTIVE",
paused: false,
mergeRetries: 3,
mergeDetails: {
mergeConfirmed: true,
commitSha: "landed123",
mergedAt: "2026-01-01T00:00:00.000Z",
},
steps: [{ status: "done" }],
workflowStepResults: [],
log: [{ action: "AI merge: landed landed12, task → done" }],
};
(store.listTasks as ReturnType<typeof vi.fn>).mockImplementation(async (filter?: { column?: string }) => {
if (filter?.column === "todo") return [task];
return [];
});
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
mockedExecSync.mockImplementation((command: string | Buffer) => {
const cmd = String(command);
if (cmd.includes("cat-file -e landed123^{commit}")) return "" as any;
if (cmd.includes("merge-base --is-ancestor landed123")) return "" as any;
return "" as any;
});
const result = await managerWithRecovery.recoverMergedReviewTasks();
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith(
"FN-6897",
expect.objectContaining({ status: null, error: null, blockedBy: null, overlapBlockedBy: null, mergeRetries: 0 }),
);
expect(store.moveTask).toHaveBeenCalledWith(
"FN-6897",
"done",
expect.objectContaining({ moveSource: "engine", recoveryRehome: true }),
);
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:auto-merge-finalize-column-mismatch-reconciled",
metadata: expect.objectContaining({ previousColumn: "todo", overlapBlockedBy: "FN-ACTIVE", commitSha: "landed123" }),
}));
managerWithRecovery.stop();
});

View File

@@ -0,0 +1,208 @@
import { getTaskHardMergeBlocker, type MergeResult, type Task, type TaskStore } from "@fusion/core";
import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type RunAuditor } from "./run-audit.js";
export function isInvalidDoneTransitionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Invalid transition:") && message.includes("→ 'done'");
}
export interface AutoMergeFinalizationResult {
outcome: "done" | "already-done" | "blocked" | "missing";
task: Task | null;
previousColumn: string | null;
reason?: string;
}
export interface FinalizeProvenAutoMergeTaskOptions {
store: TaskStore;
taskId: string;
result?: MergeResult;
audit?: RunAuditor;
auditAgentId?: string;
auditPhase?: string;
source: "direct-ai-merge" | "merge-confirmed-fast-path" | "self-healing";
log?: (message: string) => void | Promise<void>;
}
function buildMismatchMetadata(task: Task, reason: string): Record<string, unknown> {
return {
taskId: task.id,
previousColumn: task.column,
targetColumn: "done",
commitSha: task.mergeDetails?.commitSha ?? null,
status: task.status ?? null,
blockedBy: task.blockedBy ?? null,
overlapBlockedBy: task.overlapBlockedBy ?? null,
reason,
};
}
async function recordFinalizationAudit(args: {
store: TaskStore;
audit?: RunAuditor;
task: Task;
type: DatabaseMutationType;
reason: string;
auditAgentId?: string;
auditPhase?: string;
}): Promise<void> {
try {
const auditor = args.audit ?? createRunAuditor(args.store, {
runId: generateSyntheticRunId("auto-merge-finalize", args.task.id),
agentId: args.auditAgentId ?? "merger",
taskId: args.task.id,
taskLineageId: args.task.lineageId,
phase: args.auditPhase ?? "auto-merge-finalize",
});
await auditor.database({
type: args.type,
target: args.task.id,
metadata: buildMismatchMetadata(args.task, args.reason),
});
} catch {
// Best effort: audit persistence must never strand a proven landed task.
}
}
function buildFinalizationMergeDetails(task: Task, result?: MergeResult): NonNullable<Task["mergeDetails"]> {
const mergedAt = task.mergeDetails?.mergedAt ?? new Date().toISOString();
return {
...(task.mergeDetails ?? {}),
...(result?.commitSha ? { commitSha: result.commitSha } : {}),
...(result?.rebaseBaseSha ? { rebaseBaseSha: result.rebaseBaseSha } : {}),
...(result?.landedFiles ? { landedFiles: result.landedFiles } : {}),
...(typeof result?.filesChanged === "number" ? { filesChanged: result.filesChanged } : {}),
...(typeof result?.insertions === "number" ? { insertions: result.insertions } : {}),
...(typeof result?.deletions === "number" ? { deletions: result.deletions } : {}),
...(result?.mergeCommitMessage ? { mergeCommitMessage: result.mergeCommitMessage } : {}),
mergedAt,
mergeConfirmed: result?.mergeConfirmed === true || task.mergeDetails?.mergeConfirmed === true,
...(result?.noOp ? { noOpMerge: true, noOpReason: result.reason } : {}),
};
}
/**
* FNXC:AutoMergeLifecycle 2026-06-22-19:28:
* Proven auto-merge completion must refresh the authoritative row before moving to done because the merge CAS and queue retry paths can leave a landed task in todo with stale queued/overlap state. Use TaskStore recovery rehome for those column mismatches so completion remains idempotent without direct database surgery.
*/
export async function finalizeProvenAutoMergeTask({
store,
taskId,
result,
audit,
auditAgentId,
auditPhase,
source,
log,
}: FinalizeProvenAutoMergeTaskOptions): Promise<AutoMergeFinalizationResult> {
const latest = await store.getTask(taskId).catch(() => null);
if (!latest) {
return { outcome: "missing", task: null, previousColumn: null, reason: "task-not-found" };
}
if (latest.column === "done") {
if (result) result.task = latest;
return { outcome: "already-done", task: latest, previousColumn: "done" };
}
const mergeDetails = buildFinalizationMergeDetails(latest, result);
const hasProof = mergeDetails.mergeConfirmed === true || result?.mergeConfirmed === true || result?.noOp === true;
if (!hasProof) {
const reason = "missing-merge-confirmation";
await recordFinalizationAudit({
store,
audit,
task: latest,
type: "task:auto-merge-finalize-column-mismatch-no-action",
reason,
auditAgentId,
auditPhase,
});
return { outcome: "blocked", task: latest, previousColumn: latest.column, reason };
}
const hardBlocker = getTaskHardMergeBlocker({
...latest,
column: latest.column === "todo" ? "in-review" : latest.column,
paused: false,
status: latest.status === "merging" || latest.status === "merging-pr" || latest.status === "queued" ? undefined : latest.status,
error: undefined,
});
if (hardBlocker) {
await store.updateTask(taskId, {
status: "failed",
error: `Merge confirmed but finalization blocked: ${hardBlocker}`,
}).catch(() => undefined);
await recordFinalizationAudit({
store,
audit,
task: latest,
type: "task:auto-merge-finalize-column-mismatch-no-action",
reason: hardBlocker,
auditAgentId,
auditPhase,
});
return { outcome: "blocked", task: latest, previousColumn: latest.column, reason: hardBlocker };
}
await store.updateTask(taskId, {
paused: false,
status: null,
error: null,
blockedBy: null,
overlapBlockedBy: null,
mergeRetries: 0,
mergeDetails,
} as unknown as Partial<Task>);
const shouldRecoveryRehome = latest.column !== "in-review";
if (shouldRecoveryRehome) {
await log?.(
`Auto-merge finalization repairing ${taskId}: authoritative row is ${latest.column}; clearing stale lifecycle blockers and moving to done`,
);
}
try {
const moved = await store.moveTask(taskId, "done", shouldRecoveryRehome
? { moveSource: "engine", recoveryRehome: true, preserveProgress: true }
: { moveSource: "engine", preserveProgress: true });
if (result) result.task = moved;
if (shouldRecoveryRehome) {
await recordFinalizationAudit({
store,
audit,
task: latest,
type: "task:auto-merge-finalize-column-mismatch-reconciled",
reason: `${source}:recovery-rehome`,
auditAgentId,
auditPhase,
});
await store.logEntry(
taskId,
`Auto-merge finalization repaired column mismatch: ${latest.column} → done after proven merge; cleared stale status/blockers`,
).catch(() => undefined);
}
const finalTask = moved ?? (await store.getTask(taskId).catch(() => null)) ?? latest;
return { outcome: shouldRecoveryRehome ? "done" : "done", task: finalTask, previousColumn: latest.column };
} catch (error) {
if (isInvalidDoneTransitionError(error)) {
const refreshed = await store.getTask(taskId).catch(() => null);
if (refreshed?.column === "done") {
if (result) result.task = refreshed;
return { outcome: "already-done", task: refreshed, previousColumn: latest.column };
}
if (refreshed) {
await recordFinalizationAudit({
store,
audit,
task: refreshed,
type: "task:auto-merge-finalize-column-mismatch-no-action",
reason: `invalid-done-transition:${refreshed.column}`,
auditAgentId,
auditPhase,
});
}
}
throw error;
}
}

View File

@@ -70,6 +70,7 @@ import { installWorktreeDependencies } from "./merge-dependency-sync.js";
import { activeSessionRegistry } from "./active-session-registry.js";
import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js";
import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
const execFileAsync = promisify(execFile);
const aiMergeLog = createLogger("merger-ai");
@@ -1379,29 +1380,37 @@ async function finalizeMerged(
branchDeleted,
};
await audit.git({ type: "merge:ai-landed", target: integrationBranch, metadata: { taskId, landedSha, empty: opts.empty } }).catch(() => undefined);
await log(opts.empty ? `AI merge: finalized ${taskId} (no-op), finalizing task row` : `AI merge: landed ${short(landedSha)}, finalizing task row`);
const finalized = await finalizeTask(store, taskId, result, audit, log);
await log(opts.empty ? `AI merge: finalized ${taskId} (no-op) → done` : `AI merge: landed ${short(landedSha)}, task → done`);
return await finalizeTask(store, taskId, result);
return finalized;
}
/** Move the task to done and emit, mirroring the legacy completeTask. */
async function finalizeTask(store: TaskStore, taskId: string, result: MergeResult): Promise<MergeResult> {
const mergedAt = new Date().toISOString();
const mergeDetails: MergeDetails = {
...result.task.mergeDetails,
...(result.commitSha ? { commitSha: result.commitSha } : {}),
...(result.rebaseBaseSha ? { rebaseBaseSha: result.rebaseBaseSha } : {}),
...(result.landedFiles ? { landedFiles: result.landedFiles } : {}),
...(typeof result.filesChanged === "number" ? { filesChanged: result.filesChanged } : {}),
...(typeof result.insertions === "number" ? { insertions: result.insertions } : {}),
...(typeof result.deletions === "number" ? { deletions: result.deletions } : {}),
...(result.mergeCommitMessage ? { mergeCommitMessage: result.mergeCommitMessage } : {}),
mergedAt,
mergeConfirmed: result.mergeConfirmed === true,
...(result.noOp ? { noOpMerge: true, noOpReason: result.reason } : {}),
};
await store.updateTask(taskId, { status: null, mergeDetails }).catch(() => undefined);
const task = await store.moveTask(taskId, "done");
result.task = task;
async function finalizeTask(
store: TaskStore,
taskId: string,
result: MergeResult,
audit?: RunAuditor,
log?: (message: string) => Promise<void>,
): Promise<MergeResult> {
const finalization = await finalizeProvenAutoMergeTask({
store,
taskId,
result,
audit,
auditAgentId: "merger",
auditPhase: "direct-ai-merge-finalize",
source: "direct-ai-merge",
log,
});
if (finalization.outcome === "blocked") {
throw new Error(`AI merge finalization blocked for ${taskId}: ${finalization.reason ?? "unknown"}`);
}
if (!finalization.task) {
throw new Error(`AI merge finalization could not find task ${taskId}`);
}
result.task = finalization.task;
store.emit("task:merged", result);
return result;
}

View File

@@ -46,6 +46,7 @@ import {
createAutomatedFollowup,
extractFailingTestFiles,
} from "./verification-followup-dedup.js";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { isTransientError } from "./transient-error-detector.js";
import { classifyTransientMergeError } from "./transient-merge-error-classifier.js";
import { TunnelProcessManager } from "./remote-access/tunnel-process-manager.js";
@@ -101,11 +102,6 @@ function formatErrorDetails(error: unknown): { message: string; detail: string }
return { message: detail, detail };
}
function isInvalidDoneTransitionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Invalid transition:") && message.includes("→ 'done'");
}
export function shouldRetryAutoMergeConflict(
currentRetries: number,
settings: { autoResolveConflicts?: boolean; maxAutoMergeRetries?: unknown } | null | undefined,
@@ -2050,43 +2046,62 @@ export class ProjectEngine {
}
runtimeLog.log(
`Auto-merge: ${taskId} already has mergeConfirmed — unpausing and moving to done`,
`Auto-merge: ${taskId} already has mergeConfirmed — refreshing row and finalizing to done`,
);
await store.logEntry(
taskId,
"Merge already confirmed; unpausing and completing task (recovered from post-merge state inconsistency)",
"Merge already confirmed; refreshing row and completing task (recovered from post-merge state inconsistency)",
);
await store.updateTask(taskId, { paused: false, status: null, error: null });
try {
const movedTask = await store.moveTask(taskId, "done");
const mergedTask = movedTask ?? (await store.getTask(taskId).catch(() => null)) ?? task;
store.emit("task:merged", {
task: mergedTask,
branch: mergedTask.branch ?? task.branch ?? "",
const auditor = createRunAuditor(store, {
runId: generateSyntheticRunId("merger-fast-path-finalize", taskId),
agentId: "merger",
taskId,
phase: "auto-merge-fast-path-finalize",
});
/*
FNXC:AutoMergeFinalization 2026-06-23-03:29:
The merge-confirmed fast path must pass its in-memory merge proof into the shared finalizer because test stores can return stale rows without commit evidence. Reusing the proven task/result keeps landed rows from being parked as missing merge confirmation.
*/
const finalization = await finalizeProvenAutoMergeTask({
store,
taskId,
result: {
task,
ok: true,
merged: true,
worktreeRemoved: false,
branchDeleted: false,
mergeConfirmed: true,
mergedAt: mergedTask.mergeDetails?.mergedAt,
mergeTargetBranch: mergedTask.mergeDetails?.mergeTargetBranch,
mergeTargetSource: mergedTask.mergeDetails?.mergeTargetSource,
} as MergeResult);
} catch (error) {
if (isInvalidDoneTransitionError(error)) {
const latest = await store.getTask(taskId).catch(() => null);
if (latest && latest.column !== "in-review") {
runtimeLog.warn(
`Auto-merge: ${taskId} merge-confirmed finalize skipped — task moved to ${latest.column} before done transition`,
);
await store.logEntry(
taskId,
`Merge confirmed finalize skipped: task moved to '${latest.column}' before in-review → done transition`,
);
continue;
}
}
throw error;
commitSha: task.mergeDetails?.commitSha,
noOp: task.mergeDetails?.noOpMerge === true,
reason: task.mergeDetails?.noOpReason,
mergeConfirmed: task.mergeDetails?.mergeConfirmed === true,
} as MergeResult,
audit: auditor,
auditAgentId: "merger",
auditPhase: "auto-merge-fast-path-finalize",
source: "merge-confirmed-fast-path",
log: (message) => runtimeLog.warn(message),
});
if (finalization.outcome === "blocked") {
runtimeLog.warn(
`Auto-merge: ${taskId} merge-confirmed finalize blocked — ${finalization.reason ?? "unknown"}`,
);
await store.logEntry(
taskId,
`Merge confirmed finalization blocked — ${finalization.reason ?? "unknown"}. Task parked for manual completion.`,
);
continue;
}
const mergedTask = finalization.task ?? (await store.getTask(taskId).catch(() => null)) ?? task;
store.emit("task:merged", {
task: mergedTask,
branch: mergedTask.branch ?? task.branch ?? "",
merged: true,
worktreeRemoved: false,
branchDeleted: false,
mergeConfirmed: true,
mergedAt: mergedTask.mergeDetails?.mergedAt,
mergeTargetBranch: mergedTask.mergeDetails?.mergeTargetBranch,
mergeTargetSource: mergedTask.mergeDetails?.mergeTargetSource,
} as MergeResult);
continue;
}

View File

@@ -443,6 +443,10 @@ export type DatabaseMutationType =
| "mergeQueue:auto-cleanup-stale-row"
| "task:auto-recover-already-merged"
| "task:auto-recover-finalize-already-on-main"
/** Metadata: { taskId, previousColumn, targetColumn, commitSha, status, blockedBy, overlapBlockedBy, reason } */
| "task:auto-merge-finalize-column-mismatch-reconciled"
/** Metadata: { taskId, previousColumn, targetColumn, commitSha, status, blockedBy, overlapBlockedBy, reason } */
| "task:auto-merge-finalize-column-mismatch-no-action"
| "task:auto-merge-skipped-already-done"
/** Metadata: { taskId, commitSha, failedCommand, exitCode, errorTail } */
| "task:post-finalize-verification-no-op"

View File

@@ -45,6 +45,7 @@ import {
import { classifyError, extractMissingModulePath, isNonContinuableSessionError, isOperatorActionableAgentError, isStaleWorktreeModuleResolutionError } from "./transient-error-detector.js";
import { classifyForeignOnlyContamination, deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js";
import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type RunAuditor } from "./run-audit.js";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js";
import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js";
@@ -6994,45 +6995,27 @@ export class SelfHealingManager {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
const [reviewTasks, todoTasks] = await Promise.all([
this.store.listTasks({ column: "in-review", slim: true }),
this.store.listTasks({ column: "todo", slim: true }),
]);
const mergedButNotDone = tasks.filter((t) =>
const mergedButNotDone = [
...reviewTasks.filter((t) => t.column === "in-review"),
...todoTasks.filter((t) => t.column === "todo"),
].filter((t) =>
!t.deletedAt &&
t.column === "in-review" &&
allowsAutoMergeProcessing(t, settings) &&
t.mergeDetails?.mergeConfirmed === true,
);
if (mergedButNotDone.length === 0) return 0;
log.warn(`Found ${mergedButNotDone.length} merged task(s) stuck in in-review`);
log.warn(`Found ${mergedButNotDone.length} merged task(s) stuck outside done`);
let recovered = 0;
for (const task of mergedButNotDone) {
try {
const hardBlocker = getTaskHardMergeBlocker({
...task,
// Merge-confirmed tasks have already landed. Treat stale merge
// in-flight statuses as soft state to clear during finalization,
// not hard blockers that park an otherwise confirmed merge as failed.
paused: false,
status: task.status === "merging" || task.status === "merging-pr" ? undefined : task.status,
error: undefined,
steps: task.steps ?? [],
workflowStepResults: task.workflowStepResults,
});
if (hardBlocker) {
await this.store.updateTask(task.id, {
status: "failed",
error: `Merge confirmed but finalization blocked: ${hardBlocker}`,
});
await this.store.logEntry(
task.id,
`Auto-recovery skipped: merge confirmed but finalization blocked — ${hardBlocker}`,
);
continue;
}
const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-merged-review");
if (!(await this.isCommitReachableFromBranch(task.mergeDetails?.commitSha, mergeTarget.branch))) {
await this.recordSharedGroupDefaultTargetGuard(task, "recover-merged-review", {
@@ -7048,35 +7031,52 @@ export class SelfHealingManager {
paused: Boolean(task.paused),
status: Boolean(task.status),
error: Boolean(task.error),
blockedBy: Boolean(task.blockedBy),
overlapBlockedBy: Boolean(task.overlapBlockedBy),
};
await this.store.updateTask(task.id, {
paused: false,
status: null,
error: null,
mergeRetries: 0,
...(mergeTarget.source ? {
if (mergeTarget.source) {
await this.store.updateTask(task.id, {
mergeDetails: {
...(task.mergeDetails || {}),
mergeTargetBranch: task.mergeDetails?.mergeTargetBranch ?? mergeTarget.branch,
mergeTargetSource: task.mergeDetails?.mergeTargetSource ?? mergeTarget.source,
},
} : {}),
});
});
}
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-merged-review");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
/*
* FNXC:SelfHealingLifecycle 2026-06-22-19:28:
* File-scope overlap is only a scheduling blocker before content lands; after mergeConfirmed plus reachability proves the content is on the target branch, self-healing must clear stale queued/overlap fields and finalize instead of preserving todo forever.
*/
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", task.id),
agentId: "self-healing",
taskId: task.id,
taskLineageId: task.lineageId,
phase: "recover-merged-review",
});
const finalization = await finalizeProvenAutoMergeTask({
store: this.store,
taskId: task.id,
audit: auditor,
auditAgentId: "self-healing",
auditPhase: "recover-merged-review",
source: "self-healing",
log: (message) => log.warn(message),
});
if (finalization.outcome === "blocked") {
await this.store.logEntry(
task.id,
`Auto-recovery skipped: merge confirmed but finalization blocked — ${finalization.reason ?? "unknown"}`,
);
continue;
}
this.emitTaskMerged(finalization.task, { mergeConfirmed: true });
await this.store.logEntry(
task.id,
`Auto-finalized from in-review/paused: content proven via mergeConfirmed metadata. Cleared soft state paused=${clearedFlags.paused}, status=${clearedFlags.status}, error=${clearedFlags.error}`,
`Auto-finalized from ${task.column}: content proven via mergeConfirmed metadata. Cleared soft state paused=${clearedFlags.paused}, status=${clearedFlags.status}, error=${clearedFlags.error}, blockedBy=${clearedFlags.blockedBy}, overlapBlockedBy=${clearedFlags.overlapBlockedBy}`,
);
try {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", task.id),
agentId: "self-healing",
taskId: task.id,
taskLineageId: task.lineageId,
phase: "recover-merged-review",
});
await auditor.database({
type: "task:auto-recover-finalize-already-on-main",
target: task.id,