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:
5
.changeset/fn-6949-auto-merge-lifecycle.md
Normal file
5
.changeset/fn-6949-auto-merge-lifecycle.md
Normal 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.
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
208
packages/engine/src/auto-merge-finalization.ts
Normal file
208
packages/engine/src/auto-merge-finalization.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user