Files
fusion/packages/engine/src/scheduler.test.ts
2026-04-13 17:14:06 -07:00

2512 lines
91 KiB
TypeScript

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<typeof import("node:fs")>();
return {
...actual,
existsSync: vi.fn(),
};
});
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
readFile: vi.fn(),
};
});
// Helper to create mock tasks
function createMockTask(overrides: Partial<Task> = {}): 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> = {}): TaskStore {
return {
listTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue(createMockTask()),
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(),
listFeatures: vi.fn().mockReturnValue([]),
linkFeatureToTask: vi.fn((featureId: string, taskId: string) => ({
id: featureId,
taskId,
sliceId: "SL-001",
title: "Linked feature",
status: "triaged",
})),
...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({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "in-progress",
sliceId: "SL-001",
})),
});
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", column: "in-progress", sliceId: "SL-001" });
movedHandler({ task, to: "in-progress" });
await new Promise((resolve) => setTimeout(resolve, 0));
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({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "in-progress",
sliceId: "SL-001",
})),
});
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", column: "in-progress", sliceId: "SL-001" });
movedHandler({ task, to: "in-progress" });
await new Promise((resolve) => setTimeout(resolve, 0));
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({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "done",
sliceId: "SL-001",
})),
});
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({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "done",
sliceId: "SL-001",
})),
});
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({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "done",
sliceId: "SL-001",
})),
});
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", column: "done", sliceId: "SL-001" });
movedHandler({ task, from: "in-progress", to: "done" });
await new Promise((resolve) => setTimeout(resolve, 0));
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<string, any> = {
"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 moves to "done"
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
// 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("marks a linked feature in-progress when a task reaches in-review without task slice metadata", async () => {
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-1702",
column: "in-review",
sliceId: undefined,
})),
});
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({
id: "F-1702",
sliceId: "SL-001",
status: "triaged",
}),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
await (scheduler as any).handleMissionTaskMove("FN-1702", "in-review");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "in-progress");
});
it("links a one-way mission task to a matching unlinked feature before marking it done", async () => {
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-1702",
title: "First-run onboarding trigger",
column: "done",
missionId: "M-001",
sliceId: "SL-001",
} as Partial<Task>)),
});
const matchedFeature = {
id: "F-1702",
sliceId: "SL-001",
title: "First-run onboarding trigger",
status: "triaged",
};
const linkedFeature = {
...matchedFeature,
taskId: "FN-1702",
};
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn()
.mockReturnValueOnce(undefined)
.mockReturnValue(linkedFeature),
listFeatures: vi.fn().mockReturnValue([matchedFeature]),
linkFeatureToTask: vi.fn().mockReturnValue(linkedFeature),
updateFeatureStatus: vi.fn(),
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
await (scheduler as any).handleMissionTaskMove("FN-1702", "done");
expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-1702", "FN-1702");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "done");
});
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).handleMissionTaskMove("FN-001", "done");
// 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).handleMissionTaskMove("FN-001", "done");
// Delegates to autopilot, which internally checks autoAdvance
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
});
it("does not mark a feature done when the completed task is blocked", async () => {
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
blockedBy: "FN-000",
column: "done",
})),
});
const mockAutopilot = {
setScheduler: vi.fn(),
watchMission: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
isWatching: vi.fn(() => true),
};
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({
id: "F-001",
sliceId: "SL-001",
status: "in-progress",
}),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
missionAutopilot: mockAutopilot as any,
});
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(mockAutopilot.handleTaskCompletion).not.toHaveBeenCalled();
});
});
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 in-progress when task is in-review and feature is triaged", async () => {
const store = createMockStore({
getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-1702", column: "in-review" })),
});
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-1702",
taskId: "FN-1702",
status: "triaged",
}],
}],
}],
}),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "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("does not reconcile feature to done when the linked task has unresolved dependencies", async () => {
const getTask = vi.fn(async (id: string) => {
if (id === "FN-001") {
return createMockTask({
id: "FN-001",
column: "done",
dependencies: ["FN-000"],
});
}
return createMockTask({ id, column: "in-progress" });
});
const store = createMockStore({ getTask: getTask as any });
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).not.toHaveBeenCalled();
expect(result).toBe(0);
});
it("routes failed linked tasks through onTaskFailed during reconciliation", async () => {
const store = createMockStore({
getTask: vi.fn().mockReturnValue(createMockTask({
id: "FN-001",
column: "in-progress",
status: "failed",
})),
});
const onTaskFailed = vi.fn().mockResolvedValue(undefined);
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,
onTaskFailed,
});
const result = await scheduler.reconcileAllMissionFeatures();
expect(onTaskFailed).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
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("repairs one-way mission task links by exact feature title during reconciliation", async () => {
const matchedTask = createMockTask({
id: "FN-1702",
title: "First-run onboarding trigger",
column: "done",
missionId: "M-001",
sliceId: "SL-001",
} as Partial<Task>);
const matchedFeature = {
id: "F-1702",
sliceId: "SL-001",
title: "First-run onboarding trigger",
taskId: undefined,
status: "triaged",
};
const linkedFeature = {
...matchedFeature,
taskId: "FN-1702",
};
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([matchedTask]),
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: [matchedFeature],
}],
}],
}),
linkFeatureToTask: vi.fn().mockReturnValue(linkedFeature),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-1702", "FN-1702");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "done");
expect(result).toBe(2);
});
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<string, string> = {
"FN-001": "in-progress",
"FN-002": "done",
"FN-003": "triage",
};
return createMockTask({ id, column: columns[id] as "todo" });
}) as unknown as (id: string) => Promise<TaskDetail>,
});
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);
});
});
});