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:
committed by
gsxdsm
parent
2c75ac6227
commit
4b2a77b631
@@ -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);
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user