Harden executor worktree recovery coverage against fixed-path collisions. - create a unique temporary root for the stale unregistered worktree test case - derive the stale worktree path from that temporary root instead of a shared /tmp/test path - initialize TaskExecutor with the same temporary root so setup and assertions stay aligned Files changed: packages/engine/src/__tests__/executor-worktree.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-5965 Fusion-Task-Lineage: 4436bd02-b7f7-4e62-8514-0aef6cb67813
2513 lines
88 KiB
TypeScript
2513 lines
88 KiB
TypeScript
// -nocheck
|
|
/* eslint-disable -eslint/no-unused-vars */
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import "./executor-test-helpers.js";
|
|
import { AgentSemaphore } from "../concurrency.js";
|
|
import { detectReviewHandoffIntent, determineRevisionResetStart } from "../executor.js";
|
|
import { TaskExecutor, buildExecutionPrompt } from "../executor.js";
|
|
import { createFnAgent } from "../pi.js";
|
|
import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
|
|
import { execSync } from "node:child_process";
|
|
import { findWorktreeUser, aiMergeTask } from "../merger.js";
|
|
import { WorktreePool } from "../worktree-pool.js";
|
|
import * as worktreePoolModule from "../worktree-pool.js";
|
|
import { BranchConflictError } from "../branch-conflicts.js";
|
|
import * as branchConflictModule from "../branch-conflicts.js";
|
|
import { generateWorktreeName, slugify } from "../worktree-names.js";
|
|
import type { Task, TaskDetail } from "@fusion/core";
|
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
import { StepSessionExecutor } from "../step-session-executor.js";
|
|
import { executorLog } from "../logger.js";
|
|
import { withRateLimitRetry } from "../rate-limit-retry.js";
|
|
import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js";
|
|
import {
|
|
createMockStore,
|
|
mockedCreateFnAgent,
|
|
mockedSessionManager,
|
|
mockedGenerateWorktreeName,
|
|
mockedFindWorktreeUser,
|
|
mockedStepSessionExecutor,
|
|
mockedWithRateLimitRetry,
|
|
mockedExec,
|
|
mockedExecSync,
|
|
mockedExistsSync,
|
|
mockedHydrateWorktreeDb,
|
|
mockedClassifyTaskWorktree,
|
|
mockedIsUsableTaskWorktree,
|
|
mockedClassifyStaleLock,
|
|
mockedTryRemoveStaleLock,
|
|
mockedRecoverStaleRegistration,
|
|
mockedInstallTaskWorktreeIdentityGuard,
|
|
mockExecuteAll,
|
|
mockTerminateAllSessions,
|
|
mockCleanup,
|
|
resetExecutorMocks,
|
|
} from "./executor-test-helpers.js";
|
|
|
|
const mockedReviewStep = vi.mocked(mockedReviewStepFn);
|
|
|
|
describe("TaskExecutor with semaphore", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
});
|
|
|
|
it("acquires semaphore before creating agent and releases after", async () => {
|
|
const sem = new AgentSemaphore(2);
|
|
const store = createMockStore();
|
|
const acquireSpy = vi.spyOn(sem, "acquire");
|
|
const releaseSpy = vi.spyOn(sem, "release");
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(acquireSpy).toHaveBeenCalledOnce();
|
|
expect(releaseSpy).toHaveBeenCalledOnce();
|
|
expect(sem.activeCount).toBe(0);
|
|
});
|
|
|
|
it("releases semaphore on agent error", async () => {
|
|
const sem = new AgentSemaphore(1);
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("agent failed"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
semaphore: sem,
|
|
onError,
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(sem.activeCount).toBe(0);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("sets task status to 'failed' with error message when execution throws", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("agent crashed"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: expect.any(String) });
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("concurrent executions respect semaphore limit", async () => {
|
|
const sem = new AgentSemaphore(1);
|
|
const store = createMockStore();
|
|
let concurrent = 0;
|
|
let maxConcurrent = 0;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => {
|
|
concurrent++;
|
|
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
concurrent--;
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
|
|
|
|
const task = (id: string) => ({
|
|
id,
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
await Promise.all([
|
|
executor.execute(task("FN-001")),
|
|
executor.execute(task("FN-002")),
|
|
executor.execute(task("FN-003")),
|
|
]);
|
|
|
|
expect(maxConcurrent).toBe(1);
|
|
expect(sem.activeCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor worktreeInitCommand", () => {
|
|
const makeTask = (id = "FN-010") => ({
|
|
id,
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
// Default: worktree does NOT exist (new worktree)
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
it("runs worktreeInitCommand in new worktree when configured", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: "pnpm install --frozen-lockfile",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// execSync is called for worktree creation + init command
|
|
const initCall = mockedExecSync.mock.calls.find(
|
|
(call) => call[0] === "pnpm install --frozen-lockfile",
|
|
);
|
|
expect(initCall).toBeDefined();
|
|
expect(initCall![1]).toMatchObject({
|
|
cwd: expect.stringContaining(".worktrees/"),
|
|
timeout: 300_000,
|
|
});
|
|
|
|
// Should log success
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-010",
|
|
expect.stringMatching(/^\[timing\] Worktree init command completed in \d+ms$/),
|
|
"pnpm install --frozen-lockfile",
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("does NOT run init command when worktreeInitCommand is not set", async () => {
|
|
const store = createMockStore();
|
|
// getSettings returns default (no worktreeInitCommand)
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Only worktree creation calls to execSync, no "pnpm install --frozen-lockfile" etc.
|
|
const initCall = mockedExecSync.mock.calls.find(
|
|
(call) => typeof call[0] === "string" && !call[0].startsWith("git"),
|
|
);
|
|
expect(initCall).toBeUndefined();
|
|
});
|
|
|
|
it("catches init command failure and logs without aborting", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: "npm run setup",
|
|
});
|
|
|
|
// Make the init command fail (but not git worktree commands)
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (cmd === "npm run setup") {
|
|
const err: any = new Error("command failed");
|
|
err.stderr = Buffer.from("setup script error");
|
|
throw err;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should log the failure
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-010",
|
|
expect.stringContaining("Worktree init command failed"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
|
|
// The init command failure itself does not abort execution, but the mocked
|
|
// agent still exits without fn_task_done. After 3 retries it requeues to todo
|
|
// and reports an error.
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-010" }),
|
|
expect.objectContaining({ message: "Agent finished without calling fn_task_done (after 3 retries)" }),
|
|
);
|
|
|
|
// Agent should still have been created
|
|
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT run init command on worktree resume", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: "pnpm install --frozen-lockfile",
|
|
});
|
|
|
|
// Worktree already exists (resume)
|
|
mockedExistsSync.mockReturnValue(true);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// getSettings is called (for project commands in execution prompt) but init command should not run
|
|
expect(store.getSettings).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor worktree naming", () => {
|
|
const makeTask = (id = "FN-030", worktree?: string) => ({
|
|
id,
|
|
title: "Test Task Title",
|
|
description: "Test description for task",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...(worktree ? { worktree } : {}),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedGenerateWorktreeName.mockReturnValue("swift-falcon");
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
it("uses generateWorktreeName for fresh worktree directories", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask());
|
|
|
|
// The worktree path stored should use the generated name, not the task ID
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-030", {
|
|
worktree: "/tmp/test/.worktrees/swift-falcon",
|
|
branch: "fusion/fn-030",
|
|
});
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test", expect.any(Object));
|
|
});
|
|
|
|
it("does NOT use task ID as worktree directory name for fresh worktrees", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask("FN-099"));
|
|
|
|
// Verify the worktree path does NOT contain the task ID
|
|
const updateCalls = store.updateTask.mock.calls;
|
|
const worktreeUpdate = updateCalls.find(
|
|
(call: any[]) => call[1]?.worktree !== undefined,
|
|
);
|
|
expect(worktreeUpdate).toBeDefined();
|
|
expect(worktreeUpdate![1].worktree).not.toContain("FN-099");
|
|
expect(worktreeUpdate![1].worktree).toContain("swift-falcon");
|
|
});
|
|
|
|
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");
|
|
|
|
await executor.execute(makeTask("FN-031", existingPath));
|
|
|
|
// Should NOT generate a new name — reuse the stored path
|
|
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not reuse a stored worktree path that is not registered", async () => {
|
|
const stalePath = "/tmp/test/.worktrees/broken-wt";
|
|
mockedIsUsableTaskWorktree.mockResolvedValueOnce(false);
|
|
mockedClassifyTaskWorktree.mockResolvedValueOnce({ ok: false, classification: "incomplete", reason: "missing or invalid .git metadata" } as any);
|
|
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", expect.objectContaining({ worktree: null, branch: null }));
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test", expect.any(Object));
|
|
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();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeNaming: "task-id",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask("FN-042"));
|
|
|
|
// Should use task ID (lowercase) as worktree name
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-042", {
|
|
worktree: "/tmp/test/.worktrees/fn-042",
|
|
branch: "fusion/fn-042",
|
|
});
|
|
// Should NOT call generateWorktreeName when using task-id
|
|
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses slugified task title as worktree name when worktreeNaming is 'task-title'", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeNaming: "task-title",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
...makeTask("FN-043"),
|
|
title: "Fix login bug with OAuth",
|
|
});
|
|
|
|
// Should use slugified title as worktree name
|
|
const expectedSlug = slugify("Fix login bug with OAuth");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-043", {
|
|
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
|
|
branch: "fusion/fn-043",
|
|
});
|
|
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to description when title is empty for 'task-title' mode", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeNaming: "task-title",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const taskDescription = "Implement user authentication flow";
|
|
await executor.execute({
|
|
...makeTask("FN-044"),
|
|
title: "",
|
|
description: taskDescription,
|
|
});
|
|
|
|
// Should slugify the first 60 chars of description when title is empty
|
|
const expectedSlug = slugify(taskDescription.slice(0, 60));
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-044", {
|
|
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
|
|
branch: "fusion/fn-044",
|
|
});
|
|
});
|
|
|
|
it("uses generateWorktreeName when worktreeNaming is 'random'", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeNaming: "random",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask("FN-045"));
|
|
|
|
// Should use generateWorktreeName for random mode
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-045", {
|
|
worktree: "/tmp/test/.worktrees/swift-falcon",
|
|
branch: "fusion/fn-045",
|
|
});
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test", expect.any(Object));
|
|
});
|
|
|
|
it("defaults to random naming when worktreeNaming is undefined", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
// worktreeNaming is not set (undefined)
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask("FN-046"));
|
|
|
|
// Should default to random naming
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-046", {
|
|
worktree: "/tmp/test/.worktrees/swift-falcon",
|
|
branch: "fusion/fn-046",
|
|
});
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test", expect.any(Object));
|
|
});
|
|
|
|
it("ignores worktreeNaming setting when using pooled worktree (recycle mode)", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/pooled-warm-wt");
|
|
mockedIsUsableTaskWorktree.mockResolvedValue(true);
|
|
// Pool path exists on disk, task worktree path does not (not a resume)
|
|
mockedExistsSync.mockReturnValue(true);
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
worktreeNaming: "task-id", // This should be ignored for pooled worktrees
|
|
});
|
|
|
|
vi.spyOn(pool, "acquire").mockReturnValue("/tmp/test/.worktrees/pooled-warm-wt");
|
|
vi.spyOn(pool, "prepareForTask").mockResolvedValue({
|
|
branch: "fusion/fn-047",
|
|
worktreePath: "/tmp/test/.worktrees/pooled-warm-wt",
|
|
reclaimed: false,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask("FN-047"));
|
|
|
|
// Worktree naming preference should not break task startup in recycle mode.
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-047", {
|
|
worktree: "/tmp/test/.worktrees/swift-falcon",
|
|
branch: "fusion/fn-047",
|
|
});
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test", expect.any(Object));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor worktree recovery", () => {
|
|
const makeTask = (id = "FN-050") => ({
|
|
id,
|
|
title: "Test Task",
|
|
description: "Test description",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedGenerateWorktreeName.mockReturnValue("swift-falcon");
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("creates worktree successfully on first attempt", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have logged worktree creation
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Worktree created at"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
// execSync should be called for worktree creation
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git worktree add"),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("fails fast with a clear error when rootDir is not a git repository", async () => {
|
|
const store = createMockStore();
|
|
const onError = vi.fn();
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command === "git rev-parse --git-dir") {
|
|
const error: any = new Error("fatal: not a git repository (or any of the parent directories): .git");
|
|
error.stderr = Buffer.from("fatal: not a git repository (or any of the parent directories): .git");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
await executor.execute(makeTask());
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Cannot execute task: project directory is not a Git repository"),
|
|
);
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({
|
|
status: "failed",
|
|
error: expect.stringContaining("not a Git repository"),
|
|
}),
|
|
);
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-050" }),
|
|
expect.objectContaining({ message: expect.stringContaining("not a Git repository") }),
|
|
);
|
|
});
|
|
|
|
it("does not attempt git worktree add when rootDir is not a git repository", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command === "git rev-parse --git-dir") {
|
|
const error: any = new Error("fatal: not a git repository");
|
|
error.stderr = Buffer.from("fatal: not a git repository");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
|
|
);
|
|
expect(worktreeAddCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("extractWorktreeConflictInfo classifies not-a-git-repository errors", () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const error: any = new Error("fatal: not a git repository");
|
|
error.stderr = Buffer.from("fatal: not a git repository");
|
|
|
|
const conflictInfo = (executor as any).extractWorktreeConflictInfo(error);
|
|
expect(conflictInfo.type).toBe("not-git-repo");
|
|
expect(conflictInfo.message).toContain("not a git repository");
|
|
});
|
|
|
|
it("treats not-a-git-repository as non-retryable in tryCreateWorktree flow", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command === "git worktree list --porcelain") {
|
|
return Buffer.from(["worktree /tmp/test", "HEAD abc123", "branch refs/heads/main", ""].join("\n"));
|
|
}
|
|
if (command.includes("git worktree add -b")) {
|
|
const error: any = new Error("fatal: not a git repository (or any of the parent directories): .git");
|
|
error.stderr = Buffer.from("fatal: not a git repository (or any of the parent directories): .git");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
await expect(
|
|
(executor as any).createWorktree("fusion/fn-050", "/tmp/test/.worktrees/swift-falcon", "FN-050"),
|
|
).rejects.toThrow("not a Git repository");
|
|
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(call) => typeof call[0] === "string" && call[0].includes("git worktree add -b"),
|
|
);
|
|
expect(worktreeAddCalls).toHaveLength(1);
|
|
});
|
|
|
|
it("extractWorktreeConflictInfo classifies already checked out errors as already-used", () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
|
|
const conflictInfo = (executor as any).extractWorktreeConflictInfo(error);
|
|
expect(conflictInfo).toMatchObject({
|
|
type: "already-used",
|
|
path: "/tmp/test/.worktrees/green-sage",
|
|
});
|
|
});
|
|
|
|
it("recovers from already checked out worktree conflict and retries", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add") && callCount++ === 0) {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
|
|
"/tmp/test/.worktrees/swift-falcon",
|
|
);
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("records recovery context when handling a branch conflict (FN-4847: now discards + requeues instead of pausing)", async () => {
|
|
// FN-4847: branch-conflict-unrecoverable previously paused the task with
|
|
// status=failed + pausedReason="branch-conflict-unrecoverable". The user has
|
|
// opted into discard-and-recreate, so the executor's handleBranchConflict now
|
|
// delegates to the auto-recovery dispatcher which in 'deterministic-only' mode
|
|
// returns action='retry'. The handler discards the foreign branch and requeues
|
|
// the task to todo. status='failed' is no longer set; moveTask IS called.
|
|
const store = createMockStore();
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
const result = await (executor as any).handleBranchConflict(
|
|
makeTask(),
|
|
new BranchConflictError({
|
|
branchName: "fusion/fn-050",
|
|
conflictingWorktreePath: "/tmp/test/.worktrees/green-sage",
|
|
existingTipSha: "abc123def456",
|
|
strandedCommits: [
|
|
{ sha: "aaa111", subject: "Preserve prior fix" },
|
|
{ sha: "bbb222", subject: "Add regression coverage" },
|
|
],
|
|
startPoint: "HEAD",
|
|
recommendedAction: "Reclaim the existing task branch/worktree or explicitly discard prior work before retrying.",
|
|
}),
|
|
);
|
|
|
|
// New contract: handleBranchConflict returns 'retry' (not 'sticky') and does
|
|
// NOT mark the task failed. The branch-conflict context still gets logged and
|
|
// surfaced for observability, but the task continues via requeue.
|
|
expect(result).toBe("retry");
|
|
expect(store.updateTask).not.toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Existing tip: abc123def456"),
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
"Branch conflict recovery required",
|
|
"tool_error",
|
|
expect.stringContaining("stranded=aaa111 Preserve prior fix"),
|
|
"executor",
|
|
);
|
|
// onError no longer fires for the recoverable branch-conflict-unrecoverable path.
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("FN-4397 reproduces repeated branch-conflict recovery-required emissions for the same task", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const conflictError = new BranchConflictError({
|
|
branchName: "fusion/fn-050",
|
|
conflictingWorktreePath: "/tmp/test/.worktrees/green-sage",
|
|
existingTipSha: "abc123def456",
|
|
strandedCommits: [],
|
|
startPoint: "HEAD",
|
|
recommendedAction: "Reclaim the existing task branch/worktree or explicitly discard prior work before retrying.",
|
|
});
|
|
|
|
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({
|
|
kind: "live-foreign",
|
|
livePath: "/tmp/test/.worktrees/green-sage",
|
|
error: conflictError,
|
|
});
|
|
vi.spyOn(executor as any, "cleanupConflictingWorktree").mockResolvedValue(false);
|
|
|
|
await (executor as any).handleBranchConflict(makeTask(), conflictError);
|
|
await (executor as any).handleBranchConflict(makeTask(), conflictError);
|
|
await (executor as any).handleBranchConflict(makeTask(), conflictError);
|
|
|
|
expect(store.appendAgentLog).toHaveBeenCalledTimes(3);
|
|
expect(store.appendAgentLog).toHaveBeenNthCalledWith(
|
|
1,
|
|
"FN-050",
|
|
"Branch conflict recovery required",
|
|
"tool_error",
|
|
expect.any(String),
|
|
"executor",
|
|
);
|
|
});
|
|
|
|
it("FN-4397 tripwire pauses on 6th branch conflict and suppresses additional recovery-required agent logs", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const conflictError = new BranchConflictError({
|
|
branchName: "fusion/fn-050",
|
|
conflictingWorktreePath: "/tmp/test/.worktrees/green-sage",
|
|
existingTipSha: "abc123def456",
|
|
strandedCommits: [],
|
|
startPoint: "HEAD",
|
|
recommendedAction: "Reclaim the existing task branch/worktree or explicitly discard prior work before retrying.",
|
|
});
|
|
|
|
const handleSpy = vi.spyOn(executor as any, "handleBranchConflict").mockImplementation(async () => {
|
|
await store.appendAgentLog("FN-050", "Branch conflict recovery required", "tool_error", "mock", "executor");
|
|
return "sticky";
|
|
});
|
|
vi.spyOn(executor as any, "createWorktree").mockRejectedValue(conflictError);
|
|
|
|
for (let i = 0; i < 6; i += 1) {
|
|
await executor.execute(makeTask());
|
|
}
|
|
|
|
expect(handleSpy).toHaveBeenCalledTimes(5);
|
|
const tripwireLogCall = vi.mocked(store.logEntry).mock.calls.find((call: unknown[]) =>
|
|
call[0] === "FN-050" && String(call[1]).includes("Branch conflict tripwire fired after 6 events"),
|
|
);
|
|
expect(tripwireLogCall).toBeDefined();
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({
|
|
status: "failed",
|
|
paused: true,
|
|
pausedReason: "branch-conflict-tripwire",
|
|
}),
|
|
);
|
|
expect(store.appendAgentLog).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
it("falls back to default base and clears task.executionStartBranch when the configured base ref is missing (FN-2165)", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git rev-parse --verify")) {
|
|
// The stored baseBranch no longer exists — simulates a dep's branch
|
|
// being deleted while this task sat queued/stuck.
|
|
const error: any = new Error("fatal: Needed a single revision");
|
|
error.stderr = Buffer.from("fatal: Needed a single revision");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
await executor.execute({ ...makeTask(), executionStartBranch: "fusion/missing-base" });
|
|
|
|
// Should log the soft fallback, not a terminal failure
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining('Worktree base ref "fusion/missing-base" is missing'),
|
|
expect.any(String),
|
|
);
|
|
// Should clear baseBranch on the task so retries use the default
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ executionStartBranch: null }),
|
|
);
|
|
// Should proceed to create a worktree from HEAD (no startPoint)
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && c[0].includes("git worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
// None of the worktree add calls should include the stale base ref
|
|
for (const call of worktreeAddCalls) {
|
|
expect(String(call[0])).not.toContain("fusion/missing-base");
|
|
}
|
|
// The task should NOT have been marked failed because of the stale baseBranch
|
|
// (downstream errors unrelated to worktree creation may still occur in this
|
|
// integration-style test — we only assert that baseBranch-missing is no
|
|
// longer a terminal failure).
|
|
const worktreeFailureCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter(
|
|
(c) => typeof c[1] === "string" && c[1].includes("Worktree creation failed"),
|
|
);
|
|
expect(worktreeFailureCalls).toHaveLength(0);
|
|
// onError may still fire from downstream step execution in this test harness;
|
|
// what matters is that the failure reason is NOT "base ref missing".
|
|
void onError;
|
|
});
|
|
|
|
it("refuses to create a worktree nested inside another worktree (FN-2165 guard)", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Simulate `git worktree list --porcelain` returning a non-root worktree
|
|
// that would be an ancestor of the target path.
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command === "git worktree list --porcelain") {
|
|
return Buffer.from(
|
|
[
|
|
"worktree /tmp/test",
|
|
"HEAD abc123",
|
|
"branch refs/heads/main",
|
|
"",
|
|
"worktree /tmp/test/.worktrees/green-finch",
|
|
"HEAD def456",
|
|
"branch refs/heads/fusion/fn-007",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
// Task has a worktree path nested inside green-finch — must be refused
|
|
await executor.execute({
|
|
...makeTask(),
|
|
worktree: "/tmp/test/.worktrees/green-finch/.worktrees/amber-panda",
|
|
});
|
|
|
|
// Should NEVER attempt a git worktree add for the nested path
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) =>
|
|
typeof c[0] === "string" &&
|
|
c[0].includes("git worktree add") &&
|
|
c[0].includes("green-finch/.worktrees/amber-panda"),
|
|
);
|
|
expect(worktreeAddCalls).toHaveLength(0);
|
|
|
|
// Should log the refusal with both the target and ancestor paths
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
"Refusing to create nested worktree",
|
|
expect.stringContaining("green-finch"),
|
|
);
|
|
});
|
|
|
|
it("fails after 3 unsuccessful attempts with detailed error", async () => {
|
|
vi.useRealTimers();
|
|
const store = createMockStore();
|
|
|
|
// All worktree add calls fail
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
// Cleanup also fails
|
|
if (command.includes("git worktree remove")) {
|
|
throw new Error("cleanup failed");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
await executor.execute(makeTask());
|
|
|
|
// Should log final failure
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Worktree creation failed after 3 attempts"),
|
|
expect.any(String),
|
|
);
|
|
// Should update task as failed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("recovers from 'already used by worktree' error in createFromExistingBranch fallback", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
// First createWithBranch fails with "branch already exists" (not "already used")
|
|
// Then createFromExistingBranch fails with "already used by worktree"
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
callCount++;
|
|
if (command.includes("-b")) {
|
|
// First attempt: createWithBranch fails with branch already exists
|
|
const error: any = new Error(
|
|
"fatal: A branch named 'fusion/fn-050' already exists.",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: A branch named 'fusion/fn-050' already exists.",
|
|
);
|
|
throw error;
|
|
} else {
|
|
// Fallback createFromExistingBranch fails with already used
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
if (command.includes("git worktree remove")) {
|
|
return Buffer.from("");
|
|
}
|
|
if (command.includes("git branch -D")) {
|
|
return Buffer.from("");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Mock the second call to tryCreateWorktree to succeed
|
|
// by making subsequent calls succeed after cleanup
|
|
let secondAttempt = false;
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (secondAttempt) {
|
|
return Buffer.from(""); // Second attempt succeeds
|
|
}
|
|
if (command.includes("-b")) {
|
|
const error: any = new Error(
|
|
"fatal: A branch named 'fusion/fn-050' already exists.",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: A branch named 'fusion/fn-050' already exists.",
|
|
);
|
|
throw error;
|
|
} else {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
if (command.includes("git worktree remove")) {
|
|
secondAttempt = true; // After cleanup, next add will succeed
|
|
return Buffer.from("");
|
|
}
|
|
if (command.includes("git branch -D")) {
|
|
return Buffer.from("");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have cleaned up the conflicting worktree
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('git worktree remove --force "/tmp/test/.worktrees/green-sage"'),
|
|
expect.any(Object),
|
|
);
|
|
|
|
// Should have logged the cleanup
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
|
|
expect.any(String),
|
|
);
|
|
|
|
// Task should eventually succeed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("generates new worktree name when conflicting worktree belongs to active task in legacy rename mode (FN-4811: refuses force-removal of active worktree)", async () => {
|
|
// FN-4811: When the conflicting worktree is bound to a live in-progress task, the
|
|
// executor MUST NOT force-remove it (doing so yanks the active session's filesystem
|
|
// and produces FN-4781/FN-4804-style cascade failures). Instead, with sibling-rename
|
|
// enabled, it falls through to the suffix-rename path so the requesting task gets a
|
|
// fresh worktree name without disturbing the live owner.
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
executorAllowSiblingBranchRename: true,
|
|
});
|
|
store.listTasks.mockResolvedValue([
|
|
{
|
|
id: "FN-049",
|
|
title: "Other Task",
|
|
description: "Other task",
|
|
column: "in-progress",
|
|
worktree: "/tmp/test/.worktrees/green-sage",
|
|
paused: false,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
]);
|
|
|
|
mockedFindWorktreeUser.mockResolvedValue("FN-049");
|
|
mockedExistsSync.mockImplementation((path) => path === "/tmp/test/.worktrees/green-sage");
|
|
|
|
let callCount = 0;
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
// First attempt fails with conflict, subsequent attempts (suffix-rename path) succeed.
|
|
if (command.includes("git worktree add") && callCount++ === 0) {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
// Second generated name for the suffix-rename path.
|
|
mockedGenerateWorktreeName.mockReturnValueOnce("jade-finch");
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({ ...makeTask(), executionStartBranch: "fusion/fn-049" });
|
|
|
|
// FN-4811 contract: the active worktree must NOT have been force-removed.
|
|
const removeCalls = mockedExecSync.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.filter((command) => command.includes("git worktree remove"));
|
|
expect(
|
|
removeCalls.some((command) => command.includes("/tmp/test/.worktrees/green-sage")),
|
|
).toBe(false);
|
|
|
|
// The legacy "Removed foreign conflicting worktree and retrying" log must NOT fire
|
|
// for the actively-owned worktree (that path is the bug FN-4811 fixes).
|
|
const removalLogCalls = store.logEntry.mock.calls.map((c: any[]) => String(c[1] ?? ""));
|
|
expect(
|
|
removalLogCalls.some((m: string) => m.includes("Removed foreign conflicting worktree")),
|
|
).toBe(false);
|
|
|
|
// The suffix-rename path was taken instead.
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalled();
|
|
const worktreeAddCalls = mockedExecSync.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.filter((command) => command.includes("git worktree add -b"));
|
|
expect(
|
|
worktreeAddCalls.some(
|
|
(command) =>
|
|
command.includes('git worktree add -b "fusion/fn-050"') &&
|
|
command.endsWith('"fusion/fn-049"'),
|
|
),
|
|
).toBe(true);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
describe("index.lock stale recovery", () => {
|
|
it("recovers stale lock and succeeds", async () => {
|
|
const store = createMockStore();
|
|
let addCalls = 0;
|
|
mockedClassifyStaleLock.mockResolvedValue({ kind: "stale", reason: "old-lock", ageMs: 60000 } as any);
|
|
mockedTryRemoveStaleLock.mockResolvedValue({ removed: true });
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add") && addCalls++ === 0) {
|
|
const error: any = new Error("fatal: unable to create '/tmp/test/.git/worktrees/swift-falcon/index.lock': File exists");
|
|
error.stderr = Buffer.from("fatal: unable to create '/tmp/test/.git/worktrees/swift-falcon/index.lock': File exists");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
expect(mockedClassifyStaleLock).toHaveBeenCalled();
|
|
expect(mockedTryRemoveStaleLock).toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Recovered stale worktree index.lock and retrying"),
|
|
expect.any(String),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("refuses fresh lock and fails with actionable error", async () => {
|
|
const store = createMockStore();
|
|
mockedClassifyStaleLock.mockResolvedValue({ kind: "active-session", reason: "active-session-owns-worktree", owningWorktreePath: "/tmp/test/.worktrees/swift-falcon" } as any);
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
const error: any = new Error("fatal: unable to create '/tmp/test/.git/worktrees/swift-falcon/index.lock': File exists");
|
|
error.stderr = Buffer.from("fatal: unable to create '/tmp/test/.git/worktrees/swift-falcon/index.lock': File exists");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ status: "failed", error: expect.stringContaining("index.lock") }),
|
|
);
|
|
expect(mockedTryRemoveStaleLock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("stale registration recovery", () => {
|
|
it("recovers stale registration and retries git worktree add", async () => {
|
|
const store = createMockStore();
|
|
let addCalls = 0;
|
|
mockedRecoverStaleRegistration.mockResolvedValue({ recovered: true, actions: ["prune", "remove-force"] });
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add") && addCalls++ === 0) {
|
|
const error: any = new Error("fatal: '/tmp/test/.worktrees/swift-falcon' is a missing but already registered worktree");
|
|
error.stderr = Buffer.from("fatal: '/tmp/test/.worktrees/swift-falcon' is a missing but already registered worktree");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
expect(mockedRecoverStaleRegistration).toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
"Recovered stale worktree registration and retrying",
|
|
"/tmp/test/.worktrees/swift-falcon",
|
|
expect.anything(),
|
|
);
|
|
const worktreeAddCalls = mockedExecSync.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.filter((command) => command.includes("git worktree add -b"));
|
|
expect(worktreeAddCalls.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("preserves existing failure path when stale registration persists", async () => {
|
|
const store = createMockStore();
|
|
mockedRecoverStaleRegistration.mockResolvedValue({ recovered: false, actions: ["prune"], reason: "still registered" });
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
const error: any = new Error("fatal: '/tmp/test/.worktrees/swift-falcon' is a missing but already registered worktree");
|
|
error.stderr = Buffer.from("fatal: '/tmp/test/.worktrees/swift-falcon' is a missing but already registered worktree");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
(executor as any).MAX_WORKTREE_RETRIES = 1;
|
|
|
|
await expect(
|
|
(executor as any).createWorktree("fusion/fn-050", "/tmp/test/.worktrees/swift-falcon", "FN-050"),
|
|
).rejects.toThrow("Failed to create worktree after 1 attempts");
|
|
|
|
expect(mockedRecoverStaleRegistration).toHaveBeenCalled();
|
|
}, 20000);
|
|
});
|
|
|
|
it("removes stale branch and retries when branch exists without worktree", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have removed the stale branch
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git branch -D"),
|
|
expect.any(Object),
|
|
);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removed stale branch reference, retrying"),
|
|
);
|
|
});
|
|
|
|
it("runs git worktree prune before branch deletion for stale references", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have called git worktree prune as the first recovery step
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
"git worktree prune",
|
|
expect.any(Object),
|
|
);
|
|
// Should log the prune
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Pruned stale worktree metadata"),
|
|
"fusion/fn-050",
|
|
);
|
|
// Should also call branch -D after prune
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git branch -D"),
|
|
expect.any(Object),
|
|
);
|
|
// Task should eventually succeed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("falls back to git update-ref -d when git branch -D fails on stale reference", async () => {
|
|
const store = createMockStore();
|
|
let worktreeAddCallCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (worktreeAddCallCount++ === 0) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
}
|
|
// Prune succeeds
|
|
if (command.includes("git worktree prune")) {
|
|
return Buffer.from("");
|
|
}
|
|
// branch -D fails (corrupted reference)
|
|
if (command.includes("git branch -D")) {
|
|
const error: any = new Error("error: unable to delete ref 'refs/heads/fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
// update-ref -d succeeds
|
|
if (command.includes("git update-ref -d")) {
|
|
return Buffer.from("");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have tried branch -D first
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git branch -D"),
|
|
expect.any(Object),
|
|
);
|
|
// Should have fallen back to update-ref -d
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git update-ref -d"),
|
|
expect.any(Object),
|
|
);
|
|
// Should log the fallback
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("git branch -D failed for stale branch, trying update-ref"),
|
|
expect.any(String),
|
|
);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Force-removed stale branch reference via update-ref"),
|
|
expect.any(String),
|
|
);
|
|
// Task should eventually succeed after cleanup + retry
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("bounds stale-reference cleanup retries when update-ref succeeds but the ref remains invalid", async () => {
|
|
const store = createMockStore();
|
|
let worktreeAddCallCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
worktreeAddCallCount++;
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
if (command.includes("git branch -D")) {
|
|
const error: any = new Error("error: branch 'fusion/fn-050' not found");
|
|
error.stderr = Buffer.from("error: branch 'fusion/fn-050' not found");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
const executePromise = executor.execute(makeTask());
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
await executePromise;
|
|
|
|
expect(worktreeAddCallCount).toBe(3);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Worktree creation failed after 3 attempts"),
|
|
expect.any(String),
|
|
);
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails task when all stale reference cleanup steps fail", async () => {
|
|
vi.useRealTimers();
|
|
const store = createMockStore();
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
// Prune fails
|
|
if (command.includes("git worktree prune")) {
|
|
throw new Error("prune failed");
|
|
}
|
|
// branch -D fails
|
|
if (command.includes("git branch -D")) {
|
|
throw new Error("branch delete failed");
|
|
}
|
|
// update-ref -d also fails
|
|
if (command.includes("git update-ref -d")) {
|
|
throw new Error("update-ref failed");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have logged terminal failure for the stale reference
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Failed to remove stale branch reference"),
|
|
expect.any(String),
|
|
);
|
|
// Task should be marked as failed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("recovers from stale reference in createFromExistingBranch fallback path", async () => {
|
|
const store = createMockStore();
|
|
let worktreeAddCallCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
worktreeAddCallCount++;
|
|
if (command.includes("-b")) {
|
|
// createWithBranch: fails with "already exists" (not invalid-reference)
|
|
const error: any = new Error("fatal: A branch named 'fusion/fn-050' already exists.");
|
|
error.stderr = Buffer.from("fatal: A branch named 'fusion/fn-050' already exists.");
|
|
throw error;
|
|
} else {
|
|
// createFromExistingBranch: fails with invalid reference
|
|
if (worktreeAddCallCount <= 2) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
// All cleanup commands succeed
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have logged cleanup in fallback path
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Cleaned up stale reference in fallback, retrying"),
|
|
);
|
|
// Task should eventually succeed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("recognizes 'unable to resolve reference' as invalid-reference pattern", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: unable to resolve reference 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: unable to resolve reference 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have triggered cleanup (stale branch reclaim)
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git worktree prune"),
|
|
expect.any(Object),
|
|
);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removed stale branch reference, retrying"),
|
|
);
|
|
});
|
|
|
|
it("recognizes 'stale file handle' as invalid-reference pattern", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: stale file handle");
|
|
error.stderr = Buffer.from("fatal: stale file handle");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removed stale branch reference, retrying"),
|
|
);
|
|
});
|
|
|
|
it("recognizes 'not a valid ref' as invalid-reference pattern", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: not a valid ref: 'refs/heads/fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: not a valid ref: 'refs/heads/fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removed stale branch reference, retrying"),
|
|
);
|
|
});
|
|
|
|
it("removes existing directory that is not a registered worktree", async () => {
|
|
const store = createMockStore();
|
|
const fs = await import("node:fs/promises");
|
|
const tempRoot = await fs.mkdtemp("/tmp/executor-worktree-");
|
|
const staleWorktreePath = `${tempRoot}/.worktrees/swift-falcon`;
|
|
|
|
// Directory exists but is not registered
|
|
mockedExistsSync.mockReturnValue(true);
|
|
|
|
await fs.mkdir(staleWorktreePath, { recursive: true });
|
|
await fs.writeFile(`${staleWorktreePath}/marker.txt`, "stale");
|
|
|
|
// Mock git worktree list to not include our path
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree list")) {
|
|
return Buffer.from("/other/path/.git/worktrees/other\n");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, tempRoot);
|
|
await executor.execute(makeTask());
|
|
|
|
expect(
|
|
mockedExecSync.mock.calls.some((call) =>
|
|
typeof call[0] === "string" && call[0].includes("rm -rf"),
|
|
),
|
|
).toBe(false);
|
|
await expect(fs.access(staleWorktreePath)).rejects.toThrow();
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removing existing directory (not a registered worktree)"),
|
|
);
|
|
});
|
|
|
|
it("handles locked worktree by unlocking before removal", async () => {
|
|
const store = createMockStore();
|
|
|
|
let callCount = 0;
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add") && callCount++ === 0) {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should attempt to unlock the worktree before removing
|
|
const unlockCalls = mockedExecSync.mock.calls.filter((call) =>
|
|
String(call[0]).includes("git worktree unlock"),
|
|
);
|
|
expect(unlockCalls.length).toBeGreaterThanOrEqual(0); // Unlock is attempted but may fail silently
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor dependency-based worktree creation", () => {
|
|
const makeTask = (overrides: Partial<Task> = {}) => ({
|
|
id: "FN-060",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedFindWorktreeUser.mockResolvedValue(null);
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
it("creates worktree from baseBranch when set on task", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-060",
|
|
executionStartBranch: "fusion/fn-059",
|
|
}));
|
|
|
|
// The git worktree add command should include the startPoint
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
expect(worktreeAddCalls[0][0]).toContain("fusion/fn-059");
|
|
});
|
|
|
|
it("creates worktree from HEAD when baseBranch is not set", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-061",
|
|
// no baseBranch
|
|
}));
|
|
|
|
// The git worktree add command should NOT include a startPoint
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add -b"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
// Command format: git worktree add -b "branch" "path" (no extra ref after path)
|
|
const cmd = worktreeAddCalls[0][0] as string;
|
|
// Count quoted segments: branch + path = 2 quoted args
|
|
const quoted = cmd.match(/"[^"]+"/g) || [];
|
|
expect(quoted).toHaveLength(2);
|
|
});
|
|
|
|
it("logs base branch in worktree creation log entry", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-062",
|
|
executionStartBranch: "fusion/fn-061",
|
|
}));
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-062",
|
|
expect.stringContaining("based on fusion/fn-061"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("does not mention base branch in log when baseBranch is not set", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-063",
|
|
}));
|
|
|
|
// Check that log entry does NOT mention "based on"
|
|
const logCalls = store.logEntry.mock.calls.filter(
|
|
(call: any[]) => typeof call[1] === "string" && call[1].includes("Worktree created"),
|
|
);
|
|
expect(logCalls.length).toBeGreaterThan(0);
|
|
expect(logCalls[0][1]).not.toContain("based on");
|
|
});
|
|
|
|
it("retries worktree creation after cleaning up conflicting worktree", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const conflictingPath = "/tmp/test/.worktrees/sharp-stone";
|
|
const removeWorktreeSpy = vi.spyOn(worktreePoolModule, "removeWorktree");
|
|
|
|
let firstAttempt = true;
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b") && firstAttempt) {
|
|
firstAttempt = false;
|
|
const err: any = new Error(
|
|
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
|
);
|
|
err.stderr = Buffer.from(
|
|
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
|
);
|
|
throw err;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
await executor.execute(makeTask({ id: "FN-064" }));
|
|
|
|
expect(removeWorktreeSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
worktreePath: conflictingPath,
|
|
rootDir: "/tmp/test",
|
|
taskId: "FN-064",
|
|
settings: expect.any(Object),
|
|
}),
|
|
);
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
'git branch -D "fusion/fn-064"',
|
|
expect.objectContaining({ cwd: "/tmp/test" }),
|
|
);
|
|
|
|
const worktreeCreateCalls = mockedExecSync.mock.calls.filter(
|
|
(call) => typeof call[0] === "string" && call[0].includes('git worktree add') && call[0].includes("-b"),
|
|
);
|
|
expect(worktreeCreateCalls).toHaveLength(2);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-064",
|
|
expect.stringContaining("Worktree created at /tmp/test/.worktrees/swift-falcon"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("throws original error if cleanup also fails", async () => {
|
|
vi.useRealTimers();
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const conflictingPath = "/tmp/test/.worktrees/sharp-stone";
|
|
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b")) {
|
|
const err: any = new Error(
|
|
`fatal: 'fusion/fn-065' is already used by worktree at '${conflictingPath}'`,
|
|
);
|
|
err.stderr = Buffer.from(
|
|
`fatal: 'fusion/fn-065' is already used by worktree at '${conflictingPath}'`,
|
|
);
|
|
throw err;
|
|
}
|
|
if (cmd === `git worktree remove --force "${conflictingPath}"`) {
|
|
throw new Error("remove failed");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
await executor.execute(makeTask({ id: "FN-065" }));
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
|
|
status: "failed",
|
|
error: expect.stringContaining("automatic cleanup failed"),
|
|
});
|
|
});
|
|
|
|
it("passes baseBranch to pool prepareForTask when using pooled worktree", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
|
);
|
|
|
|
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue({ branch: "fusion/fn-064", worktreePath: "/tmp/test/.worktrees/idle-wt", reclaimed: false });
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-064",
|
|
executionStartBranch: "fusion/fn-063",
|
|
}));
|
|
|
|
expect(prepareSpy).toHaveBeenCalledWith(
|
|
"/tmp/test/.worktrees/idle-wt",
|
|
"fusion/fn-064",
|
|
"fusion/fn-063",
|
|
{ allowSiblingBranchRename: false, repoDir: "/tmp/test", requestingTaskId: "FN-064" },
|
|
);
|
|
});
|
|
|
|
it("passes undefined to pool prepareForTask when no baseBranch", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
|
);
|
|
|
|
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue({ branch: "fusion/fn-065", worktreePath: "/tmp/test/.worktrees/idle-wt", reclaimed: false });
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-065",
|
|
}));
|
|
|
|
expect(prepareSpy).toHaveBeenCalledWith(
|
|
"/tmp/test/.worktrees/idle-wt",
|
|
"fusion/fn-065",
|
|
undefined,
|
|
{ allowSiblingBranchRename: false, repoDir: "/tmp/test", requestingTaskId: "FN-065" },
|
|
);
|
|
});
|
|
|
|
it("records branch:auto-reclaim run-audit event when pooled prepare returns reclaimed worktree", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
mockedExistsSync.mockImplementation((p) => p === "/tmp/test/.worktrees/idle-wt" || p === "/tmp/test/.worktrees/live-wt");
|
|
|
|
vi.spyOn(pool, "prepareForTask").mockResolvedValue({
|
|
branch: "fusion/fn-066",
|
|
worktreePath: "/tmp/test/.worktrees/live-wt",
|
|
reclaimed: true,
|
|
existingTipSha: "abc123def456",
|
|
strandedCommitCount: 2,
|
|
});
|
|
|
|
const store = createMockStore();
|
|
store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask({ id: "FN-066" }));
|
|
|
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
|
mutationType: "branch:auto-reclaim",
|
|
target: "fusion/fn-066",
|
|
metadata: expect.objectContaining({
|
|
taskId: "FN-066",
|
|
trigger: "dispatch-preflight",
|
|
worktreePath: "/tmp/test/.worktrees/live-wt",
|
|
existingTipSha: "abc123def456",
|
|
strandedCommitCount: 2,
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it("stores suffixed branch name when pool returns a different name", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
|
);
|
|
|
|
// Pool returns a suffixed branch name due to conflict
|
|
vi.spyOn(pool, "prepareForTask").mockResolvedValue({ branch: "fusion/fn-066-2", worktreePath: "/tmp/test/.worktrees/idle-wt", reclaimed: false });
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-066",
|
|
}));
|
|
|
|
// Should store the suffixed branch name
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-066", {
|
|
worktree: "/tmp/test/.worktrees/idle-wt",
|
|
branch: "fusion/fn-066-2",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor worktree pool integration", () => {
|
|
const makeTask = (id = "FN-020") => ({
|
|
id,
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
// Default: worktree does NOT exist (new worktree)
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
it("acquires from pool when recycleWorktrees is true and pool has idle worktrees", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
// Pool path exists on disk, task worktree path does not (not a resume)
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
|
);
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should NOT call git worktree add (no fresh worktree)
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls).toHaveLength(0);
|
|
|
|
// Should log pool acquisition
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-020",
|
|
expect.stringContaining("Acquired worktree from pool"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
|
|
// Pool should be empty after acquire
|
|
expect(pool.size).toBe(0);
|
|
});
|
|
|
|
it("overwrites baseCommitSha when starting from a pooled worktree", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
mockedExistsSync.mockImplementation((p) => p === "/tmp/test/.worktrees/idle-wt");
|
|
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (String(cmd).includes("git merge-base --is-ancestor")) {
|
|
throw new Error("not ancestor");
|
|
}
|
|
return "" as any;
|
|
});
|
|
mockedExec.mockImplementation(((cmd: any, _opts: any, cb: any) => {
|
|
if (String(cmd).includes("git merge-base HEAD origin/main")) {
|
|
cb(null, "newbase123\n", "");
|
|
return {} as any;
|
|
}
|
|
cb(null, "", "");
|
|
return {} as any;
|
|
}) as any);
|
|
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-020",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
baseCommitSha: "stale-base",
|
|
});
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-020", { baseCommitSha: "newbase123" });
|
|
});
|
|
|
|
it("creates fresh worktree when pool is empty", async () => {
|
|
const pool = new WorktreePool();
|
|
// Pool is empty
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should call git worktree add (fresh worktree)
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
expect(mockedInstallTaskWorktreeIdentityGuard).toHaveBeenCalledWith(
|
|
expect.objectContaining({ taskId: "FN-020" }),
|
|
);
|
|
|
|
// Should log worktree creation, NOT pool acquisition
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-020",
|
|
expect.stringContaining("Worktree created at"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("skips worktree init command for pooled worktrees", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/warm-wt");
|
|
// Pool path exists on disk, task worktree path does not
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/warm-wt",
|
|
);
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
worktreeInitCommand: "pnpm install --frozen-lockfile",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
// "pnpm install --frozen-lockfile" should NOT have been called (pooled worktree has warm cache)
|
|
const initCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => c[0] === "pnpm install --frozen-lockfile",
|
|
);
|
|
expect(initCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("does not use pool when recycleWorktrees is false", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
|
|
const store = createMockStore();
|
|
// recycleWorktrees defaults to false
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should create a fresh worktree, NOT acquire from pool
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
|
|
// Pool should still have the entry (not acquired)
|
|
expect(pool.size).toBe(1);
|
|
});
|
|
|
|
it("falls through to fresh worktree when pool prepareForTask throws", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/bad-wt");
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/bad-wt",
|
|
);
|
|
vi.spyOn(pool, "prepareForTask").mockImplementation(() => {
|
|
throw new Error("branch conflict unrecoverable");
|
|
});
|
|
const releaseSpy = vi.spyOn(pool, "release");
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
expect(releaseSpy).toHaveBeenCalledWith("/tmp/test/.worktrees/bad-wt", "FN-020");
|
|
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-020",
|
|
expect.stringContaining("Pool worktree preparation failed"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("does not fall through to a fresh worktree when pooled preparation hits a typed branch conflict", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/warm-wt");
|
|
mockedExistsSync.mockImplementation((p) => p === "/tmp/test/.worktrees/warm-wt");
|
|
vi.spyOn(pool, "prepareForTask").mockRejectedValue(
|
|
new BranchConflictError({
|
|
branchName: "fusion/fn-020",
|
|
conflictingWorktreePath: "/tmp/test/.worktrees/existing-fn-020",
|
|
existingTipSha: "abc123def456",
|
|
strandedCommits: [{ sha: "aaa111", subject: "Preserve prior fix" }],
|
|
startPoint: "main",
|
|
recommendedAction: "Reclaim the existing task branch/worktree or explicitly discard prior work before retrying.",
|
|
}),
|
|
);
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask("FN-020"));
|
|
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls).toHaveLength(0);
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-020",
|
|
expect.objectContaining({
|
|
status: "failed",
|
|
paused: true,
|
|
}),
|
|
);
|
|
expect(store.moveTask).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("WorktreePool capacity", () => {
|
|
it("pool does not enforce maxWorktrees — scheduler is the capacity gatekeeper", () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/a");
|
|
pool.release("/tmp/b");
|
|
pool.release("/tmp/c");
|
|
pool.release("/tmp/d");
|
|
pool.release("/tmp/e");
|
|
expect(pool.size).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe("Merger worktree pool integration", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
});
|
|
|
|
it("passes pool option through to aiMergeTask", async () => {
|
|
const pool = new WorktreePool();
|
|
const mockedAiMergeTask = vi.mocked(aiMergeTask);
|
|
mockedAiMergeTask.mockResolvedValue({
|
|
task: { id: "FN-050" } as any,
|
|
branch: "fusion/fn-050",
|
|
merged: true,
|
|
worktreeRemoved: false,
|
|
branchDeleted: true,
|
|
});
|
|
|
|
await aiMergeTask({} as any, "/tmp/test", "FN-050", { pool });
|
|
|
|
expect(mockedAiMergeTask).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"/tmp/test",
|
|
"FN-050",
|
|
expect.objectContaining({ pool }),
|
|
);
|
|
});
|
|
|
|
// Full merger worktree pool integration tests are split across merger-merge-lifecycle.test.ts and related merger-*.test.ts files
|
|
// which tests aiMergeTask with real implementation
|
|
});
|
|
|
|
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
|
return {
|
|
id: "FN-001",
|
|
title: "Test Task",
|
|
description: "A test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
|
|
describe("worktree DB hydration", () => {
|
|
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
|
id: "FN-HYD",
|
|
title: "Hydrate",
|
|
description: "Hydrate",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedHydrateWorktreeDb.mockReset();
|
|
mockedHydrateWorktreeDb.mockResolvedValue({
|
|
tasksCopied: 1,
|
|
documentsCopied: 2,
|
|
degraded: false,
|
|
});
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() },
|
|
} as any);
|
|
});
|
|
|
|
it("runs once for fresh worktree", async () => {
|
|
mockedExistsSync.mockReturnValue(false);
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
expect(mockedHydrateWorktreeDb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("runs once for pool acquire", async () => {
|
|
mockedExistsSync.mockReturnValue(false);
|
|
const store = createMockStore();
|
|
const pool = {
|
|
acquire: vi.fn(() => "/tmp/test/.worktrees/pooled"),
|
|
prepareForTask: vi.fn(async () => "fusion/fn-hyd"),
|
|
release: vi.fn(),
|
|
} as any;
|
|
store.getSettings.mockResolvedValue({ ...(await store.getSettings()), recycleWorktrees: true });
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
expect(mockedHydrateWorktreeDb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("runs hydration path when executor reassigns unusable root worktree", async () => {
|
|
mockedIsUsableTaskWorktree.mockResolvedValueOnce(false);
|
|
mockedClassifyTaskWorktree.mockResolvedValueOnce({ ok: false, classification: "incomplete", reason: "missing or invalid .git metadata" } as any);
|
|
mockedExistsSync.mockReturnValue(true);
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask({ worktree: "/tmp/test" }));
|
|
expect(mockedHydrateWorktreeDb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("logs degraded hydration reason and continues execution", async () => {
|
|
mockedHydrateWorktreeDb.mockResolvedValueOnce({
|
|
tasksCopied: 0,
|
|
documentsCopied: 0,
|
|
degraded: true,
|
|
reason: "unable to open database file",
|
|
});
|
|
mockedExistsSync.mockReturnValue(false);
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
expect((store.logEntry as ReturnType<typeof vi.fn>).mock.calls).toEqual(
|
|
expect.arrayContaining([
|
|
[
|
|
"FN-HYD",
|
|
"Worktree DB hydration degraded: unable to open database file",
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
],
|
|
]),
|
|
);
|
|
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
|
});
|
|
|
|
it("hydration failure does not abort execute", async () => {
|
|
mockedHydrateWorktreeDb.mockRejectedValueOnce(new Error("boom"));
|
|
mockedExistsSync.mockReturnValue(false);
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
|
});
|
|
});
|