feat(FN-4500): complete Step 3 — add live zero-commit reclaim fast-path

Fusion-Task-Id: FN-4500
Fusion-Task-Lineage: 81d33759-70f6-4958-abf8-3db20f918c01
This commit is contained in:
Fusion
2026-05-14 12:25:49 -07:00
committed by gsxdsm
parent d7bf51f927
commit 3e20bd5c4a
3 changed files with 228 additions and 11 deletions

View File

@@ -35,9 +35,9 @@ describe("inspectBranchConflict zero-unique behavior", () => {
const repoDir = await setupRepo();
await run("git checkout -b fusion/fn-9001", repoDir);
await run("git checkout main", repoDir);
const livePath = path.join(repoDir, "..", "live-9001");
const livePath = path.join(repoDir, "wt-live-9001");
await run(`git worktree add ${JSON.stringify(livePath)} fusion/fn-9001`, repoDir);
const stalePath = path.join(repoDir, "..", "stale-9001");
const stalePath = path.join(repoDir, "wt-stale-9001");
await mkdir(stalePath, { recursive: true });
const result = await inspectBranchConflict({
@@ -63,9 +63,9 @@ describe("inspectBranchConflict zero-unique behavior", () => {
await run("git checkout main", repoDir);
await run(`git cherry-pick ${branchCommit}`, repoDir);
const livePath = path.join(repoDir, "..", "live-9001-upstream");
const livePath = path.join(repoDir, "wt-live-9001-upstream");
await run(`git worktree add ${JSON.stringify(livePath)} fusion/fn-9001`, repoDir);
const stalePath = path.join(repoDir, "..", "stale-9001-upstream");
const stalePath = path.join(repoDir, "wt-stale-9001-upstream");
await mkdir(stalePath, { recursive: true });
const result = await inspectBranchConflict({
@@ -88,9 +88,9 @@ describe("inspectBranchConflict zero-unique behavior", () => {
await run("git commit -m 'feat(FN-9001): unique' -m 'Fusion-Task-Id: FN-9001'", repoDir);
await run("git checkout main", repoDir);
const livePath = path.join(repoDir, "..", "live-9001-unique");
const livePath = path.join(repoDir, "wt-live-9001-unique");
await run(`git worktree add ${JSON.stringify(livePath)} fusion/fn-9001`, repoDir);
const stalePath = path.join(repoDir, "..", "stale-9001-unique");
const stalePath = path.join(repoDir, "wt-stale-9001-unique");
await mkdir(stalePath, { recursive: true });
const result = await inspectBranchConflict({
@@ -113,9 +113,9 @@ describe("inspectBranchConflict zero-unique behavior", () => {
await run("git commit -m 'chore: other work'", repoDir);
await run("git checkout main", repoDir);
const livePath = path.join(repoDir, "..", "live-other");
const livePath = path.join(repoDir, "wt-live-other");
await run(`git worktree add ${JSON.stringify(livePath)} topic/other`, repoDir);
const stalePath = path.join(repoDir, "..", "stale-other");
const stalePath = path.join(repoDir, "wt-stale-other");
await mkdir(stalePath, { recursive: true });
const result = await inspectBranchConflict({

View File

@@ -0,0 +1,119 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
const execMock = vi.fn();
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execFn: any = (cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
execMock(cmd, opts)
.then((stdout: string) => callback?.(null, stdout, ""))
.catch((err: Error) => callback?.(err, "", err.message));
};
execFn[promisify.custom] = (cmd: string, opts?: any) =>
execMock(cmd, opts).then((stdout: string) => ({ stdout, stderr: "" }));
return { exec: execFn, execSync: vi.fn() };
});
import { SelfHealingManager } from "../self-healing.js";
import * as branchConflicts from "../branch-conflicts.js";
import * as worktreePool from "../worktree-pool.js";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getSettings = vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false });
(emitter as any).listTasks = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).moveTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
return emitter;
}
describe("self-healing reclaim live zero commits", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createStore();
manager = new SelfHealingManager(store, { rootDir: "/tmp/test" });
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
execMock.mockReset();
execMock.mockResolvedValue("");
});
it("auto-reclaims self-owned fully-subsumed live branch by deleting worktree+branch", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ id: "FN-9001", column: "in-review", checkedOutBy: null, branch: "fusion/fn-9001", worktree: "/tmp/stale", paused: true, pausedReason: "branch-conflict-unrecoverable", status: "failed", lineageId: "lin-1" },
]);
vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({
kind: "fully-subsumed",
livePath: "/tmp/live",
tipSha: "1234567890abcdef",
} as any);
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(1);
expect(execMock).toHaveBeenCalledWith(expect.stringContaining("git worktree remove --force"), expect.anything());
expect(execMock).toHaveBeenCalledWith("git worktree prune", expect.anything());
expect(execMock).toHaveBeenCalledWith(expect.stringContaining("git branch -D"), expect.anything());
expect(store.updateTask).toHaveBeenCalledWith("FN-9001", expect.objectContaining({ worktree: null, branch: null, paused: false }));
expect(store.moveTask).toHaveBeenCalledWith("FN-9001", "todo", expect.objectContaining({ moveSource: "engine", preserveProgress: true, preserveResumeState: true }));
expect(store.logEntry).toHaveBeenCalledWith("FN-9001", expect.stringContaining("[recovery] reclaim-live-zero-commits"));
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "branch:auto-reclaim",
metadata: expect.objectContaining({ phase: "reclaim-live-zero-commits" }),
}));
});
it("does not run destructive fast-path for foreign branch names", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ id: "FN-9001", column: "in-review", checkedOutBy: null, branch: "fusion/fn-other", worktree: "/tmp/stale", paused: true, pausedReason: "branch-conflict-unrecoverable", status: "failed" },
]);
vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({
kind: "fully-subsumed",
livePath: "/tmp/live",
tipSha: "1234567890abcdef",
} as any);
await manager.reclaimSelfOwnedBranchConflicts();
expect(execMock).not.toHaveBeenCalledWith(expect.stringContaining("git worktree remove --force"), expect.anything());
expect(store.updateTask).toHaveBeenCalledWith("FN-9001", expect.objectContaining({ worktree: "/tmp/live", branch: "fusion/fn-other" }));
});
it("parks task without corrupting branch/worktree when worktree removal fails", async () => {
execMock.mockImplementation(async (command: string) => {
if (command.includes("git worktree remove --force")) {
throw new Error("remove failed");
}
return "";
});
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ id: "FN-9001", column: "in-review", checkedOutBy: null, branch: "fusion/fn-9001", worktree: "/tmp/stale", paused: true, pausedReason: "branch-conflict-unrecoverable", status: "failed" },
]);
vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({
kind: "fully-subsumed",
livePath: "/tmp/live",
tipSha: "1234567890abcdef",
} as any);
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(1);
expect(store.logEntry).toHaveBeenCalledWith("FN-9001", expect.stringContaining("reclaim-live-zero-commits failed"));
expect(store.updateTask).toHaveBeenCalledWith("FN-9001", expect.objectContaining({ worktree: "/tmp/live", branch: "fusion/fn-9001" }));
});
});

