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
@@ -47,7 +47,6 @@ import {
|
||||
BranchCrossContaminationError,
|
||||
assertCleanBranchAtBase,
|
||||
inspectBranchConflict,
|
||||
listBranchRecoveryCandidates,
|
||||
listUniqueBranchCommits,
|
||||
} from "../branch-conflicts.js";
|
||||
|
||||
@@ -307,7 +306,7 @@ describe("branch-conflicts", () => {
|
||||
}
|
||||
expect(result.error).toBeInstanceOf(BranchConflictError);
|
||||
expect(result.error.message).toContain("1 stranded commit since main");
|
||||
expect(result.error.message).toContain("Run branch recovery");
|
||||
expect(result.error.message).toContain("Inspect/reclaim or discard the conflicting local branch/worktree");
|
||||
});
|
||||
|
||||
it("lists zero unique commits when git cherry has no plus entries", async () => {
|
||||
@@ -429,67 +428,5 @@ describe("branch-conflicts", () => {
|
||||
await expect(assertion).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("lists canonical and sibling recovery candidates with worktrees and stranded commits", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git for-each-ref --format='%(refname:short)' refs/heads/fusion/fn-4068 refs/heads/fusion/fn-4068-*") {
|
||||
return Buffer.from("fusion/fn-4068\nfusion/fn-4068-2\n");
|
||||
}
|
||||
if (command === "git worktree list --porcelain") {
|
||||
return Buffer.from([
|
||||
"worktree /tmp/repo",
|
||||
"HEAD 1111111",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /tmp/fn-4068",
|
||||
"HEAD 2222222",
|
||||
"branch refs/heads/fusion/fn-4068",
|
||||
"",
|
||||
"worktree /tmp/fn-4068-2",
|
||||
"HEAD 3333333",
|
||||
"branch refs/heads/fusion/fn-4068-2",
|
||||
"",
|
||||
].join("\n"));
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
|
||||
return Buffer.from("abc123\n");
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'fusion/fn-4068-2^{commit}'")) {
|
||||
return Buffer.from("def456\n");
|
||||
}
|
||||
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\tCanonical fix\n");
|
||||
}
|
||||
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068-2'")) {
|
||||
return Buffer.from("bbb222\tSibling patch\nccc333\tMore work\n");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await listBranchRecoveryCandidates({
|
||||
repoDir: "/tmp/repo",
|
||||
branchName: "fusion/fn-4068",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
branchName: "fusion/fn-4068",
|
||||
tipSha: "abc123",
|
||||
worktreePath: "/tmp/fn-4068",
|
||||
strandedCommits: [{ sha: "aaa111", subject: "Canonical fix" }],
|
||||
isCanonical: true,
|
||||
},
|
||||
{
|
||||
branchName: "fusion/fn-4068-2",
|
||||
tipSha: "def456",
|
||||
worktreePath: "/tmp/fn-4068-2",
|
||||
strandedCommits: [
|
||||
{ sha: "bbb222", subject: "Sibling patch" },
|
||||
{ sha: "ccc333", subject: "More work" },
|
||||
],
|
||||
isCanonical: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1634,7 +1634,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask());
|
||||
|
||||
// Should have triggered cleanup (stale branch recovery)
|
||||
// Should have triggered cleanup (stale branch reclaim)
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("git worktree prune"),
|
||||
expect.any(Object),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -44,7 +44,6 @@ describe("FN-4733: self-healing chat cleanup maintenance", () => {
|
||||
const manager = new SelfHealingManager(store, { rootDir: tmpRoot, chatStore });
|
||||
vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(undefined);
|
||||
vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(undefined);
|
||||
vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(undefined);
|
||||
vi.spyOn(manager as any, "checkpointWal").mockReturnValue(undefined);
|
||||
vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(undefined);
|
||||
vi.spyOn(manager, "archiveStaleDoneTasks").mockResolvedValue(0);
|
||||
|
||||
@@ -33,7 +33,6 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
|
||||
const BATCH1_METHODS = [
|
||||
"pruneWorktrees",
|
||||
"cleanupOrphans",
|
||||
"cleanupOrphanedBranches",
|
||||
"enforceWorktreeCap",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ function createStore(): TaskStore & EventEmitter {
|
||||
return emitter;
|
||||
}
|
||||
|
||||
describe("self-healing ghost branch recovery", () => {
|
||||
describe("self-healing ghost branch reclaim", () => {
|
||||
let store: TaskStore & EventEmitter;
|
||||
let manager: SelfHealingManager;
|
||||
|
||||
|
||||
@@ -28,13 +28,11 @@ vi.mock("../worktree-pool.js", () => ({
|
||||
SelfHealingReclaim: "self-healing-reclaim",
|
||||
SelfHealingStaleActiveBranch: "self-healing-stale-active-branch",
|
||||
SelfHealingBranchConflict: "self-healing-branch-conflict",
|
||||
SelfHealingOrphanRescue: "self-healing-orphan-rescue",
|
||||
SelfHealingIdleSweep: "self-healing-idle-sweep",
|
||||
PoolPrune: "pool-prune",
|
||||
},
|
||||
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
|
||||
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
|
||||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
resolveWorktreeBackend: vi.fn(),
|
||||
|
||||
@@ -41,7 +41,6 @@ describe("FN-4743: self-healing mail cleanup maintenance", () => {
|
||||
});
|
||||
vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(undefined);
|
||||
vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(undefined);
|
||||
vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(undefined);
|
||||
vi.spyOn(manager as any, "checkpointWal").mockReturnValue(undefined);
|
||||
vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(undefined);
|
||||
vi.spyOn(manager, "archiveStaleDoneTasks").mockResolvedValue(0);
|
||||
|
||||
@@ -1,103 +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 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).getBootstrappedAt = vi.fn(() => null);
|
||||
(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).not.toHaveBeenCalled();
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,86 +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 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).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 fresh-db orphan rescue gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("skips orphan rescue entirely for fresh databases with zero task history", async () => {
|
||||
const store = createStore(Date.now(), []);
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
|
||||
const scanSpy = vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
|
||||
"fusion/foo",
|
||||
"fusion/bar",
|
||||
]);
|
||||
|
||||
const result = await manager.cleanupOrphanedBranches();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(scanSpy).not.toHaveBeenCalled();
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mutationType: "self-healing:orphan-rescue-skipped-fresh-db",
|
||||
metadata: expect.objectContaining({
|
||||
bootstrappedAt: expect.any(Number),
|
||||
processBootStartedAt: expect.any(Number),
|
||||
taskCount: 0,
|
||||
candidateBranches: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves existing orphan rescue behavior when the database is not fresh", async () => {
|
||||
const store = createStore(Date.now() - 1_000_000, []);
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
|
||||
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: null,
|
||||
});
|
||||
(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" }),
|
||||
);
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mutationType: "branch:orphan-rescued" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -66,13 +66,12 @@ vi.mock("../worktree-pool.js", () => ({
|
||||
SelfHealingReclaim: "self-healing-reclaim",
|
||||
SelfHealingStaleActiveBranch: "self-healing-stale-active-branch",
|
||||
SelfHealingBranchConflict: "self-healing-branch-conflict",
|
||||
SelfHealingOrphanRescue: "self-healing-orphan-rescue",
|
||||
SelfHealingIdleSweep: "self-healing-idle-sweep",
|
||||
PoolPrune: "pool-prune",
|
||||
},
|
||||
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
|
||||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
resolveWorktreeBackend: vi.fn(),
|
||||
@@ -111,11 +110,11 @@ import { classifyOwnedLandedEvidence } from "../merger.js";
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
|
||||
const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
|
||||
const mockedRemoveWorktree = vi.mocked(removeWorktree);
|
||||
const mockedResolveWorktreeBackend = vi.mocked(resolveWorktreeBackend);
|
||||
const mockedScanIdleWorktrees = vi.mocked(scanIdleWorktrees);
|
||||
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
|
||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedCreateLogger = vi.mocked(createLogger);
|
||||
const mockedClassifyOwnedLandedEvidence = vi.mocked(classifyOwnedLandedEvidence);
|
||||
@@ -1366,73 +1365,6 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── cleanupOrphanedBranches ────────────────────────────────────────
|
||||
|
||||
describe("cleanupOrphanedBranches", () => {
|
||||
it("returns 0 when no orphaned branches found", async () => {
|
||||
mockedScanOrphanedBranches.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await manager.cleanupOrphanedBranches();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(mockedExecSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
expect(result).toBe(2);
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("git branch -d 'fusion/fn-001'"),
|
||||
expect.objectContaining({ cwd: "/tmp/test-project" }),
|
||||
);
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("git branch -d 'fusion/fn-002'"),
|
||||
expect.objectContaining({ cwd: "/tmp/test-project" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not force-delete unique-commit orphaned branches", async () => {
|
||||
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-003"]);
|
||||
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(0);
|
||||
expect(mockedExecSync).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('git branch -D "fusion/fn-003"'),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts only successfully pruned subsumed branches", async () => {
|
||||
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-004", "fusion/fn-005"]);
|
||||
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);
|
||||
});
|
||||
|
||||
it("returns 0 when scanOrphanedBranches throws", async () => {
|
||||
mockedScanOrphanedBranches.mockRejectedValueOnce(new Error("git error"));
|
||||
|
||||
const result = await manager.cleanupOrphanedBranches();
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auto-archive ────────────────────────────────────────────────────
|
||||
|
||||
describe("archiveStaleDoneTasks", () => {
|
||||
@@ -7137,6 +7069,55 @@ describe("worktrunk-aware cleanup sweeps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupOrphanedBranches", () => {
|
||||
let store: TaskStore & EventEmitter;
|
||||
let manager: SelfHealingManager;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
mockedScanOrphanedBranches.mockReset();
|
||||
mockedExecSync.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("prunes subsumed orphan branches and emits branch:orphan-prune", async () => {
|
||||
mockedScanOrphanedBranches.mockResolvedValue(["fusion/FN-777"]);
|
||||
mockedExecSync.mockImplementation((command: string) => {
|
||||
if (command.startsWith("git rev-parse --verify")) return "abc123\n" as any;
|
||||
if (command.startsWith("git rev-list --count")) return "0\n" as any;
|
||||
if (command.startsWith("git branch -d")) return "" as any;
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
const result = await (manager as any).cleanupOrphanedBranches();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(expect.stringContaining("git branch -d"), expect.anything());
|
||||
expect(vi.mocked(store.recordRunAuditEvent)).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "branch:orphan-prune" }));
|
||||
});
|
||||
|
||||
it("leaves unique-commit orphan branches untouched", async () => {
|
||||
mockedScanOrphanedBranches.mockResolvedValue(["fusion/FN-888"]);
|
||||
mockedExecSync.mockImplementation((command: string) => {
|
||||
if (command.startsWith("git rev-parse --verify")) return "def456\n" as any;
|
||||
if (command.startsWith("git rev-list --count")) return "2\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
const result = await (manager as any).cleanupOrphanedBranches();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(mockedExecSync).not.toHaveBeenCalledWith(expect.stringContaining("git branch -d"), expect.anything());
|
||||
expect(vi.mocked(store.createTask)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maintenance cycle concurrency", () => {
|
||||
let store: TaskStore & EventEmitter;
|
||||
let manager: SelfHealingManager;
|
||||
@@ -7202,7 +7183,6 @@ describe("maintenance cycle concurrency", () => {
|
||||
it("resets maintenanceRunning flag on success", async () => {
|
||||
(vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverCompletedTasks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverStaleIncompleteReviewTasks").mockResolvedValue(0) as any);
|
||||
@@ -7228,7 +7208,6 @@ describe("maintenance cycle concurrency", () => {
|
||||
it("uses a passive WAL checkpoint during maintenance", async () => {
|
||||
(vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverCompletedTasks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverStaleIncompleteReviewTasks").mockResolvedValue(0) as any);
|
||||
@@ -7268,7 +7247,6 @@ describe("maintenance cycle concurrency", () => {
|
||||
|
||||
makeSlow("pruneWorktrees");
|
||||
makeSlow("cleanupOrphans");
|
||||
makeSlow("cleanupOrphanedBranches");
|
||||
makeSlow("enforceWorktreeCap");
|
||||
// checkpointWal is synchronous, no need to mock
|
||||
|
||||
@@ -7281,7 +7259,6 @@ describe("maintenance cycle concurrency", () => {
|
||||
// All operations should have run
|
||||
expect(executionOrder).toContain("pruneWorktrees");
|
||||
expect(executionOrder).toContain("cleanupOrphans");
|
||||
expect(executionOrder).toContain("cleanupOrphanedBranches");
|
||||
expect(executionOrder).toContain("enforceWorktreeCap");
|
||||
});
|
||||
|
||||
@@ -7362,7 +7339,6 @@ describe("maintenance cycle concurrency", () => {
|
||||
// Mock batch 1 and 3 as well
|
||||
(vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "archiveStaleDoneTasks").mockResolvedValue(0) as any);
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
scanIdleWorktrees,
|
||||
cleanupOrphanedWorktrees,
|
||||
reapOrphanWorktrees,
|
||||
scanOrphanedBranches,
|
||||
} from "../worktree-pool.js";
|
||||
import { BranchConflictError } from "../branch-conflicts.js";
|
||||
import * as branchConflictModule from "../branch-conflicts.js";
|
||||
@@ -398,7 +397,7 @@ describe("WorktreePool", () => {
|
||||
existingTipSha: "abc123def456",
|
||||
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
|
||||
startPoint: "main",
|
||||
recommendedAction: "Run branch recovery",
|
||||
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -441,7 +440,7 @@ describe("WorktreePool", () => {
|
||||
existingTipSha: "abc123def456",
|
||||
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
|
||||
startPoint: "fusion/fn-041",
|
||||
recommendedAction: "Run branch recovery",
|
||||
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -477,7 +476,7 @@ describe("WorktreePool", () => {
|
||||
existingTipSha: "abc123def456",
|
||||
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
|
||||
startPoint: "main",
|
||||
recommendedAction: "Run branch recovery",
|
||||
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -556,7 +555,7 @@ describe("WorktreePool", () => {
|
||||
existingTipSha: "abc123def456",
|
||||
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
|
||||
startPoint: "main",
|
||||
recommendedAction: "Run branch recovery",
|
||||
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1010,346 +1009,3 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── scanOrphanedBranches tests ────────────────────────────────────────
|
||||
|
||||
describe("scanOrphanedBranches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: return empty string (no branches)
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return "";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies branches not associated with any active task", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return " fusion/fn-001\n fusion/fn-002\n fusion/fn-003\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "in-progress"),
|
||||
makeTask("FN-002", "todo"),
|
||||
]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
expect(orphaned).toEqual(["fusion/fn-003"]);
|
||||
});
|
||||
|
||||
it("excludes in-review and done tasks (merger manages those)", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return " fusion/fn-001\n fusion/fn-002\n fusion/fn-003\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "in-review"),
|
||||
makeTask("FN-002", "done"),
|
||||
]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
expect(orphaned).toContain("fusion/fn-001");
|
||||
expect(orphaned).toContain("fusion/fn-002");
|
||||
expect(orphaned).toContain("fusion/fn-003");
|
||||
});
|
||||
|
||||
it("excludes archived tasks", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return " fusion/fn-001\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "archived"),
|
||||
]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
expect(orphaned).toEqual(["fusion/fn-001"]);
|
||||
});
|
||||
|
||||
it("uses task.branch field when set", async () => {
|
||||
const task = makeTask("FN-001", "in-progress");
|
||||
task.branch = "fusion/fn-001-custom";
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return " fusion/fn-001\n fusion/fn-001-custom\n fusion/fn-002\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([task]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
expect(orphaned).toEqual(["fusion/fn-002"]);
|
||||
});
|
||||
|
||||
it("returns empty array when git branch fails", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git branch")) {
|
||||
throw new Error("not a git repo");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
expect(orphaned).toEqual([]);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[worktree-pool] Failed to list fusion/* branches: not a git repo"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array when no fusion/* branches exist", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return "";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
expect(orphaned).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips leading * and whitespace from branch names", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return "* fusion/fn-001\n fusion/fn-002\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
expect(orphaned).toContain("fusion/fn-001");
|
||||
expect(orphaned).toContain("fusion/fn-002");
|
||||
});
|
||||
});
|
||||
|
||||
// ── reapOrphanWorktrees tests ─────────────────────────────────────────
|
||||
|
||||
describe("reapOrphanWorktrees", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: .worktrees/ exists, lstatSync returns a real directory (not a symlink)
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
|
||||
mockedReaddirSync.mockReturnValue([]);
|
||||
// Default: no registered worktrees
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return "worktree /root\nHEAD abc123\nbranch refs/heads/main\n\n" as any;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 0 when .worktrees/ does not exist", async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
expect(removed).toBe(0);
|
||||
expect(mockedRmSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 0 when .worktrees/ is empty", async () => {
|
||||
mockedReaddirSync.mockReturnValue([] as any);
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
expect(removed).toBe(0);
|
||||
expect(mockedRmSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes a directory that has no .git file and is not registered", async () => {
|
||||
mockedReaddirSync.mockReturnValue([makeDirEntry("pale-raven")] as any);
|
||||
// .gitkeep exists but NOT a .git file — simulate with existsSync returning false for .git
|
||||
mockedExistsSync.mockImplementation((p: any) => {
|
||||
if (String(p) === "/root/.worktrees") return true;
|
||||
if (String(p).endsWith("/.git")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/pale-raven", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
expect(mockedPruneWorktreeAdminEntries).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "pool-reap-orphan", target: "/root/.worktrees/pale-raven" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT remove a directory that is a registered git worktree", async () => {
|
||||
mockedReaddirSync.mockReturnValue([makeDirEntry("swift-falcon")] as any);
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return [
|
||||
"worktree /root",
|
||||
"HEAD abc123",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /root/.worktrees/swift-falcon",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/swift-falcon",
|
||||
"",
|
||||
].join("\n") as any;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
|
||||
expect(removed).toBe(0);
|
||||
expect(mockedRmSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT remove a directory that has a .git file (may be partially registered)", async () => {
|
||||
mockedReaddirSync.mockReturnValue([makeDirEntry("amber-wolf")] as any);
|
||||
mockedExistsSync.mockImplementation((p: any) => {
|
||||
if (String(p) === "/root/.worktrees") return true;
|
||||
if (String(p) === "/root/.worktrees/amber-wolf/.git") return true;
|
||||
return true;
|
||||
});
|
||||
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
|
||||
expect(removed).toBe(0);
|
||||
expect(mockedRmSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT remove symlinks", async () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
{ name: "linked-wt", isDirectory: () => true } as any,
|
||||
] as any);
|
||||
mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => true } as any);
|
||||
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
|
||||
expect(removed).toBe(0);
|
||||
expect(mockedRmSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles multiple orphans and multiple registered worktrees correctly", async () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
makeDirEntry("orphan-1"),
|
||||
makeDirEntry("orphan-2"),
|
||||
makeDirEntry("good-wt"),
|
||||
] as any);
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return [
|
||||
"worktree /root",
|
||||
"HEAD abc123",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /root/.worktrees/good-wt",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/good-wt",
|
||||
"",
|
||||
].join("\n") as any;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
mockedExistsSync.mockImplementation((p: any) => {
|
||||
const ps = String(p);
|
||||
if (ps === "/root/.worktrees") return true;
|
||||
if (ps.endsWith("/.git")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
|
||||
expect(removed).toBe(2);
|
||||
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/orphan-1", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/orphan-2", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
expect(mockedRmSync).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("good-wt"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues and logs a warning when rmSync throws for one orphan", async () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
makeDirEntry("bad-orphan"),
|
||||
makeDirEntry("good-orphan"),
|
||||
] as any);
|
||||
mockedExistsSync.mockImplementation((p: any) => {
|
||||
const ps = String(p);
|
||||
if (ps === "/root/.worktrees") return true;
|
||||
if (ps.endsWith("/.git")) return false;
|
||||
return true;
|
||||
});
|
||||
let callCount = 0;
|
||||
mockedRmSync.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) throw new Error("permission denied");
|
||||
});
|
||||
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
|
||||
// Only the second one succeeds
|
||||
expect(removed).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reapOrphanWorktrees: failed to remove bad-orphan"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 0 and logs warning when git worktree list fails", async () => {
|
||||
mockedReaddirSync.mockReturnValue([makeDirEntry("some-dir")] as any);
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
throw new Error("not a git repo");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
mockedExistsSync.mockImplementation((p: any) => {
|
||||
const ps = String(p);
|
||||
if (ps === "/root/.worktrees") return true;
|
||||
if (ps.endsWith("/.git")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// When git list fails, getRegisteredWorktreePaths returns an empty Set,
|
||||
// so any unregistered dir without a .git file would be reaped.
|
||||
// In this test we verify behavior is safe: no crash, returns a count.
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
|
||||
// some-dir has no .git, not registered (empty set due to failure) — gets reaped
|
||||
expect(removed).toBe(1);
|
||||
// The warn from getRegisteredWorktreePaths should appear
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to list registered worktrees"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,14 +16,6 @@ export interface BranchCrossContaminationCommit extends BranchConflictCommit {
|
||||
foreignTaskId: string;
|
||||
}
|
||||
|
||||
export interface BranchRecoveryCandidate {
|
||||
branchName: string;
|
||||
tipSha: string;
|
||||
worktreePath: string | null;
|
||||
strandedCommits: BranchConflictCommit[];
|
||||
isCanonical: boolean;
|
||||
}
|
||||
|
||||
export interface BranchConflictDetails {
|
||||
branchName: string;
|
||||
conflictingWorktreePath: string;
|
||||
@@ -113,12 +105,6 @@ interface UniqueBranchCommitListResult {
|
||||
degraded: boolean;
|
||||
}
|
||||
|
||||
export interface ListBranchRecoveryCandidatesInput {
|
||||
repoDir: string;
|
||||
branchName: string;
|
||||
startPoint?: string;
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -244,47 +230,6 @@ async function getWorktreeBranchMap(repoDir: string): Promise<Map<string, string
|
||||
return map;
|
||||
}
|
||||
|
||||
function parseBranchNames(output: string): string[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function listBranchRecoveryCandidates(
|
||||
input: ListBranchRecoveryCandidatesInput,
|
||||
): Promise<BranchRecoveryCandidate[]> {
|
||||
const { repoDir, branchName } = input;
|
||||
const startPoint = input.startPoint ?? "HEAD";
|
||||
const [branchListOutput, worktreeBranches] = await Promise.all([
|
||||
runGit(
|
||||
repoDir,
|
||||
`git for-each-ref --format='%(refname:short)' refs/heads/${branchName} refs/heads/${branchName}-*`,
|
||||
),
|
||||
getWorktreeBranchMap(repoDir),
|
||||
]);
|
||||
|
||||
const candidates: BranchRecoveryCandidate[] = [];
|
||||
for (const candidateName of parseBranchNames(branchListOutput)) {
|
||||
const tipSha = await revParse(repoDir, candidateName);
|
||||
const strandedCommits = await listStrandedCommits(repoDir, startPoint, candidateName);
|
||||
candidates.push({
|
||||
branchName: candidateName,
|
||||
tipSha,
|
||||
worktreePath: worktreeBranches.get(candidateName) ?? null,
|
||||
strandedCommits,
|
||||
isCanonical: candidateName === branchName,
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (left.branchName === branchName) return -1;
|
||||
if (right.branchName === branchName) return 1;
|
||||
return left.branchName.localeCompare(right.branchName);
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
interface TaskAttributionSummary {
|
||||
ownCount: number;
|
||||
@@ -970,7 +915,7 @@ export async function inspectBranchConflict(
|
||||
existingTipSha,
|
||||
strandedCommits: uniqueCommitResult.commits,
|
||||
startPoint: uniqueCommitResult.mainRef,
|
||||
recommendedAction: "Run branch recovery and explicitly choose whether to reclaim or discard prior work.",
|
||||
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7821,7 +7821,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
const strandedSummary = error.strandedCommits.length > 0
|
||||
? error.strandedCommits.map((commit) => `${commit.sha.slice(0, 12)} ${commit.subject}`).join("; ")
|
||||
: "none";
|
||||
const recommendation = `Run \`fn task branch-recovery ${taskId}\` to inspect candidates, then reclaim the existing branch or discard prior work explicitly.`;
|
||||
const recommendation = "Resolve the local branch/worktree conflict with git tooling (inspect/reclaim or discard) before retrying.";
|
||||
return [
|
||||
`Branch conflict: ${error.branchName} is already checked out at ${error.conflictingWorktreePath}`,
|
||||
`Existing tip: ${error.existingTipSha}`,
|
||||
@@ -7845,7 +7845,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
lines.push("stranded=none");
|
||||
}
|
||||
lines.push(
|
||||
`recommendation=Run 'fn task branch-recovery ${taskId}' to inspect candidates, then reclaim the existing branch or discard prior work explicitly.`,
|
||||
`recommendation=Resolve the local branch/worktree conflict with git tooling (inspect/reclaim or discard) before retrying.`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -8023,7 +8023,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
}
|
||||
|
||||
const conflictMessage = `Task branch conflict: ${error.branchName} is already checked out at ${error.conflictingWorktreePath}. ` +
|
||||
`Run 'fn task branch-recovery ${task.id}' to inspect candidates, then reclaim the existing branch or discard prior work explicitly.`;
|
||||
`Resolve the local branch/worktree conflict with git tooling (inspect/reclaim or discard) before retrying.`;
|
||||
await this.store.logEntry(task.id, this.formatBranchConflictLifecycleLog(task.id, error), undefined, this.getRunContextFor(task.id));
|
||||
await this.store.appendAgentLog(task.id, "Branch conflict recovery required", "tool_error", this.formatBranchConflictAgentLog(task.id, error), "executor");
|
||||
const autoRecoveryDispatcher = this.getAutoRecoveryDispatcher(createRunAuditor(this.store, this.getRunContextFor(task.id)));
|
||||
|
||||
@@ -127,13 +127,10 @@ export {
|
||||
classifyBootstrapMisbinding,
|
||||
isBranchConflictError,
|
||||
inspectBranchConflict,
|
||||
listBranchRecoveryCandidates,
|
||||
type BranchConflictCommit,
|
||||
type BranchConflictDetails,
|
||||
type BranchRecoveryCandidate,
|
||||
type BranchConflictInspectionResult,
|
||||
type InspectBranchConflictInput,
|
||||
type ListBranchRecoveryCandidatesInput,
|
||||
} from "./branch-conflicts.js";
|
||||
export { generateReservedWorktreeName, generateWorktreeName, planTaskWorktreePath, slugify } from "./worktree-names.js";
|
||||
export { createLogger, type Logger } from "./logger.js";
|
||||
|
||||
@@ -167,11 +167,9 @@ export type GitMutationType =
|
||||
| "branch:auto-canonicalize-case"
|
||||
| "branch:stale-active-reclaim"
|
||||
| "branch:stale-active-reclaim-deferred"
|
||||
| "branch:orphan-prune"
|
||||
// reserved; refusal currently thrown pre-audit
|
||||
| "project:bootstrap-refused-linked-worktree"
|
||||
| "branch:orphan-prune"
|
||||
| "branch:orphan-rescued"
|
||||
| "self-healing:orphan-rescue-skipped-fresh-db"
|
||||
| "branch:reanchor"
|
||||
| "stash:push"
|
||||
| "stash:pop";
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
* - `enforceWorktreeCap`: defer to backend prune/remove semantics
|
||||
* - `reclaimSelfOwnedBranchConflicts`: remains native (branch-level)
|
||||
* - `reclaimStaleActiveBranches`: remains native (branch-level)
|
||||
* - `scanOrphanedBranches` rescue: remains native (branch-level)
|
||||
*/
|
||||
|
||||
import { exec, execSync } from "node:child_process";
|
||||
@@ -69,7 +68,6 @@ const DB_CORRUPTION_NOTIFICATION_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
export const STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS = 10 * 60_000;
|
||||
export const COMPLETION_HANDOFF_LIMBO_GRACE_MS = 5 * 60_000;
|
||||
export const MAX_COMPLETION_HANDOFF_LIMBO_RECOVERIES = 3;
|
||||
const ORPHAN_RESCUE_FRESH_DB_GRACE_MS = 5_000;
|
||||
|
||||
export async function archiveAsGhostBug(
|
||||
store: TaskStore,
|
||||
@@ -308,7 +306,6 @@ const DURABLE_ERROR_RECOVERY_MAX_RETRIES = 5;
|
||||
const DURABLE_ERROR_RECOVERY_BASE_COOLDOWN_MS = 30_000;
|
||||
const DURABLE_ERROR_RECOVERY_MAX_COOLDOWN_MS = 15 * 60_000;
|
||||
const RUNNING_ON_INACTIVE_TASK_STALE_RUN_MS = 5 * 60_000;
|
||||
const ORPHAN_RESCUE_SUBJECT_CAP = 10;
|
||||
|
||||
function bumpTaskPriority(priority: TaskPriority | undefined): TaskPriority {
|
||||
switch (priority ?? "normal") {
|
||||
@@ -392,15 +389,6 @@ export async function autoRecoverWorktreeSessionStartFailure(
|
||||
return { outcome: "requeue-todo", retries: nextCount, classification };
|
||||
}
|
||||
|
||||
interface OrphanBranchInspection {
|
||||
branch: string;
|
||||
tipSha: string;
|
||||
uniqueCommitCount: number;
|
||||
uniqueCommitSubjects: string[];
|
||||
derivedTaskId: string | null;
|
||||
registeredWorktreePath: string | null;
|
||||
}
|
||||
|
||||
type RebindOutcome =
|
||||
| {
|
||||
taskId: string;
|
||||
@@ -531,7 +519,6 @@ export class SelfHealingManager {
|
||||
// ── Per-task deadlock recovery cooldown ─────────────────────────────
|
||||
private deadlockRecoveryCooldown: Map<string, number> = new Map();
|
||||
private mergeStarvationDrops: Map<string, number> = new Map();
|
||||
private orphanArchivedAcknowledged = new Set<string>();
|
||||
private finalizeUnprovenWarned = new Set<string>();
|
||||
private maintenanceTickCounter = 0;
|
||||
private readonly processBootStartedAt = Date.now();
|
||||
@@ -2157,6 +2144,26 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async inspectOrphanedBranch(branch: string): Promise<{ tipSha: string; uniqueCommitCount: number } | null> {
|
||||
try {
|
||||
const tipSha = String(execSync(`git rev-parse --verify ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})).trim();
|
||||
if (!tipSha) return null;
|
||||
const uniqueCommitCount = Number.parseInt(String(execSync(`git rev-list --count ${shellQuote(branch)} --not ${shellQuote("main")}`, {
|
||||
cwd: this.options.rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})).trim(), 10) || 0;
|
||||
return { tipSha, uniqueCommitCount };
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Failed to inspect branch ${branch} during stale-active reclaim: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async reclaimStaleActiveBranches(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
@@ -2198,7 +2205,7 @@ export class SelfHealingManager {
|
||||
|
||||
let reclaimed = 0;
|
||||
for (const branch of branches) {
|
||||
const derivedTaskId = this.deriveTaskIdFromFusionBranch(branch);
|
||||
const derivedTaskId = deriveTaskIdFromFusionBranch(branch);
|
||||
if (!derivedTaskId) continue;
|
||||
|
||||
const task = taskById.get(derivedTaskId.toUpperCase());
|
||||
@@ -5455,7 +5462,7 @@ export class SelfHealingManager {
|
||||
worktreePath: task.worktree,
|
||||
settings,
|
||||
taskId: task.id,
|
||||
reason: RemovalReason.SelfHealingOrphanRescue,
|
||||
reason: RemovalReason.SelfHealingReclaim,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -6975,221 +6982,50 @@ export class SelfHealingManager {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private deriveTaskIdFromFusionBranch(branch: string): string | null {
|
||||
const match = /^fusion\/(fn|kb)-(\d+)$/i.exec(branch.trim());
|
||||
if (!match) return null;
|
||||
return `${match[1].toUpperCase()}-${match[2]}`;
|
||||
}
|
||||
|
||||
private async getRegisteredWorktreePathForBranch(branch: string): Promise<string | null> {
|
||||
try {
|
||||
const stdout = execSync("git worktree list --porcelain", {
|
||||
cwd: this.options.rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}) || "";
|
||||
const lines = stdout.split("\n");
|
||||
let currentPath: string | null = null;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
currentPath = line.slice("worktree ".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("branch ")) {
|
||||
const fullRef = line.slice("branch ".length).trim();
|
||||
const branchName = fullRef.startsWith("refs/heads/") ? fullRef.slice("refs/heads/".length) : fullRef;
|
||||
if (branchName === branch && currentPath) {
|
||||
return currentPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Failed to inspect registered worktree for ${branch}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async inspectOrphanedBranch(branch: string): Promise<OrphanBranchInspection | null> {
|
||||
try {
|
||||
const tipSha = String(execSync(`git rev-parse --verify ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})).trim();
|
||||
if (!tipSha) return null;
|
||||
|
||||
const uniqueCommitCount = Number.parseInt(String(execSync(`git rev-list --count ${shellQuote(branch)} --not ${shellQuote("main")}`, {
|
||||
cwd: this.options.rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})).trim(), 10) || 0;
|
||||
|
||||
let uniqueCommitSubjects: string[] = [];
|
||||
if (uniqueCommitCount > 0) {
|
||||
const subjectOutput = String(execSync(`git log --format=%s --max-count=${ORPHAN_RESCUE_SUBJECT_CAP} ${shellQuote(branch)} --not ${shellQuote("main")}`, {
|
||||
cwd: this.options.rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}));
|
||||
uniqueCommitSubjects = subjectOutput.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
return {
|
||||
branch,
|
||||
tipSha,
|
||||
uniqueCommitCount,
|
||||
uniqueCommitSubjects,
|
||||
derivedTaskId: this.deriveTaskIdFromFusionBranch(branch),
|
||||
registeredWorktreePath: await this.getRegisteredWorktreePathForBranch(branch),
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Failed to inspect orphaned branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve orphaned `fusion/*` branches.
|
||||
* - Subsumed branches are pruned (`git branch -d`).
|
||||
* - Unique-commit branches with missing task rows are rescued into triage tasks.
|
||||
* - Archived matching tasks are left untouched with one-time acknowledgement logging.
|
||||
* Subsumed branches are pruned. Unique-commit branches are left untouched (operator-managed).
|
||||
*/
|
||||
async cleanupOrphanedBranches(): Promise<number> {
|
||||
try {
|
||||
const bootstrappedAt = this.store.getBootstrappedAt();
|
||||
const allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
|
||||
const taskCount = allTasks.length;
|
||||
const isFreshDb =
|
||||
bootstrappedAt !== null
|
||||
&& bootstrappedAt >= this.processBootStartedAt - ORPHAN_RESCUE_FRESH_DB_GRACE_MS
|
||||
&& taskCount === 0;
|
||||
if (isFreshDb) {
|
||||
log.log(
|
||||
`[self-healing] orphan-rescue-skipped-fresh-db bootstrappedAt=${bootstrappedAt} processBootStartedAt=${this.processBootStartedAt} taskCount=${taskCount}`,
|
||||
);
|
||||
try {
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-heal-orphan-rescue", "fresh-db"),
|
||||
agentId: "self-healing",
|
||||
phase: "orphan-branch-rescue",
|
||||
});
|
||||
await auditor.git({
|
||||
type: "self-healing:orphan-rescue-skipped-fresh-db",
|
||||
target: this.options.rootDir,
|
||||
metadata: {
|
||||
bootstrappedAt,
|
||||
processBootStartedAt: this.processBootStartedAt,
|
||||
taskCount,
|
||||
candidateBranches: 0,
|
||||
},
|
||||
});
|
||||
} catch (auditErr: unknown) {
|
||||
log.warn(`Failed to write self-healing:orphan-rescue-skipped-fresh-db run-audit event: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const orphaned = await scanOrphanedBranches(this.options.rootDir, this.store);
|
||||
if (orphaned.length === 0) return 0;
|
||||
|
||||
let cleaned = 0;
|
||||
const prunedBranches: string[] = [];
|
||||
const taskById = new Map(allTasks.map((task) => [task.id.toUpperCase(), task]));
|
||||
|
||||
for (const branch of orphaned) {
|
||||
const inspection = await this.inspectOrphanedBranch(branch);
|
||||
if (!inspection) continue;
|
||||
if (inspection.uniqueCommitCount > 0) continue;
|
||||
|
||||
if (inspection.uniqueCommitCount <= 0) {
|
||||
try {
|
||||
execSync(`git branch -d ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
prunedBranches.push(branch);
|
||||
cleaned++;
|
||||
try {
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-heal", "orphan-branch"),
|
||||
agentId: "self-healing",
|
||||
phase: "orphan-branch-rescue",
|
||||
});
|
||||
await auditor.git({
|
||||
type: "branch:orphan-prune",
|
||||
target: branch,
|
||||
metadata: {
|
||||
phase: "orphan-branch-rescue",
|
||||
tipSha: inspection.tipSha,
|
||||
uniqueCommitCount: inspection.uniqueCommitCount,
|
||||
},
|
||||
});
|
||||
} catch (auditErr: unknown) {
|
||||
log.warn(`Failed to write branch:orphan-prune run-audit event for ${branch}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Failed to prune subsumed orphaned branch ${branch}: ${err instanceof Error ? err.message : String(err)} — non-fatal`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const derivedTaskId = inspection.derivedTaskId;
|
||||
const matchedTask = derivedTaskId ? taskById.get(derivedTaskId.toUpperCase()) : undefined;
|
||||
const existingBranchTask = allTasks.find((task) => task.branch === branch);
|
||||
|
||||
if (matchedTask?.column === "archived") {
|
||||
if (!this.orphanArchivedAcknowledged.has(matchedTask.id)) {
|
||||
this.orphanArchivedAcknowledged.add(matchedTask.id);
|
||||
log.warn(`[recovery] orphan-rescue-archived-skip ${matchedTask.id} branch=${branch} tip=${inspection.tipSha.slice(0, 12)} unique=${inspection.uniqueCommitCount}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matchedTask && !existingBranchTask) {
|
||||
const summaryLines = [
|
||||
`Recovered orphaned branch: ${branch}`,
|
||||
`Tip: ${inspection.tipSha}`,
|
||||
`Unique commits vs main: ${inspection.uniqueCommitCount}`,
|
||||
];
|
||||
if (inspection.uniqueCommitSubjects.length > 0) {
|
||||
summaryLines.push("Recent commit subjects:");
|
||||
for (const subject of inspection.uniqueCommitSubjects) {
|
||||
summaryLines.push(`- ${subject}`);
|
||||
}
|
||||
}
|
||||
const rescueTask = await this.store.createTask({
|
||||
title: `Recover orphaned branch ${branch}`,
|
||||
description: summaryLines.join("\n"),
|
||||
branch,
|
||||
column: "triage",
|
||||
try {
|
||||
execSync(`git branch -d ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
allTasks.push({ ...rescueTask, branch, column: "triage" } as Task);
|
||||
if (inspection.registeredWorktreePath) {
|
||||
await this.store.updateTask(rescueTask.id, { worktree: inspection.registeredWorktreePath });
|
||||
}
|
||||
await this.store.logEntry(rescueTask.id, `[recovery] orphan-rescue-created ${rescueTask.id} from ${branch} (${inspection.uniqueCommitCount} unique commits)`);
|
||||
cleaned++;
|
||||
prunedBranches.push(branch);
|
||||
|
||||
try {
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-heal", rescueTask.id),
|
||||
runId: generateSyntheticRunId("self-heal", "orphan-branch"),
|
||||
agentId: "self-healing",
|
||||
taskId: rescueTask.id,
|
||||
taskLineageId: rescueTask.lineageId,
|
||||
phase: "orphan-branch-rescue",
|
||||
phase: "orphan-branch-prune",
|
||||
});
|
||||
await auditor.git({
|
||||
type: "branch:orphan-rescued",
|
||||
type: "branch:orphan-prune",
|
||||
target: branch,
|
||||
metadata: {
|
||||
phase: "orphan-branch-rescue",
|
||||
rescueTaskId: rescueTask.id,
|
||||
phase: "orphan-branch-prune",
|
||||
tipSha: inspection.tipSha,
|
||||
uniqueCommitCount: inspection.uniqueCommitCount,
|
||||
derivedTaskId: derivedTaskId ?? null,
|
||||
},
|
||||
});
|
||||
} catch (auditErr: unknown) {
|
||||
log.warn(`Failed to write branch:orphan-rescued run-audit event for ${branch}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
|
||||
log.warn(`Failed to write branch:orphan-prune run-audit event for ${branch}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Failed to prune subsumed orphaned branch ${branch}: ${err instanceof Error ? err.message : String(err)} — non-fatal`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7201,7 +7037,8 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Orphaned branch cleanup failed: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -730,7 +730,6 @@ export const RemovalReason = {
|
||||
SelfHealingReclaim: "self-healing-reclaim",
|
||||
SelfHealingStaleActiveBranch: "self-healing-stale-active-branch",
|
||||
SelfHealingBranchConflict: "self-healing-branch-conflict",
|
||||
SelfHealingOrphanRescue: "self-healing-orphan-rescue",
|
||||
SelfHealingIdleSweep: "self-healing-idle-sweep",
|
||||
PoolPrune: "pool-prune",
|
||||
} as const;
|
||||
|
||||
@@ -830,25 +830,14 @@ export async function reapOrphanWorktrees(
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Columns where the merger handles branch cleanup — skip these during orphan scanning. */
|
||||
/** Columns where merger/finalization owns branch lifecycle. */
|
||||
const MERGER_MANAGED_COLUMNS: ReadonlySet<Column> = new Set(["in-review", "done"]);
|
||||
|
||||
/**
|
||||
* Scan for orphaned `fusion/*` branches that are not associated with any
|
||||
* non-archived, non-merger-managed task.
|
||||
*
|
||||
* Lists all local branches matching the `fusion/*` pattern, then compares
|
||||
* against branches stored on tasks (via `task.branch` or derived as
|
||||
* canonicalFusionBranchName(taskId)). Branches belonging to tasks in the
|
||||
* `in-review` or `done` columns are excluded because the merger is
|
||||
* responsible for cleaning those up.
|
||||
*
|
||||
* @param rootDir — Project root directory (git working tree)
|
||||
* @param store — Task store for listing tasks and their branch assignments
|
||||
* @returns Array of orphaned branch names
|
||||
* Return local `fusion/*` branches not associated with any active task.
|
||||
* Branches tied to merger-managed or archived tasks are excluded.
|
||||
*/
|
||||
export async function scanOrphanedBranches(rootDir: string, store: TaskStore): Promise<string[]> {
|
||||
// List all local branches matching fusion/*
|
||||
let allBranches: string[];
|
||||
try {
|
||||
const result = await execAsync("git branch --list 'fusion/*'", {
|
||||
@@ -868,23 +857,14 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P
|
||||
|
||||
if (allBranches.length === 0) return [];
|
||||
|
||||
// Build set of branches associated with active (non-archived, non-merger-managed) tasks
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const activeBranches = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
// Skip tasks in columns where the merger handles branch cleanup
|
||||
if (MERGER_MANAGED_COLUMNS.has(task.column)) continue;
|
||||
// Also skip archived tasks
|
||||
if (task.column === "archived") continue;
|
||||
|
||||
// Use stored branch name if available, otherwise derive from task ID
|
||||
if (task.branch) {
|
||||
activeBranches.add(task.branch);
|
||||
}
|
||||
// Always add the derived name too — the task may not have `branch` set yet
|
||||
if (task.branch) activeBranches.add(task.branch);
|
||||
activeBranches.add(canonicalFusionBranchName(task.id));
|
||||
}
|
||||
|
||||
// Return branches not associated with any active task
|
||||
return allBranches.filter((branch) => !activeBranches.has(branch));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user