feat(FN-4485): complete Step 4-5 orphan rescue and audit paths

Fusion-Task-Id: FN-4485
Fusion-Task-Lineage: 034088dc-ebc4-4e12-8314-39419d41b23f
This commit is contained in:
Fusion
2026-05-14 11:03:02 -07:00
committed by gsxdsm
parent bc6c1cb1a8
commit b72bd0575d
5 changed files with 375 additions and 71 deletions

View File

@@ -0,0 +1,63 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
import * as branchConflicts from "../../branch-conflicts.js";
import * as worktreePool from "../../worktree-pool.js";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getSettings = vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false });
(emitter as any).listTasks = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).moveTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
(emitter as any).createTask = vi.fn();
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
return emitter;
}
describe("reliability interactions: branch recovery + orphan rescue", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createStore();
manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
});
it("keeps userPaused tasks unswept even if reclaimable", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([{ id: "FN-4429", column: "todo", checkedOutBy: null, branch: "fusion/fn-4429", worktree: "/tmp/fn-4429", paused: true, userPaused: true, pausedReason: "branch-conflict-unrecoverable" }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
const inspectSpy = vi.spyOn(branchConflicts, "inspectBranchConflict");
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(0);
expect(inspectSpy).not.toHaveBeenCalled();
});
it("orphan-rescue sweep is idempotent across consecutive runs", async () => {
const branch = "fusion/fn-4470";
vi.spyOn(worktreePool, "scanOrphanedBranches")
.mockResolvedValueOnce([branch])
.mockResolvedValueOnce([branch]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch, tipSha: "abc123", uniqueCommitCount: 2, uniqueCommitSubjects: ["feat: keep work"], derivedTaskId: "FN-4470", registeredWorktreePath: null })
.mockResolvedValueOnce({ branch, tipSha: "abc123", uniqueCommitCount: 2, uniqueCommitSubjects: ["feat: keep work"], derivedTaskId: "FN-4470", registeredWorktreePath: null });
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: "FN-5001", column: "triage", branch }]);
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5001", lineageId: "lin-5001" });
await manager.cleanupOrphanedBranches();
await manager.cleanupOrphanedBranches();
expect(store.createTask).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,104 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execSyncFn = vi.fn(() => Buffer.from(""));
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
});
execFn[promisify.custom] = () => Promise.resolve({ stdout: "", stderr: "" });
return { exec: execFn, execSync: execSyncFn };
});
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
import * as worktreePool from "../worktree-pool.js";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).listTasks = vi.fn();
(emitter as any).createTask = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
return emitter;
}
describe("self-healing orphan branch rescue", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createStore();
manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
});
it("prunes subsumed orphan branches and emits branch:orphan-prune", async () => {
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "abc123",
uniqueCommitCount: 0,
uniqueCommitSubjects: [],
derivedTaskId: "FN-4470",
registeredWorktreePath: null,
});
vi.spyOn(store, "listTasks" as any).mockResolvedValueOnce([]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(1);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "branch:orphan-prune" }));
});
it("creates a rescue triage task when unique commits exist and no task row exists", async () => {
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "deadbeef",
uniqueCommitCount: 2,
uniqueCommitSubjects: ["feat: preserve orphan"],
derivedTaskId: "FN-4470",
registeredWorktreePath: "/tmp/wt-fn-4470",
});
vi.spyOn(store, "listTasks" as any).mockResolvedValueOnce([]);
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5000", lineageId: "lin-5000" });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "Recover orphaned branch fusion/fn-4470",
column: "triage",
branch: "fusion/fn-4470",
}));
expect(store.updateTask).toHaveBeenCalledWith("FN-5000", { worktree: "/tmp/wt-fn-4470" });
expect(store.logEntry).toHaveBeenCalledWith("FN-5000", expect.stringContaining("[recovery] orphan-rescue-created"));
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "branch:orphan-rescued" }));
});
it("leaves archived matching tasks untouched and only acknowledges once", async () => {
const archivedTask = { id: "FN-4470", column: "archived", metadata: {} };
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "deadbeef",
uniqueCommitCount: 1,
uniqueCommitSubjects: ["feat: preserve orphan"],
derivedTaskId: "FN-4470",
registeredWorktreePath: null,
});
vi.spyOn(store, "listTasks" as any).mockResolvedValueOnce([archivedTask]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(store.updateTask).toHaveBeenCalledWith("FN-4470", expect.objectContaining({
metadata: expect.objectContaining({ orphanRescueAcknowledged: true }),
}));
expect(store.createTask).not.toHaveBeenCalled();
});
});