View File

@@ -23,7 +23,7 @@ import { createLogger } from "./logger.js";
import { getRegisteredWorktreePaths, isUsableTaskWorktree, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
import { extractMissingWorktreePathFromSessionStartFailure, isMissingWorktreeSessionStartFailure, isRecoverableMissingWorktreeReviewFailure } from "./restart-recovery-coordinator.js";
import { classifyError, extractMissingModulePath, isOperatorActionableAgentError, isStaleWorktreeModuleResolutionError } from "./transient-error-detector.js";
import { inspectBranchConflict } from "./branch-conflicts.js";
import { deriveTaskIdFromFusionBranch, inspectBranchConflict } from "./branch-conflicts.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
const log = createLogger("self-healing");
@@ -1412,6 +1412,12 @@ export class SelfHealingManager {
const todoCandidates = await this.store.listTasks({ column: "todo", slim: true });
const inProgressCandidates = await this.store.listTasks({ column: "in-progress", slim: true });
const inProgressByWorktree = new Map<string, string>();
for (const inProgressTask of inProgressCandidates) {
if (inProgressTask.worktree) {
inProgressByWorktree.set(inProgressTask.worktree, inProgressTask.id);
}
}
const inReviewPausedCandidates = (await this.store.listTasks({ column: "in-review", slim: true }))
.filter((task) => task.paused === true && task.pausedReason === "branch-conflict-unrecoverable");
const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates];
@@ -1459,11 +1465,103 @@ export class SelfHealingManager {
throw inspection.error;
}
const wasPausedBranchConflict = task.paused === true && task.pausedReason === "branch-conflict-unrecoverable";
if (inspection.kind === "fully-subsumed") {
const taskIdUpper = task.id.toUpperCase();
const branchOwnerTaskId = deriveTaskIdFromFusionBranch(task.branch);
const activeOwner = inProgressByWorktree.get(inspection.livePath);
const ownedByOtherInProgressTask = Boolean(activeOwner && activeOwner !== task.id);
const canAutoReclaimLiveZero =
branchOwnerTaskId !== null &&
branchOwnerTaskId === taskIdUpper &&
!activeTaskIds.has(taskIdUpper) &&
!ownedByOtherInProgressTask;
if (canAutoReclaimLiveZero) {
let reclaimedCleanly = false;
try {
await execAsync(`git worktree remove --force ${JSON.stringify(inspection.livePath)}`, {
cwd: this.options.rootDir,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
await execAsync("git worktree prune", {
cwd: this.options.rootDir,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
await execAsync(`git branch -D ${JSON.stringify(task.branch)}`, {
cwd: this.options.rootDir,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
await this.store.updateTask(task.id, {
worktree: null,
branch: null,
paused: false,
pausedReason: undefined,
status: null,
error: null,
});
await this.store.logEntry(
task.id,
`[recovery] reclaim-live-zero-commits ${task.id} branch=${task.branch} worktree=${inspection.livePath} tip=${inspection.tipSha.slice(0, 12)} reason=zero-unique-commits-vs-main`,
);
if (task.column === "in-review") {
await this.store.moveTask(task.id, "todo", {
moveSource: "engine",
preserveProgress: true,
preserveResumeState: true,
});
}
try {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", task.id),
agentId: "self-healing",
taskId: task.id,
taskLineageId: task.lineageId,
phase: "reclaim-live-zero-commits",
});
await auditor.git({
type: "branch:auto-reclaim",
target: task.branch,
metadata: {
taskId: task.id,
branch: task.branch,
worktreePath: inspection.livePath,
existingTipSha: inspection.tipSha,
strandedCommitCount: 0,
subsumed: true,
recoveredFromPaused: wasPausedBranchConflict,
previousPausedReason: wasPausedBranchConflict ? task.pausedReason : null,
trigger: "self-healing-sweep-live-zero",
},
});
} catch (auditErr: unknown) {
log.warn(`Failed to write branch:auto-reclaim run-audit event for ${task.id}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
}
recovered++;
reclaimedCleanly = true;
} catch (reclaimErr: unknown) {
const reclaimMessage = reclaimErr instanceof Error ? reclaimErr.message : String(reclaimErr);
await this.store.logEntry(task.id, `Auto-recovery warning: reclaim-live-zero-commits failed — ${reclaimMessage}`);
log.warn(`Failed reclaim-live-zero-commits for ${task.id}: ${reclaimMessage}`);
}
if (reclaimedCleanly) {
continue;
}
}
}
const preservedCommitCount = inspection.kind === "fully-subsumed"
? 0
: inspection.taskAttributedCommitCount;
const wasPausedBranchConflict = task.paused === true && task.pausedReason === "branch-conflict-unrecoverable";
await this.store.updateTask(task.id, {
worktree: inspection.livePath,
branch: task.branch,