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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user