feat(FN-4742): complete Step 4 — migrate pool and self-healing removals

Fusion-Task-Id: FN-4742
Fusion-Task-Lineage: 59bca56e-c9d9-4066-9f5a-6d8ee252a100
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 09:25:18 -07:00
committed by gsxdsm
parent 5104553cfc
commit e2de8fed6d
4 changed files with 69 additions and 42 deletions

View File

@@ -113,7 +113,7 @@ describe("self-healing completion fan-out", () => {
const first = await mgr.reconcileCompletedTask("FN-B", { worktreeHint: "/wt/fn-b" });
expect(first.worktreeRemoved).toBe(true);
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git worktree remove --force '/wt/fn-b'"))).toBe(true);
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git worktree remove --force") && String(c[0]).includes("/wt/fn-b"))).toBe(true);
existsSyncMock.mockReturnValue(false);
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, stdout: string, stderr: string) => void) => {
@@ -122,7 +122,7 @@ describe("self-healing completion fan-out", () => {
});
const second = await mgr.reconcileCompletedTask("FN-B");
expect(second.worktreeRemoved).toBe(false);
const rmCalls = execMock.mock.calls.filter((c) => String(c[0]).includes("git worktree remove --force"));
const rmCalls = execMock.mock.calls.filter((c) => String(c[0]).includes("git worktree remove --force") && String(c[0]).includes("/wt/fn-b"));
expect(rmCalls).toHaveLength(1);
});
@@ -138,7 +138,7 @@ describe("self-healing completion fan-out", () => {
const mgr = new SelfHealingManager(store, { rootDir: "/repo" });
vi.spyOn(mgr as any, "findWorktreePathForBranch").mockResolvedValue("/wt/fn-c");
const out = await mgr.reconcileCompletedTask("FN-C");
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git worktree remove --force '/wt/fn-c'"))).toBe(true);
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git worktree remove --force") && String(c[0]).includes("/wt/fn-c"))).toBe(true);
expect(out.branchRemoved).toBe(false);
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git branch -D"))).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("skip deletion"));

View File

@@ -55,6 +55,7 @@ vi.mock("../worktree-pool.js", () => ({
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
removeWorktree: vi.fn().mockResolvedValue(undefined),
resolveWorktreeBackend: vi.fn(),
}));
@@ -83,7 +84,7 @@ import { existsSync, readdirSync } from "node:fs";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { isUsableTaskWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "../worktree-pool.js";
import { isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "../worktree-pool.js";
import * as branchConflictModule from "../branch-conflicts.js";
import { createLogger } from "../logger.js";
import { NotificationService } from "../notification/notification-service.js";
@@ -93,6 +94,7 @@ 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 mockedReaddirSync = vi.mocked(readdirSync);
@@ -152,6 +154,7 @@ describe("SelfHealingManager", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
store = createMockStore();
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
mockedRemoveWorktree.mockResolvedValue(undefined);
mockedClassifyOwnedLandedEvidence.mockResolvedValue({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true });
});
@@ -1251,10 +1254,8 @@ describe("SelfHealingManager", () => {
mockedExistsSync.mockReset();
mockedExistsSync.mockReturnValueOnce(true);
mockedExecSync.mockReset();
mockedExecSync.mockImplementationOnce(() => {
throw new Error("cannot remove worktree");
});
mockedRemoveWorktree.mockReset();
mockedRemoveWorktree.mockRejectedValueOnce(new Error("cannot remove worktree"));
await (manager as any).cleanupInterruptedMergeArtifacts(task);
@@ -1264,7 +1265,7 @@ describe("SelfHealingManager", () => {
),
);
mockedExecSync.mockClear();
mockedRemoveWorktree.mockClear();
mockedExistsSync.mockReset();
});
@@ -3803,9 +3804,10 @@ describe("SelfHealingManager", () => {
mergeDetails: expect.objectContaining({ commitSha: "abc12345", mergeConfirmed: true }),
}));
expect(store.updateTask).toHaveBeenCalledWith("FN-dep", { blockedBy: null });
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes("git worktree remove '/tmp/wt' --force")),
).toBe(true);
expect(mockedRemoveWorktree).toHaveBeenCalledWith(expect.objectContaining({
rootDir: "/tmp/test-project",
worktreePath: "/tmp/wt",
}));
expect(getSelfHealingLogger().log).toHaveBeenCalledWith(expect.stringContaining("self-heal:deadlock-recovered"));
managerWithRecovery.stop();

View File

