import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { AgentSemaphore } from "./concurrency.js"; import { detectReviewHandoffIntent, determineRevisionResetStart } from "./executor.js"; // Mock external dependencies vi.mock("./pi.js", () => ({ createFnAgent: vi.fn(), describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"), compactSessionContext: vi.fn(async (session, instructions) => { // Delegate to session.compact if available (supports loop recovery tests) if (typeof (session as any).compact === "function") { return (session as any).compact(instructions); } return null; }), promptWithFallback: vi.fn(async (session, prompt, options) => { if (options === undefined) { await session.prompt(prompt); } else { await session.prompt(prompt, options); } }), })); vi.mock("./reviewer.js", () => ({ reviewStep: vi.fn(), })); vi.mock("./logger.js", () => { const createMockLogger = () => ({ log: vi.fn(), warn: vi.fn(), error: vi.fn(), }); return { createLogger: vi.fn(() => createMockLogger()), schedulerLog: createMockLogger(), executorLog: createMockLogger(), triageLog: createMockLogger(), mergerLog: createMockLogger(), worktreePoolLog: createMockLogger(), reviewerLog: createMockLogger(), prMonitorLog: createMockLogger(), runtimeLog: createMockLogger(), ipcLog: createMockLogger(), projectManagerLog: createMockLogger(), hybridExecutorLog: createMockLogger(), }; }); vi.mock("./merger.js", () => ({ aiMergeTask: vi.fn(), findWorktreeUser: vi.fn().mockResolvedValue(null), })); vi.mock("./agent-session-helpers.js", async () => { const { createFnAgent } = await import("./pi.js"); return { createResolvedAgentSession: async (options: any) => { const result = await createFnAgent(options); return { session: result.session, sessionFile: result.sessionFile, runtimeId: "pi", wasConfigured: false, }; }, }; }); vi.mock("./worktree-names.js", async () => { const actual = await vi.importActual("./worktree-names.js"); return { ...actual, generateWorktreeName: vi.fn().mockReturnValue("swift-falcon"), }; }); // Mock node modules used by executor. // Route async `exec` through the `execSync` mock so existing tests that set // up mockedExecSync.mockImplementation for user commands (worktreeInitCommand, // workflow step scripts, etc.) keep working unchanged after the switch to // promisify(exec) in executor.ts. vi.mock("node:child_process", async () => { const { promisify } = await import("node:util"); const { EventEmitter } = await import("node:events"); const execSyncFn = vi.fn(); const spawnFn = vi.fn((cmd: string, opts?: any) => { const child = new EventEmitter() as any; child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.pid = 12345; child.exitCode = null; child.signalCode = null; child.kill = vi.fn(); queueMicrotask(() => { try { const out = execSyncFn(cmd, opts); const stdout = out === undefined ? "" : out.toString(); if (stdout) child.stdout.emit("data", Buffer.from(stdout)); child.exitCode = 0; child.emit("close", 0, null); } catch (err) { const error = err as { stdout?: string; stderr?: string; status?: number; code?: number }; const stdout = error?.stdout?.toString?.() ?? ""; const stderr = error?.stderr?.toString?.() ?? ""; if (stdout) child.stdout.emit("data", Buffer.from(stdout)); if (stderr) child.stderr.emit("data", Buffer.from(stderr)); child.exitCode = error.status ?? error.code ?? 1; child.emit("close", child.exitCode, null); } }); return child; }); const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => { const callback = typeof opts === "function" ? opts : cb; const forwardedOpts = typeof opts === "function" ? undefined : opts; try { const out = execSyncFn(cmd, forwardedOpts); const stdout = out === undefined ? "" : out.toString(); if (typeof callback === "function") callback(null, stdout, ""); } catch (err) { if (typeof callback === "function") { const error = err as { stdout?: string; stderr?: string }; callback(err, error?.stdout?.toString?.() ?? "", error?.stderr?.toString?.() ?? ""); } } }); // Mirror real child_process.exec: promisify resolves to { stdout, stderr }. execFn[promisify.custom] = (cmd: string, opts?: any) => new Promise((resolve, reject) => { execFn(cmd, opts, (err: any, stdout: string, stderr: string) => { if (err) { (err as Record).stdout = stdout; (err as Record).stderr = stderr; reject(err); } else { resolve({ stdout, stderr }); } }); }); return { execSync: execSyncFn, exec: execFn, spawn: spawnFn }; }); vi.mock("node:fs", () => ({ existsSync: vi.fn().mockReturnValue(true), })); // Mock StepSessionExecutor for integration tests const mockExecuteAll = vi.fn().mockResolvedValue([]); const mockTerminateAllSessions = vi.fn().mockResolvedValue(undefined); const mockCleanup = vi.fn().mockResolvedValue(undefined); vi.mock("./step-session-executor.js", () => ({ StepSessionExecutor: vi.fn().mockImplementation(() => ({ executeAll: mockExecuteAll, terminateAllSessions: mockTerminateAllSessions, cleanup: mockCleanup, })), })); vi.mock("./rate-limit-retry.js", () => ({ withRateLimitRetry: vi.fn((fn: () => Promise) => fn()), })); vi.mock("@mariozechner/pi-coding-agent", () => { const mockSessionManager = {}; return { SessionManager: { create: vi.fn().mockReturnValue(mockSessionManager), open: vi.fn().mockReturnValue(mockSessionManager), inMemory: vi.fn().mockReturnValue(mockSessionManager), }, ModelRegistry: vi.fn().mockImplementation(() => ({ find: vi.fn(), refresh: vi.fn(), })), AuthStorage: { create: vi.fn().mockReturnValue({}), }, getAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"), }; }); 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 { generateWorktreeName, slugify } from "./worktree-names.js"; import type { Task, TaskDetail } from "@fusion/core"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { StepSessionExecutor } from "./step-session-executor.js"; import { executorLog } from "./logger.js"; import { withRateLimitRetry } from "./rate-limit-retry.js"; const mockedCreateFnAgent = vi.mocked(createFnAgent); const mockedSessionManager = vi.mocked(SessionManager); const mockedGenerateWorktreeName = vi.mocked(generateWorktreeName); const mockedFindWorktreeUser = vi.mocked(findWorktreeUser); const mockedStepSessionExecutor = vi.mocked(StepSessionExecutor); const mockedWithRateLimitRetry = vi.mocked(withRateLimitRetry); type EventListener = (...args: unknown[]) => void; function createMockStore() { const listeners = new Map(); const store = { on: vi.fn((event: string, fn: EventListener) => { const existing = listeners.get(event) || []; existing.push(fn); listeners.set(event, existing); }), /** Trigger registered listeners for an event (test helper). */ _trigger(event: string, ...args: unknown[]) { for (const fn of listeners.get(event) || []) fn(...args); }, emit: vi.fn(), listTasks: vi.fn().mockResolvedValue([]), getTask: vi.fn().mockResolvedValue({ id: "FN-001", title: "Test", description: "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(), }), updateTask: vi.fn().mockResolvedValue({}), moveTask: vi.fn().mockResolvedValue({}), mergeTask: vi.fn().mockResolvedValue({}), logEntry: vi.fn().mockResolvedValue(undefined), addTaskComment: vi.fn().mockResolvedValue(undefined), parseStepsFromPrompt: vi.fn().mockResolvedValue([]), updateSettings: vi.fn().mockResolvedValue({}), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, worktreeInitCommand: undefined, }), updateStep: vi.fn().mockResolvedValue({}), getWorkflowStep: vi.fn().mockResolvedValue(undefined), listWorkflowSteps: vi.fn().mockResolvedValue([]), appendAgentLog: vi.fn().mockResolvedValue(undefined), getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"), clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]), }; return store as any; } describe("TaskExecutor with semaphore", () => { beforeEach(() => { vi.clearAllMocks(); }); 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); }); }); const mockedExecSync = vi.mocked(execSync); const { existsSync: mockedExistsSyncRaw } = await import("node:fs"); const mockedExistsSync = vi.mocked(mockedExistsSyncRaw); 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(() => { vi.clearAllMocks(); // 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", }); 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", ); expect(initCall).toBeDefined(); expect(initCall![1]).toMatchObject({ cwd: expect.stringContaining(".worktrees/"), timeout: 300_000, }); // Should log success expect(store.logEntry).toHaveBeenCalledWith( "FN-010", "Worktree init command completed", "pnpm install", 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" 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 task_done. After the retry also fails, it reports an error. expect(onError).toHaveBeenCalledWith( expect.objectContaining({ id: "FN-010" }), expect.objectContaining({ message: "Agent finished without calling task_done (after retry)" }), ); // 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", }); // 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(() => { vi.clearAllMocks(); 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"); }); 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"; mockedExistsSync.mockImplementation((path) => String(path).startsWith(stalePath)); mockedExecSync.mockImplementation((cmd: any) => { if (String(cmd) === "git worktree list --porcelain") { return "worktree /tmp/test\nHEAD abc123\nbranch refs/heads/main\n" as any; } return Buffer.from(""); }); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute(makeTask("FN-032", stalePath)); expect(store.updateTask).toHaveBeenCalledWith("FN-032", { worktree: null, branch: null }); expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test"); const worktreeAddCalls = mockedExecSync.mock.calls.filter( (call) => typeof call[0] === "string" && call[0].includes("git worktree add"), ); expect(worktreeAddCalls.length).toBeGreaterThan(0); }); describe("worktreeNaming setting", () => { it("uses task ID as worktree name when worktreeNaming is 'task-id'", async () => { const store = createMockStore(); 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"); }); 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"); }); it("ignores worktreeNaming setting when using pooled worktree (recycle mode)", async () => { const pool = new WorktreePool(); pool.release("/tmp/test/.worktrees/pooled-warm-wt"); // Pool path exists on disk, task worktree path does not (not a resume) mockedExistsSync.mockImplementation( (p) => p === "/tmp/test/.worktrees/pooled-warm-wt", ); 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 }); const executor = new TaskExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask("FN-047")); // Should acquire from pool, ignoring the task-id naming preference expect(store.updateTask).toHaveBeenCalledWith("FN-047", { worktree: "/tmp/test/.worktrees/pooled-warm-wt", branch: "fusion/fn-047", }); // Should NOT call generateWorktreeName when using pooled worktree expect(mockedGenerateWorktreeName).not.toHaveBeenCalled(); // Should log pool acquisition expect(store.logEntry).toHaveBeenCalledWith( "FN-047", expect.stringContaining("Acquired worktree from pool"), undefined, expect.objectContaining({ agentId: "executor" }), ); }); }); }); 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(); vi.clearAllMocks(); 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("recovers from worktree conflict and retries", async () => { const store = createMockStore(); let callCount = 0; // First call fails with conflict, second succeeds 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 have logged cleanup and retry expect(store.logEntry).toHaveBeenCalledWith( "FN-050", expect.stringContaining("Cleaned up conflicting worktree, retrying"), "/tmp/test/.worktrees/swift-falcon", ); // Should eventually succeed expect(store.updateTask).toHaveBeenCalledWith( "FN-050", expect.objectContaining({ worktree: expect.any(String) }), ); }); it("falls back to default base and clears task.baseBranch 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(), baseBranch: "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({ baseBranch: 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 () => { 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 }); const executePromise = executor.execute(makeTask()); // Advance past all retry delays (100 + 500 + 1000ms) await vi.advanceTimersByTimeAsync(2000); await executePromise; // 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 "/tmp/test/.worktrees/green-sage" --force'), 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", async () => { const store = createMockStore(); store.listTasks.mockResolvedValue([ { id: "FN-049", title: "Other Task", description: "Other task", column: "in-progress", worktree: "/tmp/test/.worktrees/green-sage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, ]); mockedFindWorktreeUser.mockResolvedValue("FN-049"); let callCount = 0; mockedExecSync.mockImplementation((cmd: string | string[]) => { const command = typeof cmd === "string" ? cmd : cmd[0]; // First attempt fails with conflict 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 mockedGenerateWorktreeName.mockReturnValueOnce("jade-finch"); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Should log that we're trying a new path expect(store.logEntry).toHaveBeenCalledWith( "FN-050", expect.stringContaining("Conflicting worktree in use by active task, trying new path"), expect.any(String), ); // Should generate a new name expect(mockedGenerateWorktreeName).toHaveBeenCalledTimes(2); }); 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(2000); 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 () => { 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 }); const executePromise = executor.execute(makeTask()); await vi.advanceTimersByTimeAsync(2000); await executePromise; // 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 recovery) 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(); // Directory exists but is not registered mockedExistsSync.mockReturnValue(true); // Mock git worktree list to not include our path let callCount = 0; 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"); } if (command.includes("rm -rf")) { return Buffer.from(""); } return Buffer.from(""); }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute(makeTask()); // Should have removed the existing directory expect(mockedExecSync).toHaveBeenCalledWith( expect.stringContaining("rm -rf"), expect.any(Object), ); 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(); vi.clearAllMocks(); 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", baseBranch: "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", baseBranch: "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"; 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(mockedExecSync).toHaveBeenCalledWith( `git worktree remove "${conflictingPath}" --force`, expect.objectContaining({ cwd: "/tmp/test" }), ); 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.useFakeTimers(); 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 "${conflictingPath}" --force`) { throw new Error("remove failed"); } return Buffer.from(""); }); const executePromise = executor.execute(makeTask({ id: "FN-065" })); await vi.advanceTimersByTimeAsync(2000); await executePromise; vi.useRealTimers(); 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("fusion/fn-064"); 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", baseBranch: "fusion/fn-063", })); expect(prepareSpy).toHaveBeenCalledWith( "/tmp/test/.worktrees/idle-wt", "fusion/fn-064", "fusion/fn-063", ); }); 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("fusion/fn-065"); 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, ); }); 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("fusion/fn-066-2"); 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(() => { vi.clearAllMocks(); // 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) === "git rev-parse HEAD") { return "newbase123\n" as any; } return "" 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); // 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", }); const executor = new TaskExecutor(store, "/tmp/test", { pool }); await executor.execute(makeTask()); // "pnpm install" should NOT have been called (pooled worktree has warm cache) const initCalls = mockedExecSync.mock.calls.filter( (c) => c[0] === "pnpm install", ); 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"); // Pool path must exist on disk for acquire() to return it mockedExistsSync.mockImplementation( (p) => p === "/tmp/test/.worktrees/bad-wt", ); // Make prepareForTask throw 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()); // Should have released the bad worktree back to pool expect(releaseSpy).toHaveBeenCalledWith("/tmp/test/.worktrees/bad-wt"); // Should have fallen through to fresh worktree creation const worktreeAddCalls = mockedExecSync.mock.calls.filter( (c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"), ); expect(worktreeAddCalls.length).toBeGreaterThan(0); // Should log the pool failure expect(store.logEntry).toHaveBeenCalledWith( "FN-020", expect.stringContaining("Pool worktree preparation failed"), undefined, expect.objectContaining({ agentId: "executor" }), ); }); }); 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(() => { vi.clearAllMocks(); }); 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 in merger.test.ts // 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("buildExecutionPrompt", () => { it("includes attachment section with absolute paths for image attachments", () => { const task = createMockTaskDetail({ attachments: [ { filename: "abc123-screenshot.png", originalName: "screenshot.png", mimeType: "image/png", size: 2048, createdAt: new Date().toISOString() }, ], }); const result = buildExecutionPrompt(task, "/home/user/project"); expect(result).toContain("## Attachments"); expect(result).toContain("**screenshot.png** (screenshot)"); expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png"); }); it("includes attachment section with absolute paths for text attachments", () => { const task = createMockTaskDetail({ attachments: [ { filename: "def456-error.log", originalName: "error.log", mimeType: "text/plain", size: 512, createdAt: new Date().toISOString() }, ], }); const result = buildExecutionPrompt(task, "/home/user/project"); expect(result).toContain("## Attachments"); expect(result).toContain("**error.log** (text/plain)"); expect(result).toContain("read for context"); expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/def456-error.log"); }); it("includes both image and text attachments", () => { const task = createMockTaskDetail({ attachments: [ { filename: "abc-shot.png", originalName: "shot.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString() }, { filename: "def-config.json", originalName: "config.json", mimeType: "application/json", size: 256, createdAt: new Date().toISOString() }, ], }); const result = buildExecutionPrompt(task, "/home/user/project"); expect(result).toContain("**shot.png** (screenshot)"); expect(result).toContain("**config.json** (application/json)"); }); it("rewrites project-root absolute paths to the active worktree", () => { const task = createMockTaskDetail({ prompt: [ "# test", "## Context to Read First", "- `/home/user/project/web/app/page.tsx`", "- `/home/user/project/.fusion/memory/`", "## Steps", "### Step 0: Preflight", "- [ ] inspect `/home/user/project/web/app/layout.tsx`", ].join("\n"), }); const result = buildExecutionPrompt( task, "/home/user/project", undefined, "/home/user/project/.worktrees/happy-robin", ); expect(result).toContain("/home/user/project/.worktrees/happy-robin/web/app/page.tsx"); expect(result).toContain("/home/user/project/.worktrees/happy-robin/web/app/layout.tsx"); expect(result).toContain("/home/user/project/.fusion/memory/"); expect(result).not.toContain("/home/user/project/.worktrees/happy-robin/.fusion/memory/"); }); it("omits attachment section when no attachments", () => { const task = createMockTaskDetail({ attachments: [] }); const result = buildExecutionPrompt(task, "/home/user/project"); expect(result).not.toContain("## Attachments"); }); it("omits attachment section when attachments is undefined", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task); expect(result).not.toContain("## Attachments"); }); it("omits attachment section when rootDir is not provided", () => { const task = createMockTaskDetail({ attachments: [ { filename: "abc.png", originalName: "test.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString() }, ], }); const result = buildExecutionPrompt(task); expect(result).not.toContain("## Attachments"); }); it("includes Project Commands section with test command when settings.testCommand is set", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/home/user/project", { testCommand: "pnpm test", } as any); expect(result).toContain("## Project Commands"); expect(result).toContain("- **Test:** `pnpm test`"); expect(result).not.toContain("- **Build:**"); }); it("includes Project Commands section with build command when settings.buildCommand is set", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/home/user/project", { buildCommand: "pnpm build", } as any); expect(result).toContain("## Project Commands"); expect(result).toContain("- **Build:** `pnpm build`"); expect(result).not.toContain("- **Test:**"); }); it("includes both commands when both are set", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/home/user/project", { testCommand: "pnpm test", buildCommand: "pnpm build", } as any); expect(result).toContain("## Project Commands"); expect(result).toContain("- **Test:** `pnpm test`"); expect(result).toContain("- **Build:** `pnpm build`"); }); it("tells executors to fix quality-gate failures even outside initial file scope", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/home/user/project", { testCommand: "pnpm test", buildCommand: "pnpm build", } as any); expect(result).toContain("fix failures even when that requires edits outside the original File Scope"); expect(result).toContain("If the repo has a typecheck command, run it before `task_done()`"); expect(result).toContain("not for fixes required to get tests, build, or typecheck back to green"); }); it("requires resolving ALL test failures, including unrelated or pre-existing ones", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/home/user/project", { testCommand: "pnpm test", buildCommand: "pnpm build", } as any); // The stricter language must be present to prevent "unrelated failure" deferrals expect(result).toContain("Resolve ALL test failures"); expect(result).toContain("even if they appear unrelated or pre-existing"); expect(result).toContain("accumulate technical debt"); expect(result).toContain("Investigate and fix or suppress them"); expect(result).toContain("do not defer them to a separate task"); }); it("omits Project Commands section when neither command is set", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/home/user/project", {} as any); expect(result).not.toContain("## Project Commands"); }); it("omits Project Commands section when settings is undefined", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task); expect(result).not.toContain("## Project Commands"); }); it("includes Steering Comments section when steeringComments has entries", () => { const task = createMockTaskDetail({ steeringComments: [ { id: "1", text: "Please handle the edge case", createdAt: new Date().toISOString(), author: "user" as const, }, ], }); const result = buildExecutionPrompt(task); expect(result).toContain("## Steering Comments"); expect(result).toContain("**user**"); expect(result).toContain("> Please handle the edge case"); expect(result).toContain("The following comments were added by the user during execution"); }); it("formats multiple steering comments correctly", () => { const now = new Date(); const task = createMockTaskDetail({ steeringComments: [ { id: "1", text: "First comment", createdAt: new Date(now.getTime() - 60000).toISOString(), // 1 minute ago author: "user" as const, }, { id: "2", text: "Second comment", createdAt: now.toISOString(), author: "agent" as const, }, ], }); const result = buildExecutionPrompt(task); expect(result).toContain("**user**"); expect(result).toContain("**agent**"); expect(result).toContain("> First comment"); expect(result).toContain("> Second comment"); }); it("omits Steering Comments section when steeringComments is empty", () => { const task = createMockTaskDetail({ steeringComments: [] }); const result = buildExecutionPrompt(task); expect(result).not.toContain("## Steering Comments"); }); it("omits Steering Comments section when steeringComments is undefined", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task); expect(result).not.toContain("## Steering Comments"); }); it("includes only the 10 most recent steering comments", () => { const steeringComments = Array.from({ length: 15 }, (_, i) => ({ id: `${i}`, text: `Comment ${i}`, createdAt: new Date().toISOString(), author: "user" as const, })); const task = createMockTaskDetail({ steeringComments }); const result = buildExecutionPrompt(task); // Should include comments 5-14 (the 10 most recent), not 0-4 expect(result).toContain("> Comment 5"); expect(result).toContain("> Comment 14"); expect(result).not.toContain("> Comment 0"); expect(result).not.toContain("> Comment 4"); }); it("end-to-end: steering comments are fully injected into execution prompt with correct format", () => { const now = new Date(); const task = createMockTaskDetail({ id: "FN-123", title: "Verify Steering Feature", steeringComments: [ { id: "sc-001", text: "Please ensure all edge cases are handled in the validation logic", createdAt: new Date(now.getTime() - 120000).toISOString(), author: "user" as const, }, { id: "sc-002", text: "Consider adding unit tests for the new utility function", createdAt: new Date(now.getTime() - 60000).toISOString(), author: "agent" as const, }, { id: "sc-003", text: "Don't forget to update the documentation before completing", createdAt: now.toISOString(), author: "user" as const, }, ], }); const result = buildExecutionPrompt(task, "/project", { testCommand: "pnpm test" } as any); // Verify section header exists expect(result).toContain("## Steering Comments"); // Verify explanatory header text expect(result).toContain("The following comments were added by the user during execution"); expect(result).toContain("Consider adjusting your approach or replanning remaining steps based on this feedback"); // Verify all three comments appear with correct author badges expect(result).toContain("**user**"); expect(result).toContain("**agent**"); // Verify quoted text format expect(result).toContain("> Please ensure all edge cases are handled in the validation logic"); expect(result).toContain("> Consider adding unit tests for the new utility function"); expect(result).toContain("> Don't forget to update the documentation before completing"); // Verify timestamp formatting appears (either relative like "2m ago" or absolute) // The formatTimestamp function returns relative times for recent comments expect(result).toMatch(/\*\*user\*\* — \d+m? ago/); // Verify the section appears in the expected location (after progress section, before review level) const steeringSectionIndex = result.indexOf("## Steering Comments"); const reviewLevelIndex = result.indexOf("## Review level"); expect(steeringSectionIndex).toBeGreaterThan(0); expect(reviewLevelIndex).toBeGreaterThan(steeringSectionIndex); }); it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => { const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, testCommand: "npm test", buildCommand: "npm run build", }); const mockPrompt = vi.fn().mockResolvedValue(undefined); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: mockPrompt, dispose: vi.fn(), }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); 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(), }); // Called twice: initial execution + retry when agent finishes without task_done expect(mockPrompt).toHaveBeenCalledTimes(2); const agentPrompt = mockPrompt.mock.calls[0][0]; expect(agentPrompt).toContain("## Project Commands"); expect(agentPrompt).toContain("- **Test:** `npm test`"); expect(agentPrompt).toContain("- **Build:** `npm run build`"); }); describe("memoryEnabled setting", () => { it("includes memory instructions when memoryEnabled: true", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { memoryEnabled: true, } as any); expect(result).toContain("Execute this task."); expect(result).toContain("## Project Memory"); expect(result).toContain(".fusion/memory/"); }); it("excludes memory instructions when memoryEnabled: false", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { memoryEnabled: false, } as any); expect(result).toContain("Execute this task."); expect(result).not.toContain("## Project Memory"); }); it("includes memory instructions when memoryEnabled is undefined (default enabled)", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", {} as any); expect(result).toContain("Execute this task."); expect(result).toContain("## Project Memory"); expect(result).toContain(".fusion/memory/"); }); it("includes selective memory write instruction for durable learnings at end of execution", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { memoryEnabled: true, } as any); // Should instruct selective writes, not unconditional appends expect(result).toMatch(/skip.*memory.*update|selectively|durable.*learnings/i); expect(result).toMatch(/end of execution|before calling.*task_done/i); // Should forbid task-specific trivia expect(result).toMatch(/avoid.*trivia|task-specific.*trivia|per-task.*log/i); // Should allow consolidation/editing expect(result).toMatch(/consolidate|update.*refine.*existing|edit.*existing/i); }); it("uses project-root memory path not worktree-local path", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { memoryEnabled: true, } as any); expect(result).toContain("`.fusion/memory/`"); }); }); describe("memoryBackendType setting", () => { it("includes .fusion/memory/ for file backend", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { memoryEnabled: true, memoryBackendType: "file", } as any); expect(result).toContain("## Project Memory"); // Check that the Project Memory section contains .fusion/memory/ const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/); expect(memorySectionMatch).toBeTruthy(); expect(memorySectionMatch![1]).toContain(".fusion/memory/"); }); it("includes read-only wording for readonly backend without write directives in memory section", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { memoryEnabled: true, memoryBackendType: "readonly", } as any); expect(result).toContain("## Project Memory"); // Extract the Project Memory section const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/); expect(memorySectionMatch).toBeTruthy(); const memorySection = memorySectionMatch![1]; // Should NOT contain write/update directives in the memory section expect(memorySection).not.toMatch(/write.*memory|update.*memory/i); // Should NOT contain the specific file path in the memory section expect(memorySection).not.toContain(".fusion/memory/"); }); it("does not include .fusion/memory/ in Project Memory section for qmd backend", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { memoryEnabled: true, memoryBackendType: "qmd", } as any); expect(result).toContain("## Project Memory"); // Extract the Project Memory section const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/); expect(memorySectionMatch).toBeTruthy(); const memorySection = memorySectionMatch![1]; // QMD should NOT unconditionally reference .fusion/memory/ in the memory section expect(memorySection).not.toContain(".fusion/memory/"); expect(memorySection).toContain("memory_search"); expect(memorySection).toContain("memory_get"); }); it("QMD prompt has actionable memory instructions", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { memoryEnabled: true, memoryBackendType: "qmd", } as any); expect(result).toContain("## Project Memory"); // Extract the Project Memory section const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/); expect(memorySectionMatch).toBeTruthy(); const memorySection = memorySectionMatch![1]; // QMD should NOT contain .fusion/memory/ expect(memorySection).not.toContain(".fusion/memory/"); expect(memorySection).toContain("memory_search"); // Contains "end of execution" write guidance expect(memorySection).toMatch(/end of execution/i); }); it("excludes memory section when memoryEnabled: false regardless of backend", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { memoryEnabled: false, memoryBackendType: "file", } as any); expect(result).toContain("Execute this task."); expect(result).not.toContain("## Project Memory"); }); }); describe("commit author attribution", () => { it("includes default author in commit instruction when commitAuthorEnabled is true", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { commitAuthorEnabled: true, } as any); expect(result).toContain('--author="Fusion "'); }); it("includes custom author name and email in commit instruction", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { commitAuthorEnabled: true, commitAuthorName: "CustomBot", commitAuthorEmail: "bot@example.com", } as any); expect(result).toContain('--author="CustomBot "'); }); it("omits author from commit instruction when commitAuthorEnabled is false", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { commitAuthorEnabled: false, } as any); expect(result).not.toContain("--author"); // Should still contain commit instruction without author expect(result).toContain("git commit -m"); }); it("uses default author when commitAuthorEnabled is true but name/email are undefined", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", { commitAuthorEnabled: true, commitAuthorName: undefined, commitAuthorEmail: undefined, } as any); expect(result).toContain('--author="Fusion "'); }); it("uses default author when settings is undefined", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project"); expect(result).toContain('--author="Fusion "'); }); it("uses default author when settings is empty object", () => { const task = createMockTaskDetail(); const result = buildExecutionPrompt(task, "/project", {} as any); expect(result).toContain('--author="Fusion "'); }); }); }); // Import the summarizeToolArgs helper directly (not affected by mocks above) describe("summarizeToolArgs", () => { // Dynamic import to avoid mock interference let summarizeToolArgs: (name: string, args?: Record) => string | undefined; beforeEach(async () => { const mod = await vi.importActual("./executor.js"); summarizeToolArgs = mod.summarizeToolArgs; }); it("returns command for bash tool", () => { expect(summarizeToolArgs("Bash", { command: "ls -la" })).toBe("ls -la"); expect(summarizeToolArgs("bash", { command: "echo hello" })).toBe("echo hello"); }); it("returns long bash commands in full without truncation", () => { const longCmd = "a".repeat(100); const result = summarizeToolArgs("Bash", { command: longCmd }); expect(result).toBe(longCmd); }); it("returns path for read/edit/write tools", () => { expect(summarizeToolArgs("Read", { path: "src/types.ts" })).toBe("src/types.ts"); expect(summarizeToolArgs("edit", { path: "src/store.ts" })).toBe("src/store.ts"); expect(summarizeToolArgs("Write", { path: "out.txt", content: "data" })).toBe("out.txt"); }); it("returns first string arg for unknown tools", () => { expect(summarizeToolArgs("task_update", { step: 1, status: "done" })).toBe("done"); }); it("returns undefined when no args provided", () => { expect(summarizeToolArgs("Bash")).toBeUndefined(); expect(summarizeToolArgs("Bash", {})).toBeUndefined(); }); it("returns undefined when no string args found", () => { expect(summarizeToolArgs("unknown", { count: 42, flag: true })).toBeUndefined(); }); }); describe("TaskExecutor pause behavior", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("terminates agent and moves task to todo when paused during execution", async () => { const store = createMockStore(); const disposeFn = vi.fn(); mockedCreateFnAgent.mockImplementation(async () => { return { session: { prompt: vi.fn().mockImplementation(async () => { // Simulate pause happening during agent execution store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" }); // Simulate the dispose causing an error (session terminated) throw new Error("Session terminated"); }), dispose: disposeFn, }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); 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(), }); // Should move to todo, NOT mark as failed expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" }); }); it("does not move to in-review when paused during execution (graceful session end)", async () => { const store = createMockStore(); mockedCreateFnAgent.mockImplementation(async () => { return { session: { prompt: vi.fn().mockImplementation(async () => { // Simulate pause — session ends gracefully (no throw) store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" }); }), dispose: vi.fn(), }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); 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(), }); // Should NOT move to in-review (paused tasks skip that logic) expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review"); // Should move to todo instead (regression: was stranding in in-progress) expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" }); }); it("moves paused task to todo when session ends gracefully (regression for FN-827)", async () => { const store = createMockStore(); const disposeFn = vi.fn(); mockedCreateFnAgent.mockImplementation(async () => { return { session: { prompt: vi.fn().mockImplementation(async () => { // Simulate pause during execution — session ends gracefully (no throw) store._trigger("task:updated", { id: "FN-805", paused: true, column: "in-progress" }); // No error thrown — this is the "graceful exit" path }), dispose: disposeFn, }, } as any; }); const stuckTaskDetector = { trackTask: vi.fn(), untrackTask: vi.fn(), recordActivity: vi.fn() } as any; const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector }); await executor.execute({ id: "FN-805", title: "Stranded task", description: "A task that was paused and stranded", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // The critical fix: task must end in todo, not stranded in in-progress expect(store.moveTask).toHaveBeenCalledWith("FN-805", "todo"); // Should NOT be marked as failed expect(store.updateTask).not.toHaveBeenCalledWith("FN-805", expect.objectContaining({ status: "failed" })); // Should log the pause event expect(store.logEntry).toHaveBeenCalledWith("FN-805", expect.stringContaining("Execution paused")); // Session should be disposed expect(disposeFn).toHaveBeenCalled(); // Stuck detector should have untracked the task expect(stuckTaskDetector.untrackTask).toHaveBeenCalledWith("FN-805"); }); it("handles rapid pause→unpause without duplicate executor runs", async () => { const store = createMockStore(); const disposeFn = vi.fn(); let promptCallCount = 0; mockedCreateFnAgent.mockImplementation(async () => { return { session: { prompt: vi.fn().mockImplementation(async () => { promptCallCount++; // Simulate pause during execution store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" }); // Simulate rapid unpause while executor is still handling the pause store._trigger("task:updated", { id: "FN-001", paused: undefined, column: "in-progress" }); // Session ends gracefully (no throw) }), dispose: disposeFn, }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Rapid pause/unpause", description: "Test rapid pause then unpause", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // The task should still be moved to todo exactly once (the pause took effect) // Even if unpause happened rapidly, the session was already disposed const todoCalls = store.moveTask.mock.calls.filter( (call: any[]) => call[0] === "FN-001" && call[1] === "todo", ); expect(todoCalls.length).toBe(1); // Should NOT have duplicate in-review calls const inReviewCalls = store.moveTask.mock.calls.filter( (call: any[]) => call[0] === "FN-001" && call[1] === "in-review", ); expect(inReviewCalls.length).toBe(0); // Agent should only have been prompted once expect(promptCallCount).toBe(1); }); it("skips paused tasks during resumeOrphaned", async () => { const store = createMockStore(); store.listTasks.mockResolvedValue([ { id: "FN-001", column: "in-progress", paused: true, title: "Paused task", steps: [], description: "", dependencies: [] }, { id: "FN-002", column: "in-progress", paused: false, title: "Active task", steps: [], description: "", dependencies: [] }, ]); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.resumeOrphaned(); // Only KB-002 should be resumed (KB-001 is paused) expect(store.logEntry).toHaveBeenCalledWith("FN-002", "Resumed after engine restart"); expect(store.logEntry).not.toHaveBeenCalledWith("FN-001", expect.anything()); }); it("resumes unpaused in-progress task with no active session", async () => { const store = createMockStore(); const disposeFn = vi.fn(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: disposeFn, }, }) as any); const executor = new TaskExecutor(store, "/tmp/test"); // Simulate unpause of an in-progress task that has no active session // (e.g., engine restarted while task was paused in-progress) store._trigger("task:updated", { id: "FN-001", paused: undefined, column: "in-progress", description: "Test task", title: "Resumed task", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait for async execution to start await new Promise((r) => setTimeout(r, 50)); // Agent created at least twice: initial resume + retry when agent finishes without task_done // (async worktree validation may allow additional retry cycles within the timeout) expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2); expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resuming execution after unpause", undefined, undefined); }); it("does not recursively resume when resume logging emits task updated", async () => { const store = createMockStore(); const task = { id: "FN-001", paused: undefined, column: "in-progress", description: "Test task", title: "Resumed task", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, }) as any); let emittedUpdateFromLog = false; store.logEntry.mockImplementation(async (_id: string, action: string) => { if (action === "Resuming execution after unpause" && !emittedUpdateFromLog) { emittedUpdateFromLog = true; store._trigger("task:updated", { ...task, updatedAt: new Date().toISOString() }); } }); new TaskExecutor(store, "/tmp/test"); store._trigger("task:updated", task); await new Promise((r) => setTimeout(r, 50)); const resumeLogCalls = store.logEntry.mock.calls.filter( ([id, action]: [string, string]) => id === "FN-001" && action === "Resuming execution after unpause", ); expect(resumeLogCalls).toHaveLength(1); }); it("clears stale failed state before resuming unpaused in-progress task", async () => { const store = createMockStore(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, }) as any); const executor = new TaskExecutor(store, "/tmp/test"); store._trigger("task:updated", { id: "FN-001", paused: undefined, column: "in-progress", status: "failed", error: "Request was aborted.", description: "Test task", title: "Resumed task", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); await new Promise((r) => setTimeout(r, 30)); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null }); expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resuming execution after unpause", undefined, undefined); }); it("clears stale failed state before resuming orphaned in-progress task", async () => { const store = createMockStore(); store.listTasks.mockResolvedValue([ { id: "FN-001", column: "in-progress", paused: false, status: "failed", error: "Request was aborted.", title: "Active task", steps: [], description: "", dependencies: [], }, ]); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.resumeOrphaned(); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null }); expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resumed after engine restart"); }); it("does not duplicate execution when unpausing already-executing task", async () => { const store = createMockStore(); const disposeFn = vi.fn(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { // Simulate rapid unpause during execution — should NOT start a second run store._trigger("task:updated", { id: "FN-001", paused: undefined, column: "in-progress", }); // Wait a bit to let the unpause handler run await new Promise((r) => setTimeout(r, 10)); }), dispose: disposeFn, }, }) as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Already executing", description: "Test no duplicate", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // At least two agent creations (initial + retry without task_done), but no duplicate from the unpause event // (async worktree validation may allow additional retry cycles) expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2); }); it("does not resume unpaused task that is not in-progress", async () => { const store = createMockStore(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, }) as any); const executor = new TaskExecutor(store, "/tmp/test"); // Unpause a todo task — executor should NOT try to execute it store._trigger("task:updated", { id: "FN-001", paused: undefined, column: "todo", }); await new Promise((r) => setTimeout(r, 20)); // No agent should have been created expect(mockedCreateFnAgent).not.toHaveBeenCalled(); }); it("does not resume unpaused task that still has an active session", async () => { const store = createMockStore(); const disposeFn = vi.fn(); let promptResolve: () => void; const promptPromise = new Promise((r) => { promptResolve = r; }); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { // Simulate unpause while session is still active (should be a no-op) store._trigger("task:updated", { id: "FN-001", paused: undefined, column: "in-progress", }); await new Promise((r) => setTimeout(r, 10)); }), dispose: disposeFn, }, }) as any); const executor = new TaskExecutor(store, "/tmp/test"); // Start execution — session will be active const executePromise = executor.execute({ id: "FN-001", title: "Active session", description: "Test active session unpause", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); await executePromise; // Two agent sessions (initial + retry without task_done) — the unpause during active session was a no-op expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); }); it("uses SessionManager.create for fresh execution and persists sessionFile", async () => { const store = createMockStore(); const sessionFilePath = "/tmp/sessions/session_123.jsonl"; mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, sessionFile: sessionFilePath, } as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Fresh task", description: "Test fresh session", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should use SessionManager.create for fresh execution expect(mockedSessionManager.create).toHaveBeenCalledWith( expect.stringContaining(".worktrees"), ); expect(mockedSessionManager.open).not.toHaveBeenCalled(); // Should persist the session file path on the task expect(store.updateTask).toHaveBeenCalledWith("FN-001", { sessionFile: sessionFilePath }); }); it("uses SessionManager.open to resume session when task has sessionFile", async () => { const store = createMockStore(); const sessionFilePath = "/tmp/sessions/session_123.jsonl"; const resumePromptFn = vi.fn().mockResolvedValue(undefined); // existsSync must return true for the session file mockedExistsSync.mockReturnValue(true); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: resumePromptFn, dispose: vi.fn(), }, sessionFile: sessionFilePath, } as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Resumed task", description: "Test session resume", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], sessionFile: sessionFilePath, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should use SessionManager.open for the initial resumed execution expect(mockedSessionManager.open).toHaveBeenCalledWith(sessionFilePath); // The first createFnAgent call should use the opened session manager const firstCall = mockedCreateFnAgent.mock.calls[0][0] as any; expect(firstCall.sessionManager).toBeDefined(); // The log should indicate resume expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("Resumed agent session after unpause"), undefined, expect.objectContaining({ agentId: "executor" }), ); }); it("preserves sessionFile when task is paused (graceful exit)", async () => { const store = createMockStore(); const sessionFilePath = "/tmp/sessions/session_456.jsonl"; mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { // Simulate pause — session ends gracefully store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" }); }), dispose: vi.fn(), }, sessionFile: sessionFilePath, }) as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Pauseable task", description: "Test session file preserved on pause", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Session file should NOT be cleared when paused const clearCalls = store.updateTask.mock.calls.filter( (call: any[]) => call[0] === "FN-001" && call[1]?.sessionFile === null, ); expect(clearCalls.length).toBe(0); // Task should be moved to todo (ready for resume) expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); }); it("falls back to fresh session when sessionFile no longer exists on disk", async () => { const store = createMockStore(); const staleSessionFile = "/tmp/sessions/deleted_session.jsonl"; // Session file does NOT exist on disk mockedExistsSync.mockImplementation( (p) => p !== staleSessionFile, ); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, sessionFile: "/tmp/sessions/new_session.jsonl", } as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Stale session", description: "Test stale session file fallback", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], sessionFile: staleSessionFile, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should fall back to SessionManager.create (not open) expect(mockedSessionManager.create).toHaveBeenCalled(); expect(mockedSessionManager.open).not.toHaveBeenCalled(); }); }); describe("swallowed async store failure observability", () => { beforeEach(() => { vi.clearAllMocks(); mockedWithRateLimitRetry.mockImplementation((fn: () => Promise) => fn()); }); it("logs warning when rate-limit retry logEntry fails in step-session mode", async () => { const warnSpy = vi.spyOn(executorLog, "warn"); const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, runStepsInNewSessions: true, maxParallelSteps: 2, }); store.getTask.mockResolvedValue({ id: "FN-001", title: "Rate-limit step-session task", description: "Rate-limit diagnostics", column: "in-progress", dependencies: [], steps: [{ name: "Step 0", status: "pending" }], currentStep: 0, log: [], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), baseCommitSha: "abc123", enabledWorkflowSteps: [], }); mockExecuteAll.mockResolvedValue([{ stepIndex: 0, success: true, retries: 0 }]); store.logEntry.mockImplementation(async (_taskId: string, message: string) => { if (message.includes("Rate limited — retry")) { throw new Error("step-session retry log failure"); } return undefined; }); mockedWithRateLimitRetry.mockImplementationOnce((async ( fn: () => Promise, options?: { onRetry?: (attempt: number, delayMs: number, error: Error) => void }, ) => { options?.onRetry?.(2, 4_000, new Error("rate limit")); return fn(); }) as typeof withRateLimitRetry); const executor = new TaskExecutor(store, "/tmp/test"); await expect(executor.execute({ id: "FN-001", title: "Rate-limit step-session task", description: "Rate-limit diagnostics", column: "in-progress", dependencies: [], steps: [{ name: "Step 0", status: "pending" }], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), })).resolves.toBeUndefined(); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("FN-001 failed to log rate-limit retry: step-session retry log failure"), ); warnSpy.mockRestore(); }); it("logs warning when rate-limit retry logEntry fails in main-agent mode", async () => { const warnSpy = vi.spyOn(executorLog, "warn"); const store = createMockStore(); let capturedCustomTools: Array<{ name: string; execute: (callId: string, args: Record) => Promise }> = []; store.logEntry.mockImplementation(async (_taskId: string, message: string) => { if (message.includes("Rate limited — retry")) { throw new Error("main-agent retry log failure"); } return undefined; }); mockedCreateFnAgent.mockImplementation((async (opts: { customTools?: typeof capturedCustomTools }) => { capturedCustomTools = opts.customTools ?? []; return { session: { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = capturedCustomTools.find((tool) => tool.name === "task_done"); if (taskDoneTool) { await taskDoneTool.execute("call-1", { summary: "done" }); } }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }, sessionFile: "/tmp/sessions/main-agent-rate-limit.jsonl", }; }) as any); mockedWithRateLimitRetry.mockImplementationOnce((async ( fn: () => Promise, options?: { onRetry?: (attempt: number, delayMs: number, error: Error) => void }, ) => { options?.onRetry?.(1, 3_000, new Error("rate limit")); return fn(); }) as typeof withRateLimitRetry); const executor = new TaskExecutor(store, "/tmp/test"); await expect(executor.execute({ id: "FN-001", title: "Rate-limit main-agent task", description: "Rate-limit diagnostics", column: "in-progress", dependencies: [], steps: [{ name: "Step 0", status: "pending" }], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), })).resolves.toBeUndefined(); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("FN-001 failed to log rate-limit retry: main-agent retry log failure"), ); warnSpy.mockRestore(); }); it("logs warning when sessionFile update fails during retry", async () => { const warnSpy = vi.spyOn(executorLog, "warn"); const store = createMockStore(); const retrySessionFilePath = "/tmp/sessions/retry-failed.jsonl"; store.updateTask.mockImplementation(async (_taskId: string, patch: Record) => { if (patch?.sessionFile === retrySessionFilePath) { throw new Error("retry sessionFile write failed"); } return {}; }); mockedCreateFnAgent .mockResolvedValueOnce({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, sessionFile: "/tmp/sessions/initial.jsonl", } as any) .mockResolvedValueOnce({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, sessionFile: retrySessionFilePath, } as any); const executor = new TaskExecutor(store, "/tmp/test"); await expect(executor.execute({ id: "FN-001", title: "Retry session task", description: "Session retry diagnostics", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), })).resolves.toBeUndefined(); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("FN-001 failed to persist retry sessionFile: retry sessionFile write failed"), ); warnSpy.mockRestore(); }); it("logs warning when sessionFile clear fails on completion", async () => { const warnSpy = vi.spyOn(executorLog, "warn"); const store = createMockStore(); store.updateTask.mockImplementation(async (_taskId: string, patch: Record) => { if (patch?.sessionFile === null) { throw new Error("session clear failed"); } return {}; }); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, sessionFile: "/tmp/sessions/clear-test.jsonl", } as any); const executor = new TaskExecutor(store, "/tmp/test"); await expect(executor.execute({ id: "FN-001", title: "Session clear task", description: "Session clear diagnostics", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), })).resolves.toBeUndefined(); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("FN-001 failed to clear sessionFile: session clear failed"), ); warnSpy.mockRestore(); }); it("logs warning when child agent deletion fails during cleanup", async () => { vi.useFakeTimers(); const warnSpy = vi.spyOn(executorLog, "warn"); try { const store = createMockStore(); const agentStore = { updateAgentState: vi.fn().mockResolvedValue(undefined), deleteAgent: vi.fn().mockRejectedValue(new Error("delete failed")), }; const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any, }); (executor as any).childSessions.set("child-007", { dispose: vi.fn(), }); await (executor as any).terminateChildAgent("child-007"); await vi.advanceTimersByTimeAsync(5000); await Promise.resolve(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to delete spawned agent child-007: delete failed"), ); } finally { warnSpy.mockRestore(); vi.useRealTimers(); } }); }); describe("TaskExecutor executor model hot-swap", () => { const buildUpdatedTask = (overrides: Partial = {}): Task => ({ id: "FN-001", title: "Model task", description: "Test model updates", column: "in-progress", paused: false, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }); const flushTaskUpdated = async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }; beforeEach(() => { vi.clearAllMocks(); }); it("hot-swaps executor model on active session when modelProvider/modelId change", async () => { const store = createMockStore(); const setModel = vi.fn().mockResolvedValue(undefined); const findModel = vi.fn().mockReturnValue({ provider: { name: "openai" }, id: "gpt-4o", name: "GPT-4o", }); const executor = new TaskExecutor(store, "/tmp/test"); (executor as any)._modelRegistry = { find: findModel }; (executor as any).activeSessions.set("FN-001", { session: { setModel, dispose: vi.fn() }, seenSteeringIds: new Set(), lastModelProvider: "anthropic", lastModelId: "claude-sonnet-4-5", }); store._trigger("task:updated", buildUpdatedTask({ modelProvider: "openai", modelId: "gpt-4o", })); await flushTaskUpdated(); expect(setModel).toHaveBeenCalledTimes(1); expect(setModel).toHaveBeenCalledWith(expect.objectContaining({ provider: expect.objectContaining({ name: "openai" }), id: "gpt-4o", })); expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Model changed to openai/gpt-4o", undefined, undefined); }); it("does not attempt hot-swap when no active session exists", async () => { const store = createMockStore(); const setModel = vi.fn().mockResolvedValue(undefined); new TaskExecutor(store, "/tmp/test"); store._trigger("task:updated", buildUpdatedTask({ modelProvider: "openai", modelId: "gpt-4o", })); await flushTaskUpdated(); expect(setModel).not.toHaveBeenCalled(); }); it("does not hot-swap when model fields are unchanged", async () => { const store = createMockStore(); const setModel = vi.fn().mockResolvedValue(undefined); const findModel = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test"); (executor as any)._modelRegistry = { find: findModel }; (executor as any).activeSessions.set("FN-001", { session: { setModel, dispose: vi.fn() }, seenSteeringIds: new Set(), lastModelProvider: "anthropic", lastModelId: "claude-sonnet-4-5", }); store._trigger("task:updated", buildUpdatedTask({ modelProvider: "anthropic", modelId: "claude-sonnet-4-5", })); await flushTaskUpdated(); expect(findModel).not.toHaveBeenCalled(); expect(setModel).not.toHaveBeenCalled(); }); it("hot-swaps to settings default when override is cleared", async () => { const store = createMockStore(); const setModel = vi.fn().mockResolvedValue(undefined); const findModel = vi.fn().mockReturnValue({ provider: { name: "openai" }, id: "gpt-4o", name: "GPT-4o", }); store.getSettings.mockResolvedValue({ defaultProvider: "openai", defaultModelId: "gpt-4o", }); const executor = new TaskExecutor(store, "/tmp/test"); (executor as any)._modelRegistry = { find: findModel }; (executor as any).activeSessions.set("FN-001", { session: { setModel, dispose: vi.fn() }, seenSteeringIds: new Set(), lastModelProvider: "anthropic", lastModelId: "claude-sonnet-4-5", }); store._trigger("task:updated", buildUpdatedTask({ modelProvider: undefined, modelId: undefined, })); await flushTaskUpdated(); expect(findModel).toHaveBeenCalledWith("openai", "gpt-4o"); expect(setModel).toHaveBeenCalledTimes(1); }); it("logs error and continues when setModel fails", async () => { const store = createMockStore(); const setModel = vi.fn().mockRejectedValue(new Error("API key not found")); const findModel = vi.fn().mockReturnValue({ provider: { name: "openai" }, id: "gpt-4o", name: "GPT-4o", }); const executor = new TaskExecutor(store, "/tmp/test"); (executor as any)._modelRegistry = { find: findModel }; (executor as any).activeSessions.set("FN-001", { session: { setModel, dispose: vi.fn() }, seenSteeringIds: new Set(), lastModelProvider: "anthropic", lastModelId: "claude-sonnet-4-5", }); store._trigger("task:updated", buildUpdatedTask({ modelProvider: "openai", modelId: "gpt-4o", })); await flushTaskUpdated(); expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Model change failed: API key not found", undefined, undefined); expect((executor as any).activeSessions.has("FN-001")).toBe(true); }); it("does not attempt hot-swap on paused task", async () => { const store = createMockStore(); const setModel = vi.fn().mockResolvedValue(undefined); const dispose = vi.fn(); const findModel = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test"); (executor as any)._modelRegistry = { find: findModel }; (executor as any).activeSessions.set("FN-001", { session: { setModel, dispose }, seenSteeringIds: new Set(), lastModelProvider: "anthropic", lastModelId: "claude-sonnet-4-5", }); store._trigger("task:updated", buildUpdatedTask({ paused: true, modelProvider: "openai", modelId: "gpt-4o", })); await flushTaskUpdated(); expect(dispose).toHaveBeenCalledTimes(1); expect(findModel).not.toHaveBeenCalled(); expect(setModel).not.toHaveBeenCalled(); }); }); describe("TaskExecutor task:updated listener guards", () => { it("catches and logs errors from async task:updated operations", async () => { const store = createMockStore(); const terminateError = new Error("terminate failed"); const terminateAllSessions = vi.fn().mockRejectedValue(terminateError); const executor = new TaskExecutor(store, "/tmp/test"); (executor as any).activeStepExecutors.set("FN-001", { terminateAllSessions, }); const taskUpdatedHandler = (store.on as unknown as ReturnType).mock.calls .find((call: any[]) => call[0] === "task:updated")?.[1]; expect(taskUpdatedHandler).toBeTypeOf("function"); await expect(taskUpdatedHandler({ id: "FN-001", title: "Guard test", description: "Guard test", column: "in-progress", paused: true, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } satisfies Task)).resolves.toBeUndefined(); expect(terminateAllSessions).toHaveBeenCalledTimes(1); expect(executorLog.error).toHaveBeenCalledWith("Uncaught error in task:updated listener:", terminateError); }); }); describe("TaskExecutor global pause behavior", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("disposes all active sessions when settings:updated fires with globalPause: true", async () => { const store = createMockStore(); const disposeFn1 = vi.fn(); const disposeFn2 = vi.fn(); let callCount = 0; mockedCreateFnAgent.mockImplementation(async () => { callCount++; const dispose = callCount === 1 ? disposeFn1 : disposeFn2; return { session: { prompt: vi.fn().mockImplementation(async () => { // Wait for the global pause to fire; only fire once for first task if (callCount === 2) { store._trigger("settings:updated", { settings: { globalPause: true }, previous: { globalPause: false }, }); } throw new Error("Session terminated"); }), dispose, }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); // Execute two tasks concurrently await Promise.all([ executor.execute({ id: "FN-001", title: "T1", description: "T", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }), executor.execute({ id: "FN-002", title: "T2", description: "T", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }), ]); // Both tasks should be moved to todo (not marked as failed) expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(store.moveTask).toHaveBeenCalledWith("FN-002", "todo"); expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" }); expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", { status: "failed" }); }); it("moves paused tasks to todo (not marked as failed)", async () => { const store = createMockStore(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { store._trigger("settings:updated", { settings: { globalPause: true }, previous: { globalPause: false }, }); throw new Error("Session terminated"); }), dispose: vi.fn(), }, } as any)); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "T", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" }); }); it("finalizes to in-review when global pause hits after task_done", async () => { const store = createMockStore(); mockedCreateFnAgent.mockImplementation(async (opts: any) => { const customTools = opts.customTools || []; return { session: { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) { await taskDoneTool.execute("tool-1", {}); } store._trigger("settings:updated", { settings: { globalPause: true }, previous: { globalPause: false }, }); throw new Error("Session terminated"); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "T", column: "in-progress", dependencies: [], steps: [{ name: "Step 1", status: "pending" }], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); }); it("takes no action when globalPause remains false", async () => { const store = createMockStore(); const disposeFn = vi.fn(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { // Trigger settings:updated but globalPause stays false store._trigger("settings:updated", { settings: { globalPause: false }, previous: { globalPause: false }, }); }), dispose: disposeFn, }, } as any)); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "T", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should move to in-review (normal completion), not todo expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); }); it("takes no action when globalPause transitions from true to true", async () => { const store = createMockStore(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { // Trigger settings:updated but globalPause is already true store._trigger("settings:updated", { settings: { globalPause: true }, previous: { globalPause: true }, }); }), dispose: vi.fn(), }, } as any)); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "T", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should move to in-review (normal completion), not todo expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); }); }); describe("TaskExecutor enginePaused soft pause (no agent termination)", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("does NOT dispose active sessions when enginePaused transitions false→true", async () => { const store = createMockStore(); const disposeFn = vi.fn(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { // Trigger engine pause while the session is active store._trigger("settings:updated", { settings: { enginePaused: true }, previous: { enginePaused: false }, }); // Session continues normally — no error thrown }), dispose: disposeFn, }, } as any)); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "T", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // dispose called twice: initial session + retry session (both cleaned up normally), // NOT by an engine pause listener expect(disposeFn).toHaveBeenCalledTimes(2); // Task should complete normally and move to in-review, not todo expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" }); }); it("does NOT move tasks to todo when enginePaused transitions false→true", async () => { const store = createMockStore(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { store._trigger("settings:updated", { settings: { enginePaused: true }, previous: { enginePaused: false }, }); // Session continues normally }), dispose: vi.fn(), }, } as any)); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "T", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Task should complete normally (in-review), not be moved to todo expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); }); it("takes no action when enginePaused stays false (false→false)", async () => { const store = createMockStore(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { store._trigger("settings:updated", { settings: { enginePaused: false }, previous: { enginePaused: false }, }); }), dispose: vi.fn(), }, } as any)); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "T", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should move to in-review (normal completion), not todo expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); }); it("takes no action when enginePaused stays true (true→true)", async () => { const store = createMockStore(); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn().mockImplementation(async () => { store._trigger("settings:updated", { settings: { enginePaused: true }, previous: { enginePaused: true }, }); }), dispose: vi.fn(), }, } as any)); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "T", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should move to in-review (normal completion), not todo expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); }); }); // ── Code review verdict enforcement tests ──────────────────────────── const mockedReviewStep = vi.mocked(mockedReviewStepFn); /** * Helper: executes a task and captures the custom tools passed to createFnAgent. * Returns a map of tool name → tool execute function for direct testing. */ async function captureTools(): Promise Promise>> { const store = createMockStore(); store.updateStep.mockResolvedValue({ steps: [ { name: "Preflight", status: "done" }, { name: "Implement", status: "in-progress" }, { name: "Testing", status: "pending" }, ], }); mockedExistsSync.mockReturnValue(true); let capturedTools: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedTools = opts.customTools || []; return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-TEST", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); const tools: Record = {}; for (const t of capturedTools) { tools[t.name] = t.execute; } return tools; } describe("Code review verdict tracking", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("code review REVISE sets tracking state", async () => { mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix the bug", summary: "Needs fixes", }); const tools = await captureTools(); const result = await tools.review_step("call1", { step: 1, type: "code", step_name: "Implement", baseline: "abc123", }); expect(result.content[0].text).toContain("REVISE"); expect(result.content[0].text).toContain("cannot be marked done"); // Now task_update(step=1, status="done") should be blocked const updateResult = await tools.task_update("call2", { step: 1, status: "done" }); expect(updateResult.content[0].text).toContain("Cannot mark Step 1 as done"); expect(updateResult.content[0].text).toContain("REVISE"); }); it("code review APPROVE clears tracking state", async () => { // First: REVISE mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix the bug", summary: "Needs fixes", }); const tools = await captureTools(); await tools.review_step("call1", { step: 1, type: "code", step_name: "Implement", baseline: "abc123", }); // Verify it's blocked const blocked = await tools.task_update("call2", { step: 1, status: "done" }); expect(blocked.content[0].text).toContain("Cannot mark Step 1 as done"); // Now: APPROVE mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Looks good", summary: "All good", }); await tools.review_step("call3", { step: 1, type: "code", step_name: "Implement", baseline: "def456", }); // Now task_update should succeed const updateResult = await tools.task_update("call4", { step: 1, status: "done" }); expect(updateResult.content[0].text).toContain("→ done"); }); it("plan review REVISE does NOT set tracking state", async () => { mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Reconsider approach", summary: "Plan issues", }); const tools = await captureTools(); const result = await tools.review_step("call1", { step: 1, type: "plan", step_name: "Implement", }); // Plan REVISE should use the non-enforced text format expect(result.content[0].text).toContain("REVISE"); expect(result.content[0].text).not.toContain("cannot be marked done"); // task_update should still work (plan reviews are advisory) const updateResult = await tools.task_update("call2", { step: 1, status: "done" }); expect(updateResult.content[0].text).toContain("→ done"); }); }); describe("Code review verdict enforcement - task_update blocking", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("task_update(status='done') is rejected when last code review was REVISE", async () => { mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix issues", summary: "Needs work", }); const tools = await captureTools(); await tools.review_step("call1", { step: 1, type: "code", step_name: "Implement", baseline: "abc", }); const result = await tools.task_update("call2", { step: 1, status: "done" }); expect(result.content[0].text).toContain("Cannot mark Step 1 as done"); expect(result.content[0].text).toContain("review_step"); }); it("task_update succeeds after a subsequent APPROVE", async () => { const tools = await captureTools(); // REVISE first mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix", summary: "Bad" }); await tools.review_step("c1", { step: 1, type: "code", step_name: "Impl", baseline: "a" }); // Then APPROVE mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "OK", summary: "Good" }); await tools.review_step("c2", { step: 1, type: "code", step_name: "Impl", baseline: "b" }); const result = await tools.task_update("c3", { step: 1, status: "done" }); expect(result.content[0].text).toContain("→ done"); }); it("task_update succeeds when no code review was requested (review level 0)", async () => { const tools = await captureTools(); // No review_step calls at all const result = await tools.task_update("c1", { step: 1, status: "done" }); expect(result.content[0].text).toContain("→ done"); }); it("plan-only REVISE does NOT block advancement", async () => { mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Rethink", summary: "Plan issue" }); const tools = await captureTools(); await tools.review_step("c1", { step: 1, type: "plan", step_name: "Impl" }); const result = await tools.task_update("c2", { step: 1, status: "done" }); expect(result.content[0].text).toContain("→ done"); }); it("multiple steps tracked independently (REVISE on step 1 doesn't block step 2)", async () => { mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix", summary: "Bad" }); const tools = await captureTools(); await tools.review_step("c1", { step: 1, type: "code", step_name: "Step1", baseline: "a" }); // Step 1 is blocked const blocked = await tools.task_update("c2", { step: 1, status: "done" }); expect(blocked.content[0].text).toContain("Cannot mark Step 1 as done"); // Step 2 is NOT blocked (no review for step 2) const allowed = await tools.task_update("c3", { step: 2, status: "done" }); expect(allowed.content[0].text).toContain("→ done"); }); it("REVISE tool response text includes re-review instructions", async () => { mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Bug found", summary: "Issues" }); const tools = await captureTools(); const result = await tools.review_step("c1", { step: 1, type: "code", step_name: "Implement", baseline: "abc" }); expect(result.content[0].text).toContain("cannot be marked done"); expect(result.content[0].text).toContain("review_step"); expect(result.content[0].text).toContain('type="code"'); }); it("EXECUTOR_SYSTEM_PROMPT contains code review enforcement language", async () => { // Capture the system prompt passed to createFnAgent let capturedSystemPrompt = ""; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedSystemPrompt = opts.systemPrompt || ""; return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn(), branchWithSummary: vi.fn() }, navigateTree: vi.fn(), }, } as any; }); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-SYS", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Verify enforcement language is present in system prompt expect(capturedSystemPrompt).toContain("enforced"); expect(capturedSystemPrompt).toContain("will be rejected until the code review passes"); expect(capturedSystemPrompt).toContain("REVISE (plan review)"); expect(capturedSystemPrompt).toContain("advisory"); }); // Note: The EXECUTOR_SYSTEM_PROMPT constant is tested indirectly via the buildExecutionPrompt test. // The direct test for EXECUTOR_SYSTEM_PROMPT is skipped because of module caching issues in vitest. // The buildExecutionPrompt test verifies the CRITICAL language is included in execution prompts. it("task_update with non-done status is not blocked by REVISE", async () => { mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix", summary: "Bad" }); const tools = await captureTools(); await tools.review_step("c1", { step: 1, type: "code", step_name: "Step1", baseline: "a" }); // "in-progress" should still work even with REVISE const result = await tools.task_update("c2", { step: 1, status: "in-progress" }); expect(result.content[0].text).toContain("→ in-progress"); }); }); // ── RETHINK verdict handling tests ─────────────────────────────────── describe("RETHINK verdict handling", () => { const makeTask = (id = "FN-040") => ({ id, title: "Test", description: "Test", column: "in-progress" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); /** Return value for store.updateStep that satisfies the task_update tool. */ function makeStepResult(stepIndex: number, status: string) { const steps = Array.from({ length: Math.max(stepIndex + 1, 3) }, (_, i) => ({ name: `Step ${i}`, status: i === stepIndex ? status : "pending", })); return { steps }; } /** * Helper: run executor and capture custom tools from createFnAgent mock. * Returns the tools map keyed by tool name. */ async function captureRethinkTools(store: any, options?: any) { let capturedTools: any[] = []; const mockSessionManager = { getLeafId: vi.fn().mockReturnValue("leaf-checkpoint-123"), branchWithSummary: vi.fn().mockReturnValue("new-branch-id"), }; const mockNavigateTree = vi.fn().mockResolvedValue({ cancelled: false }); const mockSession = { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: mockSessionManager, navigateTree: mockNavigateTree, }; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedTools = opts.customTools || []; return { session: mockSession } as any; }); const executor = new TaskExecutor(store, "/tmp/test", options); await executor.execute(makeTask()); const toolMap = new Map(); for (const tool of capturedTools) { toolMap.set(tool.name, tool); } return { toolMap, mockSession, mockSessionManager, mockNavigateTree }; } beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("RETHINK verdict triggers git reset --hard to baseline SHA", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Wrong approach, try something else", summary: "Rejected approach", }); const { toolMap } = await captureRethinkTools(store); const reviewTool = toolMap.get("review_step"); // First call task_update to set in-progress (captures checkpoint) const updateTool = toolMap.get("task_update"); await updateTool.execute("call-1", { step: 1, status: "in-progress" }); // Now call review_step with a baseline const result = await reviewTool.execute("call-2", { step: 1, type: "code", step_name: "Test Step", baseline: "abc123def", }); // Verify git reset was called expect(mockedExecSync).toHaveBeenCalledWith( "git reset --hard abc123def", expect.objectContaining({ cwd: expect.stringContaining(".worktrees/") }), ); }); it("RETHINK verdict rewinds session to pre-step checkpoint", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Fundamentally wrong", summary: "Bad approach", }); const { toolMap, mockNavigateTree } = await captureRethinkTools(store); // Capture checkpoint const updateTool = toolMap.get("task_update"); await updateTool.execute("call-1", { step: 1, status: "in-progress" }); // Trigger RETHINK await toolMap.get("review_step").execute("call-2", { step: 1, type: "code", step_name: "Test Step", baseline: "abc123", }); // Verify navigateTree was called with checkpoint and summarize: false expect(mockNavigateTree).toHaveBeenCalledWith("leaf-checkpoint-123", { summarize: false }); }); it("RETHINK verdict resets step status to pending", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Try again", summary: "Rejected", }); const { toolMap } = await captureRethinkTools(store); const updateTool = toolMap.get("task_update"); await updateTool.execute("call-1", { step: 1, status: "in-progress" }); await toolMap.get("review_step").execute("call-2", { step: 1, type: "code", step_name: "Test Step", baseline: "abc123", }); // updateStep should be called: once for in-progress, once for pending (reset) expect(store.updateStep).toHaveBeenCalledWith("FN-040", 1, "pending"); }); it("RETHINK re-prompt includes reviewer feedback", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Your approach uses polling when it should use events", summary: "Wrong architecture", }); const { toolMap } = await captureRethinkTools(store); const updateTool = toolMap.get("task_update"); await updateTool.execute("call-1", { step: 1, status: "in-progress" }); const result = await toolMap.get("review_step").execute("call-2", { step: 1, type: "code", step_name: "Test Step", baseline: "abc123", }); const text = result.content[0].text; expect(text).toContain("RETHINK"); expect(text).toContain("Your approach uses polling when it should use events"); expect(text).toContain("Take a different approach"); expect(text).toContain("Do NOT repeat the rejected strategy"); }); it("RETHINK without baseline SHA skips git reset but still rewinds conversation", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Wrong approach", summary: "Rejected", }); const { toolMap, mockNavigateTree } = await captureRethinkTools(store); const updateTool = toolMap.get("task_update"); await updateTool.execute("call-1", { step: 1, status: "in-progress" }); // Call review_step WITHOUT baseline await toolMap.get("review_step").execute("call-2", { step: 1, type: "code", step_name: "Test Step", // no baseline }); // git reset should NOT be called (no baseline) const gitResetCalls = mockedExecSync.mock.calls.filter( (c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard"), ); expect(gitResetCalls).toHaveLength(0); // But session rewind should still happen expect(mockNavigateTree).toHaveBeenCalledWith("leaf-checkpoint-123", { summarize: false }); }); it("RETHINK without session checkpoint falls back gracefully", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Bad approach", summary: "Rejected", }); const { toolMap, mockNavigateTree } = await captureRethinkTools(store); // Do NOT call task_update for step 2, so no checkpoint exists // Call review_step for step 2 — should not crash const result = await toolMap.get("review_step").execute("call-2", { step: 2, type: "code", step_name: "Test Step", baseline: "abc123", }); // navigateTree should NOT be called (no checkpoint) expect(mockNavigateTree).not.toHaveBeenCalled(); // Should still return RETHINK feedback expect(result.content[0].text).toContain("RETHINK"); }); it("pre-step checkpoint is captured when task_update sets status to in-progress", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); const { toolMap, mockSessionManager } = await captureRethinkTools(store); const updateTool = toolMap.get("task_update"); await updateTool.execute("call-1", { step: 1, status: "in-progress" }); // Verify getLeafId was called expect(mockSessionManager.getLeafId).toHaveBeenCalled(); }); it("RETHINK falls back to branchWithSummary when navigateTree fails", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Wrong approach", summary: "Rejected", }); // Create tools but make navigateTree throw let capturedTools: any[] = []; const mockSessionManager = { getLeafId: vi.fn().mockReturnValue("leaf-checkpoint-456"), branchWithSummary: vi.fn().mockReturnValue("new-branch-id"), }; const mockNavigateTree = vi.fn().mockRejectedValue(new Error("navigateTree not available")); const mockSession = { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: mockSessionManager, navigateTree: mockNavigateTree, }; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedTools = opts.customTools || []; return { session: mockSession } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute(makeTask()); const toolMap = new Map(); for (const tool of capturedTools) toolMap.set(tool.name, tool); // Capture checkpoint await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" }); // Trigger RETHINK await toolMap.get("review_step").execute("call-2", { step: 1, type: "code", step_name: "Test Step", baseline: "abc123", }); // navigateTree was called but failed → should fall back to branchWithSummary expect(mockNavigateTree).toHaveBeenCalled(); expect(mockSessionManager.branchWithSummary).toHaveBeenCalledWith( "leaf-checkpoint-456", expect.stringContaining("RETHINK"), ); }); }); // ── Plan RETHINK verdict handling tests ────────────────────────────── describe("Plan RETHINK verdict handling", () => { const makeTask = (id = "FN-050") => ({ id, title: "Test", description: "Test", column: "in-progress" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); function makeStepResult(stepIndex: number, status: string) { const steps = Array.from({ length: Math.max(stepIndex + 1, 3) }, (_, i) => ({ name: `Step ${i}`, status: i === stepIndex ? status : "pending", })); return { steps }; } async function capturePlanRethinkTools(store: any) { let capturedTools: any[] = []; const mockSessionManager = { getLeafId: vi.fn().mockReturnValue("plan-checkpoint-789"), branchWithSummary: vi.fn().mockReturnValue("new-branch-id"), }; const mockNavigateTree = vi.fn().mockResolvedValue({ cancelled: false }); const mockSession = { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: mockSessionManager, navigateTree: mockNavigateTree, }; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedTools = opts.customTools || []; return { session: mockSession } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute(makeTask()); const toolMap = new Map(); for (const tool of capturedTools) { toolMap.set(tool.name, tool); } return { toolMap, mockSession, mockSessionManager, mockNavigateTree }; } beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("plan RETHINK verdict rewinds session to pre-step checkpoint", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Plan is fundamentally flawed", summary: "Bad plan", }); const { toolMap, mockNavigateTree } = await capturePlanRethinkTools(store); // Capture checkpoint by starting step await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" }); // Trigger plan RETHINK await toolMap.get("review_step").execute("call-2", { step: 1, type: "plan", step_name: "Test Step", }); // Session should be rewound to checkpoint expect(mockNavigateTree).toHaveBeenCalledWith("plan-checkpoint-789", { summarize: false }); }); it("plan RETHINK verdict does NOT trigger git reset", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Wrong plan", summary: "Rejected", }); const { toolMap } = await capturePlanRethinkTools(store); await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" }); // Even if baseline is passed, plan RETHINK should NOT git reset await toolMap.get("review_step").execute("call-2", { step: 1, type: "plan", step_name: "Test Step", baseline: "some-sha-that-should-be-ignored", }); // git reset should NOT be called for plan reviews const gitResetCalls = mockedExecSync.mock.calls.filter( (c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard"), ); expect(gitResetCalls).toHaveLength(0); }); it("plan RETHINK verdict resets step status to pending", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Try another plan", summary: "Rejected plan", }); const { toolMap } = await capturePlanRethinkTools(store); await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" }); await toolMap.get("review_step").execute("call-2", { step: 1, type: "plan", step_name: "Test Step", }); // updateStep should be called with "pending" to reset the step expect(store.updateStep).toHaveBeenCalledWith("FN-050", 1, "pending"); }); it("plan RETHINK re-prompt includes reviewer feedback and plan-specific language", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "This plan overlooks critical edge cases in error handling", summary: "Insufficient plan", }); const { toolMap } = await capturePlanRethinkTools(store); await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" }); const result = await toolMap.get("review_step").execute("call-2", { step: 1, type: "plan", step_name: "Test Step", }); const text = result.content[0].text; expect(text).toContain("RETHINK"); expect(text).toContain("Your plan was rejected"); expect(text).toContain("This plan overlooks critical edge cases in error handling"); expect(text).toContain("Take a different approach to planning this step"); expect(text).toContain("Do NOT repeat the rejected strategy"); }); it("plan RETHINK without session checkpoint falls back gracefully", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Bad plan", summary: "Rejected", }); const { toolMap, mockNavigateTree } = await capturePlanRethinkTools(store); // Do NOT call task_update for step 2, so no checkpoint exists // Call review_step for step 2 — should not crash const result = await toolMap.get("review_step").execute("call-2", { step: 2, type: "plan", step_name: "Test Step", }); // navigateTree should NOT be called (no checkpoint) expect(mockNavigateTree).not.toHaveBeenCalled(); // Should still return RETHINK feedback with plan-specific text expect(result.content[0].text).toContain("RETHINK"); expect(result.content[0].text).toContain("Your plan was rejected"); }); it("plan RETHINK logs correctly without git reset info", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Wrong plan", summary: "Plan rejected", }); const { toolMap } = await capturePlanRethinkTools(store); await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" }); await toolMap.get("review_step").execute("call-2", { step: 1, type: "plan", step_name: "Test Step", }); // Verify log entry uses plan-specific message (no git reset reference) expect(store.logEntry).toHaveBeenCalledWith( "FN-050", expect.stringContaining("plan rewound"), "Plan rejected", ); }); }); // ── E2E review pipeline sequence tests ───────────────────────────── describe("E2E review pipeline — multi-verdict sequence", () => { /** * Exercises the full review pipeline within a single task execution: * plan review → APPROVE * code review → REVISE (blocked) * code review → APPROVE (unblocked) * step done → success * * Verifies that verdicts compose correctly across the full lifecycle. */ function makeStepResult(stepIndex: number, status: string) { const steps = Array.from({ length: 3 }, (_, i) => ({ name: [`Preflight`, `Implement`, `Tests`][i], status: i === stepIndex ? status : i < stepIndex ? "done" : "pending", })); return { steps }; } async function captureE2ETools(store: any) { let capturedTools: any[] = []; const mockSessionManager = { getLeafId: vi.fn().mockReturnValue("e2e-checkpoint"), branchWithSummary: vi.fn(), }; const mockNavigateTree = vi.fn().mockResolvedValue({ cancelled: false }); const mockSession = { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: mockSessionManager, navigateTree: mockNavigateTree, }; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedTools = opts.customTools || []; return { session: mockSession } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-E2E", title: "E2E Test", description: "E2E pipeline test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); const tools: Record = {}; for (const t of capturedTools) { tools[t.name] = t.execute; } return { tools, mockNavigateTree, mockSessionManager }; } beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("full sequence: plan APPROVE → code REVISE (blocked) → code APPROVE (unblocked) → done", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); const { tools } = await captureE2ETools(store); // Step 1: Start the step await tools.task_update("u1", { step: 1, status: "in-progress" }); // Step 2: Plan review → APPROVE (advisory, no blocking) mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Good plan", summary: "Approved" }); const planResult = await tools.review_step("r1", { step: 1, type: "plan", step_name: "Implement", }); expect(planResult.content[0].text).toBe("APPROVE"); // Step 3: Code review → REVISE (should block advancement) mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Missing error handling in fetchUser()", summary: "Needs fixes", }); const reviseResult = await tools.review_step("r2", { step: 1, type: "code", step_name: "Implement", baseline: "sha-1", }); expect(reviseResult.content[0].text).toContain("cannot be marked done"); // Step 4: Attempt to mark done — should be blocked const blockedResult = await tools.task_update("u2", { step: 1, status: "done" }); expect(blockedResult.content[0].text).toContain("Cannot mark Step 1 as done"); // Step 5: Fix issues, re-submit code review → APPROVE mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Error handling added correctly", summary: "All good", }); const approveResult = await tools.review_step("r3", { step: 1, type: "code", step_name: "Implement", baseline: "sha-2", }); expect(approveResult.content[0].text).toBe("APPROVE"); // Step 6: Now marking done should succeed const doneResult = await tools.task_update("u3", { step: 1, status: "done" }); expect(doneResult.content[0].text).toContain("→ done"); }); it("full sequence: code RETHINK → git reset + session rewind → retry with APPROVE → done", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); const { tools, mockNavigateTree } = await captureE2ETools(store); // Step 1: Start the step (captures checkpoint) await tools.task_update("u1", { step: 1, status: "in-progress" }); // Step 2: Code review → RETHINK (rewind everything) mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Using polling instead of events is wrong", summary: "Bad approach", }); const rethinkResult = await tools.review_step("r1", { step: 1, type: "code", step_name: "Implement", baseline: "sha-bad", }); // Verify RETHINK outcomes expect(rethinkResult.content[0].text).toContain("RETHINK"); expect(rethinkResult.content[0].text).toContain("Do NOT repeat the rejected strategy"); expect(mockedExecSync).toHaveBeenCalledWith( "git reset --hard sha-bad", expect.objectContaining({ cwd: expect.any(String) }), ); expect(mockNavigateTree).toHaveBeenCalledWith("e2e-checkpoint", { summarize: false }); expect(store.updateStep).toHaveBeenCalledWith("FN-E2E", 1, "pending"); // Step 3: Restart the step (new approach) await tools.task_update("u2", { step: 1, status: "in-progress" }); // Step 4: Code review → APPROVE on second attempt mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Event-driven approach is correct", summary: "Approved", }); const approveResult = await tools.review_step("r2", { step: 1, type: "code", step_name: "Implement", baseline: "sha-good", }); expect(approveResult.content[0].text).toBe("APPROVE"); // Step 5: Mark done — should succeed (no REVISE blocking) const doneResult = await tools.task_update("u3", { step: 1, status: "done" }); expect(doneResult.content[0].text).toContain("→ done"); }); it("multi-step pipeline: step 1 APPROVE, step 2 REVISE, step 1 remains unaffected", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); const { tools } = await captureE2ETools(store); // Step 1: Complete with APPROVE await tools.task_update("u1", { step: 1, status: "in-progress" }); mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "OK", summary: "Good" }); await tools.review_step("r1", { step: 1, type: "code", step_name: "Implement", baseline: "sha-1" }); const step1Done = await tools.task_update("u2", { step: 1, status: "done" }); expect(step1Done.content[0].text).toContain("→ done"); // Step 2: Gets REVISE await tools.task_update("u3", { step: 2, status: "in-progress" }); mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Tests insufficient", summary: "Bad" }); await tools.review_step("r2", { step: 2, type: "code", step_name: "Tests", baseline: "sha-2" }); // Step 2 blocked const step2Blocked = await tools.task_update("u4", { step: 2, status: "done" }); expect(step2Blocked.content[0].text).toContain("Cannot mark Step 2 as done"); // Step 1 remains unaffected — if agent tries to re-update step 1, it still works // (step isolation: REVISE on step 2 does not affect step 1) }); it("plan RETHINK followed by plan APPROVE allows code phase to proceed", async () => { const store = createMockStore(); store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => makeStepResult(step, status), ); const { tools, mockNavigateTree } = await captureE2ETools(store); // Start step await tools.task_update("u1", { step: 1, status: "in-progress" }); // Plan review → RETHINK mockedReviewStep.mockResolvedValue({ verdict: "RETHINK", review: "Plan ignores edge cases", summary: "Bad plan", }); const rethinkResult = await tools.review_step("r1", { step: 1, type: "plan", step_name: "Implement", }); expect(rethinkResult.content[0].text).toContain("Your plan was rejected"); // Verify plan RETHINK does NOT trigger git reset const gitResetCalls = mockedExecSync.mock.calls.filter( (c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard"), ); expect(gitResetCalls).toHaveLength(0); // Session was rewound expect(mockNavigateTree).toHaveBeenCalled(); // Restart step with new plan await tools.task_update("u2", { step: 1, status: "in-progress" }); // Plan review → APPROVE mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Good plan", summary: "Approved" }); await tools.review_step("r2", { step: 1, type: "plan", step_name: "Implement" }); // Code phase: APPROVE directly mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Clean code", summary: "Good" }); await tools.review_step("r3", { step: 1, type: "code", step_name: "Implement", baseline: "sha-1" }); // Mark done — should succeed (plan reviews are advisory, code APPROVE clears the path) const doneResult = await tools.task_update("u3", { step: 1, status: "done" }); expect(doneResult.content[0].text).toContain("→ done"); }); }); // ── task_add_dep tool tests ────────────────────────────────────────── describe("task_add_dep tool", () => { /** * Helper: run executor with a customized mock store and capture custom tools. * The mock store's getTask is configured to: * - Return the executing task (KB-TEST) with configurable dependencies * - Return a target task (KB-OTHER) when requested * - Throw for unknown task IDs */ async function captureAddDepTools(opts?: { existingDeps?: string[]; targetExists?: boolean }) { const existingDeps = opts?.existingDeps ?? []; const targetExists = opts?.targetExists ?? true; const store = createMockStore(); store.getTask.mockImplementation(async (id: string) => { if (id === "FN-TEST") { return { id: "FN-TEST", title: "Test", description: "Test task", column: "in-progress", dependencies: existingDeps, steps: [], currentStep: 0, log: [], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } if (id === "FN-OTHER" && targetExists) { return { id: "FN-OTHER", title: "Other task", description: "Another task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } throw new Error(`Task ${id} not found`); }); store.updateStep.mockResolvedValue({ steps: [ { name: "Preflight", status: "done" }, { name: "Implement", status: "in-progress" }, ], }); mockedExistsSync.mockReturnValue(true); let capturedTools: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedTools = opts.customTools || []; return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-TEST", title: "Test", description: "Test", column: "in-progress", dependencies: existingDeps, steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); const tools: Record = {}; for (const t of capturedTools) { tools[t.name] = t.execute; } return { tools, store }; } beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("adds a valid dependency via store.updateTask when confirm=true", async () => { const { tools, store } = await captureAddDepTools(); const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER", confirm: true }); expect(result.content[0].text).toContain("Added dependency"); expect(result.content[0].text).toContain("triage"); expect(store.updateTask).toHaveBeenCalledWith("FN-TEST", { dependencies: ["FN-OTHER"], }); }); it("returns error for self-dependency", async () => { const { tools, store } = await captureAddDepTools(); const result = await tools.task_add_dep("call1", { task_id: "FN-TEST" }); expect(result.content[0].text).toContain("Cannot add self-dependency"); expect(result.content[0].text).toContain("FN-TEST cannot depend on itself"); // store.updateTask should NOT have been called for dependency update // (it may be called for worktree path updates, so we check specifically for dependencies) const depUpdateCalls = store.updateTask.mock.calls.filter( (call: any[]) => call[1]?.dependencies !== undefined, ); expect(depUpdateCalls).toHaveLength(0); }); it("returns error for non-existent target task", async () => { const { tools, store } = await captureAddDepTools({ targetExists: false }); const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER" }); expect(result.content[0].text).toContain("FN-OTHER not found"); expect(result.content[0].text).toContain("Cannot add dependency on a non-existent task"); const depUpdateCalls = store.updateTask.mock.calls.filter( (call: any[]) => call[1]?.dependencies !== undefined, ); expect(depUpdateCalls).toHaveLength(0); }); it("returns informational message for duplicate dependency without duplicating", async () => { const { tools, store } = await captureAddDepTools({ existingDeps: ["FN-OTHER"] }); const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER" }); expect(result.content[0].text).toContain("already a dependency"); expect(result.content[0].text).toContain("No changes made"); const depUpdateCalls = store.updateTask.mock.calls.filter( (call: any[]) => call[1]?.dependencies !== undefined, ); expect(depUpdateCalls).toHaveLength(0); }); it("logs the dependency addition via store.logEntry when confirm=true", async () => { const { tools, store } = await captureAddDepTools(); await tools.task_add_dep("call1", { task_id: "FN-OTHER", confirm: true }); expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on FN-OTHER — stopping execution for re-specification"); }); it("appends to existing dependencies without overwriting when confirm=true", async () => { const { tools, store } = await captureAddDepTools({ existingDeps: ["FN-001"] }); const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER", confirm: true }); expect(result.content[0].text).toContain("Added dependency"); expect(store.updateTask).toHaveBeenCalledWith("FN-TEST", { dependencies: ["FN-001", "FN-OTHER"], }); }); it("is registered in customTools array", async () => { const { tools } = await captureAddDepTools(); expect(tools.task_add_dep).toBeDefined(); expect(typeof tools.task_add_dep).toBe("function"); }); it("returns warning without confirm=true and does NOT add dependency", async () => { const { tools, store } = await captureAddDepTools(); const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER" }); expect(result.content[0].text).toContain("stop execution and discard current work"); expect(result.content[0].text).toContain("confirm=true"); // Should NOT have updated dependencies const depUpdateCalls = store.updateTask.mock.calls.filter( (call: any[]) => call[1]?.dependencies !== undefined, ); expect(depUpdateCalls).toHaveLength(0); // Should NOT have logged any dep addition const logCalls = store.logEntry.mock.calls.filter( (call: any[]) => typeof call[1] === "string" && call[1].includes("Added dependency"), ); expect(logCalls).toHaveLength(0); }); it("validation errors (self-dep, not-found, dedup) return immediately without requiring confirm", async () => { // Self-dep — no confirm needed const { tools: tools1 } = await captureAddDepTools(); const selfResult = await tools1.task_add_dep("call1", { task_id: "FN-TEST" }); expect(selfResult.content[0].text).toContain("Cannot add self-dependency"); // Not found — no confirm needed const { tools: tools2 } = await captureAddDepTools({ targetExists: false }); const notFoundResult = await tools2.task_add_dep("call1", { task_id: "FN-OTHER" }); expect(notFoundResult.content[0].text).toContain("not found"); // Dedup — no confirm needed const { tools: tools3 } = await captureAddDepTools({ existingDeps: ["FN-OTHER"] }); const dedupResult = await tools3.task_add_dep("call1", { task_id: "FN-OTHER" }); expect(dedupResult.content[0].text).toContain("already a dependency"); }); it("with confirm=true triggers depAborted and disposes session", async () => { const store = createMockStore(); store.getTask.mockImplementation(async (id: string) => { if (id === "FN-DEP") { return { id: "FN-DEP", title: "Test", description: "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(), }; } if (id === "FN-TARGET") { return { id: "FN-TARGET", title: "Target", description: "Target task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } throw new Error(`Task ${id} not found`); }); mockedExistsSync.mockReturnValue(true); const disposeFn = vi.fn(); let capturedTools: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedTools = opts.customTools || []; return { session: { prompt: vi.fn().mockImplementation(async () => { // The agent calls task_add_dep with confirm=true during execution const addDepTool = capturedTools.find((t: any) => t.name === "task_add_dep"); await addDepTool.execute("call1", { task_id: "FN-TARGET", confirm: true }); // After dispose is called, session.prompt throws throw new Error("Session terminated"); }), dispose: disposeFn, sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-DEP", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Worktree removal should have been attempted const worktreeRemoveCalls = mockedExecSync.mock.calls.filter( (c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"), ); expect(worktreeRemoveCalls.length).toBeGreaterThan(0); // Branch deletion should have been attempted const branchDeleteCalls = mockedExecSync.mock.calls.filter( (c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("fusion/fn-dep"), ); expect(branchDeleteCalls.length).toBeGreaterThan(0); // Task should be moved to triage expect(store.moveTask).toHaveBeenCalledWith("FN-DEP", "triage"); // Worktree and status should be cleared expect(store.updateTask).toHaveBeenCalledWith("FN-DEP", { worktree: null, status: null }); // Task should NOT be marked as failed expect(store.updateTask).not.toHaveBeenCalledWith("FN-DEP", { status: "failed" }); }); }); // ── Usage limit detection in executor ──────────────────────────────── import { UsageLimitPauser } from "./usage-limit-detector.js"; describe("TaskExecutor usage limit detection", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("triggers global pause when executor catches a usage-limit error", async () => { const store = createMockStore(); const pauser = new UsageLimitPauser(store); const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit"); mockedCreateFnAgent.mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded")); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onError, usageLimitPauser: pauser, }); 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(onUsageLimitHitSpy).toHaveBeenCalledWith( "executor", "FN-001", "rate_limit_error: Rate limit exceeded", ); expect(store.updateSettings).toHaveBeenCalledWith({ globalPause: true, globalPauseReason: "rate-limit", }); // Task should still be marked as failed expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" }); expect(onError).toHaveBeenCalled(); }); it("does NOT trigger global pause for transient non-usage-limit errors", async () => { const store = createMockStore(); const pauser = new UsageLimitPauser(store); const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit"); const onError = vi.fn(); mockedCreateFnAgent.mockRejectedValue(new Error("connection refused")); const executor = new TaskExecutor(store, "/tmp/test", { onError, usageLimitPauser: pauser, }); 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(onUsageLimitHitSpy).not.toHaveBeenCalled(); // Recovery policy: first transient error → retry 1/3 with backoff expect(store.logEntry).toHaveBeenCalledWith("FN-001", expect.stringContaining("Transient error (retry 1/3"), undefined, expect.objectContaining({ agentId: "executor" })); expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ recoveryRetryCount: 1, nextRecoveryAt: expect.any(String), })); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(store.updateTask).not.toHaveBeenCalledWith( "FN-001", expect.objectContaining({ status: "failed" }), ); expect(onError).not.toHaveBeenCalled(); }); it("works without usageLimitPauser (backward compatible)", async () => { const store = createMockStore(); mockedCreateFnAgent.mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded")); 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(), }); // Should not crash — just mark as failed expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" }); expect(onError).toHaveBeenCalled(); }); it("triggers global pause when session.prompt() resolves with exhausted-retry error on state.error", async () => { const store = createMockStore(); const pauser = new UsageLimitPauser(store); const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit"); // session.prompt() resolves normally, but session.state.error is set // (this is what happens when pi-coding-agent exhausts retries) const mockSession = { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { error: "rate_limit_error: Rate limit exceeded" }, }; mockedCreateFnAgent.mockResolvedValue({ session: mockSession } as any); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onError, usageLimitPauser: pauser, }); 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(), }); // UsageLimitPauser should be called expect(onUsageLimitHitSpy).toHaveBeenCalledWith( "executor", "FN-001", "rate_limit_error: Rate limit exceeded", ); // Task should be marked as failed expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" }); // onError callback should fire expect(onError).toHaveBeenCalled(); }); it("triggers global pause for overloaded error", async () => { const store = createMockStore(); const pauser = new UsageLimitPauser(store); const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit"); mockedCreateFnAgent.mockRejectedValue(new Error("overloaded_error: Overloaded")); const executor = new TaskExecutor(store, "/tmp/test", { usageLimitPauser: pauser, }); await executor.execute({ id: "FN-002", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(onUsageLimitHitSpy).toHaveBeenCalledWith( "executor", "FN-002", "overloaded_error: Overloaded", ); }); }); describe("TaskExecutor bounded recovery retries", () => { beforeEach(() => { vi.clearAllMocks(); }); it("increments recoveryRetryCount on successive transient failures", async () => { const store = createMockStore(); const onError = vi.fn(); mockedCreateFnAgent.mockRejectedValue(new Error("upstream connect error")); const executor = new TaskExecutor(store, "/tmp/test", { onError }); // First failure: count goes from undefined to 1 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", expect.objectContaining({ recoveryRetryCount: 1, nextRecoveryAt: expect.any(String), })); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(onError).not.toHaveBeenCalled(); // Second failure: count goes from 1 to 2 vi.clearAllMocks(); mockedCreateFnAgent.mockRejectedValue(new Error("upstream connect error")); await executor.execute({ id: "FN-001", title: "Test", description: "Test", column: "in-progress", recoveryRetryCount: 1, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ recoveryRetryCount: 2, })); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(onError).not.toHaveBeenCalled(); }); it("moves task to in-review when transient retries are exhausted (single-session)", async () => { const store = createMockStore(); const onError = vi.fn(); mockedCreateFnAgent.mockRejectedValue(new Error("socket hang up")); const executor = new TaskExecutor(store, "/tmp/test", { onError }); // Task already has 3 retries (max) — next failure should escalate await executor.execute({ id: "FN-001", title: "Test", description: "Test", column: "in-progress", recoveryRetryCount: 3, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "socket hang up", recoveryRetryCount: null, nextRecoveryAt: null, }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); expect(onError).toHaveBeenCalled(); }); it("does NOT consume retry budget for paused tasks", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", {}); // Simulate a paused abort — the executor checks pausedAborted set const task = { id: "FN-001", title: "Test", description: "Test", column: "in-progress" as const, recoveryRetryCount: 1, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Simulate: task gets paused mid-execution → abort error mockedCreateFnAgent.mockRejectedValue(new Error("Aborted")); (executor as any).pausedAborted.add("FN-001"); await executor.execute(task); // Should NOT update recoveryRetryCount expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ recoveryRetryCount: expect.any(Number), })); }); it("does NOT consume retry budget for stuck-task-detector kills", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", {}); mockedCreateFnAgent.mockRejectedValue(new Error("Aborted")); (executor as any).stuckAborted.set("FN-001", true); await executor.execute({ id: "FN-001", title: "Test", description: "Test", column: "in-progress", recoveryRetryCount: 2, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should NOT update recoveryRetryCount expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ recoveryRetryCount: expect.any(Number), })); }); it("requeues to todo when a stuck-killed session resolves without throwing", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", {}); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn(async () => { executor.markStuckAborted("FN-001", true); }), dispose: vi.fn(), state: {}, }, }) as any); 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(mockedCreateFnAgent).toHaveBeenCalledTimes(1); expect(store.updateTask).not.toHaveBeenCalledWith( "FN-001", expect.objectContaining({ status: "failed" }), ); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review"); // Executor now handles the requeue in its finally block expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "stuck-killed", worktree: null, branch: null }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); }); it("does not requeue when stuck-kill budget is exhausted", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", {}); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn(async () => { // Budget exhausted — shouldRequeue=false executor.markStuckAborted("FN-001", false); }), dispose: vi.fn(), state: {}, }, }) as any); 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(), }); // Should NOT requeue or mark as failed (budget handler already did that) expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "stuck-killed", worktree: null, branch: null }); expect(store.updateTask).not.toHaveBeenCalledWith( "FN-001", expect.objectContaining({ status: "failed" }), ); }); it("skips moveTask when task is already in todo at execute start", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", {}); mockedCreateFnAgent.mockImplementation(async () => ({ session: { prompt: vi.fn(async () => { // Simulate stuck kill executor.markStuckAborted("FN-001", true); throw new Error("Stuck task"); }), dispose: vi.fn(), state: {}, }, }) as any); await executor.execute({ id: "FN-001", title: "Test", description: "Test", column: "todo", // Task was already in todo when execute started dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should NOT call moveTask because task is already in todo expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); // Should still clean up and mark as stuck-killed expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "stuck-killed", worktree: null, branch: null }); }); it("clears recovery metadata after successful run completes", async () => { const store = createMockStore(); // Mock successful agent session const mockSession = { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { error: undefined }, }; mockedCreateFnAgent.mockResolvedValue({ session: mockSession } as any); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute({ id: "FN-001", title: "Test", description: "Test", column: "in-progress", recoveryRetryCount: 2, nextRecoveryAt: new Date().toISOString(), dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // moveTask to in-review clears recovery metadata (via store's column transition logic) expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); }); }); describe("Per-task model overrides", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("uses per-task model overrides when both provider and modelId are set", async () => { const store = createMockStore(); const capturedOptions: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedOptions.push(opts); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {}, }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); // Override getTask to return task with model overrides store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), modelProvider: "anthropic", modelId: "claude-sonnet-4-5", }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), modelProvider: "anthropic", modelId: "claude-sonnet-4-5", }); // Should use per-task model overrides expect(capturedOptions[0].defaultProvider).toBe("anthropic"); expect(capturedOptions[0].defaultModelId).toBe("claude-sonnet-4-5"); }); it("falls back to global settings when per-task model is not fully specified", async () => { const store = createMockStore(); const capturedOptions: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedOptions.push(opts); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {}, }, } as any; }); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, worktreeInitCommand: undefined, defaultProvider: "openai", defaultModelId: "gpt-4o", }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // No modelProvider/modelId set }); // Should use global settings (not task overrides) expect(capturedOptions[0].defaultProvider).toBe("openai"); expect(capturedOptions[0].defaultModelId).toBe("gpt-4o"); }); it("falls back to global settings when only modelProvider is set (missing modelId)", async () => { const store = createMockStore(); const capturedOptions: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedOptions.push(opts); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {}, }, } as any; }); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, worktreeInitCommand: undefined, defaultProvider: "openai", defaultModelId: "gpt-4o", }); const executor = new TaskExecutor(store, "/tmp/test"); // Override getTask to return task with only modelProvider set store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), modelProvider: "anthropic", // modelId is missing }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), modelProvider: "anthropic", // modelId is missing }); // Should fall back to global settings since modelId is not set expect(capturedOptions[0].defaultProvider).toBe("openai"); expect(capturedOptions[0].defaultModelId).toBe("gpt-4o"); }); }); // ── Lane hierarchy model resolution tests ───────────────────────────────────── describe("Executor lane hierarchy model resolution", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("resolves task override when both provider and modelId are set", async () => { const store = createMockStore(); const capturedOptions: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedOptions.push(opts); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {}, }, } as any; }); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, defaultProvider: "openai", defaultModelId: "gpt-4o", executionGlobalProvider: "google", executionGlobalModelId: "gemini-2.5", executionProvider: undefined, executionModelId: undefined, }); const executor = new TaskExecutor(store, "/tmp/test"); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), modelProvider: "anthropic", modelId: "claude-sonnet-4-5", }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), modelProvider: "anthropic", modelId: "claude-sonnet-4-5", }); // Task override takes precedence expect(capturedOptions[0].defaultProvider).toBe("anthropic"); expect(capturedOptions[0].defaultModelId).toBe("claude-sonnet-4-5"); }); it("resolves project execution override when task override is not set", async () => { const store = createMockStore(); const capturedOptions: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedOptions.push(opts); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {}, }, } as any; }); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, defaultProvider: "openai", defaultModelId: "gpt-4o", executionGlobalProvider: "google", executionGlobalModelId: "gemini-2.5", executionProvider: "anthropic", executionModelId: "claude-opus-4", }); const executor = new TaskExecutor(store, "/tmp/test"); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // No task-level model override }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // No task-level model override }); // Project execution override takes precedence over global lane expect(capturedOptions[0].defaultProvider).toBe("anthropic"); expect(capturedOptions[0].defaultModelId).toBe("claude-opus-4"); }); it("resolves global execution lane when project override is not set", async () => { const store = createMockStore(); const capturedOptions: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedOptions.push(opts); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {}, }, } as any; }); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, defaultProvider: "openai", defaultModelId: "gpt-4o", executionGlobalProvider: "google", executionGlobalModelId: "gemini-2.5", executionProvider: undefined, executionModelId: undefined, }); const executor = new TaskExecutor(store, "/tmp/test"); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // No task-level model override }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // No task-level model override }); // Global execution lane takes precedence over default expect(capturedOptions[0].defaultProvider).toBe("google"); expect(capturedOptions[0].defaultModelId).toBe("gemini-2.5"); }); it("falls back to default when no lane overrides are set", async () => { const store = createMockStore(); const capturedOptions: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedOptions.push(opts); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {}, }, } as any; }); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, defaultProvider: "openai", defaultModelId: "gpt-4o", executionGlobalProvider: undefined, executionGlobalModelId: undefined, executionProvider: undefined, executionModelId: undefined, }); const executor = new TaskExecutor(store, "/tmp/test"); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // No task-level model override }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // No task-level model override }); // Default takes precedence when no lane overrides are set expect(capturedOptions[0].defaultProvider).toBe("openai"); expect(capturedOptions[0].defaultModelId).toBe("gpt-4o"); }); }); // ── Per-task thinkingLevel override tests ─────────────────────────── describe("Per-task thinkingLevel override", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("uses per-task thinkingLevel when set on the task", async () => { const store = createMockStore(); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, } as any); // Override getTask to return task with thinkingLevel override store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), thinkingLevel: "high", }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), thinkingLevel: "high", }); // Should use per-task thinkingLevel override const callArgs = mockedCreateFnAgent.mock.calls[0]; expect(callArgs).toBeDefined(); expect(callArgs[0].defaultThinkingLevel).toBe("high"); }); it("falls back to global defaultThinkingLevel when task has no thinkingLevel", async () => { const store = createMockStore(); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, } as any); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, worktreeInitCommand: undefined, defaultThinkingLevel: "medium", }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // No thinkingLevel set }); // Should fall back to global defaultThinkingLevel const callArgs = mockedCreateFnAgent.mock.calls[0]; expect(callArgs).toBeDefined(); expect(callArgs[0].defaultThinkingLevel).toBe("medium"); }); it("uses explicit 'off' thinkingLevel from task over global setting", async () => { const store = createMockStore(); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, } as any); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, worktreeInitCommand: undefined, defaultThinkingLevel: "high", }); // Override getTask to return task with thinkingLevel: "off" store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), thinkingLevel: "off", }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), thinkingLevel: "off", }); // Should use task's explicit "off" instead of global "high" const callArgs = mockedCreateFnAgent.mock.calls[0]; expect(callArgs).toBeDefined(); expect(callArgs[0].defaultThinkingLevel).toBe("off"); }); }); // ── Invalid transition error handling tests ───────────────────────── describe("Invalid transition error handling", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("does not mark task as failed when invalid transition error occurs on completion", async () => { const store = createMockStore(); // Mock moveTask to throw invalid transition error (task already moved to done) store.moveTask.mockRejectedValue( new Error("Invalid transition: 'done' → 'in-review'. Valid targets: none"), ); // Mock agent that completes successfully mockedCreateFnAgent.mockImplementation(async () => { return { session: { prompt: vi.fn().mockImplementation(async () => { // Agent completes work but moveTask will fail }), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn(), branchWithSummary: vi.fn(), }, navigateTree: vi.fn(), }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); 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(), }); // A missing task_done triggers a retry. Both attempts fail to call task_done, // then the moveTask in the retry path throws the Invalid transition error, // which is caught by the outer handler. expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "Agent finished without calling task_done (after retry)", }); // Should log informative message from the outer catch for Invalid transition expect(store.logEntry).toHaveBeenCalledWith( "FN-001", "Task already moved from 'done' — skipping transition to 'in-review'", expect.stringContaining("Invalid transition"), expect.objectContaining({ agentId: "executor" }), ); }); it("calls onComplete when invalid transition occurs after successful execution", async () => { const store = createMockStore(); const onComplete = vi.fn(); // Mock moveTask to throw invalid transition error store.moveTask.mockRejectedValue( new Error("Invalid transition: 'in-progress' → 'in-review'. Valid targets: todo, triage"), ); mockedCreateFnAgent.mockImplementation(async () => { return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn(), branchWithSummary: vi.fn(), }, navigateTree: vi.fn(), }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-002", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // onComplete should be called even when invalid transition occurs expect(onComplete).toHaveBeenCalled(); expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-002" })); }); it("finalizes an already-reviewed task when it is ready to merge", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-003", title: "Test", description: "Test", column: "in-review", paused: false, status: null, error: null, worktree: "/tmp/test/.worktrees/fn-003", dependencies: [], steps: [{ name: "Done", status: "done" }], workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); const executor = new TaskExecutor(store, "/tmp/test"); const result = await (executor as any).finalizeAlreadyReviewedTask("FN-003"); expect(result).toBe("merged"); expect(store.mergeTask).toHaveBeenCalledWith("FN-003"); expect(store.logEntry).toHaveBeenCalledWith( "FN-003", "Task already in-review after completion — finalizing merge", undefined, undefined, ); }); }); describe("TaskExecutor task_done with summary", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("accepts and saves summary parameter when task is completed", async () => { const store = createMockStore(); let capturedTool: any = null; mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => { // Capture the task_done tool capturedTool = customTools?.find((t: any) => t.name === "task_done"); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); // Execute a task await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Step 1", status: "in-progress" }], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Verify task_done tool was created expect(capturedTool).toBeDefined(); expect(capturedTool.name).toBe("task_done"); // Verify the tool accepts summary parameter expect(capturedTool.parameters).toBeDefined(); // Execute the tool with a summary const result = await capturedTool.execute("tool-1", { summary: "Test summary of changes" }); // Verify the task was updated with the summary expect(store.updateTask).toHaveBeenCalledWith("FN-001", { summary: "Test summary of changes" }); // Verify success message includes summary mention expect(result.content[0].text).toContain("summary"); }); it("works without summary parameter (backward compatible)", async () => { const store = createMockStore(); let capturedTool: any = null; mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => { capturedTool = customTools?.find((t: any) => t.name === "task_done"); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-002", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Step 1", status: "in-progress" }], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Execute the tool without summary const result = await capturedTool.execute("tool-1", {}); // Verify summary was not updated const summaryUpdateCalls = store.updateTask.mock.calls.filter( (call: any[]) => call[1]?.summary !== undefined ); expect(summaryUpdateCalls).toHaveLength(0); // Verify standard success message expect(result.content[0].text).toBe("Task marked complete. All steps done. Moving to in-review."); }); }); describe("TaskExecutor task_done blockers", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("rejects task_done when the task is explicitly blocked", async () => { const store = createMockStore(); let capturedTool: any = null; store.getTask.mockImplementation(async (taskId: string) => { if (taskId === "FN-001") { return { id: "FN-001", title: "Blocked task", description: "Blocked task", column: "in-progress", blockedBy: "FN-DEP-1", dependencies: [], steps: [{ name: "Step 1", status: "in-progress" }], currentStep: 0, log: [], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } return { id: taskId, column: "done", }; }); mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => { capturedTool = customTools?.find((t: any) => t.name === "task_done"); return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }, } as any; }); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Blocked task", description: "Blocked task", column: "in-progress", dependencies: [], steps: [{ name: "Step 1", status: "in-progress" }], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(capturedTool).toBeDefined(); store.updateStep.mockClear(); store.updateTask.mockClear(); const result = await capturedTool.execute("tool-1", {}); expect(result.content[0].text).toContain("Cannot mark task done yet"); expect(store.updateStep).not.toHaveBeenCalled(); expect(store.updateTask).not.toHaveBeenCalled(); }); }); describe("Workflow Steps Execution", () => { beforeEach(() => { vi.clearAllMocks(); }); /** * Create a mock agent that auto-triggers the task_done tool when prompt is called. * This simulates a successful task execution where the agent calls task_done(). */ function createAgentWithTaskDone() { let capturedCustomTools: any[] = []; mockedCreateFnAgent.mockImplementation((async (opts: any) => { capturedCustomTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { // Find and execute task_done tool to set taskDone = true const taskDoneTool = capturedCustomTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) { await taskDoneTool.execute("tool-1", {}); } }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; }) as any); } it("marks the task failed when the agent exits without calling task_done", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "in-progress" }], currentStep: 0, log: [], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }, } as any); const onComplete = vi.fn(); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "in-progress" }], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should have been called twice: initial + retry expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); // Retry still didn't call task_done, so it fails with the retry message expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "Agent finished without calling task_done (after retry)", }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.logEntry).toHaveBeenCalledWith( "FN-001", "Agent finished without calling task_done (after retry) — moved to in-review for inspection", undefined, expect.objectContaining({ agentId: "executor" }), ); expect(onError).toHaveBeenCalledWith( expect.objectContaining({ id: "FN-001" }), expect.objectContaining({ message: "Agent finished without calling task_done (after retry)" }), ); expect(onComplete).not.toHaveBeenCalled(); }); it("runs workflow steps after main task execution", async () => { const store = createMockStore(); // Task has workflow steps enabled store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review all docs and verify they are complete.", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // First call: main agent with task_done, subsequent calls: simple mocks for workflow step agents let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { // Main execution — find and trigger task_done const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } else { // Workflow step agent (no custom tools, uses readonly tools) return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {}, }, }; } }) as any); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // createFnAgent called twice: main agent + workflow step agent expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); // Second call should be the workflow step with readonly tools const secondCall = mockedCreateFnAgent.mock.calls[1]; expect(secondCall[0].tools).toBe("readonly"); expect(secondCall[0].systemPrompt).toContain("Docs Review"); expect(secondCall[0].systemPrompt).toContain("Review all docs and verify they are complete."); // Task should move to in-review expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); }); it("runs browser verification workflow steps with coding tools", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Browser task", description: "Verify browser behavior", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", templateId: "browser-verification", name: "Browser Verification", description: "Verify with browser automation", mode: "prompt", toolMode: "coding", prompt: "Use browser automation to verify the app.", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {}, }, }; }) as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Browser task", description: "Verify browser behavior", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); const secondCall = mockedCreateFnAgent.mock.calls[1]; expect(secondCall[0].tools).toBe("coding"); }); it("runs QA workflow steps with coding tools", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "QA task", description: "Verify tests pass", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", templateId: "qa-check", name: "QA Check", description: "Run tests and verify they pass", mode: "prompt", toolMode: "coding", prompt: "Run the test suite and report results.", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {}, }, }; }) as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "QA task", description: "Verify tests pass", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); const secondCall = mockedCreateFnAgent.mock.calls[1]; expect(secondCall[0].tools).toBe("coding"); }); it("skips workflow steps with no prompt", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Empty Step", description: "No prompt", prompt: "", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); createAgentWithTaskDone(); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should only call createFnAgent once (main execution), skip workflow step expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); // Should log that it was skipped expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("has no prompt"), ); // Task should still move to in-review expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); }); it("handles tasks with no workflow steps", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); createAgentWithTaskDone(); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Only main agent call expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); // Task should still move to in-review expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); }); it("uses workflow step model override when both provider and modelId are set", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Security Audit", description: "Check security", prompt: "Scan for vulnerabilities.", enabled: true, modelProvider: "anthropic", modelId: "claude-sonnet-4-5", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { // Main execution agent const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } else { // Workflow step agent return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {}, }, }; } }) as any); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // createFnAgent called twice: main agent + workflow step agent expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); // Second call should use the workflow step's model override const secondCall = mockedCreateFnAgent.mock.calls[1]; expect(secondCall[0].defaultProvider).toBe("anthropic"); expect(secondCall[0].defaultModelId).toBe("claude-sonnet-4-5"); // Log should indicate the override expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("workflow step override"), ); }); it("uses global defaults when workflow step has no model override", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Workflow step without model override store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review all docs.", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } else { return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {}, }, }; } }) as any); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); // Second call should use settings defaults (no override indicator) const secondCall = mockedCreateFnAgent.mock.calls[1]; // defaults come from the mock store's getSettings expect(secondCall[0].defaultProvider).toBeUndefined(); expect(secondCall[0].defaultModelId).toBeUndefined(); // Log should NOT indicate override expect(store.logEntry).not.toHaveBeenCalledWith( "FN-001", expect.stringContaining("workflow step override"), ); }); it("executes script-mode workflow step successfully", async () => { const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, scripts: { test: "echo 'all tests passed'" }, }); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Run Tests", description: "Execute test suite", mode: "script", prompt: "", scriptName: "test", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Mock execSync to succeed for the script command mockedExecSync.mockImplementation((cmd: string | string[]) => { if (typeof cmd === "string" && cmd.includes("echo")) { return Buffer.from("all tests passed\n"); } return Buffer.from(""); }); // Main agent with task_done createAgentWithTaskDone(); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should only call createFnAgent once (main execution — no agent for script mode) expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); // Should log script execution expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("executing script 'test'"), ); // Task should move to in-review expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); // Should record a passed result expect(store.updateTask).toHaveBeenCalledWith( "FN-001", expect.objectContaining({ workflowStepResults: expect.arrayContaining([ expect.objectContaining({ workflowStepId: "WS-001", workflowStepName: "Run Tests", status: "passed", output: "Script 'test' completed successfully", }), ]), }), ); const updatePayloads = store.updateTask.mock.calls.map((call: any[]) => call[1]); expect(JSON.stringify(updatePayloads)).not.toContain("all tests passed"); }); it("sends task back to in-progress when script-mode workflow step fails with exhausted retries", async () => { const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, scripts: { lint: "pnpm lint" }, }); // Mutable task object to track step changes const mutableTask = { id: "FN-001", title: "Test", description: "Test task", column: "in-progress" as const, dependencies: [] as string[], steps: [{ name: "Preflight", status: "pending" as const }], currentStep: 0, log: [] as string[], enabledWorkflowSteps: ["WS-001"], workflowStepRetries: 3, // Exhaust retries so task fails immediately prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; store.getTask.mockResolvedValue(mutableTask); // Make updateStep track changes in the mutable task store.updateStep.mockImplementation(async (taskId: string, stepIndex: number, status: string) => { if (mutableTask.steps[stepIndex]) { mutableTask.steps[stepIndex].status = status as any; } return {}; }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Lint Check", description: "Run linter", mode: "script", prompt: "", scriptName: "lint", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Mock execSync to throw for the lint command const scriptErr = new Error("Command failed: pnpm lint"); (scriptErr as any).status = 1; (scriptErr as any).stderr = Buffer.from("syntax error on line 42\n"); (scriptErr as any).stdout = Buffer.from(""); mockedExecSync.mockImplementation((cmd: string | string[]) => { if (typeof cmd === "string" && cmd.includes("lint")) { throw scriptErr; } return Buffer.from(""); }); // Use createAgentWithTaskDone to properly set up the agent mock createAgentWithTaskDone(); const onComplete = vi.fn(); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError }); // Use fake timers to control the setTimeout in sendTaskBackForFix vi.useFakeTimers(); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], workflowStepRetries: 3, // Exhaust retries so task fails immediately createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should record a failed result with exit code and stderr // (This may not be the first call, so check if any call has workflowStepResults) const updateTaskCalls = store.updateTask.mock.calls; const hasWorkflowStepFailure = updateTaskCalls.some( (call: any[]) => call[0] === "FN-001" && call[1]?.workflowStepResults?.some( (r: any) => r.workflowStepId === "WS-001" && r.workflowStepName === "Lint Check" && r.status === "failed" && r.output?.includes("Exit code: 1") ) ); expect(hasWorkflowStepFailure).toBe(true); // Task should be cleared and reset for retry (not failed + in-review) expect(store.updateTask).toHaveBeenCalledWith( "FN-001", expect.objectContaining({ status: null, error: null, sessionFile: null, workflowStepRetries: 0 }), ); // Should add a comment with failure feedback // This will fail if sendTaskBackForFix is not called expect(store.addTaskComment).toHaveBeenCalledWith( "FN-001", expect.stringContaining("Workflow step failed"), "agent", ); // Should reset all steps to pending // Check that updateStep was called with "pending" for step 0 // (There may be multiple calls - first from task_done marking it done, second from sendTaskBackForFix resetting it) const updateStepCalls = store.updateStep.mock.calls; const hasResetToPending = updateStepCalls.some( (call: any[]) => call[0] === "FN-001" && call[1] === 0 && call[2] === "pending" ); expect(hasResetToPending).toBe(true); // Advance timers to trigger the setTimeout that moves task to todo then in-progress vi.advanceTimersByTime(0); // Run any pending microtasks (the async code in setTimeout) await vi.runAllTimersAsync(); // Task should move to todo then in-progress (not in-review) expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); // onComplete should NOT be called (task is being retried, not completed) expect(onComplete).not.toHaveBeenCalled(); // onError should NOT be called (task is being retried, not permanently failed) expect(onError).not.toHaveBeenCalled(); vi.useRealTimers(); }); it("sends task back to in-progress when script is missing from settings.scripts", async () => { const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, scripts: { other: "echo other" }, }); // Mutable task object to track step changes const mutableTask = { id: "FN-001", title: "Test", description: "Test task", column: "in-progress" as const, dependencies: [] as string[], steps: [{ name: "Preflight", status: "pending" as const }], currentStep: 0, log: [] as string[], enabledWorkflowSteps: ["WS-001"], workflowStepRetries: 3, // Exhaust retries so task fails immediately prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; store.getTask.mockResolvedValue(mutableTask); // Make updateStep track changes in the mutable task store.updateStep.mockImplementation(async (taskId: string, stepIndex: number, status: string) => { if (mutableTask.steps[stepIndex]) { mutableTask.steps[stepIndex].status = status as any; } return {}; }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Missing Script", description: "Uses nonexistent script", mode: "script", prompt: "", scriptName: "nonexistent", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Use createAgentWithTaskDone to properly set up the agent mock createAgentWithTaskDone(); const onComplete = vi.fn(); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError }); // Use fake timers to control the setTimeout in sendTaskBackForFix vi.useFakeTimers(); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], workflowStepRetries: 3, // Exhaust retries so task fails immediately createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should log that the script was not found expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("not found in project settings"), ); // Should record a failed result expect(store.updateTask).toHaveBeenCalledWith( "FN-001", expect.objectContaining({ workflowStepResults: expect.arrayContaining([ expect.objectContaining({ workflowStepId: "WS-001", status: "failed", output: expect.stringContaining("not found in project settings"), }), ]), }), ); // Task should be cleared and reset for retry (not failed + in-review) expect(store.updateTask).toHaveBeenCalledWith( "FN-001", expect.objectContaining({ status: null, error: null, sessionFile: null, workflowStepRetries: 0 }), ); // Should add a comment with failure feedback expect(store.addTaskComment).toHaveBeenCalledWith( "FN-001", expect.stringContaining("Workflow step failed"), "agent", ); // Should reset all steps to pending // Check that updateStep was called with "pending" for step 0 // (There may be multiple calls - first from task_done marking it done, second from sendTaskBackForFix resetting it) const updateStepCalls = store.updateStep.mock.calls; const hasResetToPending = updateStepCalls.some( (call: any[]) => call[0] === "FN-001" && call[1] === 0 && call[2] === "pending" ); expect(hasResetToPending).toBe(true); // Advance timers to trigger the setTimeout that moves task to todo then in-progress vi.advanceTimersByTime(0); // Run any pending microtasks (the async code in setTimeout) await vi.runAllTimersAsync(); // Task should move to todo then in-progress (not in-review) expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); // onComplete should NOT be called (task is being retried, not completed) expect(onComplete).not.toHaveBeenCalled(); // onError should NOT be called (task is being retried, not permanently failed) expect(onError).not.toHaveBeenCalled(); vi.useRealTimers(); }); it("skips script-mode step when scriptName is missing", async () => { const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, scripts: {}, }); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "No Script", description: "Script step without scriptName", mode: "script", prompt: "", scriptName: undefined, enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); createAgentWithTaskDone(); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Should only call createFnAgent once (main execution) expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); // Should log that it was skipped expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("no scriptName"), ); // Task should move to in-review (skipped step doesn't block) expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); }); it("treats legacy steps without mode as prompt-mode", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Legacy step without mode field store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Legacy Review", description: "Old step without mode", prompt: "Review the code changes.", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } as any); // mode field intentionally omitted let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } else { return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {}, }, }; } }) as any); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // createFnAgent called twice: main agent + workflow step agent (prompt mode) expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); // Second call should use prompt mode (readonly tools, agent-based) const secondCall = mockedCreateFnAgent.mock.calls[1]; expect(secondCall[0].tools).toBe("readonly"); expect(secondCall[0].systemPrompt).toContain("Legacy Review"); // Task should move to in-review expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); }); // ── Workflow Step Phase Filtering ──────────────────────────────────── it("skips post-merge workflow steps during executor pre-merge execution", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001", "WS-002"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockImplementation(async (id: string) => { if (id === "WS-001") { return { id: "WS-001", name: "Pre-merge Check", description: "Before merge", prompt: "Run pre-merge checks", phase: "pre-merge", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } if (id === "WS-002") { return { id: "WS-002", name: "Post-merge Notify", description: "After merge", prompt: "Send notifications", phase: "post-merge", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } return undefined; }); // Main agent calls task_done, then a workflow step agent for pre-merge only let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } else { return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {}, }, }; } }) as any); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001", "WS-002"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // createFnAgent called twice: main agent + 1 pre-merge step (post-merge skipped) expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); // Verify the workflow step results only contain pre-merge const updateCalls = store.updateTask.mock.calls; const resultsCall = updateCalls.find((c: any) => c[1]?.workflowStepResults?.length > 0 ); expect(resultsCall).toBeDefined(); const results = resultsCall![1].workflowStepResults; expect(results).toHaveLength(1); expect(results[0].workflowStepId).toBe("WS-001"); expect(results[0].phase).toBe("pre-merge"); }); it("normalizes legacy workflow steps without phase as pre-merge", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Legacy step without phase field store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Legacy Check", description: "No phase field", prompt: "Run checks", // phase is undefined — should be treated as pre-merge enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } else { return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {}, }, }; } }) as any); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Legacy step should have been executed (treated as pre-merge) expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); // Verify result has phase: "pre-merge" const updateCalls = store.updateTask.mock.calls; const resultsCall = updateCalls.find((c: any) => c[1]?.workflowStepResults?.some((r: any) => r.workflowStepId === "WS-001") ); expect(resultsCall).toBeDefined(); const results = resultsCall![1].workflowStepResults; expect(results[0].phase).toBe("pre-merge"); }); it("only runs post-merge steps when all are post-merge (skips all in executor)", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Post-merge Notify", description: "After merge", prompt: "Send notifications", phase: "post-merge", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); createAgentWithTaskDone(); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Only main agent called (no workflow step agent since all are post-merge) expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); // Task should still move to in-review expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); }); // ── Workflow Step Revision Request ────────────────────────────────── it("workflow step agent returns revisionRequested when output starts with REQUEST REVISION", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Security Audit", description: "Check for vulnerabilities", prompt: "Scan for security issues.", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // First call: main agent with task_done // Second call: workflow step agent that returns REQUEST REVISION let callIdx = 0; let subscribeHandler: any; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { // Main execution const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } else { // Workflow step agent that requests revision const session = { prompt: vi.fn().mockImplementation(async () => { // Call the subscribe handler to simulate agent outputting REQUEST REVISION if (subscribeHandler) { subscribeHandler({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix the SQL injection vulnerability in src/auth.ts" }, }); } }), dispose: vi.fn(), subscribe: vi.fn((handler: any) => { subscribeHandler = handler; }), state: {}, }; return { session }; } }) as any); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Both agents should be called expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); // Log should show revision was requested expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("requested revision"), expect.stringContaining("SQL injection"), ); // Workflow step result should be marked as failed expect(store.updateTask).toHaveBeenCalledWith( "FN-001", expect.objectContaining({ workflowStepResults: expect.arrayContaining([ expect.objectContaining({ workflowStepId: "WS-001", status: "failed", output: expect.stringContaining("SQL injection"), }), ]), }), ); // Task should NOT be marked as failed (revision requested, not hard failure) expect(store.updateTask).not.toHaveBeenCalledWith( "FN-001", expect.objectContaining({ status: "failed", error: "Workflow step failed" }), ); }); it("passing workflow step moves task to in-review normally", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Run the test suite.", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; if (callIdx === 1) { const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; } else { // Workflow step agent that passes (no REQUEST REVISION) return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), state: {}, }, }; } }) as any); const onComplete = vi.fn(); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Both agents called expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); // Log should show workflow step passed expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("Workflow step completed"), ); // Task should move to in-review (not revision loop) expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); // onComplete should be called expect(onComplete).toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled(); }); }); describe("Real-time steering injection", () => { beforeEach(() => { vi.clearAllMocks(); }); it("initializes seenSteeringIds with existing comments at session start", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); // Mock session with steer method mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockImplementation(async () => { // Simulate execution running await new Promise(resolve => setTimeout(resolve, 10)); }), dispose: vi.fn(), steer: steerFn, }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); const existingComment = { id: "1234567890-abc123", text: "Existing comment", createdAt: new Date().toISOString(), author: "user" as const, }; await executor.execute({ id: "FN-001", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], steeringComments: [existingComment], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait for execution to complete await new Promise(resolve => setTimeout(resolve, 50)); // No steer calls should be made for existing comments expect(steerFn).not.toHaveBeenCalled(); }); it("injects new steering comments via session.steer() on task:updated", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); let promptResolve: () => void; const promptPromise = new Promise(resolve => { promptResolve = resolve; }); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockImplementation(async () => { // Wait for signal to complete await promptPromise; }), dispose: vi.fn(), steer: steerFn, }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); // Start execution const executePromise = 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(), }); // Wait for agent to start await new Promise(resolve => setTimeout(resolve, 20)); // Simulate adding a steering comment mid-execution const newComment = { id: "9876543210-def456", text: "Please use a different approach", createdAt: new Date().toISOString(), author: "user" as const, }; store._trigger("task:updated", { id: "FN-001", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], steeringComments: [newComment], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait for steer to be called await new Promise(resolve => setTimeout(resolve, 20)); // Verify steer was called with the formatted message expect(steerFn).toHaveBeenCalledOnce(); expect(steerFn.mock.calls[0][0]).toContain("📣 **New feedback**"); expect(steerFn.mock.calls[0][0]).toContain("Please use a different approach"); // Verify log entry was created expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("Comment received mid-execution"), "by user" ); // Complete the execution promptResolve!(); await executePromise; }); it("does not re-inject already seen steering comments", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), steer: steerFn, }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); const commentId = "1111111111-aaa111"; // Start execution with one comment await executor.execute({ id: "FN-001", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], steeringComments: [{ id: commentId, text: "Original comment", createdAt: new Date().toISOString(), author: "user" as const, }], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait for execution to start await new Promise(resolve => setTimeout(resolve, 20)); // Trigger task:updated with the SAME comment (simulating a non-steering update) store._trigger("task:updated", { id: "FN-001", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], steeringComments: [{ id: commentId, text: "Original comment", createdAt: new Date().toISOString(), author: "user" as const, }], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait and verify steer was not called again await new Promise(resolve => setTimeout(resolve, 20)); expect(steerFn).not.toHaveBeenCalled(); }); it("marks comment as seen even if steer() throws", async () => { const store = createMockStore(); const steerFn = vi.fn().mockRejectedValue(new Error("Session disconnected")); let resolvePrompt: () => void; const promptPromise = new Promise(resolve => { resolvePrompt = resolve; }); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockImplementation(() => promptPromise), dispose: vi.fn(), steer: steerFn, }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); const commentId = "2222222222-bbb222"; // Start execution (don't await yet) const executePromise = executor.execute({ id: "FN-001", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], steeringComments: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait for execution to start await new Promise(resolve => setTimeout(resolve, 20)); // Add a new comment that will fail to inject store._trigger("task:updated", { id: "FN-001", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], steeringComments: [{ id: commentId, text: "Comment that fails", createdAt: new Date().toISOString(), author: "user" as const, }], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait for processing await new Promise(resolve => setTimeout(resolve, 20)); // Verify steer was called (and failed) expect(steerFn).toHaveBeenCalledOnce(); // Trigger task:updated again with the same comment store._trigger("task:updated", { id: "FN-001", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], steeringComments: [{ id: commentId, text: "Comment that fails", createdAt: new Date().toISOString(), author: "user" as const, }], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait and verify steer was NOT called again (comment marked as seen) await new Promise(resolve => setTimeout(resolve, 20)); expect(steerFn).toHaveBeenCalledTimes(1); // Complete execution resolvePrompt!(); await executePromise; }); it("does not inject steering comments for tasks not in activeSessions", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), steer: steerFn, }, } as any); new TaskExecutor(store, "/tmp/test"); // Trigger task:updated for a task that is not in activeSessions store._trigger("task:updated", { id: "FN-NOT-EXECUTING", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], steeringComments: [{ id: "3333333333-ccc333", text: "Should not be injected", createdAt: new Date().toISOString(), author: "user" as const, }], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait and verify steer was not called await new Promise(resolve => setTimeout(resolve, 20)); expect(steerFn).not.toHaveBeenCalled(); }); it("handles multiple new steering comments in a single task:updated", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); let resolvePrompt: () => void; const promptPromise = new Promise(resolve => { resolvePrompt = resolve; }); // Set up getTask to return the task with existing comment in comments (used for seenSteeringIds init) store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", comments: [{ id: "existing-comment", text: "Original", createdAt: new Date().toISOString(), author: "user", }], steeringComments: [{ id: "existing-comment", text: "Original", createdAt: new Date().toISOString(), author: "user", }], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockImplementation(() => promptPromise), dispose: vi.fn(), steer: steerFn, }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); // Start execution (don't await yet) const executePromise = 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(), }); // Wait for execution to start await new Promise(resolve => setTimeout(resolve, 20)); // Add two new comments at once store._trigger("task:updated", { id: "FN-001", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], steeringComments: [ { id: "existing-comment", text: "Original", createdAt: new Date().toISOString(), author: "user", }, { id: "new-comment-1", text: "First new comment", createdAt: new Date().toISOString(), author: "user", }, { id: "new-comment-2", text: "Second new comment", createdAt: new Date().toISOString(), author: "user", }, ], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Wait for processing await new Promise(resolve => setTimeout(resolve, 20)); // Verify steer was called twice (once for each new comment) expect(steerFn).toHaveBeenCalledTimes(2); // Complete execution resolvePrompt!(); await executePromise; }); }); // ── Loop recovery (compact-and-resume) integration tests ──────────── describe("TaskExecutor loop recovery", () => { beforeEach(() => { vi.clearAllMocks(); }); function createMockSessionForLoopRecovery(overrides?: { compactResult?: any }) { const defaultResult = { summary: "Compacted conversation", tokensBefore: 150000, }; const compactRetVal = overrides && "compactResult" in overrides ? overrides.compactResult : defaultResult; const compact = vi.fn(async () => compactRetVal); const steer = vi.fn(async () => {}); return { prompt: vi.fn(async () => {}), dispose: vi.fn(), subscribe: vi.fn(), setThinkingLevel: vi.fn(), steer, compact, sessionFile: "/tmp/test-session.json", model: { provider: "mock", id: "mock-model", name: "Mock" }, sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; } function setupExecutorWithActiveSession(mockSession: ReturnType) { const store = createMockStore(); (store.getSettings as any).mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, }); const executor = new TaskExecutor(store, "/tmp/test-root"); // Directly inject an active session (avoids full execute() chain) (executor as any).activeSessions.set("FN-001", { session: mockSession, seenSteeringIds: new Set(), }); return { store, executor, mockSession }; } it("handleLoopDetected returns true and compacts session when active session exists", async () => { const mockSession = createMockSessionForLoopRecovery(); const { store, executor } = setupExecutorWithActiveSession(mockSession); const result = await executor.handleLoopDetected({ taskId: "FN-001", reason: "loop", noProgressMs: 600000, inactivityMs: 0, activitySinceProgress: 100, shouldRequeue: true, }); expect(result).toBe(true); expect(mockSession.compact).toHaveBeenCalled(); expect(mockSession.steer).toHaveBeenCalled(); expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("compact-and-resume"), ); }); it("handleLoopDetected returns false when no active session", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test-root"); // No session active (activeSessions is empty) const result = await executor.handleLoopDetected({ taskId: "FN-001", reason: "loop", noProgressMs: 600000, inactivityMs: 0, activitySinceProgress: 100, shouldRequeue: true, }); expect(result).toBe(false); }); it("handleLoopDetected returns false when attempt ceiling reached", async () => { const mockSession = createMockSessionForLoopRecovery(); const { executor } = setupExecutorWithActiveSession(mockSession); // First call succeeds const result1 = await executor.handleLoopDetected({ taskId: "FN-001", reason: "loop", noProgressMs: 600000, inactivityMs: 0, activitySinceProgress: 100, shouldRequeue: true, }); expect(result1).toBe(true); // Second call hits ceiling (max 1 attempt per execute() lifecycle) const result2 = await executor.handleLoopDetected({ taskId: "FN-001", reason: "loop", noProgressMs: 600000, inactivityMs: 0, activitySinceProgress: 200, shouldRequeue: true, }); expect(result2).toBe(false); }); it("handleLoopDetected returns false when compaction fails", async () => { const mockSession = createMockSessionForLoopRecovery({ compactResult: null }); const { executor } = setupExecutorWithActiveSession(mockSession); const result = await executor.handleLoopDetected({ taskId: "FN-001", reason: "loop", noProgressMs: 600000, inactivityMs: 0, activitySinceProgress: 100, shouldRequeue: true, }); expect(result).toBe(false); }); }); // ── Context limit error recovery tests ──────────────────────────────── describe("TaskExecutor context limit error recovery", () => { beforeEach(() => { vi.clearAllMocks(); }); function createMockSessionForContextRecovery() { return { prompt: vi.fn(async () => {}), dispose: vi.fn(), subscribe: vi.fn(), setThinkingLevel: vi.fn(), steer: vi.fn(async () => {}), sessionFile: "/tmp/test-session.json", model: { provider: "mock", id: "mock-model", name: "Mock" }, sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; } it("does NOT mark task as failed when context limit error is detected and recovery succeeds", async () => { const mockSession = createMockSessionForContextRecovery(); // Mock compactSessionContext to succeed const { compactSessionContext } = await import("./pi.js"); vi.mocked(compactSessionContext).mockResolvedValueOnce({ summary: "Compacted conversation", tokensBefore: 150000, }); const store = createMockStore(); (store.getSettings as any).mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, }); const executor = new TaskExecutor(store, "/tmp/test"); // Directly inject an active session (executor as any).activeSessions.set("FN-001", { session: mockSession, seenSteeringIds: new Set(), }); // Simulate the catch block being invoked with a context limit error // This would normally happen when prompt() throws const contextError = new Error("invalid params, context window exceeds limit (2013)"); // The executor should catch this error and attempt recovery // We can't directly test the catch block, but we can test that isContextLimitError // now correctly identifies this error const { isContextLimitError } = await import("./context-limit-detector.js"); expect(isContextLimitError(contextError.message)).toBe(true); }); it("recognizes 'context window exceeds limit' as context limit error", async () => { const { isContextLimitError } = await import("./context-limit-detector.js"); // These are the specific error formats that should be recognized expect(isContextLimitError("invalid params, context window exceeds limit (2013)")).toBe(true); expect(isContextLimitError("context window exceeds limit")).toBe(true); expect(isContextLimitError("context window exceeds limit (2003)")).toBe(true); expect(isContextLimitError("Context Window Exceeds limit")).toBe(true); }); it("does NOT recognize generic 'limit exceeded' without context keywords", async () => { const { isContextLimitError } = await import("./context-limit-detector.js"); // These should NOT be recognized as context limit errors expect(isContextLimitError("limit exceeded")).toBe(false); expect(isContextLimitError("quota exceeded")).toBe(false); expect(isContextLimitError("rate limit exceeded")).toBe(false); }); it("reduced-prompt retry is attempted when compact returns null", async () => { // This test verifies the recovery flow when compactSessionContext returns null // (no history to compact) - the code should fall through to reduced-prompt retry const { isContextLimitError } = await import("./context-limit-detector.js"); // These error formats should trigger reduced-prompt recovery const contextError = "context window exceeds limit (2013)"; expect(isContextLimitError(contextError)).toBe(true); // The test passes if isContextLimitError returns true, which means // the reduced-prompt retry path would be triggered }); it("reduced-prompt retry prompt focuses on completing efficiently", async () => { // Verify the reduced prompt template includes key instructions const reducedPrompt = [ "Your previous attempt hit the context window limit.", "Focus on completing the task efficiently with minimal context:", "1. Review git status and git log to see what's been done", "2. Identify the most critical remaining work", "3. Complete it with a simpler, more focused approach", "", "Do not repeat what's already been done. Just complete the task and call task_done.", ].join("\n"); expect(reducedPrompt).toContain("context window limit"); expect(reducedPrompt).toContain("git status"); expect(reducedPrompt).toContain("task_done"); expect(reducedPrompt).toContain("Do not repeat what's already been done"); }); }); // ── Agent Spawning Tests ───────────────────────────────────────────────── function createMockAgentStore() { let nextId = 1; const agents = new Map(); return { createAgent: vi.fn(async (input: any) => { const agentId = `agent-${String(nextId++).padStart(8, "0")}`; const agent = { id: agentId, name: input.name, role: input.role, state: "idle" as string, reportsTo: input.reportsTo, metadata: input.metadata ?? {}, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; agents.set(agentId, agent); return agent; }), updateAgentState: vi.fn(async (agentId: string, newState: string) => { const agent = agents.get(agentId); if (agent) { agent.state = newState; agent.updatedAt = new Date().toISOString(); } return agent; }), _agents: agents, }; } async function captureToolsWithAgentStore(agentStore?: any, settingsOverride?: any): Promise<{ tools: Record Promise>; store: ReturnType; executor: TaskExecutor; }> { const store = createMockStore(); store.updateStep.mockResolvedValue({ steps: [ { name: "Preflight", status: "done" }, { name: "Implement", status: "in-progress" }, { name: "Testing", status: "pending" }, ], }); const mergedSettings = { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, worktreeInitCommand: undefined, ...settingsOverride, }; store.getSettings.mockResolvedValue(mergedSettings); let capturedTools: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedTools = opts.customTools || []; // Child agent sessions get a never-resolving prompt so runSpawnedChild // doesn't complete and decrement totalSpawnedCount before limit checks. const isChildAgent = opts.systemPrompt?.includes("child agent spawned"); return { session: { prompt: isChildAgent ? vi.fn(() => new Promise(() => {})) : vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any; }); mockedExistsSync.mockReturnValue(true); // Mock execSync for worktree operations vi.mocked(execSync).mockReturnValue(""); const options: any = {}; if (agentStore) { options.agentStore = agentStore; } const executor = new TaskExecutor(store, "/tmp/test", options); await executor.execute({ id: "FN-SPAWN", title: "Spawn Test", description: "Spawn test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); const tools: Record = {}; for (const t of capturedTools) { tools[t.name] = t.execute; } return { tools, store, executor }; } describe("Agent Spawning", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); vi.mocked(execSync).mockReturnValue(""); }); it("spawn_agent tool is registered in customTools", async () => { const { tools } = await captureToolsWithAgentStore(); expect(tools.spawn_agent).toBeDefined(); expect(typeof tools.spawn_agent).toBe("function"); }); it("returns error when AgentStore is not configured", async () => { const { tools } = await captureToolsWithAgentStore(undefined); const result = await tools.spawn_agent("call1", { name: "researcher", role: "engineer", task: "Research something", }); expect(result.content[0].text).toContain("not available"); expect(result.content[0].text).toContain("no AgentStore configured"); expect(result.details.state).toBe("error"); }); it("creates agent in AgentStore with correct reportsTo", async () => { const agentStore = createMockAgentStore(); const { tools } = await captureToolsWithAgentStore(agentStore); const result = await tools.spawn_agent("call1", { name: "researcher", role: "engineer", task: "Research authentication patterns", }); expect(agentStore.createAgent).toHaveBeenCalledOnce(); const createInput = agentStore.createAgent.mock.calls[0][0]; expect(createInput.name).toBe("researcher"); expect(createInput.role).toBe("engineer"); expect(createInput.reportsTo).toBe("FN-SPAWN"); expect(createInput.metadata.type).toBe("spawned"); expect(createInput.metadata.parentTaskId).toBe("FN-SPAWN"); }); it("returns correct SpawnAgentResult structure with state", async () => { const agentStore = createMockAgentStore(); const { tools } = await captureToolsWithAgentStore(agentStore); const result = await tools.spawn_agent("call1", { name: "researcher", role: "engineer", task: "Research authentication patterns", }); // Parse the JSON from the text content const parsed = JSON.parse(result.content[0].text); expect(parsed).toHaveProperty("agentId"); expect(parsed).toHaveProperty("name", "researcher"); expect(parsed).toHaveProperty("state", "running"); expect(parsed).toHaveProperty("role", "engineer"); expect(parsed).toHaveProperty("message"); expect(parsed.message).toContain("researcher"); expect(parsed.message).toContain("Research authentication patterns"); // Also check details object expect(result.details.agentId).toBe(parsed.agentId); expect(result.details.state).toBe("running"); }); it("transitions agent to active state after creation", async () => { const agentStore = createMockAgentStore(); const { tools } = await captureToolsWithAgentStore(agentStore); await tools.spawn_agent("call1", { name: "worker", role: "custom", task: "Do some work", }); // Agent is created in idle, then transitioned to active const agentId = agentStore.createAgent.mock.calls[0][0]; expect(agentStore.updateAgentState).toHaveBeenCalledWith( expect.any(String), "active" ); }); it("creates child agent session via createFnAgent", async () => { const agentStore = createMockAgentStore(); const { tools } = await captureToolsWithAgentStore(agentStore); await tools.spawn_agent("call1", { name: "worker", role: "engineer", task: "Do some work", }); // createFnAgent is called at least twice: once for parent, once for child expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2); // Find the child session call const childCall = mockedCreateFnAgent.mock.calls.find( (call: any) => call[0].systemPrompt?.includes("child agent spawned") ); expect(childCall).toBeDefined(); expect(childCall![0].tools).toBe("coding"); expect(childCall![0].systemPrompt).toContain("FN-SPAWN"); }); it("respects per-parent maxSpawnedAgentsPerParent limit", async () => { const agentStore = createMockAgentStore(); const { tools } = await captureToolsWithAgentStore(agentStore, { maxSpawnedAgentsPerParent: 2, }); // Spawn 2 agents (limit) await tools.spawn_agent("call1", { name: "a1", role: "engineer", task: "task 1" }); await tools.spawn_agent("call2", { name: "a2", role: "engineer", task: "task 2" }); // 3rd should be rejected const result = await tools.spawn_agent("call3", { name: "a3", role: "engineer", task: "task 3" }); expect(result.content[0].text).toContain("Per-parent spawn limit reached"); expect(result.content[0].text).toContain("2/2"); expect(result.details.state).toBe("error"); }); it("respects global maxSpawnedAgentsGlobal limit", async () => { const agentStore = createMockAgentStore(); const { tools } = await captureToolsWithAgentStore(agentStore, { maxSpawnedAgentsGlobal: 3, }); // Spawn 3 agents (global limit) await tools.spawn_agent("call1", { name: "a1", role: "engineer", task: "task 1" }); await tools.spawn_agent("call2", { name: "a2", role: "engineer", task: "task 2" }); await tools.spawn_agent("call3", { name: "a3", role: "engineer", task: "task 3" }); // 4th should hit global limit const result = await tools.spawn_agent("call4", { name: "a4", role: "engineer", task: "task 4" }); expect(result.content[0].text).toContain("Global spawn limit reached"); expect(result.content[0].text).toContain("3/3"); expect(result.details.state).toBe("error"); }); it("uses default limits when settings are not specified", async () => { const agentStore = createMockAgentStore(); // No spawn settings in the store — defaults should apply const { tools } = await captureToolsWithAgentStore(agentStore); // Should be able to spawn (defaults: 5 per parent, 20 global) const result = await tools.spawn_agent("call1", { name: "worker", role: "engineer", task: "task 1", }); const parsed = JSON.parse(result.content[0].text); expect(parsed.state).toBe("running"); }); it("handles errors during agent creation gracefully", async () => { const agentStore = createMockAgentStore(); agentStore.createAgent.mockRejectedValue(new Error("DB connection failed")); const { tools } = await captureToolsWithAgentStore(agentStore); const result = await tools.spawn_agent("call1", { name: "worker", role: "engineer", task: "task 1", }); expect(result.content[0].text).toContain("Failed to spawn agent"); expect(result.content[0].text).toContain("DB connection failed"); expect(result.details.state).toBe("error"); }); it("trims whitespace from agent name", async () => { const agentStore = createMockAgentStore(); const { tools } = await captureToolsWithAgentStore(agentStore); await tools.spawn_agent("call1", { name: " researcher ", role: "engineer", task: "task 1", }); const createInput = agentStore.createAgent.mock.calls[0][0]; expect(createInput.name).toBe("researcher"); }); it("truncates long task descriptions in result message", async () => { const agentStore = createMockAgentStore(); const { tools } = await captureToolsWithAgentStore(agentStore); const longTask = "A".repeat(200); const result = await tools.spawn_agent("call1", { name: "worker", role: "engineer", task: longTask, }); const parsed = JSON.parse(result.content[0].text); expect(parsed.message).toContain("..."); // The message should contain the first 100 chars expect(parsed.message.length).toBeLessThan(longTask.length + 50); }); }); describe("Agent Spawning - Child Termination", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); vi.mocked(execSync).mockReturnValue(""); }); it("parent termination triggers all child terminations", async () => { const agentStore = createMockAgentStore(); const mockDispose = vi.fn(); mockedCreateFnAgent.mockImplementation(async (opts: any) => { return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: mockDispose, sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any; }); const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, }); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); await executor.execute({ id: "FN-PARENT", title: "Parent Task", description: "Parent", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // execute() should have completed, disposing the parent session // and any child sessions that were spawned expect(mockDispose).toHaveBeenCalled(); }); it("terminateChildAgent cleans up maps and decrements count", async () => { const agentStore = createMockAgentStore(); const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, }); const mockDispose = vi.fn(); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: mockDispose, sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); // Access internal state via any for testing const internals = executor as any; // Simulate spawned agent tracking state const childId = "agent-test-child"; const mockSession = { dispose: vi.fn() }; internals.childSessions.set(childId, mockSession); internals.spawnedAgents.set("FN-PARENT", new Set([childId])); internals.totalSpawnedCount = 1; // Terminate the child await internals.terminateChildAgent(childId); expect(mockSession.dispose).toHaveBeenCalled(); expect(internals.childSessions.has(childId)).toBe(false); // Note: spawnedAgents cleanup is done by terminateAllChildren, not terminateChildAgent expect(internals.totalSpawnedCount).toBe(0); expect(agentStore.updateAgentState).toHaveBeenCalledWith(childId, "terminated"); }); it("terminateChildAgent handles missing session gracefully", async () => { const agentStore = createMockAgentStore(); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); const internals = executor as any; internals.totalSpawnedCount = 1; // Terminate a child that doesn't have a session in the map await internals.terminateChildAgent("nonexistent-agent"); // Should still decrement counter and attempt state update expect(internals.totalSpawnedCount).toBe(0); expect(agentStore.updateAgentState).toHaveBeenCalledWith("nonexistent-agent", "terminated"); }); it("terminateAllChildren handles no children gracefully", async () => { const agentStore = createMockAgentStore(); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); const internals = executor as any; // Should not throw when there are no children await internals.terminateAllChildren("FN-NONE"); expect(agentStore.updateAgentState).not.toHaveBeenCalled(); }); it("terminateAllChildren terminates all children and cleans up", async () => { const agentStore = createMockAgentStore(); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); const internals = executor as any; // Set up multiple children const child1 = { dispose: vi.fn() }; const child2 = { dispose: vi.fn() }; internals.childSessions.set("c1", child1); internals.childSessions.set("c2", child2); internals.spawnedAgents.set("FN-PARENT", new Set(["c1", "c2"])); internals.totalSpawnedCount = 2; await internals.terminateAllChildren("FN-PARENT"); expect(child1.dispose).toHaveBeenCalled(); expect(child2.dispose).toHaveBeenCalled(); expect(internals.spawnedAgents.has("FN-PARENT")).toBe(false); expect(internals.totalSpawnedCount).toBe(0); expect(agentStore.updateAgentState).toHaveBeenCalledWith("c1", "terminated"); expect(agentStore.updateAgentState).toHaveBeenCalledWith("c2", "terminated"); }); it("terminateChildAgent handles AgentStore errors gracefully", async () => { const agentStore = createMockAgentStore(); agentStore.updateAgentState.mockRejectedValue(new Error("DB error")); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); const internals = executor as any; const mockSession = { dispose: vi.fn() }; internals.childSessions.set("c1", mockSession); internals.totalSpawnedCount = 1; // Should not throw even when AgentStore fails await internals.terminateChildAgent("c1"); expect(mockSession.dispose).toHaveBeenCalled(); expect(internals.totalSpawnedCount).toBe(0); }); it("terminateChildAgent auto-deletes agent after 5 second delay", async () => { vi.useFakeTimers(); try { const agentStore = createMockAgentStore() as any; // Add deleteAgent mock to the agent store agentStore.deleteAgent = vi.fn().mockResolvedValue(undefined); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); const internals = executor as any; const mockSession = { dispose: vi.fn() }; const childId = "agent-auto-delete-test"; internals.childSessions.set(childId, mockSession); internals.totalSpawnedCount = 1; // Terminate the child const terminatePromise = internals.terminateChildAgent(childId); // Session should be disposed immediately expect(mockSession.dispose).toHaveBeenCalled(); // deleteAgent should not be called yet (before 5 seconds) expect(agentStore.deleteAgent).not.toHaveBeenCalled(); // Advance timers by 5 seconds await vi.advanceTimersByTimeAsync(5000); // Now deleteAgent should have been called expect(agentStore.deleteAgent).toHaveBeenCalledTimes(1); expect(agentStore.deleteAgent).toHaveBeenCalledWith(childId); // Should not throw even when delete fails await terminatePromise; } finally { vi.useRealTimers(); } }); }); describe("Agent Spawning - runSpawnedChild", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("updates agent state to running then active on success", async () => { const agentStore = createMockAgentStore(); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); const internals = executor as any; const mockSession = { dispose: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined) }; internals.childSessions.set("agent-test", mockSession); internals.totalSpawnedCount = 1; await internals.runSpawnedChild("agent-test", mockSession, "Do the research"); // Should transition: running → active expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "running"); expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "active"); // Should clean up expect(internals.childSessions.has("agent-test")).toBe(false); expect(internals.totalSpawnedCount).toBe(0); }); it("updates agent state to error on failure", async () => { const agentStore = createMockAgentStore(); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); const internals = executor as any; const mockSession = { dispose: vi.fn() }; internals.childSessions.set("agent-test", mockSession); internals.totalSpawnedCount = 1; // Make promptWithFallback throw const { promptWithFallback } = await import("./pi.js"); vi.mocked(promptWithFallback).mockRejectedValueOnce(new Error("API error")); await internals.runSpawnedChild("agent-test", mockSession, "Do the research"); expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "running"); expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "error"); // Should still clean up expect(internals.childSessions.has("agent-test")).toBe(false); expect(internals.totalSpawnedCount).toBe(0); }); it("cleans up even when state update fails", async () => { const agentStore = createMockAgentStore(); agentStore.updateAgentState.mockRejectedValue(new Error("DB down")); const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); const internals = executor as any; const mockSession = { dispose: vi.fn() }; internals.childSessions.set("agent-test", mockSession); internals.totalSpawnedCount = 1; // Should not throw even when state updates fail await internals.runSpawnedChild("agent-test", mockSession, "Do the research"); expect(internals.childSessions.has("agent-test")).toBe(false); expect(internals.totalSpawnedCount).toBe(0); }); }); // ─── Agent Execution Flow Integration Tests (FN-978) ──────────────────────────── // // These tests verify the complete execution flow: event listener registration, // session creation, stuck detector tracking, and heartbeat recording. describe("TaskExecutor agent execution flow (FN-978)", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("registers task:moved event listener in constructor", () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); // Verify the store.on was called with "task:moved" expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function)); }); it("executes task when task:moved event fires with to='in-progress'", async () => { const store = createMockStore(); const session = { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }; mockedCreateFnAgent.mockResolvedValue({ session } as any); const executor = new TaskExecutor(store, "/tmp/test"); const task = { id: "FN-978", title: "Test Task", description: "Test", column: "in-progress" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Trigger the task:moved event manually store._trigger("task:moved", { task, from: "todo", to: "in-progress" }); // Wait for async execution to complete await new Promise((resolve) => setTimeout(resolve, 50)); // Verify the agent was created and prompt was called expect(mockedCreateFnAgent).toHaveBeenCalledWith( expect.objectContaining({ cwd: expect.any(String), systemPrompt: expect.any(String), tools: "coding", }), ); expect(session.prompt).toHaveBeenCalled(); }); it("does not execute task when task:moved event fires with to!='in-progress'", async () => { const store = createMockStore(); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn(), dispose: vi.fn() }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); const task = { id: "FN-978", title: "Test Task", description: "Test", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Trigger the task:moved event with to='done' (should not execute) store._trigger("task:moved", { task, from: "in-progress", to: "done" }); // Wait for async await new Promise((resolve) => setTimeout(resolve, 50)); // Verify no agent was created expect(mockedCreateFnAgent).not.toHaveBeenCalled(); }); describe("when task is moved away from in-progress", () => { it("terminates active session and removes from activeSessions map", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); const disposeSpy = vi.fn(); const mockSession = { prompt: vi.fn().mockResolvedValue(undefined), dispose: disposeSpy, steer: vi.fn(), }; // Simulate an active session (executor as any).activeSessions.set("FN-001", { session: mockSession, seenSteeringIds: new Set(), }); const task = { id: "FN-001", title: "Test Task", description: "Test", column: "todo" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Trigger task:moved away from in-progress store._trigger("task:moved", { task, from: "in-progress", to: "todo" }); // Allow async handlers to complete await new Promise((resolve) => setTimeout(resolve, 50)); // Verify session was disposed and removed from map expect(disposeSpy).toHaveBeenCalled(); expect((executor as any).activeSessions.has("FN-001")).toBe(false); // Verify task was added to pausedAborted set expect((executor as any).pausedAborted.has("FN-001")).toBe(true); }); it("terminates active step executor when task is moved away", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); const mockTerminateAllSessions = vi.fn().mockResolvedValue(undefined); const mockStepExecutor = { executeAll: vi.fn().mockResolvedValue([]), terminateAllSessions: mockTerminateAllSessions, cleanup: vi.fn().mockResolvedValue(undefined), }; // Simulate an active step executor (executor as any).activeStepExecutors.set("FN-002", mockStepExecutor as any); const task = { id: "FN-002", title: "Test Task", description: "Test", column: "todo" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Trigger task:moved away from in-progress store._trigger("task:moved", { task, from: "in-progress", to: "triage" }); // Allow async handlers to complete await new Promise((resolve) => setTimeout(resolve, 50)); // Verify terminateAllSessions was called expect(mockTerminateAllSessions).toHaveBeenCalled(); // Verify removed from map expect((executor as any).activeStepExecutors.has("FN-002")).toBe(false); }); it("handles graceful no-op when no active session exists", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); // No active session set const task = { id: "FN-003", title: "Test Task", description: "Test", column: "todo" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Should not throw expect(() => { store._trigger("task:moved", { task, from: "in-progress", to: "triage" }); }).not.toThrow(); }); it("untracks task from stuck detector when moved away", async () => { const store = createMockStore(); const untrackSpy = vi.fn(); const stuckDetector = { trackTask: vi.fn(), recordActivity: vi.fn(), recordProgress: vi.fn(), untrackTask: untrackSpy, }; const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector as any, }); const disposeSpy = vi.fn(); const mockSession = { prompt: vi.fn().mockResolvedValue(undefined), dispose: disposeSpy, steer: vi.fn(), }; (executor as any).activeSessions.set("FN-004", { session: mockSession, seenSteeringIds: new Set(), }); const task = { id: "FN-004", title: "Test Task", description: "Test", column: "todo" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; store._trigger("task:moved", { task, from: "in-progress", to: "todo" }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(untrackSpy).toHaveBeenCalledWith("FN-004"); }); it("adds task to pausedAborted set to prevent re-execution", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); const disposeSpy = vi.fn(); const mockSession = { prompt: vi.fn().mockResolvedValue(undefined), dispose: disposeSpy, steer: vi.fn(), }; (executor as any).activeSessions.set("FN-005", { session: mockSession, seenSteeringIds: new Set(), }); const task = { id: "FN-005", title: "Test Task", description: "Test", column: "triage" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; store._trigger("task:moved", { task, from: "in-progress", to: "triage" }); await new Promise((resolve) => setTimeout(resolve, 50)); expect((executor as any).pausedAborted.has("FN-005")).toBe(true); }); }); it("tracks task with stuck detector after session creation", async () => { const store = createMockStore(); const stuckDetector = { trackTask: vi.fn(), recordActivity: vi.fn(), recordProgress: vi.fn(), untrackTask: vi.fn(), }; const session = { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }; mockedCreateFnAgent.mockResolvedValue({ session } as any); const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector as any, }); const task = { id: "FN-978", title: "Test Task", description: "Test", column: "in-progress" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await executor.execute(task); // Verify trackTask was called with task ID expect(stuckDetector.trackTask).toHaveBeenCalledWith("FN-978", expect.anything()); // Verify recordActivity was called (heartbeat on prompt start) expect(stuckDetector.recordActivity).toHaveBeenCalledWith("FN-978"); // Verify untrackTask was called in the finally block expect(stuckDetector.untrackTask).toHaveBeenCalledWith("FN-978"); }); it("records activity via AgentLogger onText callbacks", async () => { const store = createMockStore(); const stuckDetector = { trackTask: vi.fn(), recordActivity: vi.fn(), recordProgress: vi.fn(), untrackTask: vi.fn(), }; let capturedOnText: ((delta: string) => void) | undefined; mockedCreateFnAgent.mockImplementation(async (opts: any) => { // Capture the onText callback that's passed to createFnAgent capturedOnText = opts.onText; return { session: { prompt: vi.fn().mockImplementation(async () => { // Simulate the agent producing text output if (capturedOnText) { capturedOnText("Hello world"); } }), dispose: vi.fn(), }, } as any; }); const onAgentText = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector as any, onAgentText, }); const task = { id: "FN-978", title: "Test Task", description: "Test", column: "in-progress" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await executor.execute(task); // Verify that recordActivity was called (at least once for the initial heartbeat // and possibly more for the simulated text output) expect(stuckDetector.recordActivity).toHaveBeenCalledWith("FN-978"); // The initial recordActivity + text callback should result in multiple calls expect(stuckDetector.recordActivity.mock.calls.length).toBeGreaterThanOrEqual(2); // Verify onAgentText callback was called with the delta expect(onAgentText).toHaveBeenCalledWith("FN-978", "Hello world"); }); it("records activity via AgentLogger onToolStart callbacks", async () => { const store = createMockStore(); const stuckDetector = { trackTask: vi.fn(), recordActivity: vi.fn(), recordProgress: vi.fn(), untrackTask: vi.fn(), }; let capturedOnToolStart: ((name: string, args?: Record) => void) | undefined; mockedCreateFnAgent.mockImplementation(async (opts: any) => { capturedOnToolStart = opts.onToolStart; return { session: { prompt: vi.fn().mockImplementation(async () => { // Simulate the agent calling a tool if (capturedOnToolStart) { capturedOnToolStart("bash", { command: "echo test" }); } }), dispose: vi.fn(), }, } as any; }); const onAgentTool = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector as any, onAgentTool, }); const task = { id: "FN-978", title: "Test Task", description: "Test", column: "in-progress" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await executor.execute(task); // Verify that recordActivity was called for the tool usage expect(stuckDetector.recordActivity).toHaveBeenCalledWith("FN-978"); // Verify onAgentTool callback was called with the tool name expect(onAgentTool).toHaveBeenCalledWith("FN-978", "bash"); }); it("prevents duplicate execution when task:moved fires twice for same task", async () => { const store = createMockStore(); const session = { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), }; mockedCreateFnAgent.mockResolvedValue({ session } as any); const executor = new TaskExecutor(store, "/tmp/test"); const task = { id: "FN-978", title: "Test Task", description: "Test", column: "in-progress" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Trigger the event twice quickly store._trigger("task:moved", { task, from: "todo", to: "in-progress" }); store._trigger("task:moved", { task, from: "todo", to: "in-progress" }); // Wait for completion await new Promise((resolve) => setTimeout(resolve, 200)); // The executing guard prevents duplicate execution from the event handler. // Note: createFnAgent may be called a second time if the agent finishes // without calling task_done (retry path), but the initial trigger should // only cause one execution, not two. // Verify that store.on was called with task:moved (listener registered) expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function)); // Verify the event handler initiated execute() (not twice from events) // The executing set guard works — both triggers don't cause double execution }); it("logs error when execute() fails in task:moved handler", async () => { const store = createMockStore(); mockedCreateFnAgent.mockRejectedValue(new Error("model not found")); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onError }); const task = { id: "FN-978", title: "Test Task", description: "Test", column: "in-progress" as const, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Trigger the event store._trigger("task:moved", { task, from: "todo", to: "in-progress" }); // Wait for async await new Promise((resolve) => setTimeout(resolve, 50)); // Verify the error handler was called expect(onError).toHaveBeenCalledWith( expect.objectContaining({ id: "FN-978" }), expect.any(Error), ); }); }); // ── StepSessionExecutor integration tests ────────────────────────────────── describe("StepSessionExecutor integration", () => { beforeEach(() => { vi.clearAllMocks(); mockExecuteAll.mockResolvedValue([]); mockTerminateAllSessions.mockResolvedValue(undefined); mockCleanup.mockResolvedValue(undefined); }); /** Helper to create a task with steps for step-session mode testing */ function createTaskWithSteps(overrides: Partial = {}): Task { return { id: "FN-200", title: "Step-session test task", description: "Test task with steps for step-session execution", column: "in-progress", dependencies: [], steps: [ { name: "Step 0", status: "pending" }, { name: "Step 1", status: "pending" }, ], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } /** Helper to create a store configured for step-session mode */ function createStepSessionStore() { const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, runStepsInNewSessions: true, maxParallelSteps: 2, }); store.getTask.mockResolvedValue({ id: "FN-200", title: "Step-session test task", description: "Test task with steps for step-session execution", column: "in-progress", dependencies: [], steps: [ { name: "Step 0", status: "pending" }, { name: "Step 1", status: "pending" }, ], currentStep: 0, log: [], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check\n### Step 1: Implement\n- [ ] code", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), baseCommitSha: "abc123", enabledWorkflowSteps: [], }); return store; } it("uses step-session path when runStepsInNewSessions is true", async () => { const store = createStepSessionStore(); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute(createTaskWithSteps()); // StepSessionExecutor constructor should have been called expect(mockedStepSessionExecutor).toHaveBeenCalled(); // executeAll should have been called expect(mockExecuteAll).toHaveBeenCalledOnce(); // createFnAgent should NOT have been called for step-session path expect(mockedCreateFnAgent).not.toHaveBeenCalled(); }); it("uses single-session path when runStepsInNewSessions is false (default)", async () => { const store = createMockStore(); // Default settings: no runStepsInNewSessions store.getSettings.mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, }); store.getTask.mockResolvedValue({ id: "FN-200", title: "Test", description: "Test", column: "in-progress", dependencies: [], steps: [{ name: "Step 0", status: "pending" }], currentStep: 0, log: [], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }, } as any); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute(createTaskWithSteps()); // Should NOT use step-session executor expect(mockedStepSessionExecutor).not.toHaveBeenCalled(); // Should use the traditional single-session agent expect(mockedCreateFnAgent).toHaveBeenCalled(); }); it("success path moves task to in-review and calls onComplete", async () => { const store = createStepSessionStore(); mockExecuteAll.mockResolvedValue([ { stepIndex: 0, success: true, retries: 0 }, { stepIndex: 1, success: true, retries: 0 }, ]); const onComplete = vi.fn(); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError }); await executor.execute(createTaskWithSteps()); expect(mockExecuteAll).toHaveBeenCalledOnce(); expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review"); expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-200" })); expect(onError).not.toHaveBeenCalled(); }); it("failure path marks task as failed with step error summary", async () => { const store = createStepSessionStore(); mockExecuteAll.mockResolvedValue([ { stepIndex: 0, success: true, retries: 0 }, { stepIndex: 1, success: false, error: "compilation error", retries: 3 }, ]); const onComplete = vi.fn(); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError }); await executor.execute(createTaskWithSteps()); expect(store.updateTask).toHaveBeenCalledWith("FN-200", { status: "failed", error: "Step 1: compilation error", }); expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review"); expect(onError).toHaveBeenCalledWith( expect.objectContaining({ id: "FN-200" }), expect.objectContaining({ message: "Step 1: compilation error" }), ); expect(onComplete).not.toHaveBeenCalled(); }); it("moves task to in-review when step-session execution fails", async () => { const store = createStepSessionStore(); mockExecuteAll.mockRejectedValue(new Error("Infrastructure failure")); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onError }); await executor.execute(createTaskWithSteps()); expect(store.updateTask).toHaveBeenCalledWith("FN-200", expect.objectContaining({ status: "failed", error: "Infrastructure failure", })); expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review"); expect(onError).toHaveBeenCalled(); }); it("moves task to in-review when transient retries are exhausted (step-session)", async () => { const store = createStepSessionStore(); mockExecuteAll.mockRejectedValue(new Error("socket hang up")); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onError }); await executor.execute(createTaskWithSteps({ recoveryRetryCount: 3 })); expect(store.updateTask).toHaveBeenCalledWith("FN-200", { status: "failed", error: "socket hang up", recoveryRetryCount: null, nextRecoveryAt: null, }); expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-200", "todo"); expect(onError).toHaveBeenCalled(); }); it("pause terminates step sessions", async () => { const store = createStepSessionStore(); const stuckDetector = { trackTask: vi.fn(), untrackTask: vi.fn(), recordProgress: vi.fn(), } as any; // Make executeAll hang until we trigger pause let resolveExecuteAll: () => void; mockExecuteAll.mockReturnValue(new Promise((resolve) => { resolveExecuteAll = resolve; })); const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector }); const task = createTaskWithSteps(); // Start execution (don't await — it will hang) const executePromise = executor.execute(task); // Give it time to set up the step executor await new Promise((r) => setTimeout(r, 50)); // Trigger pause store._trigger("task:updated", { ...task, paused: true }); // Resolve executeAll so the execution can complete resolveExecuteAll!(); await executePromise; expect(mockTerminateAllSessions).toHaveBeenCalled(); expect(stuckDetector.untrackTask).toHaveBeenCalledWith("FN-200"); }); it("stuck-kill terminates step sessions", async () => { const store = createStepSessionStore(); // Make executeAll hang let resolveExecuteAll: () => void; mockExecuteAll.mockReturnValue(new Promise((resolve) => { resolveExecuteAll = resolve; })); const executor = new TaskExecutor(store, "/tmp/test", {}); const task = createTaskWithSteps(); const executePromise = executor.execute(task); // Give it time to set up the step executor await new Promise((r) => setTimeout(r, 50)); // Trigger stuck kill executor.markStuckAborted("FN-200"); // Resolve executeAll so the execution can complete resolveExecuteAll!(); await executePromise; expect(mockTerminateAllSessions).toHaveBeenCalled(); }); // ── FN-1461: Step-session stuck retry regression tests ───────────────────────────────────── it("REGRESSION: stuck-kill with bare task ID properly requeues step-session task to todo", async () => { const store = createStepSessionStore(); // Make executeAll hang initially let resolveExecuteAll: (() => void) | null = null; mockExecuteAll.mockReturnValue(new Promise((resolve) => { resolveExecuteAll = resolve; })); const executor = new TaskExecutor(store, "/tmp/test", {}); const task = createTaskWithSteps(); const executePromise = executor.execute(task); // Give it time to set up the step executor await new Promise((r) => setTimeout(r, 50)); // Verify step executor is registered expect((executor as any).activeStepExecutors.has("FN-200")).toBe(true); // Trigger stuck kill with bare task ID (as StuckTaskDetector.onStuck would call) executor.markStuckAborted("FN-200", true); // Resolve executeAll to complete the execution resolveExecuteAll!(); await executePromise; // Verify: task should be marked stuck-killed and moved to todo for retry expect(store.updateTask).toHaveBeenCalledWith("FN-200", expect.objectContaining({ status: "stuck-killed", worktree: null, branch: null, })); expect(store.moveTask).toHaveBeenCalledWith("FN-200", "todo"); }); it("REGRESSION: stuck-kill with exhausted budget does not requeue step-session task", async () => { const store = createStepSessionStore(); let resolveExecuteAll: (() => void) | null = null; mockExecuteAll.mockReturnValue(new Promise((resolve) => { resolveExecuteAll = resolve; })); const executor = new TaskExecutor(store, "/tmp/test", {}); const task = createTaskWithSteps(); const executePromise = executor.execute(task); await new Promise((r) => setTimeout(r, 50)); // Budget exhausted — should NOT requeue executor.markStuckAborted("FN-200", false); resolveExecuteAll!(); await executePromise; // Should NOT move to todo or mark as stuck-killed expect(store.moveTask).not.toHaveBeenCalledWith("FN-200", "todo"); expect(store.updateTask).not.toHaveBeenCalledWith("FN-200", expect.objectContaining({ status: "stuck-killed", })); }); it("REGRESSION: untrackTask called with bare task ID during pause in step-session mode", async () => { const store = createStepSessionStore(); const stuckDetector = { trackTask: vi.fn(), untrackTask: vi.fn(), recordProgress: vi.fn(), } as any; let resolveExecuteAll: (() => void) | null = null; mockExecuteAll.mockReturnValue(new Promise((resolve) => { resolveExecuteAll = resolve; })); const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector }); const task = createTaskWithSteps(); const executePromise = executor.execute(task); await new Promise((r) => setTimeout(r, 50)); // Trigger pause store._trigger("task:updated", { ...task, paused: true }); resolveExecuteAll!(); await executePromise; // In step-session mode, untrackTask is called with bare task ID "FN-200" // But tracking was done with step-scoped keys like "FN-200-step-0" // BUG: This test captures that the current implementation passes bare ID, // which won't match the step-scoped tracking keys expect(stuckDetector.untrackTask).toHaveBeenCalledWith("FN-200"); // After fix: untrackTask should also clean up any step-scoped entries }); it("REGRESSION: StepSessionExecutor should pass bare task ID for recordProgress in onStepStart", async () => { // Note: This test verifies the expected contract between StepSessionExecutor and StuckTaskDetector. // In step-session mode: // - StepSessionExecutor tracks with step-scoped keys (e.g., "FN-200-step-0") // - But recordProgress should be called with the bare task ID for consistency // - StuckTaskDetector.recordProgress should handle this by finding the canonical task ID // // Currently, StepSessionExecutor calls recordProgress(task.id) where task.id is "FN-200" // But the entry was tracked with key "FN-200-step-0", so the lookup fails. // // After fix: recordProgress should handle both bare task IDs and step-scoped keys // by extracting the canonical task ID from step-scoped keys. // // This test is informational - the actual fix will be in StuckTaskDetector.recordProgress() // which should find entries by canonicalTaskId when looking up by bare task ID. expect(true).toBe(true); // Placeholder - real test verifies StuckTaskDetector behavior }); it("cleanup called in finally block even on error", async () => { const store = createStepSessionStore(); mockExecuteAll.mockRejectedValue(new Error("Fatal error")); mockCleanup.mockResolvedValue(undefined); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute(createTaskWithSteps()); // cleanup should still have been called despite the error expect(mockCleanup).toHaveBeenCalledOnce(); }); it("respects semaphore for concurrency control", async () => { const store = createStepSessionStore(); const sem = new AgentSemaphore(2); const runSpy = vi.spyOn(sem, "run"); mockExecuteAll.mockResolvedValue([ { stepIndex: 0, success: true, retries: 0 }, ]); const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem }); await executor.execute(createTaskWithSteps()); expect(runSpy).toHaveBeenCalledOnce(); expect(sem.activeCount).toBe(0); }); it("runs without semaphore when not provided", async () => { const store = createStepSessionStore(); mockExecuteAll.mockResolvedValue([ { stepIndex: 0, success: true, retries: 0 }, ]); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute(createTaskWithSteps()); // Should still work fine without a semaphore expect(mockExecuteAll).toHaveBeenCalledOnce(); expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review"); }); it("dep-abort during step-session execution triggers cleanup", async () => { const store = createStepSessionStore(); // Make executeAll hang so we can trigger dep-abort mid-execution let resolveExecuteAll: () => void; mockExecuteAll.mockReturnValue(new Promise((resolve) => { resolveExecuteAll = resolve; })); const executor = new TaskExecutor(store, "/tmp/test", {}); const task = createTaskWithSteps(); const executePromise = executor.execute(task); // Give it time to set up the step executor await new Promise((r) => setTimeout(r, 50)); // Simulate dep-abort by directly triggering the task_add_dep cleanup logic // The dep-abort flag should cause the step-session path to handle cleanup // We can test this by checking that when depAborted is set, cleanup is called // For now, just resolve and verify cleanup runs resolveExecuteAll!(); await executePromise; // Verify cleanup was called in finally expect(mockCleanup).toHaveBeenCalled(); }); it("workflow steps run on success and block on failure", async () => { const store = createStepSessionStore(); // Enable a workflow step store.getTask.mockResolvedValue({ id: "FN-200", title: "Step-session test task", description: "Test task", column: "in-progress", dependencies: [], steps: [ { name: "Step 0", status: "pending" }, ], currentStep: 0, log: [], prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), baseCommitSha: "abc123", enabledWorkflowSteps: ["WS-001"], }); store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Test Workflow", description: "Test", mode: "script", phase: "pre-merge", scriptName: "test-script", prompt: undefined, enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Steps succeed, but workflow step will fail mockExecuteAll.mockResolvedValue([ { stepIndex: 0, success: true, retries: 0 }, ]); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onError }); // Use fake timers to control the setTimeout in sendTaskBackForFix vi.useFakeTimers(); // Exhaust retries so workflow step failure is immediate await executor.execute(createTaskWithSteps({ steps: [{ name: "Step 0", status: "pending" }], workflowStepRetries: 3, enabledWorkflowSteps: ["WS-001"] })); // Should have called getWorkflowStep to look up the workflow step expect(store.getWorkflowStep).toHaveBeenCalledWith("WS-001"); // With script mode and no scripts configured, the step should fail (script not found) // Task should be sent back to in-progress for remediation, NOT call onError expect(store.addTaskComment).toHaveBeenCalledWith( "FN-200", expect.stringContaining("Workflow step failed"), "agent", ); // onError should NOT be called (task is being retried, not permanently failed) expect(onError).not.toHaveBeenCalled(); // Advance timers to trigger the setTimeout that moves task to todo then in-progress vi.advanceTimersByTime(0); // Run any pending microtasks (the async code in setTimeout) await vi.runAllTimersAsync(); // Task should move to todo then in-progress (not in-review) expect(store.moveTask).toHaveBeenCalledWith("FN-200", "todo"); expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-progress"); vi.useRealTimers(); }); it("onStepStart callback updates step status to in-progress", async () => { const store = createStepSessionStore(); store.updateStep.mockResolvedValue({} as any); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute(createTaskWithSteps()); // Capture the StepSessionExecutor constructor options expect(mockedStepSessionExecutor).toHaveBeenCalled(); const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0]; // Invoke the onStepStart callback ctorOptions.onStepStart!(0); // Should update step status in store expect(store.updateStep).toHaveBeenCalledWith("FN-200", 0, "in-progress"); }); it("onStepComplete callback updates step status to done on success", async () => { const store = createStepSessionStore(); store.updateStep.mockResolvedValue({} as any); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute(createTaskWithSteps()); const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0]; ctorOptions.onStepComplete!(0, { stepIndex: 0, success: true, retries: 0 }); expect(store.updateStep).toHaveBeenCalledWith("FN-200", 0, "done"); }); it("onStepComplete callback updates step status to skipped on failure", async () => { const store = createStepSessionStore(); store.updateStep.mockResolvedValue({} as any); const executor = new TaskExecutor(store, "/tmp/test", {}); await executor.execute(createTaskWithSteps()); const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0]; ctorOptions.onStepComplete!(1, { stepIndex: 1, success: false, retries: 3 }); expect(store.updateStep).toHaveBeenCalledWith("FN-200", 1, "skipped"); }); it("step status update errors do not block execution", async () => { const store = createStepSessionStore(); store.updateStep.mockRejectedValue(new Error("DB error")); const executor = new TaskExecutor(store, "/tmp/test", {}); // Should not throw even when updateStep rejects await executor.execute(createTaskWithSteps()); const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0]; // Invoking callbacks should not throw expect(() => ctorOptions.onStepStart!(0)).not.toThrow(); expect(() => ctorOptions.onStepComplete!(0, { stepIndex: 0, success: true, retries: 0 })).not.toThrow(); }); }); describe("detectReviewHandoffIntent", () => { it("returns true for 'send it back to me'", () => { expect(detectReviewHandoffIntent("Please send it back to me for review")).toBe(true); }); it("returns true for 'hand off to user'", () => { expect(detectReviewHandoffIntent("I need to hand off to user")).toBe(true); }); it("returns true for 'needs human review'", () => { expect(detectReviewHandoffIntent("This needs human review")).toBe(true); }); it("returns true for 'assign to user'", () => { expect(detectReviewHandoffIntent("Please assign to user")).toBe(true); }); it("returns true for 'return to user'", () => { expect(detectReviewHandoffIntent("Return to user for final approval")).toBe(true); }); it("returns true for 'user review needed'", () => { expect(detectReviewHandoffIntent("User review needed")).toBe(true); }); it("returns true for 'requesting user review'", () => { expect(detectReviewHandoffIntent("I am requesting user review")).toBe(true); }); it("is case-insensitive", () => { expect(detectReviewHandoffIntent("SEND IT BACK TO ME")).toBe(true); expect(detectReviewHandoffIntent("Send It Back To Me")).toBe(true); }); it("returns false for regular comments without handoff intent", () => { expect(detectReviewHandoffIntent("Good progress on the implementation")).toBe(false); expect(detectReviewHandoffIntent("Please add more tests")).toBe(false); expect(detectReviewHandoffIntent("The code looks great")).toBe(false); }); it("returns false for empty strings", () => { expect(detectReviewHandoffIntent("")).toBe(false); }); }); describe("buildExecutionPrompt", () => { it("includes worktree boundary guidance in the execution prompt", () => { const task: any = { id: "FN-TEST", title: "Test task", dependencies: [], prompt: "# Test task\n## Steps\n- Step 1", steps: [], currentStep: 0, attachments: [], }; const prompt = buildExecutionPrompt(task, "/project"); expect(prompt).toContain("## Worktree Boundaries"); expect(prompt).toContain("isolated git worktree"); expect(prompt).toContain("All code changes must be made inside the current worktree directory"); }); it("mentions project memory exception in worktree boundary guidance", () => { const task: any = { id: "FN-TEST", title: "Test task", dependencies: [], prompt: "# Test task\n## Steps\n- Step 1", steps: [], currentStep: 0, attachments: [], }; const prompt = buildExecutionPrompt(task, "/project"); expect(prompt).toContain(".fusion/memory/"); expect(prompt).toContain("memory"); expect(prompt).toContain("durable"); }); it("mentions task attachments exception in worktree boundary guidance", () => { const task: any = { id: "FN-TEST", title: "Test task", dependencies: [], prompt: "# Test task\n## Steps\n- Step 1", steps: [], currentStep: 0, attachments: [], }; const prompt = buildExecutionPrompt(task, "/project"); expect(prompt).toContain("attachments"); expect(prompt).toContain("context"); }); it("includes worktree boundary guidance regardless of review level", () => { const task: any = { id: "FN-TEST", title: "Test task", dependencies: [], prompt: "# Test task\n## Review Level: 0\n## Steps\n- Step 1", steps: [], currentStep: 0, attachments: [], }; const prompt = buildExecutionPrompt(task, "/project"); expect(prompt).toContain("## Worktree Boundaries"); }); }); // ── Skill Selection Regression Tests (FN-1514) ────────────────────────── describe("TaskExecutor skillSelection regression (FN-1511)", () => { const projectRoot = "/tmp/test-project"; beforeEach(() => { vi.clearAllMocks(); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any); }); /** * Helper: execute a task and capture createFnAgent call arguments. */ async function captureCreateFnAgentArgs(options?: { assignedAgentId?: string; assignedAgentSkills?: string[]; settings?: Record; }) { const { assignedAgentId, assignedAgentSkills } = options || {}; const mockAgentStore = { getAgent: vi.fn().mockImplementation(async (id: string) => { if (id === assignedAgentId) { return { id, name: "Test Agent", role: "executor", state: "idle", metadata: { skills: assignedAgentSkills || [] }, }; } return null; }), }; const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-SKILL", title: "Skill Test", description: "Test skill selection", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], assignedAgentId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); let capturedArgs: any = null; mockedCreateFnAgent.mockImplementationOnce(async (opts: any) => { capturedArgs = opts; return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any; }); const executor = new TaskExecutor(store, projectRoot, { agentStore: mockAgentStore as any }); await executor.execute({ id: "FN-SKILL", title: "Skill Test", description: "Test skill selection", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], assignedAgentId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); return capturedArgs; } describe("single-session mode (runStepsInNewSessions: false)", () => { it("passes skillSelection to createFnAgent when assigned agent has skills", async () => { const args = await captureCreateFnAgentArgs({ assignedAgentId: "agent-001", assignedAgentSkills: ["triage", "executor"], }); expect(args).not.toBeNull(); expect(args).toHaveProperty("skillSelection"); // The agent's skills are passed directly; filtering happens at skill resolver level expect(args.skillSelection).toMatchObject({ projectRootDir: projectRoot, requestedSkillNames: expect.arrayContaining(["triage", "executor"]), sessionPurpose: "executor", }); }); it("normalizes whitespace in requestedSkillNames", async () => { const args = await captureCreateFnAgentArgs({ assignedAgentId: "agent-001", assignedAgentSkills: [" triage ", " executor ", "reviewer"], }); expect(args).not.toBeNull(); expect(args.skillSelection).toMatchObject({ projectRootDir: projectRoot, requestedSkillNames: expect.arrayContaining(["triage", "executor", "reviewer"]), }); }); it("deduplicates requestedSkillNames while preserving first occurrence", async () => { const args = await captureCreateFnAgentArgs({ assignedAgentId: "agent-001", assignedAgentSkills: ["triage", "executor", "triage", "reviewer", "executor"], }); expect(args).not.toBeNull(); // Should contain triage, executor, reviewer in that order (first occurrence) expect(args.skillSelection).toMatchObject({ projectRootDir: projectRoot, requestedSkillNames: ["triage", "executor", "reviewer"], }); }); it("omits skillSelection when assigned agent has no skills", async () => { const args = await captureCreateFnAgentArgs({ assignedAgentId: "agent-001", assignedAgentSkills: [], }); expect(args).not.toBeNull(); // When no skills, skillSelection may be undefined or executor uses role fallback // The key is it doesn't crash and handles the case gracefully }); it("omits skillSelection when no assigned agent", async () => { const args = await captureCreateFnAgentArgs({}); expect(args).not.toBeNull(); // Legacy fallback: no skillSelection when no assigned agent }); }); describe("step-session mode (runStepsInNewSessions: true)", () => { // Note: These tests verify that skillSelection flows from executor to // StepSessionExecutor. The full integration is complex due to mock setup, // so we verify the contract indirectly through the step-session-executor tests. // The executor tests focus on verifying skillSelection is present in createFnAgent calls. // See StepSessionExecutor skillSelection tests in step-session-executor.test.ts. // Skipped: Integration tests for step-session skill selection are covered // in step-session-executor.test.ts where StepSessionExecutor is tested directly. it.skip("step-session skill selection covered in step-session-executor.test.ts", () => {}); }); }); // ── Agent Messaging Tool Tests ──────────────────────────────────────── describe("TaskExecutor messaging tools", () => { beforeEach(() => { vi.clearAllMocks(); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any); }); /** * Helper: execute a task and capture the customTools array passed to createFnAgent. */ async function captureCustomTools(options?: { messageStore?: unknown; agentStore?: unknown; assignedAgentId?: string; executionMode?: "standard" | "fast"; }): Promise { const { messageStore, agentStore, assignedAgentId, executionMode } = options || {}; let captured: any[] = []; mockedCreateFnAgent.mockImplementation(async (opts: any) => { captured = opts.customTools || []; return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-id"), branchWithSummary: vi.fn(), navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), }, } as any; }); const store = createMockStore(); // Override getTask to return the correct assignedAgentId and executionMode store.getTask.mockImplementation(async (id: string) => ({ id, title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], assignedAgentId, executionMode, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), })); const taskExecutor = new TaskExecutor(store, "/tmp/test", { messageStore: messageStore as any, agentStore: agentStore as any, }); await taskExecutor.execute({ id: "FN-MSG", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], assignedAgentId, executionMode, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); return captured; } it("includes send_message when messageStore and assignedAgentId are available", async () => { const mockMessageStore = { sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }), }; const tools = await captureCustomTools({ messageStore: mockMessageStore, assignedAgentId: "agent-001", }); const toolNames = tools.map((t: any) => t.name); expect(toolNames).toContain("send_message"); }); it("includes read_messages when messageStore and assignedAgentId are available", async () => { const mockMessageStore = { sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }), getInbox: vi.fn().mockReturnValue([]), }; const tools = await captureCustomTools({ messageStore: mockMessageStore, assignedAgentId: "agent-001", }); const toolNames = tools.map((t: any) => t.name); expect(toolNames).toContain("read_messages"); }); it("excludes read_messages when messageStore is not provided", async () => { const tools = await captureCustomTools({ assignedAgentId: "agent-001", }); const toolNames = tools.map((t: any) => t.name); expect(toolNames).not.toContain("read_messages"); }); it("excludes read_messages when assignedAgentId is not provided", async () => { const mockMessageStore = { sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }), getInbox: vi.fn().mockReturnValue([]), }; const tools = await captureCustomTools({ messageStore: mockMessageStore, }); const toolNames = tools.map((t: any) => t.name); expect(toolNames).not.toContain("read_messages"); }); it("excludes messaging tools when messageStore is not provided", async () => { const tools = await captureCustomTools({ assignedAgentId: "agent-001", }); const toolNames = tools.map((t: any) => t.name); expect(toolNames).not.toContain("send_message"); expect(toolNames).not.toContain("read_messages"); }); it("includes list_agents and delegate_task when agentStore is available", async () => { const mockAgentStore = { listAgents: vi.fn().mockResolvedValue([]), getAgent: vi.fn().mockResolvedValue(null), }; const tools = await captureCustomTools({ agentStore: mockAgentStore, }); const toolNames = tools.map((t: any) => t.name); expect(toolNames).toContain("list_agents"); expect(toolNames).toContain("delegate_task"); }); it("excludes delegation tools when agentStore is not provided", async () => { const tools = await captureCustomTools({}); const toolNames = tools.map((t: any) => t.name); expect(toolNames).not.toContain("list_agents"); expect(toolNames).not.toContain("delegate_task"); }); describe("fast mode", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(true); }); it("excludes review_step tool when executionMode is 'fast'", async () => { const tools = await captureCustomTools({ executionMode: "fast", }); const toolNames = tools.map((t: any) => t.name); expect(toolNames).not.toContain("review_step"); }); it("includes task_update and task_done tools in fast mode", async () => { const tools = await captureCustomTools({ executionMode: "fast", }); const toolNames = tools.map((t: any) => t.name); expect(toolNames).toContain("task_update"); expect(toolNames).toContain("task_done"); }); it("includes review_step tool when executionMode is 'standard'", async () => { const tools = await captureCustomTools({ executionMode: "standard", }); const toolNames = tools.map((t: any) => t.name); expect(toolNames).toContain("review_step"); }); it("includes review_step tool when executionMode is undefined (defaults to standard)", async () => { const tools = await captureCustomTools({}); const toolNames = tools.map((t: any) => t.name); expect(toolNames).toContain("review_step"); }); it("logs executor model usage when execution starts", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], executionMode: "fast", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, }, } as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], executionMode: "fast", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Verify logEntry was called (indicates executor is running) expect(store.logEntry).toHaveBeenCalled(); }); }); describe("Fast mode completion path", () => { beforeEach(() => { vi.clearAllMocks(); mockedExistsSync.mockReturnValue(false); }); it("skips workflow steps in fast mode when task completes", async () => { const store = createMockStore(); // Task with workflow steps enabled AND fast mode store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], executionMode: "fast", prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Mock workflow step exists store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review docs.", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Mock agent with task_done mockedCreateFnAgent.mockImplementation((async (opts: any) => { const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; }) as any); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], executionMode: "fast", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Verify task moved to in-review expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); // Verify onComplete was called expect(onComplete).toHaveBeenCalled(); // Verify workflow step was NOT called (fast mode skips workflow steps) // The agent should only be called once (main execution), not twice (main + workflow) expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); }); it("still runs workflow steps in standard mode when task completes", async () => { const store = createMockStore(); // Task with workflow steps enabled in standard mode store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], executionMode: "standard", prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Mock workflow step exists store.getWorkflowStep.mockResolvedValue({ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review docs.", enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Track agent calls let callIdx = 0; mockedCreateFnAgent.mockImplementation((async (opts: any) => { callIdx++; const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { if (callIdx === 1) { // Main execution — find and trigger task_done const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); } else { // Workflow step — no task_done needed } }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; }) as any); const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["WS-001"], executionMode: "standard", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Verify task moved to in-review expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); // Verify workflow step WAS called (standard mode runs workflow steps) // Agent should be called twice: main execution + workflow step expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); }); it("still enforces task_done requirement in fast mode", async () => { const store = createMockStore(); // Task in fast mode store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], executionMode: "fast", prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Mock agent that exits WITHOUT calling task_done mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }, } as any); const onError = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onError }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], executionMode: "fast", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Fast mode should still enforce task_done requirement // Should fail after retry and call onError expect(onError).toHaveBeenCalled(); expect(store.updateTask).toHaveBeenCalledWith( "FN-001", expect.objectContaining({ status: "failed" }), ); }); it("still checks completion blockers in fast mode", async () => { const store = createMockStore(); // Task in fast mode with no workflow steps store.getTask.mockResolvedValue({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], executionMode: "fast", prompt: "# test\n## Steps\n", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Mock agent with task_done mockedCreateFnAgent.mockImplementation((async (opts: any) => { const customTools = opts.customTools || []; const session = { prompt: vi.fn().mockImplementation(async () => { const taskDoneTool = customTools.find((t: any) => t.name === "task_done"); if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); }), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {}, }; return { session }; }) as any); const onComplete = vi.fn(); const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); await executor.execute({ id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], executionMode: "fast", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); // Verify task completed normally even without workflow steps // Completion blockers (test/build/typecheck) are checked via getTaskCompletionBlocker // which is called before finalizing expect(onComplete).toHaveBeenCalled(); }); }); }); describe("determineRevisionResetStart", () => { const steps = [ { name: "Preflight" }, { name: "Reposition the agent badge in TaskCard.tsx" }, { name: "Restyle `.card-agent-badge` and add `.card-agent-row` in styles.css" }, { name: "Update tests" }, { name: "Testing & Verification" }, { name: "Documentation & Delivery" }, ]; it("skips Preflight when feedback targets a later step", () => { // Feedback is phrased to hit step 2's "restyle" without also mentioning // step 1's distinctive tokens (reposition / badge / taskcard / agent). const feedback = "The card styling needs more contrast — restyle the class tokens."; expect(determineRevisionResetStart(steps, feedback)).toBe(2); }); it("matches earliest step when multiple step names are mentioned", () => { const feedback = "The reposition logic is off, and the restyle tokens also need a second pass."; expect(determineRevisionResetStart(steps, feedback)).toBe(1); }); it("falls back to first non-Preflight step when feedback matches nothing", () => { const feedback = "Please improve overall polish and typography hierarchy."; expect(determineRevisionResetStart(steps, feedback)).toBe(1); }); it("never resets a Preflight step even if feedback somehow mentions preflight", () => { const feedback = "Preflight context looks wrong; restyle the row as well."; // Step 0 is Preflight → skipped. Earliest remaining match is step 2 (restyle). expect(determineRevisionResetStart(steps, feedback)).toBe(2); }); it("is case-insensitive across both feedback and step names", () => { const feedback = "RESTYLE the component, please."; expect(determineRevisionResetStart(steps, feedback)).toBe(2); }); it("returns 0 when there is no Preflight and no match", () => { const noPreflight = [{ name: "Apply Fix" }, { name: "Testing & Verification" }]; expect(determineRevisionResetStart(noPreflight, "please improve polish")).toBe(0); }); it("returns steps.length for an empty step list (nothing to reset)", () => { expect(determineRevisionResetStart([], "anything")).toBe(0); }); it("returns steps.length when only a Preflight step exists (nothing to reset)", () => { expect(determineRevisionResetStart([{ name: "Preflight" }], "anything")).toBe(1); }); it("ignores short tokens like 'test' to avoid matching 'Update tests' for generic feedback", () => { // "test" is 4 chars — below the 5+ char threshold — so generic feedback // mentioning "test" alone should not target the "Update tests" step. const feedback = "Please test this by clicking."; expect(determineRevisionResetStart(steps, feedback)).toBe(1); }); });