Harden dashboard worktree shutdown handling
This commit is contained in:
@@ -543,6 +543,21 @@ describe("TaskExecutor worktree naming", () => {
|
||||
it("reuses stored worktree path for resumed tasks", async () => {
|
||||
const existingPath = "/tmp/test/.worktrees/calm-river";
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return [
|
||||
"worktree /tmp/test",
|
||||
"HEAD abc123",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
`worktree ${existingPath}`,
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/fn-031",
|
||||
"",
|
||||
].join("\n") as any;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
@@ -553,6 +568,29 @@ describe("TaskExecutor worktree naming", () => {
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reuse a stored worktree path that is not registered", async () => {
|
||||
const stalePath = "/tmp/test/.worktrees/broken-wt";
|
||||
mockedExistsSync.mockImplementation((path) => String(path).startsWith(stalePath));
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return "worktree /tmp/test\nHEAD abc123\nbranch refs/heads/main\n" as any;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
await executor.execute(makeTask("FN-032", stalePath));
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-032", { worktree: null, branch: null });
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
||||
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
|
||||
);
|
||||
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe("worktreeNaming setting", () => {
|
||||
it("uses task ID as worktree name when worktreeNaming is 'task-id'", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
@@ -15,7 +15,7 @@ import { createKbAgent, describeModel, promptWithFallback, compactSessionContext
|
||||
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
|
||||
import { AuthStorage, ModelRegistry, SessionManager, getAgentDir, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import { isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { executorLog, reviewerLog } from "./logger.js";
|
||||
import { TokenCapDetector } from "./token-cap-detector.js";
|
||||
@@ -903,6 +903,20 @@ export class TaskExecutor {
|
||||
// Resolve the base branch — set by the scheduler when a dep is in-review
|
||||
const baseBranch = task.baseBranch || null;
|
||||
|
||||
if (task.worktree && isResume && !isUsableTaskWorktree(this.rootDir, worktreePath)) {
|
||||
const invalidWorktreePath = worktreePath;
|
||||
executorLog.log(`${task.id}: assigned worktree is not usable; creating a fresh worktree instead: ${invalidWorktreePath}`);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Assigned worktree is not a registered, usable git worktree; creating a fresh worktree instead`,
|
||||
invalidWorktreePath,
|
||||
this.currentRunContext,
|
||||
);
|
||||
await this.store.updateTask(task.id, { worktree: null, branch: null });
|
||||
worktreePath = join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
|
||||
isResume = existsSync(worktreePath);
|
||||
}
|
||||
|
||||
if (!isResume) {
|
||||
|
||||
// Try acquiring a warm worktree from the pool
|
||||
@@ -3258,16 +3272,7 @@ and show an appropriate message to the user.\`
|
||||
* Check if a path is registered as a git worktree.
|
||||
*/
|
||||
private isRegisteredWorktree(path: string): boolean {
|
||||
try {
|
||||
const output = execSync("git worktree list --porcelain", {
|
||||
cwd: this.rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
return output.includes(path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return isRegisteredGitWorktree(this.rootDir, path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,22 @@ const settingsManagerCreateMock = vi.fn(() => ({ kind: "settings-manager-create"
|
||||
const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" }));
|
||||
const setFallbackResolverMock = vi.fn();
|
||||
const reloadMock = vi.fn(async () => {});
|
||||
const execSyncMock = vi.fn(() => "");
|
||||
const existsSyncMock = vi.fn(() => false);
|
||||
const readFileSyncMock = vi.fn(() => "{}");
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: execSyncMock,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
AuthStorage: {
|
||||
@@ -279,6 +295,9 @@ describe("worktree path boundary helpers", () => {
|
||||
describe("createKbAgent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
execSyncMock.mockReturnValue("");
|
||||
existsSyncMock.mockReturnValue(false);
|
||||
readFileSyncMock.mockReturnValue("{}");
|
||||
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
||||
createAgentSessionMock.mockResolvedValue({
|
||||
session: {
|
||||
@@ -290,6 +309,53 @@ describe("createKbAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to start a coding agent in an unregistered worktree", async () => {
|
||||
existsSyncMock.mockImplementation((path) => {
|
||||
const value = String(path);
|
||||
return value === "/project/.worktrees/fn-001" ||
|
||||
value === "/project/.worktrees/fn-001/.git" ||
|
||||
value === "/project/.worktrees/fn-001/package.json";
|
||||
});
|
||||
execSyncMock.mockReturnValue("worktree /project\nHEAD abc123\nbranch refs/heads/main\n");
|
||||
|
||||
const { createKbAgent } = await import("./pi.js");
|
||||
|
||||
await expect(createKbAgent({
|
||||
cwd: "/project/.worktrees/fn-001",
|
||||
systemPrompt: "test",
|
||||
tools: "coding",
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
})).rejects.toThrow("Refusing to start coding agent in unregistered git worktree");
|
||||
|
||||
expect(createAgentSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a coding agent in a registered complete worktree", async () => {
|
||||
existsSyncMock.mockImplementation((path) => {
|
||||
const value = String(path);
|
||||
return value === "/project/.worktrees/fn-001" ||
|
||||
value === "/project/.worktrees/fn-001/.git" ||
|
||||
value === "/project/.worktrees/fn-001/package.json";
|
||||
});
|
||||
execSyncMock.mockReturnValue(
|
||||
"worktree /project\nHEAD abc123\nbranch refs/heads/main\n\n" +
|
||||
"worktree /project/.worktrees/fn-001\nHEAD def456\nbranch refs/heads/fusion/fn-001\n",
|
||||
);
|
||||
|
||||
const { createKbAgent } = await import("./pi.js");
|
||||
|
||||
await createKbAgent({
|
||||
cwd: "/project/.worktrees/fn-001",
|
||||
systemPrompt: "test",
|
||||
tools: "coding",
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
});
|
||||
|
||||
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("registers extension providers before resolving configured models", async () => {
|
||||
packageManagerResolveMock.mockResolvedValueOnce({
|
||||
extensions: [{ enabled: true, path: "/extensions/zai-provider" }],
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, relative, isAbsolute, resolve } from "node:path";
|
||||
import {
|
||||
AuthStorage,
|
||||
@@ -273,6 +274,34 @@ function getProjectRootFromWorktree(cwd: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRegisteredGitWorktree(projectRoot: string, worktreePath: string): boolean {
|
||||
try {
|
||||
const output = String(execSync("git worktree list --porcelain", {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
}));
|
||||
const resolvedWorktree = resolve(worktreePath);
|
||||
return output.split("\n").some((line) =>
|
||||
line.startsWith("worktree ") && resolve(line.slice("worktree ".length)) === resolvedWorktree
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidWorktreeSession(cwd: string, projectRoot: string): void {
|
||||
if (!existsSync(cwd)) {
|
||||
throw new Error(`Refusing to start coding agent in missing worktree: ${cwd}`);
|
||||
}
|
||||
if (!existsSync(join(cwd, ".git")) || !existsSync(join(cwd, "package.json"))) {
|
||||
throw new Error(`Refusing to start coding agent in incomplete worktree: ${cwd}`);
|
||||
}
|
||||
if (!isRegisteredGitWorktree(projectRoot, cwd)) {
|
||||
throw new Error(`Refusing to start coding agent in unregistered git worktree: ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is allowed to be accessed from a worktree session.
|
||||
* Rules:
|
||||
@@ -398,6 +427,9 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
|
||||
// Detect if this is a worktree session and apply path boundaries
|
||||
const worktreePath = options.cwd;
|
||||
const projectRoot = getProjectRootFromWorktree(worktreePath);
|
||||
if (projectRoot) {
|
||||
assertValidWorktreeSession(worktreePath, projectRoot);
|
||||
}
|
||||
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, projectRoot);
|
||||
|
||||
// Compaction is explicitly enabled to prevent context-window overflow during
|
||||
|
||||
@@ -204,7 +204,37 @@ function createAgentWithTaskDone() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true); // Default: worktrees exist (resume scenario)
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return [
|
||||
"worktree /tmp/test",
|
||||
"HEAD abc123",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /tmp/wt/KB-010",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/fn-010",
|
||||
"",
|
||||
"worktree /tmp/wt/FN-963",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/fn-963",
|
||||
"",
|
||||
"worktree /root/.worktrees/active-wt",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/active-wt",
|
||||
"",
|
||||
"worktree /root/.worktrees/review-wt",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/review-wt",
|
||||
"",
|
||||
"worktree /root/.worktrees/idle-wt",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/idle-wt",
|
||||
"",
|
||||
].join("\n") as any;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Step 2: In-progress task resume tests ─────────────────────────────────
|
||||
@@ -874,6 +904,26 @@ function makeDirEntry(name: string) {
|
||||
return { name, isDirectory: () => true } as any;
|
||||
}
|
||||
|
||||
function mockRegisteredWorktrees(rootDir: string, names: string[]) {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return [
|
||||
`worktree ${rootDir}`,
|
||||
"HEAD abc123",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
...names.flatMap((name) => [
|
||||
`worktree ${rootDir}/.worktrees/${name}`,
|
||||
"HEAD def456",
|
||||
`branch refs/heads/fusion/${name}`,
|
||||
"",
|
||||
]),
|
||||
].join("\n") as any;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
describe("Worktree pool restart with recycleWorktrees=true", () => {
|
||||
it("pool is rehydrated with idle worktrees from disk", async () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
@@ -882,6 +932,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
|
||||
makeDirEntry("bold-eagle"),
|
||||
] as any);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockRegisteredWorktrees("/root", ["swift-falcon", "calm-river", "bold-eagle"]);
|
||||
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([
|
||||
@@ -908,6 +959,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
makeDirEntry("idle-wt"),
|
||||
] as any);
|
||||
mockRegisteredWorktrees("/root", ["idle-wt"]);
|
||||
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
@@ -958,6 +1010,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
|
||||
makeDirEntry("idle-wt"),
|
||||
] as any);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockRegisteredWorktrees("/root", ["active-wt", "idle-wt"]);
|
||||
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([
|
||||
@@ -979,6 +1032,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
|
||||
makeDirEntry("review-wt"),
|
||||
] as any);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockRegisteredWorktrees("/root", ["review-wt"]);
|
||||
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([
|
||||
@@ -1001,7 +1055,7 @@ describe("Worktree cleanup on restart with recycleWorktrees=false", () => {
|
||||
makeDirEntry("orphan-2"),
|
||||
] as any);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
mockRegisteredWorktrees("/root", ["orphan-1", "orphan-2"]);
|
||||
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
@@ -1021,7 +1075,7 @@ describe("Worktree cleanup on restart with recycleWorktrees=false", () => {
|
||||
makeDirEntry("orphan-wt"),
|
||||
] as any);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
mockRegisteredWorktrees("/root", ["active-wt", "orphan-wt"]);
|
||||
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([
|
||||
@@ -1044,6 +1098,7 @@ describe("Worktree cleanup on restart with recycleWorktrees=false", () => {
|
||||
makeDirEntry("review-wt"),
|
||||
] as any);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockRegisteredWorktrees("/root", ["review-wt"]);
|
||||
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([
|
||||
|
||||
@@ -7,16 +7,18 @@ vi.mock("node:child_process", () => ({
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readdirSync: vi.fn().mockReturnValue([]),
|
||||
rmSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { existsSync, readdirSync, rmSync } from "node:fs";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedRmSync = vi.mocked(rmSync);
|
||||
|
||||
describe("WorktreePool", () => {
|
||||
let pool: WorktreePool;
|
||||
@@ -367,12 +369,33 @@ function makeDirEntry(name: string) {
|
||||
return { name, isDirectory: () => true } as any;
|
||||
}
|
||||
|
||||
function mockRegisteredWorktrees(rootDir: string, names: string[]) {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return [
|
||||
`worktree ${rootDir}`,
|
||||
"HEAD abc123",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
...names.flatMap((name) => [
|
||||
`worktree ${rootDir}/.worktrees/${name}`,
|
||||
"HEAD def456",
|
||||
`branch refs/heads/fusion/${name}`,
|
||||
"",
|
||||
]),
|
||||
].join("\n") as any;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
// ── scanIdleWorktrees tests ───────────────────────────────────────────
|
||||
|
||||
describe("scanIdleWorktrees", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockRegisteredWorktrees("/root", []);
|
||||
});
|
||||
|
||||
it("correctly identifies idle vs active worktrees", async () => {
|
||||
@@ -381,6 +404,7 @@ describe("scanIdleWorktrees", () => {
|
||||
makeDirEntry("calm-river"),
|
||||
makeDirEntry("bold-eagle"),
|
||||
] as any);
|
||||
mockRegisteredWorktrees("/root", ["swift-falcon", "calm-river", "bold-eagle"]);
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "in-progress", "/root/.worktrees/swift-falcon"),
|
||||
@@ -417,6 +441,7 @@ describe("scanIdleWorktrees", () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
makeDirEntry("review-wt"),
|
||||
] as any);
|
||||
mockRegisteredWorktrees("/root", ["review-wt"]);
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-010", "in-review", "/root/.worktrees/review-wt"),
|
||||
@@ -431,6 +456,7 @@ describe("scanIdleWorktrees", () => {
|
||||
makeDirEntry("wt-1"),
|
||||
makeDirEntry("wt-2"),
|
||||
] as any);
|
||||
mockRegisteredWorktrees("/root", ["wt-1", "wt-2"]);
|
||||
|
||||
const store = createMockStore([]);
|
||||
|
||||
@@ -449,6 +475,21 @@ describe("scanIdleWorktrees", () => {
|
||||
const idle = await scanIdleWorktrees("/root", store);
|
||||
expect(idle).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not return unregistered directories for pool rehydration", async () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
makeDirEntry("registered-wt"),
|
||||
makeDirEntry("broken-wt"),
|
||||
] as any);
|
||||
mockRegisteredWorktrees("/root", ["registered-wt"]);
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "in-progress", "/root/.worktrees/broken-wt"),
|
||||
]);
|
||||
|
||||
const idle = await scanIdleWorktrees("/root", store);
|
||||
expect(idle).toEqual(["/root/.worktrees/registered-wt"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cleanupOrphanedWorktrees tests ────────────────────────────────────
|
||||
@@ -457,7 +498,7 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
mockRegisteredWorktrees("/root", []);
|
||||
});
|
||||
|
||||
it("removes worktrees not assigned to any active task", async () => {
|
||||
@@ -465,6 +506,7 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
makeDirEntry("orphan-1"),
|
||||
makeDirEntry("orphan-2"),
|
||||
] as any);
|
||||
mockRegisteredWorktrees("/root", ["orphan-1", "orphan-2"]);
|
||||
|
||||
const store = createMockStore([]);
|
||||
|
||||
@@ -484,6 +526,7 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
makeDirEntry("active-wt"),
|
||||
makeDirEntry("orphan-wt"),
|
||||
] as any);
|
||||
mockRegisteredWorktrees("/root", ["active-wt", "orphan-wt"]);
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "in-progress", "/root/.worktrees/active-wt"),
|
||||
@@ -507,6 +550,22 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
] as any);
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return [
|
||||
"worktree /root",
|
||||
"HEAD abc123",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /root/.worktrees/fail-wt",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/fail-wt",
|
||||
"",
|
||||
"worktree /root/.worktrees/ok-wt",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/fusion/ok-wt",
|
||||
"",
|
||||
].join("\n") as any;
|
||||
}
|
||||
if (typeof cmd === "string" && cmd.includes("fail-wt")) {
|
||||
throw new Error("worktree locked");
|
||||
}
|
||||
@@ -527,7 +586,10 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
|
||||
const cleaned = await cleanupOrphanedWorktrees("/root", store);
|
||||
expect(cleaned).toBe(0);
|
||||
expect(mockedExecSync).not.toHaveBeenCalled();
|
||||
const removeCalls = mockedExecSync.mock.calls.filter(
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
|
||||
);
|
||||
expect(removeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 0 when all worktrees are assigned to active tasks", async () => {
|
||||
@@ -535,6 +597,7 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
makeDirEntry("active-1"),
|
||||
makeDirEntry("active-2"),
|
||||
] as any);
|
||||
mockRegisteredWorktrees("/root", ["active-1", "active-2"]);
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "in-progress", "/root/.worktrees/active-1"),
|
||||
@@ -543,7 +606,29 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
|
||||
const cleaned = await cleanupOrphanedWorktrees("/root", store);
|
||||
expect(cleaned).toBe(0);
|
||||
expect(mockedExecSync).not.toHaveBeenCalled();
|
||||
const removeCalls = mockedExecSync.mock.calls.filter(
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
|
||||
);
|
||||
expect(removeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes unregistered directories even when stale active task metadata references them", async () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
makeDirEntry("broken-wt"),
|
||||
] as any);
|
||||
mockRegisteredWorktrees("/root", []);
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "in-progress", "/root/.worktrees/broken-wt"),
|
||||
]);
|
||||
|
||||
const cleaned = await cleanupOrphanedWorktrees("/root", store);
|
||||
|
||||
expect(cleaned).toBe(1);
|
||||
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/broken-wt", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,50 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join, relative, resolve, isAbsolute } from "node:path";
|
||||
import type { Column, TaskStore } from "@fusion/core";
|
||||
import { worktreePoolLog } from "./logger.js";
|
||||
|
||||
export function getRegisteredWorktreePaths(rootDir: string): Set<string> {
|
||||
try {
|
||||
const output = String(execSync("git worktree list --porcelain", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
}));
|
||||
|
||||
const paths = new Set<string>();
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
paths.add(resolve(line.slice("worktree ".length)));
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function isRegisteredGitWorktree(rootDir: string, worktreePath: string): boolean {
|
||||
return getRegisteredWorktreePaths(rootDir).has(resolve(worktreePath));
|
||||
}
|
||||
|
||||
export function hasRequiredWorktreeFiles(worktreePath: string): boolean {
|
||||
return existsSync(join(worktreePath, ".git")) && existsSync(join(worktreePath, "package.json"));
|
||||
}
|
||||
|
||||
export function isUsableTaskWorktree(rootDir: string, worktreePath: string): boolean {
|
||||
return existsSync(worktreePath) &&
|
||||
isRegisteredGitWorktree(rootDir, worktreePath) &&
|
||||
hasRequiredWorktreeFiles(worktreePath);
|
||||
}
|
||||
|
||||
function isInsideWorktreesDir(rootDir: string, worktreePath: string): boolean {
|
||||
const worktreesDir = resolve(rootDir, ".worktrees");
|
||||
const target = resolve(worktreePath);
|
||||
const rel = relative(worktreesDir, target);
|
||||
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pool of idle git worktrees that can be recycled across tasks.
|
||||
*
|
||||
@@ -220,17 +261,24 @@ export async function scanIdleWorktrees(rootDir: string, store: TaskStore): Prom
|
||||
return [];
|
||||
}
|
||||
|
||||
const registeredWorktrees = getRegisteredWorktreePaths(rootDir);
|
||||
const registeredDirs = dirs.filter((dir) => registeredWorktrees.has(resolve(dir)));
|
||||
|
||||
// Find worktree paths assigned to non-done tasks (active worktrees)
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const activeWorktrees = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
if (task.worktree && task.column !== "done") {
|
||||
activeWorktrees.add(task.worktree);
|
||||
if (task.worktree && task.column !== "done" && registeredWorktrees.has(resolve(task.worktree))) {
|
||||
activeWorktrees.add(resolve(task.worktree));
|
||||
} else if (task.worktree && task.column !== "done") {
|
||||
worktreePoolLog.log(`Ignoring task ${task.id} worktree metadata because it is not a registered git worktree: ${task.worktree}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Return worktrees on disk that are NOT active
|
||||
return dirs.filter((dir) => !activeWorktrees.has(dir));
|
||||
// Return registered worktrees on disk that are NOT active. Unregistered
|
||||
// directories are intentionally excluded here so recycle mode never adds a
|
||||
// broken directory to the warm pool; cleanup handles those separately.
|
||||
return registeredDirs.filter((dir) => !activeWorktrees.has(resolve(dir)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,15 +295,42 @@ export async function scanIdleWorktrees(rootDir: string, store: TaskStore): Prom
|
||||
* @returns Number of worktrees cleaned up
|
||||
*/
|
||||
export async function cleanupOrphanedWorktrees(rootDir: string, store: TaskStore): Promise<number> {
|
||||
const worktreesDir = join(rootDir, ".worktrees");
|
||||
if (!existsSync(worktreesDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const orphaned = await scanIdleWorktrees(rootDir, store);
|
||||
const registeredWorktrees = getRegisteredWorktreePaths(rootDir);
|
||||
|
||||
let dirs: string[] = [];
|
||||
if (existsSync(worktreesDir)) {
|
||||
try {
|
||||
dirs = readdirSync(worktreesDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => join(worktreesDir, e.name));
|
||||
} catch {
|
||||
dirs = [];
|
||||
}
|
||||
}
|
||||
|
||||
const unregistered = dirs.filter((dir) => !registeredWorktrees.has(resolve(dir)));
|
||||
const candidates = [...orphaned, ...unregistered];
|
||||
let cleaned = 0;
|
||||
|
||||
for (const worktreePath of orphaned) {
|
||||
for (const worktreePath of candidates) {
|
||||
try {
|
||||
execSync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
if (registeredWorktrees.has(resolve(worktreePath))) {
|
||||
execSync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} else {
|
||||
if (!isInsideWorktreesDir(rootDir, worktreePath)) {
|
||||
throw new Error(`Refusing to remove path outside .worktrees: ${worktreePath}`);
|
||||
}
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
worktreePoolLog.log(`Cleaned up orphaned worktree: ${worktreePath}`);
|
||||
cleaned++;
|
||||
} catch (err: any) {
|
||||
|
||||
Reference in New Issue
Block a user