// -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).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 staleWorktreePath = "/tmp/test/.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, "/tmp/test"); 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 = {}) => ({ 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 { 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 => ({ 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).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(); }); });