import { describe, it, expect, vi, beforeEach } from "vitest"; import type { PrMonitor } from "./pr-monitor.js"; import { Scheduler, pathsOverlap } from "./scheduler.js"; import { AgentSemaphore } from "./concurrency.js"; import type { TaskStore, Task, TaskDetail } from "@fusion/core"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; // Mock fs modules vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, existsSync: vi.fn(), }; }); vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readFile: vi.fn(), }; }); // Helper to create mock tasks function createMockTask(overrides: Partial = {}): Task { return { id: "FN-001", description: "Test task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", prompt: "", ...overrides, } as Task; } // Mock store factory function createMockStore(overrides: Partial = {}): TaskStore { return { listTasks: vi.fn().mockResolvedValue([]), getSettings: vi.fn().mockResolvedValue({}), updateTask: vi.fn().mockResolvedValue(undefined), moveTask: vi.fn().mockResolvedValue(undefined), parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), logEntry: vi.fn().mockResolvedValue(undefined), getRootDir: vi.fn().mockReturnValue("/test/project"), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), on: vi.fn(), off: vi.fn(), ...overrides, } as unknown as TaskStore; } describe("pathsOverlap", () => { it("returns false for empty arrays", () => { expect(pathsOverlap([], [])).toBe(false); expect(pathsOverlap(["src/index.ts"], [])).toBe(false); expect(pathsOverlap([], ["src/index.ts"])).toBe(false); }); it("detects exact file path matches", () => { expect(pathsOverlap(["src/index.ts"], ["src/index.ts"])).toBe(true); expect(pathsOverlap(["a.ts", "b.ts"], ["b.ts", "c.ts"])).toBe(true); }); it("detects directory prefix overlaps with /* globs", () => { // Directory glob overlaps with file in that directory expect(pathsOverlap(["src/*"], ["src/index.ts"])).toBe(true); expect(pathsOverlap(["src/*"], ["src/utils/helpers.ts"])).toBe(true); // File overlaps with directory glob containing it expect(pathsOverlap(["src/index.ts"], ["src/*"])).toBe(true); }); it("detects nested directory overlaps", () => { expect(pathsOverlap(["src/components/*"], ["src/components/Button.tsx"])).toBe(true); expect(pathsOverlap(["src/*"], ["src/components/Button.tsx"])).toBe(true); }); it("returns false for non-overlapping paths", () => { expect(pathsOverlap(["src/*"], ["test/*"])).toBe(false); expect(pathsOverlap(["src/index.ts"], ["test/index.ts"])).toBe(false); expect(pathsOverlap(["a.ts", "b.ts"], ["c.ts", "d.ts"])).toBe(false); }); it("handles multiple paths in each array", () => { const a = ["src/*", "test/*"]; const b = ["src/components/Button.tsx"]; expect(pathsOverlap(a, b)).toBe(true); const c = ["docs/*", "examples/*"]; const d = ["src/index.ts"]; expect(pathsOverlap(c, d)).toBe(false); }); it("handles mixed globs and exact paths", () => { expect(pathsOverlap(["src/*", "package.json"], ["package.json"])).toBe(true); expect(pathsOverlap(["src/*", "package.json"], ["README.md"])).toBe(false); }); it("handles both having globs with overlapping prefixes", () => { expect(pathsOverlap(["src/*"], ["src/components/*"])).toBe(true); expect(pathsOverlap(["src/components/*"], ["src/*"])).toBe(true); }); }); describe("Scheduler", () => { // Helper to create mock MissionStore (shared across mission-related test suites) function createMockMissionStore(overrides = {}) { return { getFeatureByTaskId: vi.fn(), updateFeatureStatus: vi.fn().mockResolvedValue(undefined), getSlice: vi.fn(), getMilestone: vi.fn(), computeSliceStatus: vi.fn(), getMission: vi.fn(), getMissionWithHierarchy: vi.fn(), findNextPendingSlice: vi.fn(), activateSlice: vi.fn(), ...overrides, }; } describe("constructor", () => { it("initializes with default options", () => { const store = createMockStore(); const scheduler = new Scheduler(store); expect(scheduler).toBeDefined(); }); it("registers settings update handlers", () => { const store = createMockStore(); const scheduler = new Scheduler(store); expect(store.on).toHaveBeenCalledWith("settings:updated", expect.any(Function)); }); it("accepts custom options", () => { const store = createMockStore(); const onSchedule = vi.fn(); const onBlocked = vi.fn(); const scheduler = new Scheduler(store, { maxConcurrent: 3, maxWorktrees: 6, pollIntervalMs: 5000, onSchedule, onBlocked, }); expect(scheduler).toBeDefined(); }); }); describe("event-driven scheduling", () => { it("registers task:created event listener", () => { const store = createMockStore(); new Scheduler(store); // Verify task:created listener is registered expect(store.on).toHaveBeenCalledWith("task:created", expect.any(Function)); }); it("triggers scheduling immediately when task:created event fires", async () => { // Mock filesystem validation so schedule() can proceed vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); // First call (from start()) returns empty todo, second call (from event) returns the new task const listTasksMock = vi.fn() .mockResolvedValueOnce([]) // Initial schedule from start() sees no tasks .mockResolvedValue([ createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), ]); const store = createMockStore({ listTasks: listTasksMock, getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), moveTask: vi.fn().mockResolvedValue(undefined), }); const scheduler = new Scheduler(store); scheduler.start(); // Wait for initial schedule pass to complete await new Promise((r) => setTimeout(r, 10)); // Find and call the task:created handler const onCalls = (store.on as any).mock.calls; const createdHandler = onCalls.find((call: any) => call[0] === "task:created")?.[1]; expect(createdHandler).toBeDefined(); // Simulate task:created event — triggers schedule() which now sees FN-001 const newTask = createMockTask({ id: "FN-001", column: "todo" }); await createdHandler(newTask); // Wait for async schedule to complete await new Promise((r) => setTimeout(r, 10)); // Verify schedule() was called (moveTask should be called since task can start) expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); }); it("registers task:moved event listener", () => { const store = createMockStore(); new Scheduler(store); // Verify task:moved listener is registered expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function)); }); it("triggers scheduling immediately when task:moved to done event fires", async () => { // Mock filesystem validation so schedule() can proceed vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); // Initially return only FN-001 in-progress so start() doesn't schedule FN-002 const listTasksMock = vi.fn() .mockResolvedValueOnce([ createMockTask({ id: "FN-001", column: "in-progress", dependencies: [] }), createMockTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }), ]) // After event fires, FN-001 is done so FN-002's deps are satisfied .mockResolvedValue([ createMockTask({ id: "FN-001", column: "done", dependencies: [] }), createMockTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }), ]); const store = createMockStore({ listTasks: listTasksMock, getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), moveTask: vi.fn().mockResolvedValue(undefined), }); const scheduler = new Scheduler(store); scheduler.start(); // Wait for initial schedule pass to complete await new Promise((r) => setTimeout(r, 10)); // Find and call the task:moved handler const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; expect(movedHandler).toBeDefined(); // Simulate task:moved to done event const doneTask = createMockTask({ id: "FN-001", column: "in-progress" }); await movedHandler({ task: doneTask, from: "in-progress", to: "done" }); // Wait for async schedule to complete await new Promise((r) => setTimeout(r, 10)); // Verify schedule() was called - FN-002 should now be able to start expect(store.moveTask).toHaveBeenCalledWith("FN-002", "in-progress"); }); it("does not trigger scheduling for non-done task:moved events", async () => { const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([ createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), ]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), moveTask: vi.fn().mockResolvedValue(undefined), }); const scheduler = new Scheduler(store); scheduler.start(); // Clear previous calls (store.moveTask as any).mockClear(); // Find and call the task:moved handler const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; // Simulate task:moved to in-progress (not done) const task = createMockTask({ id: "FN-001", column: "in-progress" }); await movedHandler({ task, from: "todo", to: "in-progress" }); // Should NOT have triggered additional scheduling (no new task moved to in-progress) // Note: The existing handler runs, but it doesn't call schedule() for non-done transitions // So moveTask won't be called for a task already in in-progress expect(store.moveTask).not.toHaveBeenCalled(); }); it("triggers scheduling when task moves to todo (retry)", async () => { // Mock filesystem validation so schedule() can proceed vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); // Return FN-001 in todo with satisfied deps const listTasksMock = vi.fn() .mockResolvedValueOnce([]) // Initial schedule from start() .mockResolvedValueOnce([ createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), ]); const store = createMockStore({ listTasks: listTasksMock, getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), moveTask: vi.fn().mockResolvedValue(undefined), }); const scheduler = new Scheduler(store); scheduler.start(); // Wait for initial schedule pass to complete await new Promise((r) => setTimeout(r, 10)); // Find and call the task:moved handler const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; expect(movedHandler).toBeDefined(); // Simulate task:moved to todo (retry scenario) const todoTask = createMockTask({ id: "FN-001", column: "in-progress" }); await movedHandler({ task: todoTask, from: "in-progress", to: "todo" }); // Wait for async schedule to complete await new Promise((r) => setTimeout(r, 10)); // Verify schedule() was called — task in todo should be scheduled expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); }); }); describe("task unpause scheduling", () => { it("triggers scheduling immediately when a paused todo task is unpaused", async () => { // Mock filesystem validation so schedule() can proceed vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); const listTasksMock = vi.fn() .mockResolvedValueOnce([]) // Initial schedule from start() .mockResolvedValueOnce([ createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), ]); const store = createMockStore({ listTasks: listTasksMock, getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), moveTask: vi.fn().mockResolvedValue(undefined), }); const scheduler = new Scheduler(store); scheduler.start(); // Wait for initial schedule pass to complete await new Promise((r) => setTimeout(r, 10)); // Find the task:updated handler const onCalls = (store.on as any).mock.calls; const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; expect(updatedHandler).toBeDefined(); // First, simulate pause event (to register the task as paused) await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: true })); // Now simulate unpause event await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined })); // Wait for async scheduling to complete await new Promise((r) => setTimeout(r, 10)); // Should have triggered scheduling and moved the task to in-progress expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); }); it("does not trigger scheduling on unpause if scheduler is not running", async () => { const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), }); const scheduler = new Scheduler(store); // Don't start the scheduler const onCalls = (store.on as any).mock.calls; const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; // Pause then unpause await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: true })); await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined })); // Should NOT have moved any tasks expect(store.moveTask).not.toHaveBeenCalled(); }); it("does not trigger scheduling for tasks that were never paused", async () => { const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([ createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), ]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), moveTask: vi.fn().mockResolvedValue(undefined), }); const scheduler = new Scheduler(store); scheduler.start(); await new Promise((r) => setTimeout(r, 10)); // Clear calls from initial schedule (store.moveTask as any).mockClear(); const onCalls = (store.on as any).mock.calls; const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; // Fire task:updated for a task that was never paused — should NOT trigger extra scheduling await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined })); await new Promise((r) => setTimeout(r, 10)); // moveTask should not be called (no scheduling triggered) expect(store.moveTask).not.toHaveBeenCalled(); }); it("does not trigger scheduling on unpause for in-progress tasks", async () => { const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), moveTask: vi.fn().mockResolvedValue(undefined), }); const scheduler = new Scheduler(store); scheduler.start(); await new Promise((r) => setTimeout(r, 10)); (store.moveTask as any).mockClear(); const onCalls = (store.on as any).mock.calls; const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; // Pause then unpause an in-progress task — executor handles this, not scheduler await updatedHandler(createMockTask({ id: "FN-001", column: "in-progress", paused: true })); await updatedHandler(createMockTask({ id: "FN-001", column: "in-progress", paused: undefined })); await new Promise((r) => setTimeout(r, 10)); // Scheduler should NOT try to schedule an in-progress task expect(store.moveTask).not.toHaveBeenCalled(); }); it("triggers scheduling for unpaused triage tasks", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); const scheduleSpy = vi.spyOn(Scheduler.prototype, "schedule"); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), }); const scheduler = new Scheduler(store); scheduler.start(); await new Promise((r) => setTimeout(r, 10)); // Clear calls from initial start() schedule scheduleSpy.mockClear(); const onCalls = (store.on as any).mock.calls; const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; // Pause then unpause a triage task await updatedHandler(createMockTask({ id: "FN-001", column: "triage", paused: true })); await updatedHandler(createMockTask({ id: "FN-001", column: "triage", paused: undefined })); // schedule() should have been triggered by the unpause expect(scheduleSpy).toHaveBeenCalled(); scheduleSpy.mockRestore(); }); }); describe("start/stop", () => { it("starts and stops the scheduler", () => { const store = createMockStore(); const scheduler = new Scheduler(store); scheduler.start(); // Should set up polling interval scheduler.stop(); // Should clear polling interval }); }); describe("schedule() concurrency limits", () => { it("respects maxConcurrent limit", async () => { const tasks = [ createMockTask({ id: "FN-001", column: "in-progress" }), createMockTask({ id: "FN-002", column: "in-progress" }), createMockTask({ id: "FN-003", column: "todo" }), createMockTask({ id: "FN-004", column: "todo" }), ]; const store = createMockStore({ listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), moveTask: vi.fn().mockResolvedValue(undefined), }); const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); // With 2 already in-progress and maxConcurrent=2, no new tasks should start expect(store.moveTask).not.toHaveBeenCalled(); }); it("respects maxWorktrees limit", async () => { const tasks = [ createMockTask({ id: "FN-001", column: "in-progress" }), createMockTask({ id: "FN-002", column: "in-progress" }), createMockTask({ id: "FN-003", column: "in-progress" }), createMockTask({ id: "FN-004", column: "in-progress" }), createMockTask({ id: "FN-005", column: "todo" }), ]; const store = createMockStore({ listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 4 }), }); const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); // With 4 in-progress and maxWorktrees=4, no new tasks should start expect(store.moveTask).not.toHaveBeenCalled(); }); }); describe("worktree reservation", () => { it("assigns a planned worktree path before moving a task to in-progress", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); const task = createMockTask({ id: "FN-010", column: "todo" }); const updateTask = vi.fn().mockResolvedValue(undefined); const moveTask = vi.fn().mockResolvedValue(undefined); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([task]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, worktreeNaming: "task-id" }), updateTask, moveTask, }); const scheduler = new Scheduler(store); (scheduler as any).running = true; await scheduler.schedule(); expect(updateTask).toHaveBeenCalledWith("FN-010", { status: null, blockedBy: null, baseBranch: undefined, worktree: "/test/project/.worktrees/fn-010", }); expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress"); expect(updateTask.mock.invocationCallOrder[0]).toBeLessThan(moveTask.mock.invocationCallOrder[0]); }); it("reserves unique random worktree names within the same scheduling pass", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); const randomSpy = vi.spyOn(Math, "random") .mockReturnValueOnce(0) .mockReturnValueOnce(0) .mockReturnValueOnce(0) .mockReturnValueOnce(0); const updateTask = vi.fn().mockResolvedValue(undefined); const moveTask = vi.fn().mockResolvedValue(undefined); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([ createMockTask({ id: "FN-011", column: "todo" }), createMockTask({ id: "FN-012", column: "todo" }), ]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 4, maxWorktrees: 4, worktreeNaming: "random" }), updateTask, moveTask, }); const scheduler = new Scheduler(store); (scheduler as any).running = true; await scheduler.schedule(); expect(updateTask).toHaveBeenNthCalledWith(1, "FN-011", { status: null, blockedBy: null, baseBranch: undefined, worktree: "/test/project/.worktrees/amber-aspen", }); expect(updateTask).toHaveBeenNthCalledWith(2, "FN-012", { status: null, blockedBy: null, baseBranch: undefined, worktree: "/test/project/.worktrees/amber-aspen-2", }); randomSpy.mockRestore(); }); }); describe("semaphore integration", () => { it("respects semaphore available count", async () => { const semaphore = { availableCount: 0, totalCount: 2, acquire: vi.fn().mockResolvedValue(undefined), release: vi.fn(), } as unknown as AgentSemaphore; const tasks = [ createMockTask({ id: "FN-001", column: "in-progress" }), createMockTask({ id: "FN-002", column: "in-progress" }), createMockTask({ id: "FN-003", column: "todo" }), ]; const store = createMockStore({ listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 4 }), }); const scheduler = new Scheduler(store, { semaphore }); scheduler.start(); await scheduler.schedule(); expect(store.moveTask).not.toHaveBeenCalled(); }); }); describe("global pause", () => { it("halts scheduling when globalPause is active", async () => { const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-001", column: "todo" })]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, globalPause: true, }), }); const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); expect(store.moveTask).not.toHaveBeenCalled(); }); }); describe("engine pause", () => { it("halts new scheduling when enginePaused is active", async () => { const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-001", column: "todo" })]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, enginePaused: true, }), }); const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); expect(store.moveTask).not.toHaveBeenCalled(); }); }); describe("filesystem validation", () => { it("validates tasks using the .fusion task directory layout", async () => { const todoTask = createMockTask({ id: "FN-010", column: "todo" }); const moveTask = vi.fn().mockResolvedValue(undefined); const updateTask = vi.fn().mockResolvedValue(undefined); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([todoTask]), moveTask, updateTask, }); vi.mocked(existsSync).mockImplementation((path) => { const value = String(path); return value.includes(".fusion/tasks/FN-010") || value.includes("PROMPT.md"); }); vi.mocked(readFile).mockResolvedValue("# Prompt\n" as any); const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); // Flush any remaining microtasks await new Promise(resolve => setTimeout(resolve, 0)); expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress"); expect(moveTask).not.toHaveBeenCalledWith("FN-010", "triage"); }); it("moves task to triage when task directory is missing", async () => { const tasks = [ createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), ]; const store = createMockStore({ listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), getRootDir: vi.fn().mockReturnValue("/test/project"), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); // Set up mocks directly on the store const moveTask = vi.fn().mockResolvedValue(undefined); const logEntry = vi.fn().mockResolvedValue(undefined); store.moveTask = moveTask; store.logEntry = logEntry; // Mock missing directory vi.mocked(existsSync).mockReturnValue(false); const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); // Flush any remaining microtasks await new Promise(resolve => setTimeout(resolve, 0)); // Task should be moved to triage expect(moveTask).toHaveBeenCalledWith("FN-001", "triage"); // Log entry should be written with reason expect(logEntry).toHaveBeenCalledWith( "FN-001", "Task moved to triage — filesystem validation failed", "missing directory" ); // Task should not be moved to in-progress expect(moveTask).not.toHaveBeenCalledWith("FN-001", "in-progress"); }); it("moves task to triage when PROMPT.md is missing", async () => { const tasks = [ createMockTask({ id: "FN-002", column: "todo", dependencies: [] }), ]; const store = createMockStore({ listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), getRootDir: vi.fn().mockReturnValue("/test/project"), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); const moveTask = vi.fn().mockResolvedValue(undefined); const logEntry = vi.fn().mockResolvedValue(undefined); store.moveTask = moveTask; store.logEntry = logEntry; // Mock directory exists but PROMPT.md doesn't vi.mocked(existsSync).mockImplementation((path) => { if (typeof path === "string" && path.includes("FN-002") && !path.endsWith("PROMPT.md")) { return true; // Directory exists } return false; // PROMPT.md missing }); const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); // Flush any remaining microtasks await new Promise(resolve => setTimeout(resolve, 0)); expect(moveTask).toHaveBeenCalledWith("FN-002", "triage"); expect(logEntry).toHaveBeenCalledWith( "FN-002", "Task moved to triage — filesystem validation failed", "missing or empty PROMPT.md" ); }); it("moves task to triage when PROMPT.md is empty", async () => { const tasks = [ createMockTask({ id: "FN-003", column: "todo", dependencies: [] }), ]; const store = createMockStore({ listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), getRootDir: vi.fn().mockReturnValue("/test/project"), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); const moveTask = vi.fn().mockResolvedValue(undefined); const logEntry = vi.fn().mockResolvedValue(undefined); store.moveTask = moveTask; store.logEntry = logEntry; // Mock directory and PROMPT.md exist vi.mocked(existsSync).mockReturnValue(true); // Mock empty file content vi.mocked(readFile).mockResolvedValue(" "); // whitespace only const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); // Flush any remaining microtasks await new Promise(resolve => setTimeout(resolve, 0)); expect(moveTask).toHaveBeenCalledWith("FN-003", "triage"); expect(logEntry).toHaveBeenCalledWith( "FN-003", "Task moved to triage — filesystem validation failed", "missing or empty PROMPT.md" ); }); it("proceeds with scheduling when filesystem is valid", async () => { const tasks = [ createMockTask({ id: "FN-004", column: "todo", dependencies: [] }), ]; const store = createMockStore({ listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), getRootDir: vi.fn().mockReturnValue("/test/project"), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); const moveTask = vi.fn().mockResolvedValue(undefined); const logEntry = vi.fn().mockResolvedValue(undefined); store.moveTask = moveTask; store.logEntry = logEntry; // Mock directory and PROMPT.md exist with valid content vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue("# Valid PROMPT.md content\n\nThis task is valid."); const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); // Flush any remaining microtasks await new Promise(resolve => setTimeout(resolve, 0)); // Should NOT move to triage expect(moveTask).not.toHaveBeenCalledWith("FN-004", "triage"); // Should NOT log validation failure expect(logEntry).not.toHaveBeenCalledWith( "FN-004", "Task moved to triage — filesystem validation failed", expect.any(String) ); // Should move to in-progress (since deps are satisfied and concurrency allows) expect(moveTask).toHaveBeenCalledWith("FN-004", "in-progress"); }); it("does not validate filesystem for tasks with unmet dependencies", async () => { const tasks = [ createMockTask({ id: "FN-005", column: "todo", dependencies: ["FN-006"] }), createMockTask({ id: "FN-006", column: "todo", dependencies: [] }), // Unsatisfied dep ]; const store = createMockStore({ listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), updateTask: vi.fn().mockResolvedValue(undefined), getRootDir: vi.fn().mockReturnValue("/test/project"), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); const moveTask = vi.fn().mockResolvedValue(undefined); const updateTask = vi.fn().mockResolvedValue(undefined); store.moveTask = moveTask; store.updateTask = updateTask; // Mock that directory/PROMPT.md don't exist (would fail validation if checked) vi.mocked(existsSync).mockReturnValue(false); const scheduler = new Scheduler(store); scheduler.start(); await scheduler.schedule(); // Flush any remaining microtasks await new Promise(resolve => setTimeout(resolve, 0)); // Task with unmet deps should be queued, not validated // Since KB-006 is not done, KB-005 should not be validated expect(updateTask).toHaveBeenCalledWith("FN-005", { status: "queued" }); // No filesystem validation should occur (no move to triage) expect(moveTask).not.toHaveBeenCalledWith("FN-005", "triage"); }); }); describe("pr monitoring", () => { it("stops monitoring when task moves out of in-review based on from column", () => { const prMonitor = { startMonitoring: vi.fn(), stopMonitoring: vi.fn(), updatePrInfo: vi.fn(), getTrackedPrs: vi.fn().mockReturnValue(new Map()), stopAll: vi.fn(), } as unknown as PrMonitor; const store = createMockStore(); new Scheduler(store, { prMonitor }); const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; const task = createMockTask({ id: "FN-001", column: "done", prInfo: { status: "open" } as any }); movedHandler({ task, from: "in-review", to: "done" }); expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); }); it("invokes onClosedPrFeedback with drained comments for closed/merged PR", async () => { const mockComments = [ { id: 1, body: "Fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "https://example.com" }, { id: 2, body: "Update that", user: { login: "reviewer2" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "https://example.com" }, ]; const prMonitor = { startMonitoring: vi.fn(), stopMonitoring: vi.fn(), updatePrInfo: vi.fn(), getTrackedPrs: vi.fn().mockReturnValue(new Map()), stopAll: vi.fn(), drainComments: vi.fn().mockReturnValue(mockComments), } as unknown as PrMonitor; const onClosedPrFeedback = vi.fn().mockResolvedValue(undefined); const store = createMockStore(); new Scheduler(store, { prMonitor, onClosedPrFeedback }); const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; const task = createMockTask({ id: "FN-001", column: "done", prInfo: { status: "merged", number: 42 } as any, }); movedHandler({ task, from: "in-review", to: "done" }); // Wait for the void Promise.resolve chain to complete await new Promise((r) => setTimeout(r, 10)); expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001"); expect(onClosedPrFeedback).toHaveBeenCalledWith("FN-001", task.prInfo, mockComments); expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); }); it("does not invoke onClosedPrFeedback when buffer is empty", async () => { const prMonitor = { startMonitoring: vi.fn(), stopMonitoring: vi.fn(), updatePrInfo: vi.fn(), getTrackedPrs: vi.fn().mockReturnValue(new Map()), stopAll: vi.fn(), drainComments: vi.fn().mockReturnValue([]), } as unknown as PrMonitor; const onClosedPrFeedback = vi.fn(); const store = createMockStore(); new Scheduler(store, { prMonitor, onClosedPrFeedback }); const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; const task = createMockTask({ id: "FN-001", column: "done", prInfo: { status: "merged", number: 42 } as any, }); movedHandler({ task, from: "in-review", to: "done" }); await new Promise((r) => setTimeout(r, 10)); expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001"); expect(onClosedPrFeedback).not.toHaveBeenCalled(); expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); }); it("does not invoke onClosedPrFeedback for open PR", async () => { const prMonitor = { startMonitoring: vi.fn(), stopMonitoring: vi.fn(), updatePrInfo: vi.fn(), getTrackedPrs: vi.fn().mockReturnValue(new Map()), stopAll: vi.fn(), drainComments: vi.fn(), } as unknown as PrMonitor; const onClosedPrFeedback = vi.fn(); const store = createMockStore(); new Scheduler(store, { prMonitor, onClosedPrFeedback }); const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; const task = createMockTask({ id: "FN-001", column: "done", prInfo: { status: "open", number: 42 } as any, }); movedHandler({ task, from: "in-review", to: "done" }); await new Promise((r) => setTimeout(r, 10)); expect(prMonitor.drainComments).not.toHaveBeenCalled(); expect(onClosedPrFeedback).not.toHaveBeenCalled(); expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); }); it("does not invoke onClosedPrFeedback when callback is not provided", async () => { const prMonitor = { startMonitoring: vi.fn(), stopMonitoring: vi.fn(), updatePrInfo: vi.fn(), getTrackedPrs: vi.fn().mockReturnValue(new Map()), stopAll: vi.fn(), drainComments: vi.fn().mockReturnValue([ { id: 1, body: "Fix", user: { login: "r" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" }, ]), } as unknown as PrMonitor; // No onClosedPrFeedback provided const store = createMockStore(); new Scheduler(store, { prMonitor }); const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; const task = createMockTask({ id: "FN-001", column: "done", prInfo: { status: "closed", number: 42 } as any, }); // Should not throw movedHandler({ task, from: "in-review", to: "done" }); await new Promise((r) => setTimeout(r, 10)); expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001"); expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); }); it("drains comments before stopping monitoring (order matters)", async () => { const callOrder: string[] = []; const mockComments = [ { id: 1, body: "Fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" }, ]; const prMonitor = { startMonitoring: vi.fn(), stopMonitoring: vi.fn(() => { callOrder.push("stopMonitoring"); }), updatePrInfo: vi.fn(), getTrackedPrs: vi.fn().mockReturnValue(new Map()), stopAll: vi.fn(), drainComments: vi.fn(() => { callOrder.push("drainComments"); return mockComments; }), } as unknown as PrMonitor; const onClosedPrFeedback = vi.fn().mockResolvedValue(undefined); const store = createMockStore(); new Scheduler(store, { prMonitor, onClosedPrFeedback }); const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; const task = createMockTask({ id: "FN-001", column: "done", prInfo: { status: "merged", number: 42 } as any, }); movedHandler({ task, from: "in-review", to: "done" }); await new Promise((r) => setTimeout(r, 10)); // drainComments should be called before stopMonitoring expect(callOrder).toEqual(["drainComments", "stopMonitoring"]); }); it("second move event with empty drain does not create duplicate follow-up", async () => { const prMonitor = { startMonitoring: vi.fn(), stopMonitoring: vi.fn(), updatePrInfo: vi.fn(), getTrackedPrs: vi.fn().mockReturnValue(new Map()), stopAll: vi.fn(), drainComments: vi.fn().mockReturnValue([]), } as unknown as PrMonitor; const onClosedPrFeedback = vi.fn(); const store = createMockStore(); new Scheduler(store, { prMonitor, onClosedPrFeedback }); const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; const task = createMockTask({ id: "FN-001", column: "done", prInfo: { status: "merged", number: 42 } as any, }); // First move — comments were already drained, buffer is empty movedHandler({ task, from: "in-review", to: "done" }); await new Promise((r) => setTimeout(r, 10)); // Second move — still empty movedHandler({ task, from: "in-review", to: "done" }); await new Promise((r) => setTimeout(r, 10)); // onClosedPrFeedback should never be called since buffer is empty expect(onClosedPrFeedback).not.toHaveBeenCalled(); }); }); describe("mission integration", () => { it("activateNextPendingSlice returns null when no missionStore", async () => { const store = createMockStore(); const scheduler = new Scheduler(store); const result = await scheduler.activateNextPendingSlice("M-001"); expect(result).toBeNull(); }); it("triggers feature in-progress update when task with sliceId moves to in-progress", async () => { const mockMissionStore = createMockMissionStore({ getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "triaged" }), updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "in-progress" }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); // Trigger task:moved event by calling the registered handler const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; expect(movedHandler).toBeDefined(); // Simulate task moving to in-progress with sliceId const task = createMockTask({ id: "FN-001", sliceId: "SL-001" }); movedHandler({ task, to: "in-progress" }); await Promise.resolve(); expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001"); expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress"); }); it("does not update feature status when already past triaged", async () => { const mockMissionStore = createMockMissionStore({ getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "in-progress" }), updateFeatureStatus: vi.fn(), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; const task = createMockTask({ id: "FN-001", sliceId: "SL-001" }); movedHandler({ task, to: "in-progress" }); await Promise.resolve(); expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001"); expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); }); it("onSliceComplete auto-advances when autoAdvance is enabled", async () => { const missionHierarchy = { id: "M-001", status: "active", milestones: [ { id: "MS-001", dependencies: [], slices: [ { id: "SL-001", status: "complete" }, { id: "SL-002", status: "pending" }, ], }, ], }; const mockMissionStore = createMockMissionStore({ getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: true }), getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; await scheduler.onSliceComplete(slice); expect(mockMissionStore.getMilestone).toHaveBeenCalledWith("MS-001"); expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); expect(mockMissionStore.getMissionWithHierarchy).toHaveBeenCalledWith("M-001"); expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); }); it("onSliceComplete does not auto-advance when autoAdvance is disabled", async () => { const mockMissionStore = createMockMissionStore({ getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: false }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; await scheduler.onSliceComplete(slice); expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); }); // ── autopilotEnabled as primary control for onSliceComplete fallback ───────── it("onSliceComplete auto-advances when autopilotEnabled is true (autoAdvance false)", async () => { const missionHierarchy = { id: "M-001", status: "active", milestones: [ { id: "MS-001", dependencies: [], slices: [ { id: "SL-001", status: "complete" }, { id: "SL-002", status: "pending" }, ], }, ], }; const mockMissionStore = createMockMissionStore({ getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autopilotEnabled: true, autoAdvance: false }), getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; await scheduler.onSliceComplete(slice); expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); }); it("onSliceComplete auto-advances when autopilotEnabled is true (autoAdvance unset)", async () => { const missionHierarchy = { id: "M-001", status: "active", milestones: [ { id: "MS-001", dependencies: [], slices: [ { id: "SL-001", status: "complete" }, { id: "SL-002", status: "pending" }, ], }, ], }; const mockMissionStore = createMockMissionStore({ getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autopilotEnabled: true }), getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; await scheduler.onSliceComplete(slice); expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); }); it("onSliceComplete does not auto-advance when both autopilotEnabled and autoAdvance are false", async () => { const mockMissionStore = createMockMissionStore({ getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autopilotEnabled: false, autoAdvance: false }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; await scheduler.onSliceComplete(slice); expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); }); it("onSliceComplete auto-advances when autopilotEnabled is false but autoAdvance is true (legacy compat)", async () => { const missionHierarchy = { id: "M-001", status: "active", milestones: [ { id: "MS-001", dependencies: [], slices: [ { id: "SL-001", status: "complete" }, { id: "SL-002", status: "pending" }, ], }, ], }; const mockMissionStore = createMockMissionStore({ getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: true }), getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; await scheduler.onSliceComplete(slice); expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); }); it("skips mission progression when task sliceId mismatches linked feature sliceId", async () => { const mockMissionStore = createMockMissionStore({ getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-OTHER" }), updateFeatureStatus: vi.fn(), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const onCalls = (store.on as any).mock.calls; const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; const task = createMockTask({ id: "FN-001", sliceId: "SL-001" }); movedHandler({ task, from: "in-progress", to: "done" }); await Promise.resolve(); expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); expect(mockMissionStore.getSlice).not.toHaveBeenCalled(); }); it("onSliceComplete does not auto-advance when mission is not active", async () => { const mockMissionStore = createMockMissionStore({ getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "planning", autoAdvance: true }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; await scheduler.onSliceComplete(slice); expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); }); it("handles task with sliceId but no linked feature gracefully", async () => { const mockMissionStore = createMockMissionStore({ getFeatureByTaskId: vi.fn().mockReturnValue(undefined), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; await scheduler.onSliceComplete(slice); // onSliceComplete does not call getFeatureByTaskId; it checks milestone/mission/missionHierarchy // This test verifies no errors are thrown when slice has no linked feature expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); }); it("activateNextPendingSlice finds and activates correct slice", async () => { const nextSlice = { id: "SL-002", status: "pending", orderIndex: 1 }; const mockMissionStore = createMockMissionStore({ getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "active", milestones: [ { id: "MS-001", orderIndex: 0, dependencies: [], slices: [ nextSlice, { id: "SL-003", status: "pending", orderIndex: 2 }, { id: "SL-001", status: "complete", orderIndex: 0 }, ], }, ], }), activateSlice: vi.fn().mockReturnValue({ ...nextSlice, status: "active" }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const result = await scheduler.activateNextPendingSlice("M-001"); expect(mockMissionStore.getMissionWithHierarchy).toHaveBeenCalledWith("M-001"); expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); expect(result).toEqual({ id: "SL-002", status: "active", orderIndex: 1 }); }); it("activateNextPendingSlice skips milestones with incomplete dependencies", async () => { const mockMissionStore = createMockMissionStore({ getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "active", milestones: [ { id: "MS-001", orderIndex: 0, status: "planning", dependencies: ["MS-999"], slices: [{ id: "SL-001", status: "pending", orderIndex: 0 }], }, { id: "MS-002", orderIndex: 1, status: "planning", dependencies: [], slices: [{ id: "SL-002", status: "pending", orderIndex: 0 }], }, ], }), activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const result = await scheduler.activateNextPendingSlice("M-001"); expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); expect(result).toEqual({ id: "SL-002", status: "active" }); }); it("activateNextPendingSlice returns null when mission is not active", async () => { const mockMissionStore = createMockMissionStore({ getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "planning", milestones: [], }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const result = await scheduler.activateNextPendingSlice("M-001"); expect(result).toBeNull(); expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); }); it("activateNextPendingSlice returns null when no pending slices", async () => { const mockMissionStore = createMockMissionStore({ getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "active", milestones: [ { id: "MS-001", orderIndex: 0, dependencies: [], slices: [{ id: "SL-001", status: "complete", orderIndex: 0 }], }, ], }), }); const store = createMockStore(); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); const result = await scheduler.activateNextPendingSlice("M-001"); expect(result).toBeNull(); expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); }); }); describe("blocked mission scheduling", () => { it("skips tasks belonging to a blocked mission", async () => { const task = createMockTask({ id: "FN-100", column: "todo", sliceId: "SL-001", }); const mockMissionStore = createMockMissionStore({ getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }), getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "blocked" }), }); (existsSync as any).mockReturnValue(true); (readFile as any).mockResolvedValue("# Task\n\nSome content\n"); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([task]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); const onSchedule = vi.fn(); const scheduler = new Scheduler(store, { onSchedule, missionStore: mockMissionStore as any }); (scheduler as any).running = true; await scheduler.schedule(); // Task should NOT be scheduled because its mission is blocked expect(store.moveTask).not.toHaveBeenCalled(); expect(onSchedule).not.toHaveBeenCalled(); }); it("schedules tasks when mission is active", async () => { const task = createMockTask({ id: "FN-100", column: "todo", sliceId: "SL-001", }); const mockMissionStore = createMockMissionStore({ getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }), getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active" }), }); (existsSync as any).mockReturnValue(true); (readFile as any).mockResolvedValue("# Task\n\nSome content\n"); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([task]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); const onSchedule = vi.fn(); const scheduler = new Scheduler(store, { onSchedule, missionStore: mockMissionStore as any }); (scheduler as any).running = true; await scheduler.schedule(); expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress"); }); it("schedules tasks without sliceId regardless of mission state", async () => { const task = createMockTask({ id: "FN-100", column: "todo", // No sliceId — not associated with any mission }); (existsSync as any).mockReturnValue(true); (readFile as any).mockResolvedValue("# Task\n\nSome content\n"); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([task]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); const onSchedule = vi.fn(); const scheduler = new Scheduler(store, { onSchedule, missionStore: createMockMissionStore() as any }); (scheduler as any).running = true; await scheduler.schedule(); expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress"); }); }); describe("recovery due-time gating (nextRecoveryAt)", () => { it("skips todo tasks whose nextRecoveryAt is in the future", async () => { const future = new Date(Date.now() + 60_000).toISOString(); const task = createMockTask({ id: "FN-010", column: "todo", nextRecoveryAt: future, recoveryRetryCount: 1, }); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([task]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), }); const onSchedule = vi.fn(); const scheduler = new Scheduler(store, { onSchedule }); scheduler.start(); await scheduler.schedule(); scheduler.stop(); // Should NOT have been started expect(onSchedule).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled(); }); it("picks up todo tasks whose nextRecoveryAt has elapsed", async () => { const past = new Date(Date.now() - 1000).toISOString(); const task = createMockTask({ id: "FN-011", column: "todo", nextRecoveryAt: past, recoveryRetryCount: 1, }); // Mock filesystem validation: task dir exists, PROMPT.md exists and non-empty (existsSync as any).mockReturnValue(true); (readFile as any).mockResolvedValue("# Task\n\nSome content\n## File Scope\n- foo.ts\n"); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([task]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); const onSchedule = vi.fn(); const scheduler = new Scheduler(store, { onSchedule }); // Call schedule() directly without start() to avoid scheduling guard race (scheduler as any).running = true; await scheduler.schedule(); expect(store.moveTask).toHaveBeenCalledWith("FN-011", "in-progress"); }); it("picks up todo tasks without nextRecoveryAt normally", async () => { const task = createMockTask({ id: "FN-012", column: "todo", // No nextRecoveryAt — should be picked up normally }); (existsSync as any).mockReturnValue(true); (readFile as any).mockResolvedValue("# Task\n\nSome content\n## File Scope\n- foo.ts\n"); const store = createMockStore({ listTasks: vi.fn().mockResolvedValue([task]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), }); const onSchedule = vi.fn(); const scheduler = new Scheduler(store, { onSchedule }); // Call schedule() directly without start() to avoid scheduling guard race (scheduler as any).running = true; await scheduler.schedule(); expect(store.moveTask).toHaveBeenCalledWith("FN-012", "in-progress"); }); }); describe("autopilot integration", () => { it("watches missions with autopilotEnabled on start", () => { const store = createMockStore(); const mockMissionStore = createMockMissionStore({ listMissions: vi.fn().mockReturnValue([ { id: "M-001", autopilotEnabled: true, status: "active" }, { id: "M-002", autopilotEnabled: false, status: "active" }, { id: "M-003", autopilotEnabled: true, status: "complete" }, ]), getMission: vi.fn((id: string) => { const missions: Record = { "M-001": { id: "M-001", autopilotEnabled: true, autopilotState: "inactive" }, "M-002": { id: "M-002", autopilotEnabled: false, autopilotState: "inactive" }, }; return missions[id]; }), }); const mockAutopilot = { setScheduler: vi.fn(), watchMission: vi.fn(), start: vi.fn(), stop: vi.fn(), }; const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, missionAutopilot: mockAutopilot as any, }); scheduler.start(); // setScheduler should be called with the scheduler instance expect(mockAutopilot.setScheduler).toHaveBeenCalledWith(scheduler); // Only M-001 should be watched (autopilotEnabled, not complete/archived) expect(mockAutopilot.watchMission).toHaveBeenCalledWith("M-001"); expect(mockAutopilot.watchMission).not.toHaveBeenCalledWith("M-002"); expect(mockAutopilot.watchMission).not.toHaveBeenCalledWith("M-003"); // Autopilot should be started expect(mockAutopilot.start).toHaveBeenCalled(); scheduler.stop(); // Autopilot should be stopped expect(mockAutopilot.stop).toHaveBeenCalled(); }); it("does not start autopilot when no missionAutopilot option", () => { const store = createMockStore(); const scheduler = new Scheduler(store); scheduler.start(); // Should not throw scheduler.stop(); }); it("delegates to autopilot.handleTaskCompletion when autopilot is available", async () => { const store = createMockStore(); const mockAutopilot = { setScheduler: vi.fn(), watchMission: vi.fn(), start: vi.fn(), stop: vi.fn(), handleTaskCompletion: vi.fn().mockResolvedValue(undefined), isWatching: vi.fn(() => true), // autopilot IS watching }; const completeSlice = { id: "SL-001", milestoneId: "MS-001", status: "complete", orderIndex: 0, }; const mockMissionStore = createMockMissionStore({ getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "active", }), updateFeatureStatus: vi.fn(), getSlice: vi.fn().mockReturnValue(completeSlice), getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, missionAutopilot: mockAutopilot as any, }); // Simulate task:moved event: task with sliceId moves to "done" await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001"); // Feature status should be updated to "done" expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done"); // Should delegate to autopilot (not call onSliceComplete) expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001"); }); it("falls back to onSliceComplete when no autopilot", async () => { const store = createMockStore(); const completeSlice = { id: "SL-001", milestoneId: "MS-001", status: "complete", orderIndex: 0, }; const missionHierarchy = { id: "M-001", status: "active", autoAdvance: true, milestones: [ { id: "MS-001", missionId: "M-001", status: "active", dependencies: [], slices: [ { id: "SL-001", status: "complete", orderIndex: 0 }, { id: "SL-002", status: "pending", orderIndex: 1 }, ], }, ], }; const mockMissionStore = createMockMissionStore({ getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "active", }), updateFeatureStatus: vi.fn(), getSlice: vi.fn().mockReturnValue(completeSlice), getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: true }), getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), activateSlice: vi.fn().mockResolvedValue({ id: "SL-002" }), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, }); await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001"); // Feature status should be updated expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done"); // Legacy path: activateSlice should be called via onSliceComplete expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); }); it("autopilot does not advance when autoAdvance is false", async () => { const store = createMockStore(); const mockAutopilot = { setScheduler: vi.fn(), watchMission: vi.fn(), start: vi.fn(), stop: vi.fn(), handleTaskCompletion: vi.fn().mockResolvedValue(undefined), isWatching: vi.fn(() => true), // autopilot IS watching }; const mockMissionStore = createMockMissionStore({ getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "done", }), updateFeatureStatus: vi.fn(), getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001", status: "complete", orderIndex: 0, }), getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, missionAutopilot: mockAutopilot as any, }); await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001"); // Delegates to autopilot, which internally checks autoAdvance expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001"); }); }); describe("reconcileAllMissionFeatures", () => { it("returns early when missionStore is not provided", async () => { const store = createMockStore(); const scheduler = new Scheduler(store); const result = await scheduler.reconcileAllMissionFeatures(); expect(result).toBe(0); }); it("skips non-active missions", async () => { const store = createMockStore(); const mockMissionStore = createMockMissionStore({ listMissions: vi.fn().mockReturnValue([ { id: "M-001", status: "complete" }, { id: "M-002", status: "archived" }, ]), getMissionWithHierarchy: vi.fn(), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, }); await scheduler.reconcileAllMissionFeatures(); expect(mockMissionStore.getMissionWithHierarchy).not.toHaveBeenCalled(); }); it("updates feature to in-progress when task is in-progress and feature is triaged", async () => { const store = createMockStore({ getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "in-progress" })), }); const mockMissionStore = createMockMissionStore({ listMissions: vi.fn().mockReturnValue([ { id: "M-001", status: "active" }, ]), getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "active", milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", taskId: "FN-001", status: "triaged", }], }], }], }), updateFeatureStatus: vi.fn(), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, }); const result = await scheduler.reconcileAllMissionFeatures(); expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress"); expect(result).toBe(1); }); it("updates feature to done when task is done and feature is not done", async () => { const store = createMockStore({ getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "done" })), }); const mockMissionStore = createMockMissionStore({ listMissions: vi.fn().mockReturnValue([ { id: "M-001", status: "active" }, ]), getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "active", milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", taskId: "FN-001", status: "in-progress", }], }], }], }), updateFeatureStatus: vi.fn(), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, }); const result = await scheduler.reconcileAllMissionFeatures(); expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done"); expect(result).toBe(1); }); it("updates feature to triaged when task moves back to todo and feature is in-progress", async () => { const store = createMockStore({ getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "todo" })), }); const mockMissionStore = createMockMissionStore({ listMissions: vi.fn().mockReturnValue([ { id: "M-001", status: "active" }, ]), getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "active", milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", taskId: "FN-001", status: "in-progress", }], }], }], }), updateFeatureStatus: vi.fn(), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, }); const result = await scheduler.reconcileAllMissionFeatures(); expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "triaged"); expect(result).toBe(1); }); it("does not update correctly synced features", async () => { const store = createMockStore({ getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "in-progress" })), }); const mockMissionStore = createMockMissionStore({ listMissions: vi.fn().mockReturnValue([ { id: "M-001", status: "active" }, ]), getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "active", milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", taskId: "FN-001", status: "in-progress", // Already synced }], }], }], }), updateFeatureStatus: vi.fn(), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, }); const result = await scheduler.reconcileAllMissionFeatures(); expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); expect(result).toBe(0); }); it("skips features without taskId", async () => { const store = createMockStore({ getTask: vi.fn(), }); const mockMissionStore = createMockMissionStore({ listMissions: vi.fn().mockReturnValue([ { id: "M-001", status: "active" }, ]), getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "active", milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", taskId: undefined, // No linked task status: "defined", }], }], }], }), updateFeatureStatus: vi.fn(), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, }); const result = await scheduler.reconcileAllMissionFeatures(); expect(store.getTask).not.toHaveBeenCalled(); expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); expect(result).toBe(0); }); it("skips inactive slices", async () => { const store = createMockStore({ getTask: vi.fn(), }); const mockMissionStore = createMockMissionStore({ listMissions: vi.fn().mockReturnValue([ { id: "M-001", status: "active" }, ]), getMissionWithHierarchy: vi.fn().mockReturnValue({ id: "M-001", status: "active", milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "pending", // Not active features: [{ id: "F-001", taskId: "FN-001", status: "triaged", }], }], }], }), updateFeatureStatus: vi.fn(), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, }); const result = await scheduler.reconcileAllMissionFeatures(); expect(store.getTask).not.toHaveBeenCalled(); expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); expect(result).toBe(0); }); it("handles multiple features across multiple missions and slices", async () => { const store = createMockStore({ getTask: vi.fn(async (id: string) => { const columns: Record = { "FN-001": "in-progress", "FN-002": "done", "FN-003": "triage", }; return createMockTask({ id, column: columns[id] as "todo" }); }) as unknown as (id: string) => Promise, }); const mockMissionStore = createMockMissionStore({ listMissions: vi.fn().mockReturnValue([ { id: "M-001", status: "active" }, { id: "M-002", status: "active" }, ]), getMissionWithHierarchy: vi.fn((id: string) => { if (id === "M-001") { return { id: "M-001", status: "active", milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [ { id: "F-001", taskId: "FN-001", status: "triaged" }, // Should update to in-progress { id: "F-002", taskId: "FN-002", status: "in-progress" }, // Should update to done ], }], }], }; } return { id: "M-002", status: "active", milestones: [{ id: "MS-002", slices: [{ id: "SL-002", status: "active", features: [ { id: "F-003", taskId: "FN-003", status: "in-progress" }, // Should update to triaged ], }], }], }; }), updateFeatureStatus: vi.fn(), }); const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any, }); const result = await scheduler.reconcileAllMissionFeatures(); expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledTimes(3); expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress"); expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-002", "done"); expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-003", "triaged"); expect(result).toBe(3); }); }); });