feat(FN-5329): remove orphan rescue and branch-recovery primitives from eng
Removes the branch-recovery CLI surface, orphan-rescue engine primitives, and their associated tests (over 1,500 lines deleted), while restoring a minimal prune-only orphan branch sweep with proper git audit mutation types. Documentation across `cli-reference.md`, `task-management.md`, and `AGENTS.m Fusion-Task-Id: FN-5329
This commit is contained in:
committed by
gsxdsm
parent
0e5cb4d292
commit
fd202e9356
@@ -1,111 +0,0 @@
|
||||
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";
|
||||
import { RestartRecoveryCoordinator } from "../../restart-recovery-coordinator.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).getBootstrappedAt = vi.fn(() => null);
|
||||
(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("restart recovery safe-requeue and reclaim sweep do not race on paused branch-conflict tasks", async () => {
|
||||
const task: any = {
|
||||
id: "FN-6000",
|
||||
column: "in-progress",
|
||||
checkedOutBy: null,
|
||||
branch: "fusion/fn-6000",
|
||||
worktree: "/tmp/fn-6000",
|
||||
paused: true,
|
||||
userPaused: false,
|
||||
pausedReason: "branch-conflict-unrecoverable",
|
||||
status: "failed",
|
||||
error: "Agent exited without calling fn_task_done",
|
||||
steps: [{ name: "A", status: "pending" }],
|
||||
};
|
||||
const statefulStore: any = createStore();
|
||||
statefulStore.listTasks = vi.fn(async ({ column }: { column?: string }) => {
|
||||
if (!column) return [task];
|
||||
return task.column === column ? [task] : [];
|
||||
});
|
||||
statefulStore.updateTask = vi.fn(async (_id: string, updates: Record<string, unknown>) => {
|
||||
Object.assign(task, updates);
|
||||
});
|
||||
statefulStore.moveTask = vi.fn(async (_id: string, column: string) => {
|
||||
task.column = column;
|
||||
});
|
||||
|
||||
const restart = new RestartRecoveryCoordinator(statefulStore, { resumeOrphaned: vi.fn().mockResolvedValue(undefined) } as any);
|
||||
const localManager = new SelfHealingManager(statefulStore, { rootDir: "/tmp/repo" });
|
||||
|
||||
vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "reclaimable",
|
||||
livePath: "/tmp/fn-6000",
|
||||
tipSha: "abc123def456",
|
||||
taskAttributedCommitCount: 0,
|
||||
strandedCommits: [],
|
||||
} as any);
|
||||
|
||||
await restart.recoverInterruptedRuns();
|
||||
const recovered = await localManager.reclaimSelfOwnedBranchConflicts();
|
||||
|
||||
expect(task.column).toBe("in-progress");
|
||||
expect(task.branch).toBe("fusion/fn-6000");
|
||||
expect(task.worktree).toBe("/tmp/fn-6000");
|
||||
expect(recovered).toBe(1);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ function createStore(): TaskStore & EventEmitter {
|
||||
return emitter;
|
||||
}
|
||||
|
||||
describe("reliability interactions: branch recovery stale cached base", () => {
|
||||
describe("reliability interactions: stale cached-base branch reclaim", () => {
|
||||
let store: TaskStore & EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
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 { execSync } from "node:child_process";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
import * as worktreePool from "../../worktree-pool.js";
|
||||
|
||||
function createStore(bootstrappedAt: number | null, tasks: any[] = []): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter() as TaskStore & EventEmitter;
|
||||
(emitter as any).getBootstrappedAt = vi.fn(() => bootstrappedAt);
|
||||
(emitter as any).listTasks = vi.fn().mockResolvedValue(tasks);
|
||||
(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: orphan-rescue fresh-db gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("leaves both subsumed and unique orphan branches untouched for a fresh DB", async () => {
|
||||
const store = createStore(Date.now(), []);
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
|
||||
const scanSpy = vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
|
||||
"fusion/fn-subsumed",
|
||||
"fusion/fn-unique",
|
||||
]);
|
||||
const inspectSpy = vi.spyOn(manager as any, "inspectOrphanedBranch");
|
||||
const execSyncMock = vi.mocked(execSync);
|
||||
|
||||
const cleaned = await manager.cleanupOrphanedBranches();
|
||||
|
||||
expect(cleaned).toBe(0);
|
||||
expect(scanSpy).not.toHaveBeenCalled();
|
||||
expect(inspectSpy).not.toHaveBeenCalled();
|
||||
expect(execSyncMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("git branch -d"),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mutationType: "self-healing:orphan-rescue-skipped-fresh-db" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves prune-and-rescue behavior for non-fresh DBs", async () => {
|
||||
const store = createStore(Date.now() - 1_000_000, [{ id: "FN-0001", column: "done" }]);
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
|
||||
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
|
||||
"fusion/fn-subsumed",
|
||||
"fusion/fn-unique",
|
||||
]);
|
||||
vi.spyOn(manager as any, "inspectOrphanedBranch")
|
||||
.mockResolvedValueOnce({
|
||||
branch: "fusion/fn-subsumed",
|
||||
tipSha: "aaa111",
|
||||
uniqueCommitCount: 0,
|
||||
uniqueCommitSubjects: [],
|
||||
derivedTaskId: "FN-SUBSUMED",
|
||||
registeredWorktreePath: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
branch: "fusion/fn-unique",
|
||||
tipSha: "bbb222",
|
||||
uniqueCommitCount: 2,
|
||||
uniqueCommitSubjects: ["feat: keep work"],
|
||||
derivedTaskId: "FN-UNIQUE",
|
||||
registeredWorktreePath: null,
|
||||
});
|
||||
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5001", lineageId: "lin-5001" });
|
||||
|
||||
const cleaned = await manager.cleanupOrphanedBranches();
|
||||
|
||||
expect(cleaned).toBe(1);
|
||||
expect(vi.mocked(execSync)).toHaveBeenCalledWith(
|
||||
expect.stringContaining("git branch -d"),
|
||||
expect.objectContaining({ cwd: "/tmp/repo" }),
|
||||
);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Recover orphaned branch fusion/fn-unique" }),
|
||||
);
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mutationType: "branch:orphan-prune" }),
|
||||
);
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mutationType: "branch:orphan-rescued" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("remains idempotent across repeated fresh-DB sweeps", async () => {
|
||||
const store = createStore(Date.now(), []);
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
|
||||
const scanSpy = vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
|
||||
"fusion/fn-subsumed",
|
||||
"fusion/fn-unique",
|
||||
]);
|
||||
const execSyncMock = vi.mocked(execSync);
|
||||
|
||||
const first = await manager.cleanupOrphanedBranches();
|
||||
const second = await manager.cleanupOrphanedBranches();
|
||||
const third = await manager.cleanupOrphanedBranches();
|
||||
|
||||
expect([first, second, third]).toEqual([0, 0, 0]);
|
||||
expect(scanSpy).not.toHaveBeenCalled();
|
||||
expect(execSyncMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("git branch -d"),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,11 @@ import { EventEmitter } from "node:events";
|
||||
import type { RunAuditEventInput, Settings, TaskStore } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
|
||||
const { execSpy, execSyncSpy, resolveBackendSpy, scanIdleSpy, scanOrphanedBranchesSpy, readdirSpy, existsSpy, inspectBranchConflictSpy } = vi.hoisted(() => ({
|
||||
const { execSpy, execSyncSpy, resolveBackendSpy, scanIdleSpy, readdirSpy, existsSpy, inspectBranchConflictSpy } = vi.hoisted(() => ({
|
||||
execSpy: vi.fn(),
|
||||
execSyncSpy: vi.fn(),
|
||||
resolveBackendSpy: vi.fn(),
|
||||
scanIdleSpy: vi.fn(),
|
||||
scanOrphanedBranchesSpy: vi.fn().mockResolvedValue([]),
|
||||
readdirSpy: vi.fn(),
|
||||
existsSpy: vi.fn().mockReturnValue(false),
|
||||
inspectBranchConflictSpy: vi.fn(),
|
||||
@@ -38,7 +37,6 @@ vi.mock("../../worktree-pool.js", async () => {
|
||||
...actual,
|
||||
resolveWorktreeBackend: resolveBackendSpy,
|
||||
scanIdleWorktrees: scanIdleSpy,
|
||||
scanOrphanedBranches: scanOrphanedBranchesSpy,
|
||||
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
@@ -93,8 +91,6 @@ describe("reliability interactions: worktrunk x self-healing", () => {
|
||||
execSyncSpy.mockReset();
|
||||
resolveBackendSpy.mockReset();
|
||||
scanIdleSpy.mockReset();
|
||||
scanOrphanedBranchesSpy.mockReset();
|
||||
scanOrphanedBranchesSpy.mockResolvedValue([]);
|
||||
readdirSpy.mockReset();
|
||||
existsSpy.mockReset();
|
||||
existsSpy.mockReturnValue(false);
|
||||
|
||||
Reference in New Issue
Block a user