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:
committed by
gsxdsm
parent
5104553cfc
commit
e2de8fed6d
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user