diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 7ead94617c..f645c99cb5 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -699,6 +699,7 @@ export function createMockStore() { listGoals: vi.fn().mockReturnValue([]), }), getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"), + getRootDir: vi.fn().mockReturnValue("/tmp/test"), clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]), // FNXC:EngineTestDrift 2026-07-11-22:40: // FN-7750 / Runfusion#1980 made isLiveSharedBranchGroupMemberIntegration @@ -769,6 +770,76 @@ FNXC:TaskVerificationRequest 2026-07-19-04:30 (merged with U5f 2026-07-19-06:00) return store as any; } +/** Minimal durable routing seam for production-path executor fixture tests. */ +export function createWorkflowRoutingAgentStore(store: Pick) { + const leases = new Map(); + const agent = { + id: "workflow-test-executor", + name: "Workflow Test Executor", + role: "executor", + roles: ["executor"], + state: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + runtimeConfig: {}, + }; + const countAgentLeases = (agentId: string) => [...leases.values()].filter((holder) => holder === agentId).length; + const agentStore = { + workflowProjectId: "executor-worktree-test-project", + listAgents: vi.fn(async () => [agent]), + getAgent: vi.fn(async (agentId: string) => agentId === agent.id ? agent : null), + acquireWorkflowSessionCapacity: vi.fn(async (input: { + agentId: string; + attemptId: string; + maxProjectSessions?: number; + maxAgentSessions?: number; + }) => { + const existing = leases.get(input.attemptId); + if (existing) return existing === input.agentId ? "acquired" : "agent-capacity"; + if (input.maxProjectSessions !== undefined && leases.size >= input.maxProjectSessions) return "project-capacity"; + if (input.maxAgentSessions !== undefined && countAgentLeases(input.agentId) >= input.maxAgentSessions) return "agent-capacity"; + leases.set(input.attemptId, input.agentId); + return "acquired"; + }), + renewWorkflowSessionCapacity: vi.fn(async (attemptId: string) => leases.has(attemptId)), + releaseWorkflowSessionCapacity: vi.fn(async (attemptId: string) => { + leases.delete(attemptId); + }), + checkoutTask: vi.fn(async ( + agentId: string, + taskId: string, + leaseContext?: { nodeId?: string; runId?: string; leaseEpoch?: number; renewedAt?: string }, + ) => { + const task = await store.getTask(taskId); + if (!task) throw new Error(`Task ${taskId} not found`); + const renewedAt = leaseContext?.renewedAt ?? new Date().toISOString(); + await store.updateTask(taskId, { + checkedOutBy: agentId, + checkedOutAt: task.checkedOutBy === agentId ? task.checkedOutAt ?? renewedAt : renewedAt, + checkoutNodeId: leaseContext?.nodeId ?? task.checkoutNodeId ?? null, + checkoutRunId: leaseContext?.runId ?? task.checkoutRunId ?? null, + checkoutLeaseRenewedAt: renewedAt, + checkoutLeaseEpoch: leaseContext?.leaseEpoch ?? task.checkoutLeaseEpoch ?? 0, + }); + return await store.getTask(taskId); + }), + }; + return { + agent, + agentStore, + leases, + reset() { + leases.clear(); + agentStore.listAgents.mockClear(); + agentStore.getAgent.mockClear(); + agentStore.acquireWorkflowSessionCapacity.mockClear(); + agentStore.renewWorkflowSessionCapacity.mockClear(); + agentStore.releaseWorkflowSessionCapacity.mockClear(); + agentStore.checkoutTask.mockClear(); + }, + }; +} + /* FNXC:ExecutorTests 2026-07-19-09:40: Under graph ownership mockedCreateFnAgent fires once per graph session (e.g. Plan diff --git a/packages/engine/src/__tests__/executor-worktree.test.ts b/packages/engine/src/__tests__/executor-worktree.test.ts index 590c3eeb05..f9ebd21a17 100644 --- a/packages/engine/src/__tests__/executor-worktree.test.ts +++ b/packages/engine/src/__tests__/executor-worktree.test.ts @@ -12,6 +12,7 @@ import { findWorktreeUser, aiMergeTask } from "../merger.js"; import { WorktreePool } from "../worktree/worktree-pool.js"; import * as worktreePoolModule from "../worktree/worktree-pool.js"; import { BranchConflictError } from "../execution/branch-conflicts.js"; +import { routeWorkflowPrincipal } from "../agents/workflow-agent-router.js"; import * as branchConflictModule from "../execution/branch-conflicts.js"; import { activeSessionRegistry } from "../agents/active-session-registry.js"; import { ActiveSessionWorktreeRemovalError } from "../worktree/worktree-backend.js"; @@ -25,6 +26,7 @@ import { runVerificationCommand as mockedRunVerificationCommand } from "../execu import { __resetSandboxBackendForTests, __setSandboxBackendForTests } from "../sandbox/index.js"; import { createMockStore, + createWorkflowRoutingAgentStore, mockedCreateFnAgent, mockedSessionManager, mockedGenerateWorktreeName, @@ -49,6 +51,94 @@ import { const mockedReviewStep = vi.mocked(mockedReviewStepFn); +/* + * FNXC:WorkflowPrincipalRouting 2026-08-08-04:27: + * Mandatory graph principal routing is part of every production-path executor run, including + * worktree tests. This factory supplies only a durable executor role, capacity leases, and + * refresh-safe checkout renewal; it must not bypass graph admission or worktree refresh, so + * each existing Git/worktree assertion continues to exercise the production acquisition path. + */ +function createWorktreeExecutor(store: any, rootDir: string, options: any = {}) { + return new TaskExecutor(store, rootDir, { + agentStore: createWorkflowRoutingAgentStore(store).agentStore, + ...options, + }); +} + +describe("worktree workflow routing fixture", () => { + it("selects its eligible executor from the role pool", async () => { + const store = createMockStore(); + const fixture = createWorkflowRoutingAgentStore(store); + const agents = await fixture.agentStore.listAgents({ includeEphemeral: true }); + const route = routeWorkflowPrincipal({ + task: {}, + ir: { version: "v1", name: "fixture", nodes: [], edges: [] }, + node: { id: "execute", kind: "script", config: { seam: "execute" } }, + agents, + } as any); + + expect(route).toEqual(expect.objectContaining({ + status: "routed", + route: expect.objectContaining({ + agent: expect.objectContaining({ id: fixture.agent.id }), + role: "executor", + authority: "role-pool", + }), + })); + expect(fixture.agentStore.listAgents).toHaveBeenCalledWith({ includeEphemeral: true }); + }); + + it("releases capacity after successful and failed fixture work", async () => { + const fixture = createWorkflowRoutingAgentStore(createMockStore()); + const run = async (attemptId: string, fail = false) => { + expect(await fixture.agentStore.acquireWorkflowSessionCapacity({ agentId: fixture.agent.id, attemptId })).toBe("acquired"); + try { + if (fail) throw new Error("fixture failure"); + } finally { + await fixture.agentStore.releaseWorkflowSessionCapacity(attemptId); + } + }; + + await run("success"); + await expect(run("failure", true)).rejects.toThrow("fixture failure"); + expect(fixture.leases).toHaveLength(0); + expect(fixture.agentStore.releaseWorkflowSessionCapacity).toHaveBeenCalledTimes(2); + }); + + it("renews checkout without replacing populated worktree state", async () => { + const store = createMockStore(); + store._setRow("FN-routing", { + worktree: "/tmp/existing-worktree", + branch: "fusion/FN-routing", + baseBranch: "main", + baseSha: "abc123", + }); + const fixture = createWorkflowRoutingAgentStore(store); + + await fixture.agentStore.checkoutTask(fixture.agent.id, "FN-routing", { nodeId: "node", runId: "run", leaseEpoch: 1, renewedAt: "2026-01-01T00:00:00.000Z" }); + await fixture.agentStore.checkoutTask(fixture.agent.id, "FN-routing", { nodeId: "node", runId: "run", leaseEpoch: 1, renewedAt: "2026-01-01T00:01:00.000Z" }); + + expect(await store.getTask("FN-routing")).toEqual(expect.objectContaining({ + worktree: "/tmp/existing-worktree", + branch: "fusion/FN-routing", + baseBranch: "main", + baseSha: "abc123", + checkedOutBy: fixture.agent.id, + checkoutLeaseRenewedAt: "2026-01-01T00:01:00.000Z", + })); + }); + + it("resets lease state without leaking into the next fixture", async () => { + const fixture = createWorkflowRoutingAgentStore(createMockStore()); + await fixture.agentStore.acquireWorkflowSessionCapacity({ agentId: fixture.agent.id, attemptId: "leaked" }); + fixture.reset(); + + expect(fixture.leases).toHaveLength(0); + expect(fixture.agentStore.acquireWorkflowSessionCapacity).not.toHaveBeenCalled(); + expect(createWorkflowRoutingAgentStore(createMockStore()).leases).toHaveLength(0); + }); +}); + describe("TaskExecutor with semaphore", () => { beforeEach(() => { resetExecutorMocks(); @@ -67,7 +157,7 @@ describe("TaskExecutor with semaphore", () => { }, } as any); - const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem }); + const executor = createWorktreeExecutor(store, "/tmp/test", { semaphore: sem }); await executor.execute({ id: "FN-001", @@ -94,7 +184,7 @@ describe("TaskExecutor with semaphore", () => { mockedCreateFnAgent.mockRejectedValue(new Error("agent failed")); const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { + const executor = createWorktreeExecutor(store, "/tmp/test", { semaphore: sem, onError, }); @@ -122,7 +212,7 @@ describe("TaskExecutor with semaphore", () => { mockedCreateFnAgent.mockRejectedValue(new Error("agent crashed")); const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); + const executor = createWorktreeExecutor(store, "/tmp/test", { onError }); await executor.execute({ id: "FN-001", @@ -170,7 +260,7 @@ describe("TaskExecutor with semaphore", () => { } as any; }); - const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem }); + const executor = createWorktreeExecutor(store, "/tmp/test", { semaphore: sem }); const task = (id: string) => ({ id, @@ -259,7 +349,7 @@ describe("TaskExecutor worktreeInitCommand", () => { worktreeInitCommand: "pnpm install --frozen-lockfile", }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Should log success @@ -275,7 +365,7 @@ describe("TaskExecutor worktreeInitCommand", () => { const store = createMockStore(); // getSettings returns default (no worktreeInitCommand) - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Only worktree creation calls to execSync, no "pnpm install --frozen-lockfile" etc. @@ -307,7 +397,7 @@ describe("TaskExecutor worktreeInitCommand", () => { }); const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); + const executor = createWorktreeExecutor(store, "/tmp/test", { onError }); await executor.execute(makeTask()); // Should log the failure @@ -344,7 +434,7 @@ describe("TaskExecutor worktreeInitCommand", () => { // Worktree already exists (resume) mockedExistsSync.mockReturnValue(true); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // getSettings is called (for project commands in execution prompt) but init command should not run @@ -381,7 +471,7 @@ describe("TaskExecutor worktree naming", () => { it("uses generateWorktreeName for fresh worktree directories", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); @@ -395,7 +485,7 @@ describe("TaskExecutor worktree naming", () => { it("does NOT use task ID as worktree directory name for fresh worktrees", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask("FN-099")); @@ -438,7 +528,7 @@ describe("TaskExecutor worktree naming", () => { is generated for a resumed task) is unchanged. */ store._setRow("FN-031", { worktree: existingPath }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask("FN-031", existingPath)); @@ -466,7 +556,7 @@ describe("TaskExecutor worktree naming", () => { stored row for the clear-and-recreate branch to be reachable at all. */ store._setRow("FN-032", { worktree: stalePath }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask("FN-032", stalePath)); @@ -490,7 +580,7 @@ describe("TaskExecutor worktree naming", () => { worktreeNaming: "task-id", }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask("FN-042")); // Should use task ID (lowercase) as worktree name @@ -520,7 +610,7 @@ describe("TaskExecutor worktree naming", () => { passed to `execute()` is provably ignored — the requirement is about stored task data. */ store._setRow("FN-043", { title: "Fix login bug with OAuth" }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute({ ...makeTask("FN-043"), title: "Fix login bug with OAuth", @@ -546,7 +636,7 @@ describe("TaskExecutor worktree naming", () => { worktreeNaming: "task-title", }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const taskDescription = "Implement user authentication flow"; /* FNXC:EngineTests 2026-07-19-16:28 (U10b): @@ -579,7 +669,7 @@ describe("TaskExecutor worktree naming", () => { worktreeNaming: "random", }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask("FN-045")); // Should use generateWorktreeName for random mode @@ -601,7 +691,7 @@ describe("TaskExecutor worktree naming", () => { // worktreeNaming is not set (undefined) }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask("FN-046")); // Should default to random naming @@ -637,7 +727,7 @@ describe("TaskExecutor worktree naming", () => { reclaimed: false, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask("FN-047")); // Worktree naming preference should not break task startup in recycle mode. @@ -683,7 +773,7 @@ describe("TaskExecutor worktree recovery", () => { it("creates worktree successfully on first attempt", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); @@ -715,7 +805,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); + const executor = createWorktreeExecutor(store, "/tmp/test", { onError }); await executor.execute(makeTask()); expect(store.logEntry).toHaveBeenCalledWith( @@ -748,7 +838,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); const worktreeAddCalls = mockedExecSync.mock.calls.filter( @@ -772,7 +862,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, rootDir, { onError }); + const executor = createWorktreeExecutor(store, rootDir, { onError }); await executor.execute(makeTask()); const worktreeAddCalls = mockedExecSync.mock.calls.filter( @@ -807,7 +897,7 @@ describe("TaskExecutor worktree recovery", () => { it("extractWorktreeConflictInfo classifies not-a-git-repository errors", () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const error: any = new Error("fatal: not a git repository"); error.stderr = Buffer.from("fatal: not a git repository"); @@ -819,7 +909,7 @@ describe("TaskExecutor worktree recovery", () => { it("extractWorktreeConflictInfo does not misclassify dubious ownership as not-git-repo", () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const rootDir = "C:/Users/drewd/Documents/1. App Development/1. Active/NextGenEHS"; const error: any = new Error(`fatal: detected dubious ownership in repository at '${rootDir}'`); @@ -832,7 +922,7 @@ describe("TaskExecutor worktree recovery", () => { it("treats not-a-git-repository as non-retryable in tryCreateWorktree flow", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); mockedExecSync.mockImplementation((cmd: string | string[]) => { const command = typeof cmd === "string" ? cmd : cmd[0]; @@ -859,7 +949,7 @@ describe("TaskExecutor worktree recovery", () => { it("extractWorktreeConflictInfo classifies already checked out errors as already-used", () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const error: any = new Error( "fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'", @@ -893,7 +983,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(store.logEntry).toHaveBeenCalledWith( @@ -909,7 +999,7 @@ describe("TaskExecutor worktree recovery", () => { it("reclaims an inactive same-task conflict when the branch preserves task commits", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const conflictPath = "/tmp/test/.worktrees/light-cedar"; vi.spyOn(executor as any, "shouldGenerateNewWorktreeName").mockResolvedValue(false); const cleanup = vi.spyOn(executor as any, "cleanupConflictingWorktree").mockResolvedValue(true); @@ -945,7 +1035,7 @@ describe("TaskExecutor worktree recovery", () => { "relocates an out-of-root %s same-task worktree before reclaiming it", async (kind) => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const conflictPath = "/tmp/legacy-worktrees/recover-fn-8400"; const targetPath = "/tmp/test/.worktrees/pearl-otter"; vi.spyOn(executor as any, "shouldGenerateNewWorktreeName").mockResolvedValue(false); @@ -984,7 +1074,7 @@ describe("TaskExecutor worktree recovery", () => { it("normalizes an out-of-root branch-conflict reclaim before persisting it", async () => { const store = createMockStore(); store.getSettings.mockResolvedValue({ worktreesDir: ".worktrees" } as any); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const conflictPath = "/tmp/legacy-worktrees/recover-fn-8400"; const targetPath = "/tmp/test/.worktrees/recover-fn-8400"; vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({ @@ -1016,7 +1106,7 @@ describe("TaskExecutor worktree recovery", () => { it("uses the task-pinned target when normalizing a branch-conflict reclaim", async () => { const store = createMockStore(); store.getSettings.mockResolvedValue({ worktreesDir: ".worktrees", worktreeNaming: "task-id" } as any); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const conflictPath = "/tmp/legacy-worktrees/recover-fn-8400"; const pinnedPath = "/tmp/test/.worktrees/fn-8400"; vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({ @@ -1054,7 +1144,7 @@ describe("TaskExecutor worktree recovery", () => { // 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 executor = createWorktreeExecutor(store, "/tmp/test", { onError }); const result = await (executor as any).handleBranchConflict( makeTask(), @@ -1098,7 +1188,7 @@ describe("TaskExecutor worktree recovery", () => { 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 executor = createWorktreeExecutor(store, "/tmp/test"); const conflictError = new BranchConflictError({ branchName: "fusion/fn-050", conflictingWorktreePath: "/tmp/test/.worktrees/green-sage", @@ -1132,7 +1222,7 @@ describe("TaskExecutor worktree recovery", () => { 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 executor = createWorktreeExecutor(store, "/tmp/test"); const conflictError = new BranchConflictError({ branchName: "fusion/fn-050", conflictingWorktreePath: "/tmp/test/.worktrees/green-sage", @@ -1184,7 +1274,7 @@ describe("TaskExecutor worktree recovery", () => { }); const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); + const executor = createWorktreeExecutor(store, "/tmp/test", { onError }); /* FNXC:EngineTests 2026-07-19-16:32 (U10b): `executionStartBranch` is a PERSISTED field: the missing-base fallback reads it from the row @@ -1253,7 +1343,7 @@ describe("TaskExecutor worktree recovery", () => { }); const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); + const executor = createWorktreeExecutor(store, "/tmp/test", { onError }); /* FNXC:EngineTests 2026-07-19-16:35 (U10b): The nested-worktree refusal guards the PERSISTED worktree path. The graph re-reads the row, @@ -1310,7 +1400,7 @@ describe("TaskExecutor worktree recovery", () => { }); const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); + const executor = createWorktreeExecutor(store, "/tmp/test", { onError }); await executor.execute(makeTask()); @@ -1367,7 +1457,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); // Mock the second call to tryCreateWorktree to succeed // by making subsequent calls succeed after cleanup @@ -1470,7 +1560,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(removeSpy).toHaveBeenCalledTimes(1); @@ -1537,7 +1627,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(activeSessionRegistry.lookupByPath(conflictPath)?.kind).toBe("workflow-step"); @@ -1603,7 +1693,7 @@ describe("TaskExecutor worktree recovery", () => { // Second generated name for the suffix-rename path. mockedGenerateWorktreeName.mockReturnValueOnce("jade-finch"); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); /* FNXC:EngineTests 2026-07-19-16:38 (U10b): The suffix-rename retry must reuse the task's persisted start point, so `executionStartBranch` @@ -1660,7 +1750,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(mockedClassifyStaleLock).toHaveBeenCalled(); @@ -1687,7 +1777,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(store.updateTask).toHaveBeenCalledWith( @@ -1714,7 +1804,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(mockedRecoverStaleRegistration).toHaveBeenCalled(); @@ -1744,7 +1834,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); (executor as any).MAX_WORKTREE_RETRIES = 1; await expect( @@ -1771,7 +1861,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Should have removed the stale branch @@ -1801,7 +1891,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Should have called git worktree prune as the first recovery step @@ -1857,7 +1947,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Should have tried branch -D first @@ -1909,7 +1999,7 @@ describe("TaskExecutor worktree recovery", () => { }); const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); + const executor = createWorktreeExecutor(store, "/tmp/test", { onError }); const executePromise = executor.execute(makeTask()); await vi.advanceTimersByTimeAsync(5000); await executePromise; @@ -1954,7 +2044,7 @@ describe("TaskExecutor worktree recovery", () => { }); const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); + const executor = createWorktreeExecutor(store, "/tmp/test", { onError }); await executor.execute(makeTask()); // Should have logged terminal failure for the stale reference @@ -1997,7 +2087,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Should have logged cleanup in fallback path @@ -2028,7 +2118,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Should have triggered cleanup (stale branch reclaim) @@ -2058,7 +2148,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(store.logEntry).toHaveBeenCalledWith( @@ -2083,7 +2173,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(store.logEntry).toHaveBeenCalledWith( @@ -2115,7 +2205,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, rootDir); + const executor = createWorktreeExecutor(store, rootDir); await executor.execute(makeTask()); expect( @@ -2152,7 +2242,7 @@ describe("TaskExecutor worktree recovery", () => { return Buffer.from(""); }); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Should attempt to unlock the worktree before removing @@ -2193,7 +2283,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { it("creates worktree from baseBranch when set on task", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); /* FNXC:EngineTests 2026-07-19-16:41 (U10b): @@ -2217,7 +2307,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { it("creates worktree from integration branch when baseBranch is not set", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask({ id: "FN-061", @@ -2234,7 +2324,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { it("logs base branch in worktree creation log entry", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); /* FNXC:EngineTests 2026-07-19-16:42 (U10b): @@ -2257,7 +2347,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { it("logs integration branch in worktree creation log when baseBranch is not set", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask({ id: "FN-063", @@ -2272,7 +2362,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { it("retries worktree creation after cleaning up conflicting worktree", async () => { const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const conflictingPath = "/tmp/test/.worktrees/sharp-stone"; const removeWorktreeSpy = vi.spyOn(worktreePoolModule, "removeWorktree"); @@ -2316,7 +2406,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { it("throws original error if cleanup also fails", async () => { vi.useRealTimers(); const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); const conflictingPath = "/tmp/test/.worktrees/sharp-stone"; mockedExecSync.mockImplementation((cmd: any) => { @@ -2362,7 +2452,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { recycleWorktrees: true, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); /* FNXC:EngineTests 2026-07-19-16:44 (U10b): @@ -2402,7 +2492,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { recycleWorktrees: true, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask({ id: "FN-065", @@ -2440,7 +2530,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { recycleWorktrees: true, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask({ id: "FN-066" })); expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ @@ -2476,7 +2566,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { recycleWorktrees: true, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask({ id: "FN-066", @@ -2534,7 +2624,7 @@ describe("TaskExecutor worktree pool integration", () => { recycleWorktrees: true, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask()); // Should NOT call git worktree add (no fresh worktree) @@ -2612,7 +2702,7 @@ describe("TaskExecutor worktree pool integration", () => { recycleWorktrees: true, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask()); expect(store.updateTask).toHaveBeenCalledWith("FN-020", { baseCommitSha: "newbase123" }); @@ -2632,7 +2722,7 @@ describe("TaskExecutor worktree pool integration", () => { recycleWorktrees: true, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask()); // Should call git worktree add (fresh worktree) @@ -2672,7 +2762,7 @@ describe("TaskExecutor worktree pool integration", () => { worktreeInitCommand: "pnpm install --frozen-lockfile", }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask()); // "pnpm install --frozen-lockfile" should NOT have been called (pooled worktree has warm cache) @@ -2689,7 +2779,7 @@ describe("TaskExecutor worktree pool integration", () => { const store = createMockStore(); // recycleWorktrees defaults to false - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask()); // Should create a fresh worktree, NOT acquire from pool @@ -2723,7 +2813,7 @@ describe("TaskExecutor worktree pool integration", () => { recycleWorktrees: true, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask()); expect(releaseSpy).toHaveBeenCalledWith("/tmp/test/.worktrees/bad-wt", "FN-020"); @@ -2766,7 +2856,7 @@ describe("TaskExecutor worktree pool integration", () => { recycleWorktrees: true, }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask("FN-020")); const worktreeAddCalls = mockedExecSync.mock.calls.filter( @@ -2880,7 +2970,7 @@ describe("worktree DB hydration", () => { it("runs once for fresh worktree", async () => { mockedExistsSync.mockReturnValue(false); const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(mockedHydrateWorktreeDb).toHaveBeenCalledTimes(1); }); @@ -2894,7 +2984,7 @@ describe("worktree DB hydration", () => { release: vi.fn(), } as any; store.getSettings.mockResolvedValue({ ...(await store.getSettings()), recycleWorktrees: true }); - const executor = new TaskExecutor(store, "/tmp/test", { pool }); + const executor = createWorktreeExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask()); expect(mockedHydrateWorktreeDb).toHaveBeenCalledTimes(1); }); @@ -2904,7 +2994,7 @@ describe("worktree DB hydration", () => { 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"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask({ worktree: "/tmp/test" })); expect(mockedHydrateWorktreeDb).toHaveBeenCalledTimes(1); }); @@ -2919,7 +3009,7 @@ describe("worktree DB hydration", () => { }); mockedExistsSync.mockReturnValue(false); const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect((store.logEntry as ReturnType).mock.calls).toEqual( @@ -2939,7 +3029,7 @@ describe("worktree DB hydration", () => { mockedHydrateWorktreeDb.mockRejectedValueOnce(new Error("boom")); mockedExistsSync.mockReturnValue(false); const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test"); + const executor = createWorktreeExecutor(store, "/tmp/test"); await executor.execute(makeTask()); expect(mockedCreateFnAgent).toHaveBeenCalled(); });