View File

@@ -1294,8 +1294,11 @@ describe("SelfHealingManager", () => {
expect(mockedExecSync).not.toHaveBeenCalled();
});
it("deletes orphaned branches with safe delete (-d)", async () => {
it("deletes only subsumed orphaned branches with safe delete (-d)", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-001", "fusion/fn-002"]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch: "fusion/fn-001", tipSha: "abc", uniqueCommitCount: 0, uniqueCommitSubjects: [], derivedTaskId: "FN-001", registeredWorktreePath: null })
.mockResolvedValueOnce({ branch: "fusion/fn-002", tipSha: "def", uniqueCommitCount: 0, uniqueCommitSubjects: [], derivedTaskId: "FN-002", registeredWorktreePath: null });
const result = await manager.cleanupOrphanedBranches();
@@ -1310,45 +1313,31 @@ describe("SelfHealingManager", () => {
);
});
it("falls back to force delete (-D) when safe delete fails", async () => {
it("does not force-delete unique-commit orphaned branches", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-003"]);
// Safe delete fails
mockedExecSync.mockImplementationOnce(() => {
throw new Error("not fully merged");
});
// Force delete succeeds
mockedExecSync.mockImplementationOnce(() => Buffer.from(""));
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch: "fusion/fn-003", tipSha: "abc", uniqueCommitCount: 2, uniqueCommitSubjects: ["feat: keep"], derivedTaskId: "FN-003", registeredWorktreePath: null });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(1);
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining('git branch -d "fusion/fn-003"'),
expect.any(Object),
);
expect(mockedExecSync).toHaveBeenCalledWith(
expect(result).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalledWith(
expect.stringContaining('git branch -D "fusion/fn-003"'),
expect.any(Object),
);
});
it("counts only successfully deleted branches", async () => {
it("counts only successfully pruned subsumed branches", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-004", "fusion/fn-005"]);
// First branch: safe delete succeeds
mockedExecSync.mockImplementationOnce(() => Buffer.from(""));
// Second branch: both safe and force delete fail
mockedExecSync.mockImplementationOnce(() => {
throw new Error("not fully merged");
});
mockedExecSync.mockImplementationOnce(() => {
throw new Error("branch not found");
});
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch: "fusion/fn-004", tipSha: "abc", uniqueCommitCount: 0, uniqueCommitSubjects: [], derivedTaskId: "FN-004", registeredWorktreePath: null })
.mockResolvedValueOnce({ branch: "fusion/fn-005", tipSha: "def", uniqueCommitCount: 1, uniqueCommitSubjects: ["feat"], derivedTaskId: "FN-005", registeredWorktreePath: null });
mockedExecSync.mockReset();
mockedExecSync.mockImplementation(() => Buffer.from(""));
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(1);
expect(result).toBe(0);
});
it("returns 0 when scanOrphanedBranches throws", async () => {
@@ -2099,7 +2088,7 @@ describe("SelfHealingManager", () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
taskStuckTimeoutMs: 60_000,
});
const staleUpdatedAt = new Date(Date.now() - 61_000).toISOString();
const staleUpdatedAt = new Date(Date.now() - 6 * 60_000).toISOString();
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{