@@ -29,7 +29,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
import { getInReviewStallReason, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger } from "./logger.js";
import { getRegisteredWorktreePaths, isUsableTaskWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
import { getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
import {
extractMissingWorktreePathFromSessionStartFailure,
isMissingWorktreeSessionStartFailure,
@@ -888,9 +888,12 @@ export class SelfHealingManager {
private async cleanupWorktreeOnly(task: Task): Promise<void> {
if (task.worktree && existsSync(task.worktree)) {
try {
await execAsync(`git worktree remove ${shellQuote(task.worktree)} --force`, {
cwd: this.options.rootDir,
timeout: 120_000,
const settings = await this.store.getSettings();
await removeWorktree({
rootDir: this.options.rootDir,
worktreePath: task.worktree,
settings,
taskId: task.id,
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -904,9 +907,12 @@ export class SelfHealingManager {
private async cleanupInterruptedMergeArtifacts(task: Task): Promise<void> {
if (task.worktree && existsSync(task.worktree)) {
try {
await execAsync(`git worktree remove ${shellQuote(task.worktree)} --force`, {
cwd: this.options.rootDir,
timeout: 120_000,
const settings = await this.store.getSettings();
await removeWorktree({
rootDir: this.options.rootDir,
worktreePath: task.worktree,
settings,
taskId: task.id,
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -1390,14 +1396,16 @@ export class SelfHealingManager {
let reclaimedCleanly = false;
try {
if (inspection.livePath && existsSync(inspection.livePath)) {
await execAsync(`git worktree remove --force ${JSON.stringify(inspection.livePath)}`, {
cwd: this.options.rootDir,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
await removeWorktree({
rootDir: this.options.rootDir,
worktreePath: inspection.livePath,
settings,
taskId: task.id,
});
}
// Branch-level reclaim remains active in worktrunk mode; this is
// idempotent git metadata cleanup, not layout ownership.
// FN-4742: keep native prune; see WorktreeBackend.prune docs
await execAsync("git worktree prune", {
cwd: this.options.rootDir,
timeout: 120_000,
@@ -1488,13 +1496,15 @@ export class SelfHealingManager {
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 removeWorktree({
rootDir: this.options.rootDir,
worktreePath: inspection.livePath,
settings,
taskId: task.id,
});
// Branch-level reclaim remains active in worktrunk mode; this is
// idempotent git metadata cleanup, not layout ownership.
// FN-4742: keep native prune; see WorktreeBackend.prune docs
await execAsync("git worktree prune", {
cwd: this.options.rootDir,
timeout: 120_000,
@@ -1743,6 +1753,7 @@ export class SelfHealingManager {
});
// Branch-level reclaim remains active in worktrunk mode; this is
// idempotent git metadata cleanup, not layout ownership.
// FN-4742: keep native prune; see WorktreeBackend.prune docs
await execAsync("git worktree prune", {
cwd: this.options.rootDir,
timeout: 120_000,
@@ -1958,9 +1969,12 @@ export class SelfHealingManager {
}
if (worktreePath && existsSync(worktreePath)) {
try {
await execAsync(`git worktree remove --force ${shellQuote(worktreePath)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
const settings = await this.store.getSettings();
await removeWorktree({
rootDir: this.options.rootDir,
worktreePath,
settings,
taskId,
});
result.worktreeRemoved = true;
} catch (err: unknown) {
@@ -3693,9 +3707,11 @@ export class SelfHealingManager {
});
if (task.worktree && existsSync(task.worktree)) {
await execAsync(`git worktree remove --force ${shellQuote(task.worktree)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
await removeWorktree({
rootDir: this.options.rootDir,
worktreePath: task.worktree,
settings,
taskId: task.id,
}).catch(() => undefined);
}
@@ -4894,9 +4910,10 @@ export class SelfHealingManager {
let cleaned = 0;
for (const worktreePath of orphaned) {
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: this.options.rootDir,
timeout: 30_000,
await removeWorktree({
rootDir: this.options.rootDir,
worktreePath,
settings,
});
cleaned++;
} catch (err: unknown) {
@@ -5227,9 +5244,10 @@ export class SelfHealingManager {
for (const { path: worktreePath } of withMtime) {
if (removed >= excess) break;
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: this.options.rootDir,
timeout: 30_000,
await removeWorktree({
rootDir: this.options.rootDir,
worktreePath,
settings,
});
removed++;
} catch (err: unknown) {

View File

@@ -9,6 +9,10 @@ import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-
import {
resolveWorktrunkBinary,
} from "./worktrunk-installer.js";
import {
removeWorktree as removeWorktreeViaBackend,
resolveWorktreeBackend as resolveWorktreeBackendViaSettings,
} from "./worktree-backend.js";
export {
NativeWorktreeBackend,
@@ -348,7 +352,8 @@ export class WorktreePool {
startPoint: base,
});
if (inspection.kind === "stale" || inspection.kind === "stale-resolved" || inspection.kind === "tip-already-merged") {
await execAsync("git worktree prune", { cwd: worktreePath });
const backend = resolveWorktreeBackendViaSettings({}, { logger: worktreePoolLog });
await backend.prune({ rootDir: options?.repoDir ?? worktreePath });
if (inspection.kind === "tip-already-merged") {
try {
await execAsync(`git branch -D "${branchName}"`, { cwd: worktreePath });
@@ -526,8 +531,10 @@ export async function cleanupOrphanedWorktrees(
for (const worktreePath of candidates) {
try {
if (registeredWorktrees.has(resolve(worktreePath))) {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: rootDir,
await removeWorktreeViaBackend({
rootDir,
worktreePath,
settings: settings ?? {},
});
} else {
if (!isInsideWorktreesDir(rootDir, worktreePath, settings)) {