feat(FN-4628): complete Step 1 — backend-aware prune gate
Fusion-Task-Id: FN-4628 Fusion-Task-Lineage: ebd36565-946b-4b4f-b8df-4d99d7a5f074
This commit is contained in:
committed by
gsxdsm
parent
98c88c7c2c
commit
b745d8049c
@@ -55,6 +55,7 @@ vi.mock("../worktree-pool.js", () => ({
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
|
||||
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
|
||||
resolveWorktreeBackend: vi.fn(),
|
||||
}));
|
||||
|
||||
const { selfHealingLoggerMock } = vi.hoisted(() => ({
|
||||
@@ -82,7 +83,7 @@ import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { isUsableTaskWorktree, scanOrphanedBranches } from "../worktree-pool.js";
|
||||
import { isUsableTaskWorktree, resolveWorktreeBackend, scanOrphanedBranches } from "../worktree-pool.js";
|
||||
import * as branchConflictModule from "../branch-conflicts.js";
|
||||
import { createLogger } from "../logger.js";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
@@ -92,6 +93,7 @@ const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
|
||||
const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
|
||||
const mockedResolveWorktreeBackend = vi.mocked(resolveWorktreeBackend);
|
||||
const mockedCreateLogger = vi.mocked(createLogger);
|
||||
const mockedClassifyOwnedLandedEvidence = vi.mocked(classifyOwnedLandedEvidence);
|
||||
|
||||
@@ -6256,6 +6258,82 @@ describe("recoverDoneTaskMergeMetadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneWorktrees", () => {
|
||||
let store: TaskStore & EventEmitter;
|
||||
let manager: SelfHealingManager;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
mockedResolveWorktreeBackend.mockReturnValue({
|
||||
kind: "native",
|
||||
create: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
sync: vi.fn(),
|
||||
prune: vi.fn(),
|
||||
resolveWorktreePath: vi.fn(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("delegates prune to worktrunk backend when enabled", async () => {
|
||||
const prune = vi.fn().mockResolvedValue(undefined);
|
||||
mockedResolveWorktreeBackend.mockReturnValue({
|
||||
kind: "worktrunk",
|
||||
create: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
sync: vi.fn(),
|
||||
prune,
|
||||
resolveWorktreePath: vi.fn(),
|
||||
} as any);
|
||||
vi.mocked(store.getSettings).mockResolvedValue({ worktrunk: { enabled: true, onFailure: "fail" } } as any);
|
||||
|
||||
await (manager as any).pruneWorktrees();
|
||||
|
||||
expect(prune).toHaveBeenCalledWith({ rootDir: "/tmp/test-project" });
|
||||
expect(mockedExecSync).not.toHaveBeenCalledWith(expect.stringContaining("git worktree prune"), expect.anything());
|
||||
});
|
||||
|
||||
it("does not run native prune when worktrunk fail-hard prune fails", async () => {
|
||||
const prune = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
mockedResolveWorktreeBackend.mockReturnValue({
|
||||
kind: "worktrunk",
|
||||
create: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
sync: vi.fn(),
|
||||
prune,
|
||||
resolveWorktreePath: vi.fn(),
|
||||
} as any);
|
||||
vi.mocked(store.getSettings).mockResolvedValue({ worktrunk: { enabled: true, onFailure: "fail" } } as any);
|
||||
|
||||
await (manager as any).pruneWorktrees();
|
||||
|
||||
expect(prune).toHaveBeenCalledTimes(1);
|
||||
expect(mockedExecSync).not.toHaveBeenCalledWith(expect.stringContaining("git worktree prune"), expect.anything());
|
||||
});
|
||||
|
||||
it("falls back to native prune when worktrunk fallback-native prune fails", async () => {
|
||||
const prune = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
mockedResolveWorktreeBackend.mockReturnValue({
|
||||
kind: "worktrunk",
|
||||
create: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
sync: vi.fn(),
|
||||
prune,
|
||||
resolveWorktreePath: vi.fn(),
|
||||
} as any);
|
||||
vi.mocked(store.getSettings).mockResolvedValue({ worktrunk: { enabled: true, onFailure: "fallback-native" } } as any);
|
||||
|
||||
await (manager as any).pruneWorktrees();
|
||||
|
||||
expect(prune).toHaveBeenCalledTimes(1);
|
||||
expect(mockedExecSync).toHaveBeenCalledWith("git worktree prune", expect.objectContaining({ cwd: "/tmp/test-project", timeout: 30000, stdio: ["pipe", "pipe", "pipe"] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("maintenance cycle concurrency", () => {
|
||||
let store: TaskStore & EventEmitter;
|
||||
let manager: SelfHealingManager;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { getInReviewStallReason, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, 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, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
import { getRegisteredWorktreePaths, isUsableTaskWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
import {
|
||||
extractMissingWorktreePathFromSessionStartFailure,
|
||||
isMissingWorktreeSessionStartFailure,
|
||||
@@ -4772,6 +4772,34 @@ export class SelfHealingManager {
|
||||
/** Run `git worktree prune` to clean stale metadata. */
|
||||
private async pruneWorktrees(): Promise<void> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
const worktrunkEnabled = settings.worktrunk?.enabled === true;
|
||||
if (worktrunkEnabled) {
|
||||
const backend = resolveWorktreeBackend(settings, { logger: log });
|
||||
if (backend.kind === "worktrunk") {
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-heal", "worktrunk-prune"),
|
||||
agentId: "self-healing",
|
||||
phase: "maintenance-prune",
|
||||
});
|
||||
|
||||
try {
|
||||
await backend.prune({ rootDir: this.options.rootDir });
|
||||
await auditor.git({ type: "worktree:worktrunk-prune", target: this.options.rootDir, metadata: { success: true } });
|
||||
log.log("Worktree prune delegated to worktrunk backend");
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
await auditor.git({ type: "worktree:worktrunk-prune", target: this.options.rootDir, metadata: { success: false, error: errorMessage } });
|
||||
if (settings.worktrunk?.onFailure === "fail") {
|
||||
log.error(`Worktrunk prune failed (fail-hard): ${errorMessage}`);
|
||||
return;
|
||||
}
|
||||
log.warn(`Worktrunk prune failed; falling back to native git prune: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await execAsync("git worktree prune", {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
|
||||
Reference in New Issue
Block a user