From 9218613552c970ee10f018ba6e44b266b548d4e5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 23:18:28 -0700 Subject: [PATCH] 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 --- .changeset/fn-6949-auto-merge-lifecycle.md | 5 + .../__tests__/merge-error-recovery.test.ts | 50 ++++- .../engine/src/__tests__/merger-ai.test.ts | 8 +- .../__tests__/merger-merge-lifecycle.test.ts | 155 +++++++++++++ ...ai-merge-cleanup-enoent-idempotent.test.ts | 2 +- .../engine/src/__tests__/self-healing.test.ts | 196 ++++++++++------- .../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 | 90 ++++---- 11 files changed, 663 insertions(+), 189 deletions(-) create mode 100644 .changeset/fn-6949-auto-merge-lifecycle.md create mode 100644 packages/engine/src/auto-merge-finalization.ts diff --git a/.changeset/fn-6949-auto-merge-lifecycle.md b/.changeset/fn-6949-auto-merge-lifecycle.md new file mode 100644 index 0000000000..d04cd11181 --- /dev/null +++ b/.changeset/fn-6949-auto-merge-lifecycle.md @@ -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. diff --git a/packages/engine/src/__tests__/merge-error-recovery.test.ts b/packages/engine/src/__tests__/merge-error-recovery.test.ts index 1af313a910..ce0feef9b7 100644 --- a/packages/engine/src/__tests__/merge-error-recovery.test.ts +++ b/packages/engine/src/__tests__/merge-error-recovery.test.ts @@ -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 () => { diff --git a/packages/engine/src/__tests__/merger-ai.test.ts b/packages/engine/src/__tests__/merger-ai.test.ts index 5ce35ecb82..283444b78e 100644 --- a/packages/engine/src/__tests__/merger-ai.test.ts +++ b/packages/engine/src/__tests__/merger-ai.test.ts @@ -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 () => { diff --git a/packages/engine/src/__tests__/merger-merge-lifecycle.test.ts b/packages/engine/src/__tests__/merger-merge-lifecycle.test.ts index ef47b9b0b1..3ad56d5343 100644 --- a/packages/engine/src/__tests__/merger-merge-lifecycle.test.ts +++ b/packages/engine/src/__tests__/merger-merge-lifecycle.test.ts @@ -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 = {}, 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; + updateTask: ReturnType; + moveTask: ReturnType; + logEntry: ReturnType; + recordRunAuditEvent: ReturnType; + }; + 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; + updateTask: ReturnType; + moveTask: ReturnType; + recordRunAuditEvent: ReturnType; + }; + 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; + updateTask: ReturnType; + moveTask: ReturnType; + recordRunAuditEvent: ReturnType; + }; + 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), diff --git a/packages/engine/src/__tests__/reliability-interactions/ai-merge-cleanup-enoent-idempotent.test.ts b/packages/engine/src/__tests__/reliability-interactions/ai-merge-cleanup-enoent-idempotent.test.ts index 627bf689bc..ca42f5779c 100644 --- a/packages/engine/src/__tests__/reliability-interactions/ai-merge-cleanup-enoent-idempotent.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/ai-merge-cleanup-enoent-idempotent.test.ts @@ -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, diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 62dffeb07f..4305fd3963 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -4405,34 +4405,32 @@ describe("SelfHealingManager", () => { rootDir: "/tmp/test-project", }); - (store.listTasks as ReturnType).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).mockResolvedValue([task]); + (store.getTask as ReturnType).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).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).mockResolvedValue([task]); + (store.getTask as ReturnType).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).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).mockResolvedValue([task]); + (store.getTask as ReturnType).mockResolvedValue(task); const result = await managerWithRecovery.recoverMergedReviewTasks(); @@ -4537,33 +4533,85 @@ describe("SelfHealingManager", () => { rootDir: "/tmp/test-project", }); - (store.listTasks as ReturnType).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).mockResolvedValue([task]); + (store.getTask as ReturnType).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).mockImplementation(async (filter?: { column?: string }) => { + if (filter?.column === "todo") return [task]; + return []; + }); + (store.getTask as ReturnType).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(); }); diff --git a/packages/engine/src/auto-merge-finalization.ts b/packages/engine/src/auto-merge-finalization.ts new file mode 100644 index 0000000000..e56d6ceb4e --- /dev/null +++ b/packages/engine/src/auto-merge-finalization.ts @@ -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; +} + +function buildMismatchMetadata(task: Task, reason: string): Record { + 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 { + 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 { + 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 { + 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); + + 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; + } +} diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index b23b32da39..6e2f1cf03f 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -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 { - 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, +): Promise { + 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; } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index d0bc0f5a6f..9bad094058 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -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; } diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 1baaf8da1d..33984caecf 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -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" diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index d65c4ac997..e6b662d9a2 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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,