feat(FN-5188): complete Step 3 — gate fresh-db orphan rescue

Fusion-Task-Id: FN-5188
Fusion-Task-Lineage: 6d238ff1-2d69-44e7-8694-ec19e412f8ef
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 12:33:15 -07:00
committed by gsxdsm
parent 2c75ac6227
commit 4b2a77b631
4 changed files with 125 additions and 1 deletions

View File

@@ -18,6 +18,7 @@ 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);

View File

@@ -0,0 +1,86 @@
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" }),
);
});
});

View File

@@ -160,8 +160,11 @@ export type GitMutationType =
| "branch:auto-canonicalize-case"
| "branch:stale-active-reclaim"
| "branch:stale-active-reclaim-deferred"
// 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";

View File

@@ -67,6 +67,7 @@ const BOARD_STALL_NOTIFICATION_COOLDOWN_MS = 60 * 60_000;
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,
@@ -531,6 +532,7 @@ export class SelfHealingManager {
private orphanArchivedAcknowledged = new Set<string>();
private finalizeUnprovenWarned = new Set<string>();
private maintenanceTickCounter = 0;
private readonly processBootStartedAt = Date.now();
private dependencyBlockedTodoReporter: DependencyBlockedTodoReporter | null = null;
private boardStallWindow: {
@@ -6767,12 +6769,44 @@ export class SelfHealingManager {
*/
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 allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
const taskById = new Map(allTasks.map((task) => [task.id.toUpperCase(), task]));
for (const branch of orphaned) {