refactor(FN-4021): split core store tests into domain-focused suites

- Replace monolithic store.test.ts with focused suites for create, update, upsert, movement, scheduling, comments, activity, and resilience behavior
- Add parsing and snapshot-specific test files to isolate coverage for conversion and serialization paths
- Keep shared store test harness in store-test-helpers.ts and migrate existing coverage to use the split suite structure
- Remove legacy monolith test file after preserving and redistributing coverage across new suite files

Fusion-Task-Id: FN-4021
This commit is contained in:
Fusion
2026-05-11 13:25:00 -07:00
committed by gsxdsm
parent 6604826aba
commit 36e1309374
13 changed files with 7220 additions and 6876 deletions

View File

@@ -0,0 +1,717 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("activity log", () => {
it("recordActivity appends to log file", async () => {
await store.recordActivity({ type: "task:created", taskId: "FN-001", taskTitle: "Test", details: "Created" });
const logs = await store.getActivityLog();
expect(logs).toHaveLength(1);
expect(logs[0].type).toBe("task:created");
expect(logs[0].id).toBeDefined();
expect(logs[0].timestamp).toBeDefined();
});
it("recordActivity logs failures and stays best-effort", async () => {
const storeAny = store as any;
const originalPrepare = storeAny.db.prepare.bind(storeAny.db);
const prepareSpy = vi.spyOn(storeAny.db, "prepare").mockImplementation((sql: string) => {
if (sql.includes("INSERT INTO activityLog")) {
return {
run: () => {
throw new Error("activity insert failed");
},
};
}
return originalPrepare(sql);
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(
store.recordActivity({
type: "task:created",
taskId: "FN-404",
taskTitle: "Resilient record",
details: "Create event",
metadata: { source: "test" },
}),
).resolves.toMatchObject({
type: "task:created",
taskId: "FN-404",
taskTitle: "Resilient record",
details: "Create event",
});
const failureCall = errorSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Failed to record activity"),
);
expect(failureCall).toBeDefined();
const [, context] = failureCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
type: "task:created",
taskId: "FN-404",
taskTitle: "Resilient record",
detailsLength: "Create event".length,
hasMetadata: true,
error: "activity insert failed",
});
} finally {
prepareSpy.mockRestore();
errorSpy.mockRestore();
}
});
it("logs listener-level activity recording failures without throwing", async () => {
const task = await store.createTask({ description: "Listener test" });
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const recordSpy = vi.spyOn(store, "recordActivity").mockRejectedValue(new Error("listener rejected"));
try {
expect(() => {
store.emit("task:created", task);
}).not.toThrow();
await Promise.resolve();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Activity logging listener failed"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
sourceEvent: "task:created",
type: "task:created",
taskId: task.id,
error: "listener rejected",
});
} finally {
recordSpy.mockRestore();
warnSpy.mockRestore();
}
});
it("getActivityLog returns entries newest first", async () => {
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "First" });
await new Promise((r) => setTimeout(r, 10));
await store.recordActivity({ type: "task:created", taskId: "FN-002", details: "Second" });
const logs = await store.getActivityLog();
expect(logs[0].taskId).toBe("FN-002");
expect(logs[1].taskId).toBe("FN-001");
});
it("getActivityLog respects limit", async () => {
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "First" });
await new Promise((r) => setTimeout(r, 10));
await store.recordActivity({ type: "task:created", taskId: "FN-002", details: "Second" });
const logs = await store.getActivityLog({ limit: 1 });
expect(logs).toHaveLength(1);
expect(logs[0].taskId).toBe("FN-002");
});
it("getActivityLog filters by type", async () => {
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Created" });
await store.recordActivity({ type: "task:moved", taskId: "FN-001", details: "Moved" });
const logs = await store.getActivityLog({ type: "task:created" });
expect(logs).toHaveLength(1);
expect(logs[0].type).toBe("task:created");
});
it("getActivityLog filters by since timestamp", async () => {
const first = await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Created" });
await new Promise((r) => setTimeout(r, 50));
const second = await store.recordActivity({ type: "task:created", taskId: "FN-002", details: "Created later" });
// Filter for entries strictly after the first one (should return only second)
const logs = await store.getActivityLog({ since: first.timestamp });
expect(logs).toHaveLength(1);
expect(logs[0].taskId).toBe("FN-002");
// Filter for entries strictly after a time before the first one (should return both)
const beforeFirst = new Date(new Date(first.timestamp).getTime() - 100).toISOString();
const allLogs = await store.getActivityLog({ since: beforeFirst });
expect(allLogs).toHaveLength(2);
});
it("clearActivityLog removes all entries", async () => {
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Test" });
await store.clearActivityLog();
const logs = await store.getActivityLog();
expect(logs).toHaveLength(0);
});
it("handles missing log file gracefully", async () => {
const logs = await store.getActivityLog();
expect(logs).toHaveLength(0);
});
it("recordActivity includes metadata when provided", async () => {
await store.recordActivity({
type: "task:moved",
taskId: "FN-001",
taskTitle: "Test Task",
details: "Moved to in-progress",
metadata: { from: "todo", to: "in-progress" },
});
const logs = await store.getActivityLog();
expect(logs[0].metadata).toEqual({ from: "todo", to: "in-progress" });
expect(logs[0].taskTitle).toBe("Test Task");
});
it("activity log survives TaskStore reinitialization", async () => {
// Cross-instance persistence test — see archive-log counterpart
// above for the in-memory carve-out rationale.
store.close();
store = new TaskStore(rootDir, globalDir);
await store.init();
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Test" });
// Create new store instance
const newStore = new TaskStore(rootDir, globalDir);
await newStore.init();
const logs = await newStore.getActivityLog();
expect(logs).toHaveLength(1);
expect(logs[0].taskId).toBe("FN-001");
newStore.close();
});
});
// ── Activity Log Event Listener Tests ────────────────────────────
describe("activity log event listeners", () => {
it("records activity on task:created", async () => {
const task = await store.createTask({ description: "Test created event" });
// Wait for async activity recording
await new Promise((r) => setTimeout(r, 10));
const logs = await store.getActivityLog({ type: "task:created" });
expect(logs.length).toBeGreaterThanOrEqual(1);
expect(logs[0].taskId).toBe(task.id);
expect(logs[0].type).toBe("task:created");
});
it("records activity on task:moved", async () => {
const task = await store.createTask({ description: "Test moved event" });
await store.moveTask(task.id, "todo");
// Wait for async activity recording
await new Promise((r) => setTimeout(r, 10));
const logs = await store.getActivityLog({ type: "task:moved" });
expect(logs.length).toBeGreaterThanOrEqual(1);
expect(logs[0].taskId).toBe(task.id);
expect(logs[0].type).toBe("task:moved");
expect(logs[0].metadata).toHaveProperty("from");
expect(logs[0].metadata).toHaveProperty("to");
});
it("records activity when task status becomes failed", async () => {
const task = await store.createTask({ description: "Test failure event" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.updateTask(task.id, { status: "failed", error: "Something went wrong" });
// Wait for async activity recording
await new Promise((r) => setTimeout(r, 10));
const logs = await store.getActivityLog({ type: "task:failed" });
expect(logs.length).toBeGreaterThanOrEqual(1);
expect(logs[0].taskId).toBe(task.id);
expect(logs[0].type).toBe("task:failed");
});
it("records activity on settings:updated for important changes", async () => {
// ntfyEnabled/ntfyTopic are now global settings, use updateGlobalSettings
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
// Wait for async activity recording
await new Promise((r) => setTimeout(r, 10));
const logs = await store.getActivityLog({ type: "settings:updated" });
expect(logs.length).toBeGreaterThanOrEqual(1);
expect(logs[0].type).toBe("settings:updated");
});
it("records activity on task:deleted", async () => {
const task = await store.createTask({ description: "Test deleted event" });
await store.deleteTask(task.id);
// Wait for async activity recording
await new Promise((r) => setTimeout(r, 10));
const logs = await store.getActivityLog({ type: "task:deleted" });
expect(logs.length).toBeGreaterThanOrEqual(1);
expect(logs[0].taskId).toBe(task.id);
expect(logs[0].type).toBe("task:deleted");
});
it("captures merge details when merging a task", async () => {
const task = await store.createTask({ description: "Test merge details" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
worktree: "/tmp/test-worktree",
});
const { execSync } = await import("node:child_process");
try {
execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
} catch {
return;
}
try {
const result = await store.mergeTask(task.id);
expect(result.mergeConfirmed ?? result.merged).toBeDefined();
expect(result.task.mergeDetails).toBeDefined();
if (result.merged) {
expect(result.task.mergeDetails?.commitSha).toBeTruthy();
expect(result.task.mergeDetails?.mergeCommitMessage).toContain(task.id);
expect(result.task.mergeDetails?.mergedAt).toBeDefined();
}
} catch {
// merge may fail depending on repo state; skip strict assertions in that case
}
});
it("records activity on task:merged", async () => {
const task = await store.createTask({ description: "Test merged event" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
// Manually set worktree for merge
await store.updateTask(task.id, { worktree: "/tmp/test-worktree" });
// Create branch for merge
const { execSync } = await import("node:child_process");
try {
execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
} catch {
// Branch may already exist or no main/master, skip merge test
}
try {
await store.mergeTask(task.id);
} catch {
// Merge may fail due to branch setup, that's ok for activity log test
}
// Wait for async activity recording
await new Promise((r) => setTimeout(r, 10));
const logs = await store.getActivityLog({ type: "task:merged" });
// We check if the merge was attempted (logs may exist even if merge failed)
// The key is that the event listener was called
});
it("does not record activity for non-failure task updates", async () => {
const task = await store.createTask({ description: "Test non-failure update" });
await store.moveTask(task.id, "todo");
await store.updateTask(task.id, { status: "in-progress" });
// Wait for any async activity recording
await new Promise((r) => setTimeout(r, 10));
// Get all failed logs - should not include this task
const failedLogs = await store.getActivityLog({ type: "task:failed" });
const taskFailedLogs = failedLogs.filter((l) => l.taskId === task.id);
expect(taskFailedLogs).toHaveLength(0);
});
});
// ── Workflow Steps ─────────────────────────────────────────────────
describe("event emissions", () => {
it("createTask emits task:created with the new task", async () => {
const events: any[] = [];
store.on("task:created", (t: any) => events.push(t));
const task = await store.createTask({ description: "event test" });
expect(events).toHaveLength(1);
expect(events[0].id).toBe(task.id);
expect(events[0].description).toBe("event test");
});
it("moveTask emits task:moved with from/to columns", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:moved", (data: any) => events.push(data));
await store.moveTask(task.id, "todo");
expect(events).toHaveLength(1);
expect(events[0].from).toBe("triage");
expect(events[0].to).toBe("todo");
expect(events[0].task.id).toBe(task.id);
});
it("updateTask emits task:updated with the updated task", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:updated", (t: any) => events.push(t));
await store.updateTask(task.id, { title: "Updated" });
expect(events.length).toBeGreaterThanOrEqual(1);
expect(events.some((e: any) => e.title === "Updated")).toBe(true);
});
it("pauseTask emits task:updated", async () => {
const task = await createTestTask();
await store.moveTask(task.id, "todo");
const events: any[] = [];
store.on("task:updated", (t: any) => events.push(t));
await store.pauseTask(task.id, true);
expect(events.length).toBeGreaterThanOrEqual(1);
expect(events.some((e: any) => e.paused === true)).toBe(true);
});
it("updateStep emits task:updated", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
const events: any[] = [];
store.on("task:updated", (t: any) => events.push(t));
await store.updateStep(task.id, 0, "in-progress");
expect(events.length).toBeGreaterThanOrEqual(1);
});
it("deleteTask emits task:deleted", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:deleted", (t: any) => events.push(t));
await store.deleteTask(task.id);
expect(events).toHaveLength(1);
expect(events[0].id).toBe(task.id);
});
it("logEntry emits task:updated", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:updated", (t: any) => events.push(t));
await store.logEntry(task.id, "test action", "test outcome");
expect(events.length).toBeGreaterThanOrEqual(1);
});
});
describe("execution timing timestamps", () => {
it("preserves the original executionStartedAt across an internal rerun bounce", async () => {
const task = await store.createTask({ description: "retry bounce timing" });
await store.moveTask(task.id, "todo");
const started = await store.moveTask(task.id, "in-progress");
const originalExecutionStartedAt = started.executionStartedAt;
expect(originalExecutionStartedAt).toBeDefined();
await new Promise((r) => setTimeout(r, 10));
const bouncedToTodo = await store.moveTask(task.id, "todo");
expect(bouncedToTodo.executionStartedAt).toBeUndefined();
await store.updateTask(task.id, {
worktree: "/tmp/retry-bounce",
executionStartedAt: originalExecutionStartedAt ?? null,
});
const bouncedBack = await store.moveTask(task.id, "in-progress");
expect(bouncedBack.executionStartedAt).toBe(originalExecutionStartedAt);
});
});
describe("settings:updated event", () => {
it("fires on updateSettings with correct old and new values", async () => {
const events: { settings: any; previous: any }[] = [];
store.on("settings:updated", (data) => events.push(data));
await store.updateSettings({ maxConcurrent: 5 });
expect(events).toHaveLength(1);
expect(events[0].previous.maxConcurrent).toBe(2); // DEFAULT_SETTINGS value
expect(events[0].settings.maxConcurrent).toBe(5);
});
it("includes previous globalPause: false → new globalPause: true when toggled", async () => {
const events: { settings: any; previous: any }[] = [];
store.on("settings:updated", (data) => events.push(data));
// Default globalPause is false
await store.updateSettings({ globalPause: true });
expect(events).toHaveLength(1);
expect(events[0].previous.globalPause).toBe(false);
expect(events[0].settings.globalPause).toBe(true);
});
it("includes previous globalPause: true → new globalPause: false when toggled off", async () => {
await store.updateSettings({ globalPause: true });
const events: { settings: any; previous: any }[] = [];
store.on("settings:updated", (data) => events.push(data));
await store.updateSettings({ globalPause: false });
expect(events).toHaveLength(1);
expect(events[0].previous.globalPause).toBe(true);
expect(events[0].settings.globalPause).toBe(false);
});
it("fires on every updateSettings call even when value unchanged", async () => {
const events: { settings: any; previous: any }[] = [];
store.on("settings:updated", (data) => events.push(data));
await store.updateSettings({ maxConcurrent: 2 });
await store.updateSettings({ maxConcurrent: 2 });
expect(events).toHaveLength(2);
});
});
// ── Duplicate Task Tests ─────────────────────────────────────────
describe("task-store diagnostics for best-effort catch paths", () => {
it("logs init config sync failures without blocking startup", async () => {
const localRoot = makeTmpDir();
const localGlobal = makeTmpDir();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
let localStore: TaskStore | undefined;
try {
localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true });
(localStore as any).configPath = join(localRoot, ".fusion", "missing-dir", "config.json");
await expect(localStore.init()).resolves.toBeUndefined();
await expect(localStore.createTask({ description: "still boots" })).resolves.toMatchObject({
id: "FN-001",
});
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed during init"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "init:config-sync",
configPath: join(localRoot, ".fusion", "missing-dir", "config.json"),
});
expect(typeof context.error).toBe("string");
} finally {
localStore?.close();
warnSpy.mockRestore();
await rm(localRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(localGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
it("logs writeConfig disk sync failures while preserving project settings updates", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const storeAny = store as any;
const originalConfigPath = storeAny.configPath;
storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json");
try {
const updated = await store.updateSettings({ mergeStrategy: "pull-request" });
expect(updated.mergeStrategy).toBe("pull-request");
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed after config write"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "writeConfig:disk-sync",
configPath: join(rootDir, ".fusion", "missing-sync", "config.json"),
});
expect(typeof context.error).toBe("string");
const settings = await store.getSettings();
expect(settings.mergeStrategy).toBe("pull-request");
} finally {
storeAny.configPath = originalConfigPath;
warnSpy.mockRestore();
}
});
it("logs allocateId disk sync failures while preserving task creation", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const storeAny = store as any;
const originalConfigPath = storeAny.configPath;
storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json");
try {
const task = await store.createTask({ description: "allocate despite sync failure" });
expect(task.id).toBe("FN-001");
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed after ID allocation"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "allocateId:disk-sync",
configPath: join(rootDir, ".fusion", "missing-sync", "config.json"),
taskId: task.id,
});
expect(typeof context.error).toBe("string");
} finally {
storeAny.configPath = originalConfigPath;
warnSpy.mockRestore();
}
});
it("logs init memory bootstrap failures without blocking startup", async () => {
const localRoot = makeTmpDir();
const localGlobal = makeTmpDir();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ensureSpy = vi
.spyOn(projectMemory, "ensureMemoryFileWithBackend")
.mockRejectedValueOnce(new Error("memory backend unavailable"));
let localStore: TaskStore | undefined;
try {
localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true });
await expect(localStore.init()).resolves.toBeUndefined();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Project-memory bootstrap failed during init"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "init:memory-bootstrap",
rootDir: localRoot,
error: "memory backend unavailable",
});
expect(ensureSpy).toHaveBeenCalled();
} finally {
localStore?.close();
ensureSpy.mockRestore();
warnSpy.mockRestore();
await rm(localRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(localGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
it("logs memory toggle-on bootstrap failures without blocking settings updates", async () => {
await store.updateSettings({ memoryEnabled: false } as any);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ensureSpy = vi
.spyOn(projectMemory, "ensureMemoryFileWithBackend")
.mockRejectedValueOnce(new Error("memory toggle write failed"));
try {
const updated = await store.updateSettings({ memoryEnabled: true } as any);
expect(updated.memoryEnabled).toBe(true);
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Project-memory bootstrap failed after memory toggle-on"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "updateSettings:memory-toggle-on",
rootDir,
error: "memory toggle write failed",
});
expect(ensureSpy).toHaveBeenCalled();
} finally {
ensureSpy.mockRestore();
warnSpy.mockRestore();
}
});
it("logs fs.watch setup failures and keeps polling active", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const storeAny = store as any;
const originalTasksDir = storeAny.tasksDir;
storeAny.tasksDir = join(rootDir, ".fusion", "missing-tasks-dir");
try {
await store.watch();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] fs.watch unavailable; falling back to polling-only updates"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "watch:fs-watch-setup",
tasksDir: join(rootDir, ".fusion", "missing-tasks-dir"),
});
expect(typeof context.error).toBe("string");
expect(storeAny.pollInterval).not.toBeNull();
await expect(storeAny.checkForChanges()).resolves.toBeUndefined();
} finally {
store.stopWatching();
storeAny.tasksDir = originalTasksDir;
warnSpy.mockRestore();
}
});
it("logs unreadable legacy agent.log files while keeping import non-fatal", async () => {
const task = await createTestTask();
const taskDir = join(rootDir, ".fusion", "tasks", task.id);
const logPath = join(taskDir, "agent.log");
await mkdir(logPath);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(store.importLegacyAgentLogs()).resolves.toBe(0);
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Skipping unreadable legacy agent.log file during import"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "importLegacyAgentLogs:read-file",
taskId: task.id,
logPath,
});
expect(typeof context.error).toBe("string");
} finally {
warnSpy.mockRestore();
}
});
});
// ── Branch Cleanup on Delete/Archive ────────────────────────────
});

View File

@@ -0,0 +1,925 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("task comments", () => {
it("adds a task comment to a task", async () => {
const task = await createTestTask();
const updated = await store.addTaskComment(task.id, "Please review this", "alice");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Please review this");
expect(updated.comments![0].author).toBe("alice");
expect(updated.comments![0].id).toBeDefined();
expect(updated.comments![0].createdAt).toBeDefined();
expect(updated.comments![0].updatedAt).toBeDefined();
});
it("updates an existing task comment", async () => {
const task = await createTestTask();
const added = await store.addTaskComment(task.id, "First draft", "alice");
const commentId = added.comments![0].id;
const updated = await store.updateTaskComment(task.id, commentId, "Updated draft");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Updated draft");
expect(updated.comments![0].updatedAt).toBeDefined();
expect(updated.log.some((entry) => entry.action === "Comment updated")).toBe(true);
});
it("deletes a task comment", async () => {
const task = await createTestTask();
const added = await store.addTaskComment(task.id, "Disposable", "alice");
const commentId = added.comments![0].id;
const updated = await store.deleteTaskComment(task.id, commentId);
expect(updated.comments).toBeUndefined();
expect(updated.log.some((entry) => entry.action === "Comment deleted")).toBe(true);
});
it("throws when updating a missing task comment", async () => {
const task = await createTestTask();
await expect(store.updateTaskComment(task.id, "missing", "Nope")).rejects.toThrow(
`Comment missing not found on task ${task.id}`,
);
});
it("throws when deleting a missing task comment", async () => {
const task = await createTestTask();
await expect(store.deleteTaskComment(task.id, "missing")).rejects.toThrow(
`Comment missing not found on task ${task.id}`,
);
});
it("persists all comments in unified comments field", async () => {
const task = await createTestTask();
await store.addTaskComment(task.id, "General note", "alice");
await store.addComment(task.id, "Execution note");
const reopened = await store.getTask(task.id);
// Both comments should be in the unified comments array
expect(reopened.comments).toHaveLength(2);
expect(reopened.comments![0].text).toBe("General note");
expect(reopened.comments![1].text).toBe("Execution note");
});
});
describe("addComment", () => {
it("adds a steering comment to a task", async () => {
const task = await createTestTask();
const updated = await store.addComment(task.id, "Please handle the edge case");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Please handle the edge case");
expect(updated.comments![0].author).toBe("user");
expect(updated.comments![0].id).toBeDefined();
expect(updated.comments![0].createdAt).toBeDefined();
});
it("accepts agent as author", async () => {
const task = await createTestTask();
const updated = await store.addComment(task.id, "Note from agent", "agent");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].author).toBe("agent");
});
it("initializes comments array if undefined", async () => {
const task = await createTestTask();
expect(task.comments).toBeUndefined();
const updated = await store.addComment(task.id, "First comment");
expect(updated.comments).toBeDefined();
expect(updated.comments).toHaveLength(1);
});
it("appends multiple comments in order", async () => {
const task = await createTestTask();
await store.addComment(task.id, "First comment");
await store.addComment(task.id, "Second comment");
await store.addComment(task.id, "Third comment");
const fetched = await store.getTask(task.id);
expect(fetched.comments).toHaveLength(3);
expect(fetched.comments![0].text).toBe("First comment");
expect(fetched.comments![1].text).toBe("Second comment");
expect(fetched.comments![2].text).toBe("Third comment");
});
it("generates unique IDs for each comment", async () => {
const task = await createTestTask();
const updated1 = await store.addComment(task.id, "Comment 1");
const updated2 = await store.addComment(task.id, "Comment 2");
const id1 = updated1.comments![0].id;
const id2 = updated2.comments![1].id;
expect(id1).not.toBe(id2);
});
it("emits task:updated event", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
await store.addComment(task.id, "Test comment");
expect(events).toHaveLength(1);
expect(events[0].comments).toHaveLength(1);
expect(events[0].comments![0].text).toBe("Test comment");
});
it("persists to disk and round-trips correctly", async () => {
const task = await createTestTask();
await store.addComment(task.id, "Persisted comment");
const fetched = await store.getTask(task.id);
expect(fetched.comments).toHaveLength(1);
expect(fetched.comments![0].text).toBe("Persisted comment");
expect(fetched.comments![0].author).toBe("user");
});
it("adds log entry for the action", async () => {
const task = await createTestTask();
const updated = await store.addComment(task.id, "Comment with log");
expect(updated.log.some((l) => l.action === "Comment added by user")).toBe(true);
});
it("updates updatedAt timestamp", async () => {
const task = await createTestTask();
const before = task.updatedAt;
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
const updated = await store.addComment(task.id, "Timestamp test");
expect(updated.updatedAt).not.toBe(before);
});
it("creates refinement task when steering comment added to done task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Need to fix edge case");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length + 1);
const refinement = allTasksAfter.find((t) => t.id !== task.id && t.title?.includes("Refinement"));
expect(refinement).toBeDefined();
expect(refinement?.column).toBe("triage");
expect(refinement?.dependencies).toContain(task.id);
});
it("does not create refinement when steering comment added to non-done task (triage)", async () => {
const task = await store.createTask({ description: "Original task" });
// Task starts in triage
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Some feedback");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
});
it("does not create refinement when steering comment added to non-done task (in-progress)", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Some feedback");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
});
it("does not create refinement when steering comment added to non-done task (in-review)", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Some feedback");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
});
it("steering comment is still added to original task even when refinement is created", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const updated = await store.addComment(task.id, "Need to fix edge case");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Need to fix edge case");
});
it("refinement task has correct dependency on original done task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.addComment(task.id, "Need to fix edge case");
const allTasks = await store.listTasks();
const refinement = allTasks.find((t) => t.id !== task.id && t.dependencies?.includes(task.id));
expect(refinement).toBeDefined();
expect(refinement?.dependencies).toEqual([task.id]);
});
it("does not create refinement for agent-authored comments", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Agent feedback", "agent");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
});
it("does not fail when steering comment is empty or whitespace on done task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// Should not throw - refineTask will reject empty feedback but we catch it
const updated = await store.addComment(task.id, " ");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe(" ");
});
it("logs warning and still persists comment when best-effort auto-refinement fails", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const runContext = { runId: "run-refinement-failure", agentId: "agent-refinement" };
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const refineSpy = vi.spyOn(store, "refineTask").mockRejectedValue(new Error("refine unavailable"));
try {
const taskCountBefore = (await store.listTasks()).length;
const updated = await store.addComment(task.id, "Need refinement", "user", undefined, runContext);
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Need refinement");
const taskCountAfter = (await store.listTasks()).length;
expect(taskCountAfter).toBe(taskCountBefore);
const persisted = await store.getTask(task.id);
expect(persisted.comments).toHaveLength(1);
expect(persisted.comments![0].text).toBe("Need refinement");
expect(refineSpy).toHaveBeenCalledWith(task.id, "Need refinement");
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment auto-refinement failed"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
taskId: task.id,
author: "user",
commentLength: "Need refinement".length,
column: "done",
priorStatus: null,
phase: "addComment:auto-refinement",
runId: "run-refinement-failure",
agentId: "agent-refinement",
error: "refine unavailable",
});
} finally {
refineSpy.mockRestore();
warnSpy.mockRestore();
}
});
it("logs warning and still persists comment when status update fails during awaiting-approval invalidation", async () => {
const task = await store.createTask({ description: "Task in triage" });
await store.updateTask(task.id, { status: "awaiting-approval" });
const runContext = { runId: "run-invalidation-failure", agentId: "agent-invalidation" };
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const updateSpy = vi.spyOn(store, "updateTask").mockRejectedValueOnce(new Error("status update failed"));
try {
const updated = await store.addComment(task.id, "New user feedback", "user", undefined, runContext);
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("New user feedback");
const persisted = await store.getTask(task.id);
expect(persisted.comments).toHaveLength(1);
expect(persisted.comments![0].text).toBe("New user feedback");
expect(persisted.status).toBe("awaiting-approval");
expect(updateSpy).toHaveBeenCalled();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment re-triage failed"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
taskId: task.id,
author: "user",
commentLength: "New user feedback".length,
column: "triage",
priorStatus: "awaiting-approval",
phase: "addComment:awaiting-approval-invalidation",
stage: "status-update",
nextStatus: "needs-replan",
runId: "run-invalidation-failure",
agentId: "agent-invalidation",
error: "status update failed",
});
} finally {
updateSpy.mockRestore();
warnSpy.mockRestore();
}
});
it("logs warning and keeps invalidated status when log entry fails after awaiting-approval invalidation", async () => {
const task = await store.createTask({ description: "Task in triage" });
await store.updateTask(task.id, { status: "awaiting-approval" });
const runContext = { runId: "run-post-invalidation-log-failure", agentId: "agent-invalidation" };
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const logEntrySpy = vi.spyOn(store, "logEntry").mockRejectedValueOnce(new Error("log entry failed"));
try {
const updated = await store.addComment(task.id, "New user feedback", "user", undefined, runContext);
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("New user feedback");
const persisted = await store.getTask(task.id);
expect(persisted.comments).toHaveLength(1);
expect(persisted.comments![0].text).toBe("New user feedback");
expect(persisted.status).toBe("needs-replan");
expect(logEntrySpy).toHaveBeenCalled();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment re-triage failed"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
taskId: task.id,
author: "user",
commentLength: "New user feedback".length,
column: "triage",
priorStatus: "awaiting-approval",
phase: "addComment:awaiting-approval-invalidation",
stage: "post-invalidation-log-entry",
nextStatus: "needs-replan",
runId: "run-post-invalidation-log-failure",
agentId: "agent-invalidation",
error: "log entry failed",
});
} finally {
logEntrySpy.mockRestore();
warnSpy.mockRestore();
}
});
it("addSteeringComment on done task does NOT create a refinement task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const allTasksBefore = await store.listTasks();
await store.addSteeringComment(task.id, "Please handle the edge case");
const allTasksAfter = await store.listTasks();
// No refinement task should be created
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
});
it("addSteeringComment writes to both comments and steeringComments", async () => {
const task = await createTestTask();
const updated = await store.addSteeringComment(task.id, "Focus on error handling");
// Should appear in unified comments (for UI display)
expect(updated.comments).toBeDefined();
expect(updated.comments!.some(c => c.text === "Focus on error handling")).toBe(true);
// Should appear in steeringComments (for executor injection)
expect(updated.steeringComments).toBeDefined();
expect(updated.steeringComments!.some(c => c.text === "Focus on error handling")).toBe(true);
});
it("addSteeringComment steeringComments persist through round-trip", async () => {
const task = await createTestTask();
await store.addSteeringComment(task.id, "Focus on error handling");
const fetched = await store.getTask(task.id);
expect(fetched.steeringComments).toBeDefined();
expect(fetched.steeringComments!).toHaveLength(1);
expect(fetched.steeringComments![0].text).toBe("Focus on error handling");
});
it("steering comments do not duplicate in comments across read-write cycles", async () => {
const task = await createTestTask();
// Add a steering comment (writes to both comments and steeringComments columns)
await store.addSteeringComment(task.id, "Focus on error handling");
// Read the task back — comments should have exactly 1 entry
const read1 = await store.getTask(task.id);
expect(read1.comments).toHaveLength(1);
expect(read1.steeringComments).toHaveLength(1);
// Simulate a write-back (updateTask writes via upsertTask)
await store.updateTask(task.id, { status: "planning" });
// Read again — should still have exactly 1 comment, not 2
const read2 = await store.getTask(task.id);
expect(read2.comments).toHaveLength(1);
expect(read2.comments![0].text).toBe("Focus on error handling");
});
it("no duplication accumulation over multiple read-write cycles with steering comments", async () => {
const task = await createTestTask();
await store.addSteeringComment(task.id, "Comment A");
await store.addSteeringComment(task.id, "Comment B");
// Perform 5 read-write cycles
for (let i = 0; i < 5; i++) {
const fetched = await store.getTask(task.id);
expect(fetched.comments).toHaveLength(2);
expect(fetched.steeringComments).toHaveLength(2);
// Write back via an innocuous update
await store.updateTask(task.id, { status: "planning" });
}
// Final read — still exactly 2 comments
const final = await store.getTask(task.id);
expect(final.comments).toHaveLength(2);
expect(final.comments!.map(c => c.text).sort()).toEqual(["Comment A", "Comment B"]);
});
it("mixed regular and steering comments maintain correct counts through cycles", async () => {
const task = await createTestTask();
// Add 1 regular comment and 1 steering comment
await store.addTaskComment(task.id, "Regular note", "alice");
await store.addSteeringComment(task.id, "Steering note");
// Should have 2 comments total, 1 steering comment
const read1 = await store.getTask(task.id);
expect(read1.comments).toHaveLength(2);
expect(read1.steeringComments).toHaveLength(1);
// Perform 3 read-write cycles
for (let i = 0; i < 3; i++) {
const fetched = await store.getTask(task.id);
expect(fetched.comments).toHaveLength(2);
await store.updateTask(task.id, { status: "planning" });
}
const final = await store.getTask(task.id);
expect(final.comments).toHaveLength(2);
expect(final.steeringComments).toHaveLength(1);
});
it("regular addComment on done task still creates refinement", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Need to fix edge case");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length + 1);
const refinement = allTasksAfter.find((t) => t.id !== task.id && t.title?.includes("Refinement"));
expect(refinement).toBeDefined();
});
it("transitions awaiting-approval to needs-replan when user comments on triage task", async () => {
const task = await store.createTask({ description: "Task in triage" });
// Keep in triage but set awaiting-approval status
await store.updateTask(task.id, { status: "awaiting-approval" });
const result = await store.addComment(task.id, "I want to change the approach", "user");
// Re-read the task to get the Phase 3 status update
const updated = await store.getTask(task.id);
// Task should remain in triage but status should change to needs-replan
expect(updated.column).toBe("triage");
expect(updated.status).toBe("needs-replan");
// Comment should still be added
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("I want to change the approach");
});
it("does NOT transition to needs-replan when agent comments on awaiting-approval task", async () => {
const task = await store.createTask({ description: "Task in triage" });
await store.updateTask(task.id, { status: "awaiting-approval" });
const updated = await store.addComment(task.id, "Agent system note", "agent");
// Status should remain awaiting-approval for agent comments
expect(updated.status).toBe("awaiting-approval");
// Comment should still be added
expect(updated.comments).toHaveLength(1);
});
it("transitions to needs-replan when user comments on non-awaiting-approval triage task with real spec", async () => {
const task = await store.createTask({ description: "Task in triage" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# Task: ${task.id} - Triage Plan\n\n## Mission\n\nPlanned task.`);
await store.addComment(task.id, "User feedback", "user");
const updated = await store.getTask(task.id);
expect(updated.status).toBe("needs-replan");
expect(updated.column).toBe("triage");
expect(updated.comments?.[0]?.text).toBe("User feedback");
});
it("does NOT transition to needs-replan when user comments on triage task with bootstrap stub prompt", async () => {
const task = await store.createTask({ description: "Task in triage" });
await store.addComment(task.id, "User feedback", "user");
const updated = await store.getTask(task.id);
expect(updated.status).toBeUndefined();
});
it("transitions todo task to needs-replan when user comments and task has real spec", async () => {
const task = await store.createTask({ description: "Task in todo", column: "todo" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# Task: ${task.id} - Todo Plan\n\n## Mission\n\nPlanned task.`);
await store.addComment(task.id, "Please update approach", "user");
const updated = await store.getTask(task.id);
expect(updated.status).toBe("needs-replan");
expect(updated.column).toBe("todo");
expect(updated.log.some((entry) => entry.action === "User comment requested re-specification of planned task")).toBe(true);
});
it("does NOT transition todo task to needs-replan when prompt matches bootstrap stub", async () => {
const task = await store.createTask({ description: "Task in todo", column: "todo" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# ${task.id}\n\nTask in todo\n`);
await store.addComment(task.id, "Please update approach", "user");
const updated = await store.getTask(task.id);
expect(updated.status).toBeUndefined();
});
it("does NOT transition to needs-replan when user comments on in-progress task", async () => {
const task = await store.createTask({ description: "Task in progress", column: "todo" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# Task: ${task.id} - Plan\n\n## Mission\n\nPlanned task.`);
await store.moveTask(task.id, "in-progress");
await store.addComment(task.id, "Please adjust implementation", "user");
const updated = await store.getTask(task.id);
expect(updated.column).toBe("in-progress");
expect(updated.status).toBeUndefined();
});
it("does NOT transition to needs-replan when user comments on in-review task", async () => {
const task = await store.createTask({ description: "Task in review", column: "todo" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# Task: ${task.id} - Plan\n\n## Mission\n\nPlanned task.`);
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.addComment(task.id, "Please adjust before merge", "user");
const updated = await store.getTask(task.id);
expect(updated.column).toBe("in-review");
expect(updated.status).toBeUndefined();
});
});
describe("task comments and merge details types", () => {
it("has undefined comments on new tasks", async () => {
const task = await createTestTask();
const reopened = await store.getTask(task.id);
expect(reopened.comments).toBeUndefined();
});
it("supports the task comment and merge details shapes", async () => {
const comment: NonNullable<Task["comments"]>[number] = {
id: `comment-${Date.now()}`,
text: "Looks good",
author: "alice",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mergeDetails: NonNullable<Task["mergeDetails"]> = {
commitSha: "abc123def456",
filesChanged: 3,
insertions: 10,
deletions: 2,
mergeCommitMessage: "feat(KB-001): merge fusion/fn-001",
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
prNumber: 42,
};
const taskShape: Pick<Task, "comments" | "mergeDetails"> = {
comments: [comment],
mergeDetails,
};
expect(taskShape.comments).toEqual([comment]);
expect(taskShape.mergeDetails).toEqual(mergeDetails);
});
});
describe("updatePrInfo", () => {
it("adds PR info to a task without existing PR", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
const updated = await store.updatePrInfo(task.id, prInfo);
expect(updated.prInfo).toEqual(prInfo);
expect(updated.log.some((l) => l.action === "PR linked" && l.outcome?.includes("#42"))).toBe(true);
});
it("keeps PR number/url after moving task to done", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const updated = await store.getTask(task.id);
expect(updated.prInfo?.number).toBe(42);
expect(updated.prInfo?.url).toBe("https://github.com/owner/repo/pull/42");
});
it("updates existing PR info with new values", async () => {
const task = await createTestTask();
const prInfo1 = {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open" as const,
title: "Initial PR",
headBranch: "branch-1",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo1);
const prInfo2 = {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "merged" as const,
title: "Initial PR (updated)",
headBranch: "branch-1",
baseBranch: "main",
commentCount: 3,
lastCommentAt: "2026-01-01T00:00:00.000Z",
};
const updated = await store.updatePrInfo(task.id, prInfo2);
expect(updated.prInfo?.status).toBe("merged");
expect(updated.prInfo?.commentCount).toBe(3);
expect(updated.prInfo?.lastCommentAt).toBe("2026-01-01T00:00:00.000Z");
});
it("clears PR info when passed null", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
const updated = await store.updatePrInfo(task.id, null);
expect(updated.prInfo).toBeUndefined();
expect(updated.log.some((l) => l.action === "PR unlinked")).toBe(true);
});
it("emits task:updated event when PR info changes", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
expect(events).toHaveLength(1);
expect(events[0].prInfo?.number).toBe(42);
});
it("does NOT emit task:updated when PR info is unchanged", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
// Update with same values (status and number unchanged)
await store.updatePrInfo(task.id, { ...prInfo });
// Should not emit because number and status are the same
expect(events).toHaveLength(0);
});
it("persists to disk and round-trips correctly", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 5,
lastCommentAt: "2026-03-30T12:00:00.000Z",
};
await store.updatePrInfo(task.id, prInfo);
const fetched = await store.getTask(task.id);
expect(fetched.prInfo).toEqual(prInfo);
});
it("updates updatedAt timestamp", async () => {
const task = await createTestTask();
const before = task.updatedAt;
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
const updated = await store.updatePrInfo(task.id, prInfo);
expect(updated.updatedAt).not.toBe(before);
});
it("serializes concurrent updates correctly", async () => {
const task = await createTestTask();
// Fire 5 concurrent updates
const promises = Array.from({ length: 5 }, (_, i) =>
store.updatePrInfo(task.id, {
url: `https://github.com/owner/repo/pull/${i + 1}`,
number: i + 1,
status: "open" as const,
title: `PR ${i + 1}`,
headBranch: `branch-${i + 1}`,
baseBranch: "main",
commentCount: i,
}),
);
await Promise.all(promises);
// Read back and verify valid JSON
const taskJsonPath = join(rootDir, ".fusion", "tasks", task.id, "task.json");
const raw = await readFile(taskJsonPath, "utf-8");
const result = JSON.parse(raw) as Task;
// Should have exactly one of the PRs set (last one wins)
expect(result.prInfo).toBeDefined();
expect(result.prInfo!.number).toBeGreaterThanOrEqual(1);
expect(result.prInfo!.number).toBeLessThanOrEqual(5);
// Should have all the PR linked log entries
const prLogs = result.log.filter((l) => l.action === "PR linked");
expect(prLogs).toHaveLength(5);
});
});
});

View File

@@ -0,0 +1,803 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("breakIntoSubtasks task creation flag", () => {
it("persists breakIntoSubtasks=true when explicitly requested", async () => {
const task = await store.createTask({
description: "Large feature",
breakIntoSubtasks: true,
});
expect(task.breakIntoSubtasks).toBe(true);
const detail = await store.getTask(task.id);
expect(detail.breakIntoSubtasks).toBe(true);
});
it("persists modelPresetId when provided during task creation", async () => {
const task = await store.createTask({
description: "Preset task",
modelPresetId: "budget",
});
expect(task.modelPresetId).toBe("budget");
const detail = await store.getTask(task.id);
expect(detail.modelPresetId).toBe("budget");
});
it("leaves breakIntoSubtasks unset by default", async () => {
const task = await store.createTask({
description: "Regular task",
});
expect(task.breakIntoSubtasks).toBeUndefined();
const detail = await store.getTask(task.id);
expect(detail.breakIntoSubtasks).toBeUndefined();
});
it("persists missionId and sliceId when provided during task creation", async () => {
const task = await store.createTask({
description: "Mission-linked task",
missionId: "MS-001",
sliceId: "SL-001",
});
expect(task.missionId).toBe("MS-001");
expect(task.sliceId).toBe("SL-001");
const detail = await store.getTask(task.id);
expect(detail.missionId).toBe("MS-001");
expect(detail.sliceId).toBe("SL-001");
});
it("leaves missionId and sliceId unset when not provided", async () => {
const task = await store.createTask({
description: "Regular task",
});
expect(task.missionId).toBeUndefined();
expect(task.sliceId).toBeUndefined();
const detail = await store.getTask(task.id);
expect(detail.missionId).toBeUndefined();
expect(detail.sliceId).toBeUndefined();
});
});
describe("createTask — model overrides", () => {
it("persists executor and validator model overrides on creation", async () => {
const created = await store.createTask({
title: "Task with model overrides",
description: "Use explicit executor and validator models",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
expect(created.modelProvider).toBe("anthropic");
expect(created.modelId).toBe("claude-sonnet-4-5");
expect(created.validatorModelProvider).toBe("openai");
expect(created.validatorModelId).toBe("gpt-4o");
const persisted = await store.getTask(created.id);
expect(persisted.modelProvider).toBe("anthropic");
expect(persisted.modelId).toBe("claude-sonnet-4-5");
expect(persisted.validatorModelProvider).toBe("openai");
expect(persisted.validatorModelId).toBe("gpt-4o");
});
});
describe("createTask — assigneeUserId", () => {
it("persists assigneeUserId on creation", async () => {
const created = await store.createTask({
title: "Task with user assignment",
description: "A task assigned to a user",
assigneeUserId: "requesting-user",
});
expect(created.assigneeUserId).toBe("requesting-user");
const persisted = await store.getTask(created.id);
expect(persisted.assigneeUserId).toBe("requesting-user");
});
});
describe("task provenance", () => {
it("defaults sourceType to unknown when source is omitted", async () => {
const task = await store.createTask({ description: "Provenance default" });
const fetched = await store.getTask(task.id);
expect(fetched.sourceType).toBe("unknown");
});
it("persists simple source type from createTask", async () => {
const task = await store.createTask({
description: "Created from dashboard",
source: { sourceType: "dashboard_ui" },
});
const fetched = await store.getTask(task.id);
expect(fetched.sourceType).toBe("dashboard_ui");
});
it("roundtrips full provenance metadata", async () => {
const task = await store.createTask({
description: "Heartbeat-generated task",
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-123",
sourceRunId: "run-456",
sourceSessionId: "session-789",
sourceMessageId: "msg-001",
sourceMetadata: { reason: "scheduled" },
},
});
const fetched = await store.getTask(task.id);
expect(fetched.sourceType).toBe("agent_heartbeat");
expect(fetched.sourceAgentId).toBe("agent-123");
expect(fetched.sourceRunId).toBe("run-456");
expect(fetched.sourceSessionId).toBe("session-789");
expect(fetched.sourceMessageId).toBe("msg-001");
expect(fetched.sourceMetadata).toEqual({ reason: "scheduled" });
});
it("sets duplicate and refine provenance parent links", async () => {
const source = await store.createTask({ description: "Original" });
const duplicated = await store.duplicateTask(source.id);
expect(duplicated.sourceType).toBe("task_duplicate");
expect(duplicated.sourceParentTaskId).toBe(source.id);
await store.moveTask(source.id, "todo");
await store.moveTask(source.id, "in-progress");
await store.moveTask(source.id, "in-review");
await store.moveTask(source.id, "done");
const refined = await store.refineTask(source.id, "Needs polish");
expect(refined.sourceType).toBe("task_refine");
expect(refined.sourceParentTaskId).toBe(source.id);
});
it("preserves provenance on updateTask", async () => {
const task = await store.createTask({
description: "Will be updated",
source: {
sourceType: "automation",
sourceAgentId: "agent-auto",
sourceMetadata: { trigger: "nightly" },
},
});
await store.updateTask(task.id, { title: "Updated" });
const fetched = await store.getTask(task.id);
expect(fetched.sourceType).toBe("automation");
expect(fetched.sourceAgentId).toBe("agent-auto");
expect(fetched.sourceMetadata).toEqual({ trigger: "nightly" });
});
it("persists research provenance metadata", async () => {
const task = await store.createTask({
description: "Research finding follow-up",
source: {
sourceType: "research",
sourceMetadata: {
runId: "RR-42",
findingId: "finding-1",
findingLabel: "Key risk",
documentKey: "research-RR-42",
},
},
});
const fetched = await store.getTask(task.id);
expect(fetched.sourceType).toBe("research");
expect(fetched.sourceMetadata).toEqual({
runId: "RR-42",
findingId: "finding-1",
findingLabel: "Key risk",
documentKey: "research-RR-42",
});
});
});
describe("title handling", () => {
it("creates task with undefined title when none provided", async () => {
const task = await store.createTask({ description: "Fix the login bug on the settings page" });
expect(task.title).toBeUndefined();
expect(task.description).toBe("Fix the login bug on the settings page");
// Verify persisted to disk
const fetched = await store.getTask(task.id);
expect(fetched.title).toBeUndefined();
});
it("creates task with provided title", async () => {
const task = await store.createTask({
title: "Custom Title",
description: "This is the description",
});
expect(task.title).toBe("Custom Title");
const fetched = await store.getTask(task.id);
expect(fetched.title).toBe("Custom Title");
});
it("trims whitespace from provided title", async () => {
const task = await store.createTask({
title: " Padded Title ",
description: "Some description",
});
expect(task.title).toBe("Padded Title");
});
it("treats empty string title as undefined", async () => {
const task = await store.createTask({
title: "",
description: "Some description",
});
expect(task.title).toBeUndefined();
});
it("treats whitespace-only title as undefined", async () => {
const task = await store.createTask({
title: " ",
description: "Some description",
});
expect(task.title).toBeUndefined();
});
it("preserves description exactly as provided", async () => {
const description = "Fix $$$ bug @ home-page (urgent!)";
const task = await store.createTask({ description });
expect(task.description).toBe(description);
});
it("includes ID only in PROMPT.md heading when no title", async () => {
const task = await store.createTask({ description: "Implement the new feature" });
const detail = await store.getTask(task.id);
// Heading should be just the task ID when no title is provided
expect(detail.prompt).toMatch(/^# FN-001\n/);
});
it("includes title in PROMPT.md heading when provided", async () => {
const task = await store.createTask({
title: "My Feature",
description: "Build something great",
column: "todo",
});
const detail = await store.getTask(task.id);
expect(detail.prompt).toMatch(/^# FN-001: My Feature\n/);
});
it("handles empty description gracefully (should throw)", async () => {
await expect(store.createTask({ description: "" })).rejects.toThrow("Description is required");
});
});
// ── Archive Cleanup Tests ────────────────────────────────────────
describe("createTask with title summarization", () => {
it("should use generated title when onSummarize returns a title", async () => {
const longDescription = "a".repeat(201);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Generated Title");
const task = await store.createTask(
{ description: longDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
// Title is not set synchronously - summarization happens async
expect(task.title).toBeUndefined();
expect(mockOnSummarize).toHaveBeenCalledWith(longDescription);
// Wait for async summarization to complete
await new Promise((resolve) => setTimeout(resolve, 10));
// Verify title was set asynchronously
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("AI Generated Title");
});
it("should not call onSummarize when title is already provided", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ title: "User Title", description: "a".repeat(201) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBe("User Title");
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should not call onSummarize when description is too short", async () => {
const shortDescription = "a".repeat(100);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: shortDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBeUndefined();
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should not call onSummarize when autoSummarizeTitles is false", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: false } }
);
expect(task.title).toBeUndefined();
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should not call onSummarize when no settings provided", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: mockOnSummarize }
);
expect(task.title).toBeUndefined();
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should call onSummarize when summarize input flag is true", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: "a".repeat(201), summarize: true },
{ onSummarize: mockOnSummarize }
);
// Title is not set synchronously
expect(task.title).toBeUndefined();
expect(mockOnSummarize).toHaveBeenCalled();
// Wait for async summarization to complete
await new Promise((resolve) => setTimeout(resolve, 10));
// Verify title was set asynchronously
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("AI Title");
});
it("should ignore malformed confirmation-prose generated titles", async () => {
const mockOnSummarize = vi
.fn()
.mockResolvedValue("Created task **FN-9999** in the triage column. Here's a summary.");
const task = await store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBeUndefined();
await new Promise((resolve) => setTimeout(resolve, 10));
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBeUndefined();
});
it("should handle onSummarize returning null", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue(null);
const task = await store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
// Task created without title
expect(task.title).toBeUndefined();
// Wait for async summarization to complete
await new Promise((resolve) => setTimeout(resolve, 10));
// Title should remain undefined
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBeUndefined();
});
it("should handle onSummarize throwing error gracefully", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const mockOnSummarize = vi.fn().mockRejectedValue(new Error("AI service failed"));
try {
const task = await store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBeUndefined();
expect(task.id).toMatch(/^FN-\d+$/); // Task still created
// Wait for async error to be logged
await new Promise((resolve) => setTimeout(resolve, 10));
expect(consoleSpy).toHaveBeenCalled();
const [message, context] = consoleSpy.mock.calls[0] as [string, Record<string, unknown>];
expect(message).toContain("[task-store] Title summarization failed for task");
expect(context).toMatchObject({
taskId: task.id,
descriptionLength: 201,
autoSummarizeEnabled: true,
error: "AI service failed",
});
} finally {
consoleSpy.mockRestore();
}
});
it("logs outer promise-chain failure when inner warning logger throws", async () => {
const syntheticError = "Synthetic warn logger failure";
// Throw inside warn logging so the failure escapes the inner summarize try/catch
// and is handled by the outer Promise.resolve().then(...).catch(...) branch.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
throw new Error(syntheticError);
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockOnSummarize = vi.fn().mockRejectedValue(new Error("AI service failed"));
try {
const task = await store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.id).toMatch(/^FN-\d+$/);
expect(task.title).toBeUndefined();
await new Promise((resolve) => setTimeout(resolve, 10));
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBeUndefined();
const outerErrorCall = errorSpy.mock.calls.find(([message]) =>
typeof message === "string"
&& message.includes("[task-store] Unexpected title summarization promise-chain failure")
);
expect(outerErrorCall).toBeDefined();
const [message, context] = outerErrorCall as [string, Record<string, unknown>];
expect(message).toContain("[task-store] Unexpected title summarization promise-chain failure");
expect(context).toMatchObject({
taskId: task.id,
descriptionLength: 201,
autoSummarizeEnabled: true,
error: syntheticError,
});
} finally {
warnSpy.mockRestore();
errorSpy.mockRestore();
}
});
it("should trigger summarization at exactly 201 characters", async () => {
const boundaryDescription = "a".repeat(201);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: boundaryDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(mockOnSummarize).toHaveBeenCalled();
// Wait for async summarization
await new Promise((resolve) => setTimeout(resolve, 10));
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("AI Title");
});
it("should not trigger summarization at exactly 200 characters", async () => {
const boundaryDescription = "a".repeat(200);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: boundaryDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(mockOnSummarize).not.toHaveBeenCalled();
expect(task.title).toBeUndefined();
});
it("should prioritize explicit title over summarize flag", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ title: "User Title", description: "a".repeat(201), summarize: true },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBe("User Title");
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should include generated title in PROMPT.md heading", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("Generated Task Title");
const task = await store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
// Title not set synchronously
expect(task.title).toBeUndefined();
// Wait for async summarization
await new Promise((resolve) => setTimeout(resolve, 10));
// Verify title and PROMPT.md were updated
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("Generated Task Title");
expect(updatedTask.prompt).toMatch(/^# FN-\d+: Generated Task Title\n/);
});
it("should preserve original description when generating a title", async () => {
const originalDescription = "a".repeat(201);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Summary Title");
const task = await store.createTask(
{ description: originalDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
// Title not set synchronously
expect(task.title).toBeUndefined();
expect(task.description).toBe(originalDescription);
// Wait for async summarization
await new Promise((resolve) => setTimeout(resolve, 10));
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("AI Summary Title");
expect(updatedTask.description).toBe(originalDescription);
});
it("should not overwrite user-set title during async summarization", async () => {
const mockOnSummarize = vi.fn().mockImplementation(async () => {
// Simulate slow AI response
await new Promise((resolve) => setTimeout(resolve, 50));
return "AI Title";
});
const task = await store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
// Immediately update with user title
await store.updateTask(task.id, { title: "User Title" });
// Wait for delayed onSummarize to resolve
await new Promise((resolve) => setTimeout(resolve, 100));
// Title should still be "User Title" (race guard should have prevented overwrite)
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("User Title");
});
});
// ── Utility Path Independence Regression ─────────────────────────────────────
// FN-1727: Title summarization runs on a separate utility lane (async microtask)
// and is NOT gated by task-lane semaphore settings. This test proves that:
// 1. createTask returns immediately (synchronous) regardless of maxConcurrent
// 2. onSummarize callback fires asynchronously via Promise.resolve().then()
// 3. Task creation succeeds even when onSummarize would be blocked by semaphore
//
// The engine's maxConcurrent setting lives at the execution layer and does NOT
// affect the core store's createTask method, which has no semaphore dependency.
describe("createTask summarization is independent of engine maxConcurrent settings", () => {
it("creates task and calls onSummarize even with maxConcurrent: 0", async () => {
// Set extreme concurrency setting to prove the core store is unaffected.
// Note: The core store does NOT read maxConcurrent from settings during
// createTask - this is purely a documentation regression proving the
// architectural separation between core (store) and engine (semaphore).
await store.updateSettings({ maxConcurrent: 0 });
const longDescription = "a".repeat(201);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title From Saturation Test");
// Create task with summarization enabled
const task = await store.createTask(
{ description: longDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
// CRITICAL ASSERTIONS:
// 1. Task was created immediately (synchronous return)
expect(task.id).toMatch(/^FN-\d+$/);
expect(task.title).toBeUndefined(); // Not set synchronously
// 2. onSummarize was called (async but independent of maxConcurrent)
expect(mockOnSummarize).toHaveBeenCalledWith(longDescription);
// 3. Wait for async summarization and verify title was set
await vi.waitFor(async () => {
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("AI Title From Saturation Test");
});
// Reset maxConcurrent to normal value
await store.updateSettings({ maxConcurrent: 2 });
});
it("task creation succeeds when onSummarize is blocked by slow callback (proving no semaphore dependency)", async () => {
// Simulate a slow/stalled onSummarize callback to prove there's no
// semaphore that would block task creation. The core store has no
// dependency on any concurrency limiter.
const slowOnSummarize = vi.fn().mockImplementation(
async () => new Promise<string>(() => {}),
);
const taskPromise = store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: slowOnSummarize, settings: { autoSummarizeTitles: true } }
);
// Task creation MUST complete quickly (before slowOnSummarize resolves)
const task = await taskPromise;
expect(task.id).toMatch(/^FN-\d+$/);
// Verify slowOnSummarize was initiated (async microtask)
expect(slowOnSummarize).toHaveBeenCalled();
// The slow callback is still pending (would take 1000ms to resolve)
// but task creation already succeeded - proving no blocking dependency
const freshTask = await store.getTask(task.id);
expect(freshTask.id).toBe(task.id);
// Title not yet set because onSummarize is still pending
});
});
describe("distributed task-id allocator seam", () => {
it("returns a stable allocator instance", () => {
const first = store.getDistributedTaskIdAllocator();
const second = store.getDistributedTaskIdAllocator();
expect(first).toBe(second);
});
it("createTaskWithReservedId creates using provided id", async () => {
const created = await store.createTaskWithReservedId(
{ description: "replicated task", nodeId: "node-b" },
{ taskId: "FN-9001" },
);
expect(created.id).toBe("FN-9001");
expect(created.nodeId).toBe("node-b");
const detail = await store.getTask("FN-9001");
expect(detail.prompt).toBe("# FN-9001\n\nreplicated task\n");
});
it("createTaskWithReservedId rejects duplicates and self-dependencies", async () => {
await store.createTaskWithReservedId({ description: "first" }, { taskId: "FN-9003" });
await expect(
store.createTaskWithReservedId({ description: "duplicate" }, { taskId: "FN-9003" }),
).rejects.toThrow("Task ID already exists: FN-9003");
await expect(
store.createTaskWithReservedId(
{ description: "self dep", dependencies: ["FN-9004"] },
{ taskId: "FN-9004" },
),
).rejects.toThrow("Task FN-9004 cannot depend on itself");
});
it("applyReplicatedTaskCreate does not auto-apply default workflow steps", async () => {
const workflowStep = await store.createWorkflowStep({
name: "Default step",
description: "auto",
enabled: true,
defaultOn: true,
});
const payload = {
replicationVersion: 1 as const,
reservationId: "res-default-step",
taskId: "FN-9010",
sourceNodeId: "node-a",
createdAt: "2026-05-05T00:00:00.000Z",
updatedAt: "2026-05-05T00:00:00.000Z",
prompt: "# FN-9010\n\ncluster create\n",
input: {
description: "cluster create",
column: "triage" as const,
},
};
const result = await store.applyReplicatedTaskCreate(payload);
expect(result.applied).toBe(true);
expect(result.task.enabledWorkflowSteps).toBeUndefined();
expect(workflowStep.defaultOn).toBe(true);
});
it("applyReplicatedTaskCreate is idempotent and detects collisions", async () => {
const payload = {
replicationVersion: 1 as const,
reservationId: "res-1",
taskId: "FN-9002",
sourceNodeId: "node-a",
createdAt: "2026-05-05T00:00:00.000Z",
updatedAt: "2026-05-05T00:00:00.000Z",
prompt: "# FN-9002\n\ncluster create\n",
input: {
description: "cluster create",
column: "triage" as const,
nodeId: "node-c",
},
};
const first = await store.applyReplicatedTaskCreate(payload);
expect(first.applied).toBe(true);
const second = await store.applyReplicatedTaskCreate(payload);
expect(second.applied).toBe(false);
expect(second.task.id).toBe("FN-9002");
await expect(
store.applyReplicatedTaskCreate({
...payload,
input: { ...payload.input, description: "different" },
}),
).rejects.toThrow("Replicated task payload collision");
});
});
});

View File

@@ -0,0 +1,786 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("moveTask — in-progress to triage", () => {
it("allows moving an in-progress task to triage", async () => {
const task = await store.createTask({ description: "test in-progress to triage" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const moved = await store.moveTask(task.id, "triage");
expect(moved.column).toBe("triage");
});
});
describe("moveTask — resets steps when moving back to todo/triage", () => {
async function setMixedStepStatuses(taskId: string): Promise<void> {
await store.updateStep(taskId, 0, "done");
await store.updateStep(taskId, 1, "in-progress");
await store.updateStep(taskId, 2, "pending");
}
it("resets all steps to pending and currentStep to 0 when moving from in-progress to todo", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await setMixedStepStatuses(task.id);
await store.updateTask(task.id, { currentStep: 2 });
const moved = await store.moveTask(task.id, "todo");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("resets all steps to pending and currentStep to 0 when moving from in-progress to triage", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await setMixedStepStatuses(task.id);
await store.updateTask(task.id, { currentStep: 1 });
const moved = await store.moveTask(task.id, "triage");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("preserves step progress when moving in-progress → todo with preserveResumeState option", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await setMixedStepStatuses(task.id);
await store.updateTask(task.id, { currentStep: 2 });
const moved = await store.moveTask(task.id, "todo", { preserveResumeState: true });
expect(moved.steps[0].status).toBe("done");
expect(moved.steps[1].status).toBe("in-progress");
expect(moved.steps[2].status).toBe("pending");
expect(moved.currentStep).toBe(2);
});
it("preserves step progress and currentStep when moving in-progress → todo with preserveProgress", async () => {
const task = await createTaskWithSteps();
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Checkbox keep
## Steps
### Step 0: Preflight
- [x] Done thing
### Step 1: Implement
- [ ] Pending thing
### Step 2: Verify
- [ ] Pending thing
`,
);
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await setMixedStepStatuses(task.id);
await store.updateTask(task.id, {
currentStep: 2,
worktree: "/tmp/worktree",
executionStartedAt: new Date().toISOString(),
executionCompletedAt: new Date().toISOString(),
});
const moved = await store.moveTask(task.id, "todo", { preserveProgress: true });
const prompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(moved.steps[0].status).toBe("done");
expect(moved.steps[1].status).toBe("in-progress");
expect(moved.currentStep).toBe(2);
expect(moved.worktree).toBeUndefined();
expect(moved.executionStartedAt).toBeUndefined();
expect(moved.executionCompletedAt).toBeUndefined();
expect(prompt).toContain("- [x] Done thing");
});
it("still resets when preserveProgress is true but all steps are pending", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await setMixedStepStatuses(task.id);
await store.updateStep(task.id, 0, "pending");
await store.updateStep(task.id, 1, "pending");
await store.updateTask(task.id, { currentStep: 2 });
const moved = await store.moveTask(task.id, "todo", { preserveProgress: true });
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("preserves steps for in-review → todo and done → todo with preserveProgress", async () => {
const fromReview = await createTaskWithSteps();
await store.moveTask(fromReview.id, "todo");
await store.moveTask(fromReview.id, "in-progress");
await setMixedStepStatuses(fromReview.id);
await store.moveTask(fromReview.id, "in-review");
await store.updateTask(fromReview.id, { currentStep: 1, executionStartedAt: new Date().toISOString() });
const reviewMoved = await store.moveTask(fromReview.id, "todo", { preserveProgress: true });
expect(reviewMoved.steps[0].status).toBe("done");
expect(reviewMoved.steps[1].status).toBe("in-progress");
expect(reviewMoved.currentStep).toBe(1);
expect(reviewMoved.executionStartedAt).toBeUndefined();
const fromDone = await createTaskWithSteps();
await store.moveTask(fromDone.id, "todo");
await store.moveTask(fromDone.id, "in-progress");
await setMixedStepStatuses(fromDone.id);
await store.updateStep(fromDone.id, 1, "done");
await store.updateStep(fromDone.id, 2, "done");
await store.moveTask(fromDone.id, "in-review");
await store.moveTask(fromDone.id, "done");
await store.updateTask(fromDone.id, { currentStep: 2, executionStartedAt: new Date().toISOString() });
const doneMoved = await store.moveTask(fromDone.id, "todo", { preserveProgress: true });
expect(doneMoved.steps[0].status).toBe("done");
expect(doneMoved.steps[1].status).toBe("done");
expect(doneMoved.currentStep).toBe(2);
expect(doneMoved.executionStartedAt).toBeUndefined();
});
it("preserveResumeState keeps step progress and timing but always releases the worktree", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await setMixedStepStatuses(task.id);
const startedAt = new Date().toISOString();
await store.updateTask(task.id, {
currentStep: 2,
worktree: "/tmp/worktree",
branch: "fusion/fn-test",
executionStartedAt: startedAt,
executionCompletedAt: new Date().toISOString(),
});
const moved = await store.moveTask(task.id, "todo", {
preserveProgress: true,
preserveResumeState: true,
});
expect(moved.steps[0].status).toBe("done");
expect(moved.steps[1].status).toBe("in-progress");
expect(moved.currentStep).toBe(2);
// Worktree is always released on requeue so the directory can be
// reused by another task; the branch stays so progress is kept.
expect(moved.worktree).toBeUndefined();
expect(moved.branch).toBe("fusion/fn-test");
expect(moved.executionStartedAt).toBe(startedAt);
expect(moved.executionCompletedAt).toBeUndefined();
// Round-trip: when the task is re-promoted to in-progress with a
// fresh allocator, the branch reference must survive the requeue
// so the executor can reattach to it via createFromExistingBranch
// and resume the in-flight changes. Guards against regressions in
// the in-review → todo full-reset path leaking into other paths.
const repromoted = await store.moveTask(task.id, "in-progress", {
allocateWorktree: () => "/tmp/worktree-fresh",
});
expect(repromoted.branch).toBe("fusion/fn-test");
expect(repromoted.worktree).toBe("/tmp/worktree-fresh");
});
it("preserveWorktree keeps the directory across an internal bounce", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.updateTask(task.id, { worktree: "/tmp/wt-bounce" });
const moved = await store.moveTask(task.id, "todo", {
preserveResumeState: true,
preserveWorktree: true,
});
// The bounce path keeps the same checkout assigned so listeners
// never observe an interim worktree=null state and self-healing
// can't reclaim the directory as idle.
expect(moved.worktree).toBe("/tmp/wt-bounce");
});
it("allocateWorktree assigns a path under the cross-task lock and avoids names already in use", async () => {
const a = await createTaskWithSteps();
const b = await createTaskWithSteps();
await store.moveTask(a.id, "todo");
await store.moveTask(a.id, "in-progress");
await store.updateTask(a.id, { worktree: "/tmp/.worktrees/eager-daisy" });
await store.moveTask(b.id, "todo");
const seenReserved: Set<string>[] = [];
const moved = await store.moveTask(b.id, "in-progress", {
allocateWorktree: (reservedNames) => {
seenReserved.push(new Set(reservedNames));
// Caller picks a name; if it collides with reservedNames the
// caller is responsible for choosing a different one. Here we
// assert the reservedNames snapshot reflects task A's
// assignment, then return a non-colliding path.
return "/tmp/.worktrees/swift-falcon";
},
});
expect(seenReserved).toHaveLength(1);
expect(seenReserved[0].has("eager-daisy")).toBe(true);
// The allocator's task itself must not appear in reservedNames —
// a task should never be told to avoid its own current name.
expect(seenReserved[0].has("swift-falcon")).toBe(false);
expect(moved.worktree).toBe("/tmp/.worktrees/swift-falcon");
});
it("resets steps when moving from in-review to todo", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const withSteps = await store.getTask(task.id);
await store.updateTask(task.id, {
steps: withSteps.steps.map((step) => ({ ...step, status: "done" })),
currentStep: 2,
});
const moved = await store.moveTask(task.id, "todo");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("resets steps when moving from done to todo", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const withDoneSteps = await store.getTask(task.id);
await store.updateTask(task.id, {
steps: withDoneSteps.steps.map((step) => ({ ...step, status: "done" })),
currentStep: 2,
});
const moved = await store.moveTask(task.id, "todo");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("resets steps when moving from done to triage", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const withDoneSteps = await store.getTask(task.id);
await store.updateTask(task.id, {
steps: withDoneSteps.steps.map((step) => ({ ...step, status: "done" })),
currentStep: 2,
});
const moved = await store.moveTask(task.id, "triage");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("does not reset steps when moving from todo to triage", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.updateStep(task.id, 0, "done");
const moved = await store.moveTask(task.id, "triage");
expect(moved.steps[0]?.status).toBe("done");
});
it("resets PROMPT.md checkboxes when moving from in-progress to todo", async () => {
const task = await createTaskWithSteps();
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Checkbox reset
## Steps
### Step 0: Preflight
- [x] Done thing
### Step 1: Implement
- [x] Done thing
`,
);
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "todo");
const prompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(prompt).not.toContain("- [x]");
expect(prompt).toContain("- [ ] Done thing");
});
it("is a no-op when steps array is empty", async () => {
const task = await store.createTask({ description: "no steps reset" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await expect(store.moveTask(task.id, "todo")).resolves.toMatchObject({ id: task.id, column: "todo" });
});
});
describe("moveTask — clears transient fields when leaving in-progress", () => {
it("clears status, error, worktree, and blockedBy when moving from in-progress to todo", async () => {
const task = await store.createTask({ description: "test clear fields" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
// Simulate a failed state
await store.updateTask(task.id, {
status: "failed",
error: "Something went wrong",
worktree: "test-worktree",
blockedBy: "FN-001"
});
const moved = await store.moveTask(task.id, "todo");
expect(moved.column).toBe("todo");
expect(moved.status).toBeUndefined();
expect(moved.error).toBeUndefined();
expect(moved.worktree).toBeUndefined();
expect(moved.blockedBy).toBeUndefined();
});
it("clears status, error, worktree, and blockedBy when moving from in-progress to triage", async () => {
const task = await store.createTask({ description: "test clear fields to triage" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
// Simulate a failed state
await store.updateTask(task.id, {
status: "failed",
error: "Something went wrong",
worktree: "test-worktree",
blockedBy: "FN-001"
});
const moved = await store.moveTask(task.id, "triage");
expect(moved.column).toBe("triage");
expect(moved.status).toBeUndefined();
expect(moved.error).toBeUndefined();
expect(moved.worktree).toBeUndefined();
expect(moved.blockedBy).toBeUndefined();
});
it("preserves status when moving from todo to in-progress", async () => {
const task = await store.createTask({ description: "test preserve status", column: "todo" });
// Set a custom status before moving to in-progress
await store.updateTask(task.id, { status: "planning" });
const moved = await store.moveTask(task.id, "in-progress");
expect(moved.column).toBe("in-progress");
expect(moved.status).toBe("planning");
});
it("does not clear status when moving between non-in-progress columns", async () => {
const task = await store.createTask({ description: "test non-in-progress move" });
// Task starts in triage
// Set a custom status
await store.updateTask(task.id, { status: "custom-status" });
// Move from triage to todo
const moved = await store.moveTask(task.id, "todo");
expect(moved.column).toBe("todo");
expect(moved.status).toBe("custom-status");
});
it("clears status, error, worktree, and blockedBy when moving from in-progress to done", async () => {
const task = await store.createTask({ description: "test clear fields to done" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
// Simulate transient state that should not block completion
await store.updateTask(task.id, {
status: "custom-status",
error: "Transient note",
worktree: "test-worktree",
blockedBy: "FN-001"
});
// Must go through in-review to reach done
await store.moveTask(task.id, "in-review");
const moved = await store.moveTask(task.id, "done");
expect(moved.column).toBe("done");
expect(moved.status).toBeUndefined();
expect(moved.error).toBeUndefined();
expect(moved.worktree).toBeUndefined();
expect(moved.blockedBy).toBeUndefined();
});
it("clears recovery fields when moving to done (FN-985 regression)", async () => {
const task = await store.createTask({ description: "test recovery fields" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
// Set recovery metadata via updateTask
await store.updateTask(task.id, {
recoveryRetryCount: 3,
nextRecoveryAt: new Date(Date.now() + 86400000).toISOString(),
});
await store.moveTask(task.id, "in-review");
const moved = await store.moveTask(task.id, "done");
expect(moved.column).toBe("done");
expect(moved.recoveryRetryCount).toBeUndefined();
expect(moved.nextRecoveryAt).toBeUndefined();
});
it("treats repeated done finalization as an idempotent no-op", async () => {
const task = await store.createTask({ description: "test repeated done finalization" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const done = await store.moveTask(task.id, "done");
const repeated = await store.moveTask(task.id, "done");
expect(repeated.column).toBe("done");
expect(repeated.updatedAt).toBe(done.updatedAt);
});
it("normalizes stale completion fields on repeated done finalization", async () => {
const task = await store.createTask({ description: "test repeated dirty done finalization" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.updateTask(task.id, {
status: "failed",
error: "stale failure",
blockedBy: "FN-000",
worktree: "/tmp/fusion-stale-worktree",
recoveryRetryCount: 2,
nextRecoveryAt: new Date(Date.now() + 86400000).toISOString(),
});
const repeated = await store.moveTask(task.id, "done");
expect(repeated.column).toBe("done");
expect(repeated.status).toBeUndefined();
expect(repeated.error).toBeUndefined();
expect(repeated.blockedBy).toBeUndefined();
expect(repeated.worktree).toBeUndefined();
expect(repeated.recoveryRetryCount).toBeUndefined();
expect(repeated.nextRecoveryAt).toBeUndefined();
});
it("blocks moving failed in-review tasks to done", async () => {
const task = await store.createTask({ description: "test block failed review task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.updateTask(task.id, {
status: "failed",
error: "Workflow step failed",
});
await store.moveTask(task.id, "in-review");
await expect(store.moveTask(task.id, "done")).rejects.toThrow(
"Cannot move",
);
});
it("blocks moving in-review tasks with incomplete steps to done", async () => {
const task = await store.createTask({ description: "test block incomplete review task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.updateTask(task.id, { prompt: "## Steps\n### Step 0: First\n### Step 1: Second" });
await store.updateStep(task.id, 0, "done");
await store.updateStep(task.id, 1, "in-progress");
await store.moveTask(task.id, "in-review");
await expect(store.moveTask(task.id, "done")).rejects.toThrow(
"task has incomplete steps",
);
});
it("allows reopening done tasks back to todo", async () => {
const task = await store.createTask({ description: "test reopen done task to todo" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const reopened = await store.moveTask(task.id, "todo");
expect(reopened.column).toBe("todo");
});
it("allows reopening done tasks back to triage and clears transient execution state", async () => {
const task = await store.createTask({ description: "test reopen done task to triage" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.updateTask(task.id, {
status: "failed",
error: "stale completion error",
worktree: "stale-worktree",
blockedBy: "FN-123",
workflowStepResults: [{
workflowStepId: "wf-1",
workflowStepName: "Workflow step 1",
status: "passed",
startedAt: new Date().toISOString(),
}],
});
const reopened = await store.moveTask(task.id, "triage");
expect(reopened.column).toBe("triage");
expect(reopened.status).toBeUndefined();
expect(reopened.error).toBeUndefined();
expect(reopened.worktree).toBeUndefined();
expect(reopened.blockedBy).toBeUndefined();
expect(reopened.workflowStepResults).toBeUndefined();
});
it("allows retrying in-review tasks back to todo and clears transient fields", async () => {
const task = await store.createTask({ description: "test retry in-review task to todo" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "completed",
error: "stale error",
worktree: "stale-worktree",
blockedBy: "FN-456",
branch: "fn/stale-branch",
baseBranch: "main",
baseCommitSha: "abc123",
summary: "stale summary from prior attempt",
recoveryRetryCount: 2,
nextRecoveryAt: new Date().toISOString(),
workflowStepResults: [{
workflowStepId: "wf-1",
workflowStepName: "Workflow step 1",
status: "passed",
startedAt: new Date().toISOString(),
}],
});
const retried = await store.moveTask(task.id, "todo");
expect(retried.column).toBe("todo");
expect(retried.status).toBeUndefined();
expect(retried.error).toBeUndefined();
expect(retried.worktree).toBeUndefined();
expect(retried.blockedBy).toBeUndefined();
expect(retried.workflowStepResults).toBeUndefined();
// Full reset: prior branch/summary/recovery state discarded so the next
// run starts from scratch.
expect(retried.branch).toBeUndefined();
expect(retried.baseBranch).toBe("main");
expect(retried.executionStartBranch).toBeUndefined();
expect(retried.baseCommitSha).toBeUndefined();
expect(retried.summary).toBeUndefined();
expect(retried.recoveryRetryCount).toBeUndefined();
expect(retried.nextRecoveryAt).toBeUndefined();
});
it("allows respec'ing in-review tasks back to triage and clears transient fields", async () => {
const task = await store.createTask({ description: "test respec in-review task to triage" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "completed",
error: "stale error",
worktree: "stale-worktree",
blockedBy: "FN-456",
branch: "fn/stale-branch",
baseBranch: "main",
baseCommitSha: "abc123",
summary: "stale summary from prior attempt",
recoveryRetryCount: 2,
nextRecoveryAt: new Date().toISOString(),
workflowStepResults: [{
workflowStepId: "wf-1",
workflowStepName: "Workflow step 1",
status: "passed",
startedAt: new Date().toISOString(),
}],
});
const respec = await store.moveTask(task.id, "triage");
expect(respec.column).toBe("triage");
expect(respec.status).toBeUndefined();
expect(respec.error).toBeUndefined();
expect(respec.worktree).toBeUndefined();
expect(respec.blockedBy).toBeUndefined();
expect(respec.workflowStepResults).toBeUndefined();
expect(respec.branch).toBeUndefined();
expect(respec.baseBranch).toBe("main");
expect(respec.executionStartBranch).toBeUndefined();
expect(respec.baseCommitSha).toBeUndefined();
expect(respec.summary).toBeUndefined();
expect(respec.recoveryRetryCount).toBeUndefined();
expect(respec.nextRecoveryAt).toBeUndefined();
});
});
describe("columnMovedAt", () => {
it("createTask sets columnMovedAt", async () => {
const before = new Date().toISOString();
const task = await store.createTask({ description: "test columnMovedAt" });
const after = new Date().toISOString();
expect(task.columnMovedAt).toBeDefined();
expect(task.columnMovedAt! >= before).toBe(true);
expect(task.columnMovedAt! <= after).toBe(true);
});
it("moveTask sets columnMovedAt to a recent ISO timestamp", async () => {
const task = await store.createTask({ description: "move test", column: "triage" });
const originalMovedAt = task.columnMovedAt;
// Small delay to ensure timestamp differs
await new Promise((r) => setTimeout(r, 10));
const before = new Date().toISOString();
const moved = await store.moveTask(task.id, "todo");
const after = new Date().toISOString();
expect(moved.columnMovedAt).toBeDefined();
expect(moved.columnMovedAt! >= before).toBe(true);
expect(moved.columnMovedAt! <= after).toBe(true);
expect(moved.columnMovedAt).not.toBe(originalMovedAt);
});
it("updateTask does NOT change columnMovedAt", async () => {
const task = await store.createTask({ description: "no change test" });
const originalMovedAt = task.columnMovedAt;
await new Promise((r) => setTimeout(r, 10));
const updated = await store.updateTask(task.id, { title: "new title" });
expect(updated.columnMovedAt).toBe(originalMovedAt);
});
});
describe("VALID_TRANSITIONS — invalid archived transitions via moveTask", () => {
it("moveTask from archived → in-progress should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
await expect(store.moveTask(task.id, "in-progress")).rejects.toThrow("Invalid transition");
});
it("moveTask from archived → triage should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
await expect(store.moveTask(task.id, "triage")).rejects.toThrow("Invalid transition");
});
it("moveTask from archived → todo should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
await expect(store.moveTask(task.id, "todo")).rejects.toThrow("Invalid transition");
});
it("moveTask from archived → in-review should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
await expect(store.moveTask(task.id, "in-review")).rejects.toThrow("Invalid transition");
});
it("moveTask from triage → archived should fail", async () => {
const task = await store.createTask({ description: "Test task" });
// Task starts in triage
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
});
it("moveTask from todo → archived should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
});
it("moveTask from in-progress → archived should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
});
it("moveTask from in-review → archived should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
});
});
});

View File

@@ -0,0 +1,829 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createTaskStoreTestHarness, makeTmpDir, mockedExecSync, mockedRunCommandAsync } from "./store-test-helpers.js";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import type { runCommandAsync } from "../run-command.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("duplicateTask", () => {
it("duplicates from triage column", async () => {
const task = await store.createTask({ description: "Test task" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.id).not.toBe(task.id);
expect(duplicated.id).toMatch(/^FN-\d+$/);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(task.description);
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("duplicates from todo column", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("duplicates from in-progress column", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
await store.moveTask(task.id, "in-progress");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("duplicates from in-review column", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("duplicates from done column", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("new task is always in triage regardless of source column", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
});
it("description includes source reference", async () => {
const task = await store.createTask({ description: "Original description" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.description).toBe(`Original description\n\n(Duplicated from ${task.id})`);
});
it("resets execution state (no steps, no worktree, etc.)", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
// Add some execution state
await store.updateTask(task.id, { worktree: "/some/path", status: "executing" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.steps).toEqual([]);
expect(duplicated.currentStep).toBe(0);
expect(duplicated.worktree).toBeUndefined();
expect(duplicated.status).toBeUndefined();
});
it("clears nullable execution fields via updateTask(null)", async () => {
const task = await store.createTask({ description: "Test clear nullable execution fields", column: "todo" });
await store.updateTask(task.id, {
worktree: "/some/path",
branch: "fusion/fn-001",
baseBranch: "main",
baseCommitSha: "abc123",
status: "executing",
error: "boom",
});
const updated = await store.updateTask(task.id, {
worktree: null,
branch: null,
baseBranch: null,
baseCommitSha: null,
status: null,
error: null,
});
expect(updated.worktree).toBeUndefined();
expect(updated.branch).toBeUndefined();
expect(updated.baseBranch).toBeUndefined();
expect(updated.baseCommitSha).toBeUndefined();
expect(updated.status).toBeUndefined();
expect(updated.error).toBeUndefined();
});
it("does NOT copy dependencies", async () => {
const dep = await store.createTask({ description: "Dependency" });
const task = await store.createTask({ description: "Test task", dependencies: [dep.id] });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.dependencies).toEqual([]);
});
it("does NOT copy attachments", async () => {
const task = await store.createTask({ description: "Test task" });
// Add an attachment
await store.addAttachment(task.id, "test.png", Buffer.from("fake"), "image/png");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.attachments).toBeUndefined();
});
it("does NOT copy steering comments", async () => {
const task = await store.createTask({ description: "Test task" });
await store.addComment(task.id, "Test comment");
const duplicated = await store.duplicateTask(task.id);
// Comments should not be copied when duplicating
expect(duplicated.comments).toBeUndefined();
});
it("emits task:created event", async () => {
const task = await store.createTask({ description: "Test task" });
const events: any[] = [];
store.on("task:created", (t) => events.push(t));
const duplicated = await store.duplicateTask(task.id);
expect(events).toHaveLength(1);
expect(events[0].id).toBe(duplicated.id);
});
it("adds log entry for duplicate action", async () => {
const task = await store.createTask({ description: "Test task" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.log).toHaveLength(1);
expect(duplicated.log[0].action).toContain(`Duplicated from ${task.id}`);
});
it("copies source PROMPT.md content", async () => {
const task = await store.createTask({ description: "Test task" });
const sourceDetail = await store.getTask(task.id);
const duplicated = await store.duplicateTask(task.id);
const dupDetail = await store.getTask(duplicated.id);
expect(dupDetail.prompt).toBe(sourceDetail.prompt);
});
it("throws ENOENT when source task does not exist", async () => {
await expect(store.duplicateTask("KB-999")).rejects.toThrow();
});
it("copies title if present", async () => {
const task = await store.createTask({ title: "My Task", description: "Test" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.title).toBe("My Task");
});
it("does NOT copy prInfo", async () => {
const task = await store.createTask({ description: "Test task" });
await store.updatePrInfo(task.id, {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Test PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
});
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.prInfo).toBeUndefined();
});
it("does NOT copy paused state", async () => {
const task = await store.createTask({ description: "Test task" });
await store.pauseTask(task.id, true);
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.paused).toBeUndefined();
});
it("does NOT copy blockedBy", async () => {
const blocker = await store.createTask({ description: "Blocker" });
const task = await store.createTask({ description: "Test task" });
await store.updateTask(task.id, { blockedBy: blocker.id });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.blockedBy).toBeUndefined();
});
it("copies baseBranch", async () => {
const task = await store.createTask({ description: "Test task" });
await store.updateTask(task.id, { baseBranch: "some-branch" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.baseBranch).toBe("some-branch");
});
});
// ── Refine Task Tests ────────────────────────────────────────────
describe("refineTask", () => {
it("creates refinement from done task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need to fix edge case");
expect(refined.id).not.toBe(task.id);
expect(refined.id).toMatch(/^FN-\d+$/);
expect(refined.column).toBe("triage");
// Untitled source: uses first line of description as readable label
expect(refined.title).toBe("Refinement: Original task");
});
it("creates refinement from in-review task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.column).toBe("triage");
// Untitled source: uses first line of description as readable label
expect(refined.title).toBe("Refinement: Original task");
});
it("throws error when refining task in triage", async () => {
const task = await store.createTask({ description: "Original task" });
// Task starts in triage
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
});
it("throws error when refining task in todo", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
});
it("throws error when refining task in in-progress", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
});
it("throws error when feedback is empty", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await expect(store.refineTask(task.id, "")).rejects.toThrow("Feedback is required");
});
it("throws error when feedback is whitespace only", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await expect(store.refineTask(task.id, " ")).rejects.toThrow("Feedback is required");
});
it("sets correct title format with original title", async () => {
const task = await store.createTask({ title: "My Feature", description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Add more tests");
expect(refined.title).toBe("Refinement: My Feature");
});
it("sets correct title format without original title (uses description fallback)", async () => {
const task = await store.createTask({ description: "Fix the login bug" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Add more tests");
// Falls back to first line of description when no title
expect(refined.title).toBe("Refinement: Fix the login bug");
});
it("description includes feedback and refines reference", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Fix the edge case handling");
expect(refined.description).toBe(`Fix the edge case handling\n\nRefines: ${task.id}`);
});
it("sets dependency on original task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.dependencies).toEqual([task.id]);
});
it("adds log entry for refinement creation", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.log).toHaveLength(1);
expect(refined.log[0].action).toBe(`Created as refinement of ${task.id}`);
});
it("emits task:created event", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const events: any[] = [];
store.on("task:created", (t) => events.push(t));
const refined = await store.refineTask(task.id, "Need improvements");
expect(events).toHaveLength(1);
expect(events[0].id).toBe(refined.id);
});
it("copies attachments from original task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// Add an attachment
await store.addAttachment(task.id, "test.png", Buffer.from("fake image"), "image/png");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.attachments).toHaveLength(1);
expect(refined.attachments![0].originalName).toBe("test.png");
expect(refined.attachments![0].mimeType).toBe("image/png");
});
it("copies attachment files to new task directory", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// Add an attachment
await store.addAttachment(task.id, "test.png", Buffer.from("fake image data"), "image/png");
const refined = await store.refineTask(task.id, "Need improvements");
// Verify file exists in new task directory
const attachDir = join(rootDir, ".fusion", "tasks", refined.id, "attachments");
const files = await readdir(attachDir);
expect(files.length).toBe(1);
// Verify content was copied
const content = await readFile(join(attachDir, files[0]));
expect(content.toString()).toBe("fake image data");
});
it("works when source has no attachments", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.attachments).toBeUndefined();
});
it("resets execution state (no steps, no worktree, etc.)", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.steps).toEqual([]);
expect(refined.currentStep).toBe(0);
expect(refined.worktree).toBeUndefined();
expect(refined.status).toBeUndefined();
});
it("creates PROMPT.md for the refinement", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
// Untitled source: uses first line of description
expect(detail.prompt).toContain("Refinement: Original task");
expect(detail.prompt).toContain("Need improvements");
expect(detail.prompt).toContain(`Refines: ${task.id}`);
});
it("uses first non-empty line of description when title is absent", async () => {
const task = await store.createTask({
description: "Use source task labels for refinement titles\n\nThis is a longer description.",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Add more tests");
expect(refined.title).toBe("Refinement: Use source task labels for refinement titles");
});
it("collapses internal whitespace in description fallback", async () => {
const task = await store.createTask({
description: "Fix the \t spacing issue in UI",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "More feedback");
expect(refined.title).toBe("Refinement: Fix the spacing issue in UI");
});
it("skips leading blank lines in multi-line description", async () => {
const task = await store.createTask({
description: "\n \n \nFirst real line of description\nSecond line",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Feedback");
expect(refined.title).toBe("Refinement: First real line of description");
});
it("falls back to task ID when description has no non-empty lines", async () => {
// Create a task with a valid description, then update to all-whitespace
// (createTask rejects all-whitespace descriptions, but updates could produce this edge case)
const task = await store.createTask({ description: "Valid description" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.updateTask(task.id, { description: " \n \n\t\n" });
const refined = await store.refineTask(task.id, "Feedback");
expect(refined.title).toBe(`Refinement: ${task.id}`);
});
it("PROMPT.md heading matches the refinement title", async () => {
const task = await store.createTask({
title: "My Feature",
description: "Some description",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
expect(refined.title).toBe("Refinement: My Feature");
expect(detail.prompt).toMatch(/^# Refinement: My Feature\n/);
});
it("PROMPT.md heading uses description fallback when untitled", async () => {
const task = await store.createTask({
description: "Fix the login bug on settings page",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
expect(refined.title).toBe("Refinement: Fix the login bug on settings page");
expect(detail.prompt).toMatch(/^# Refinement: Fix the login bug on settings page\n/);
});
it("throws ENOENT when source task does not exist", async () => {
await expect(store.refineTask("KB-999", "Feedback")).rejects.toThrow();
});
});
// ── Archive/Unarchive Tests ──────────────────────────────────────
describe("branch cleanup on delete and archive", () => {
beforeEach(() => {
mockedExecSync.mockClear();
mockedRunCommandAsync.mockClear();
});
afterEach(() => {
mockedExecSync.mockImplementation(
(...args: Parameters<typeof execSync>) => {
// Restore pass-through to real implementation
const { execSync: realExecSync } = require("node:child_process");
return realExecSync(...args);
},
);
mockedRunCommandAsync.mockImplementation((...args: Parameters<typeof runCommandAsync>) =>
vi.importActual<typeof import("../run-command.js")>("../run-command.js").then((mod) =>
mod.runCommandAsync(...args),
),
);
});
it("deleteTask attempts branch cleanup via cleanupBranchForTask", async () => {
const task = await createTestTask();
// Mock: verify succeeds, delete succeeds
mockedRunCommandAsync.mockImplementation(async (cmd: string) => {
if (cmd.includes("git rev-parse --verify") || cmd.includes("git branch -D")) {
return { stdout: "", stderr: "", exitCode: 0, signal: null, bufferExceeded: false, timedOut: false };
}
throw new Error(`unexpected runCommandAsync call: ${cmd}`);
});
await store.deleteTask(task.id);
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
const verifyCalls = calls.filter((c) => c.includes("git rev-parse --verify") && c.includes(`fusion/${task.id.toLowerCase()}`));
const deleteCalls = calls.filter((c) => c.includes("git branch -D") && c.includes(`fusion/${task.id.toLowerCase()}`));
expect(verifyCalls.length).toBeGreaterThanOrEqual(1);
expect(deleteCalls.length).toBeGreaterThanOrEqual(1);
});
it("deleteTask cleans up stored branch and derived branch when set", async () => {
const task = await store.createTask({ description: "Branch test" });
await store.updateTask(task.id, { branch: "fusion/my-custom-branch" });
mockedRunCommandAsync.mockImplementation(async (cmd: string) => {
if (cmd.includes("git rev-parse --verify") || cmd.includes("git branch -D")) {
return { stdout: "", stderr: "", exitCode: 0, signal: null, bufferExceeded: false, timedOut: false };
}
throw new Error(`unexpected runCommandAsync call: ${cmd}`);
});
await store.deleteTask(task.id);
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
// Should verify and delete both stored and derived branches
const customBranchVerify = calls.filter((c) => c.includes(`git rev-parse --verify "fusion/my-custom-branch"`));
const customBranchDelete = calls.filter((c) => c.includes(`git branch -D "fusion/my-custom-branch"`));
const derivedBranchVerify = calls.filter((c) => c.includes(`git rev-parse --verify "fusion/${task.id.toLowerCase()}"`));
const derivedBranchDelete = calls.filter((c) => c.includes(`git branch -D "fusion/${task.id.toLowerCase()}"`));
expect(customBranchVerify.length).toBeGreaterThanOrEqual(1);
expect(customBranchDelete.length).toBeGreaterThanOrEqual(1);
expect(derivedBranchVerify.length).toBeGreaterThanOrEqual(1);
expect(derivedBranchDelete.length).toBeGreaterThanOrEqual(1);
});
it("deleteTask succeeds even when branch cleanup fails", async () => {
const task = await createTestTask();
mockedRunCommandAsync.mockResolvedValue({
stdout: "",
stderr: "not a git repo",
exitCode: 128,
signal: null,
bufferExceeded: false,
timedOut: false,
});
const deleted = await store.deleteTask(task.id);
expect(deleted.id).toBe(task.id);
});
it("archiveTask with cleanup attempts branch cleanup", async () => {
const task = await createTestTask();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
mockedRunCommandAsync.mockImplementation(async (cmd: string) => {
if (cmd.includes("git rev-parse --verify") || cmd.includes("git branch -D")) {
return { stdout: "", stderr: "", exitCode: 0, signal: null, bufferExceeded: false, timedOut: false };
}
throw new Error(`unexpected runCommandAsync call: ${cmd}`);
});
await store.archiveTask(task.id, true);
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
const verifyCalls = calls.filter((c) => c.includes("git rev-parse --verify") && c.includes(`fusion/${task.id.toLowerCase()}`));
const deleteCalls = calls.filter((c) => c.includes("git branch -D") && c.includes(`fusion/${task.id.toLowerCase()}`));
expect(verifyCalls.length).toBeGreaterThanOrEqual(1);
expect(deleteCalls.length).toBeGreaterThanOrEqual(1);
});
it("archiveTask without cleanup does NOT attempt branch cleanup", async () => {
const task = await createTestTask();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
mockedRunCommandAsync.mockClear();
await store.archiveTask(task.id, false);
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
const branchCommands = calls.filter((c) => c.includes("git branch -D") || c.includes("git rev-parse --verify"));
expect(branchCommands).toHaveLength(0);
});
});
describe("project memory bootstrap", () => {
it("creates .fusion/memory/MEMORY.md on init when memoryEnabled is default (true)", async () => {
const memoryPath = join(rootDir, ".fusion", "memory", "MEMORY.md");
expect(existsSync(memoryPath)).toBe(true);
const content = await readFile(memoryPath, "utf-8");
expect(content).toContain("# Project Memory");
expect(content).toContain("## Architecture");
expect(content).toContain("## Conventions");
});
it("does not create .fusion/memory/MEMORY.md when memoryEnabled is false after re-init", async () => {
const localRoot = makeTmpDir();
const localGlobal = makeTmpDir();
let localStore: TaskStore | undefined;
let secondStore: TaskStore | undefined;
try {
localStore = new TaskStore(localRoot, localGlobal);
await localStore.init();
await localStore.updateSettings({ memoryEnabled: false } as any);
const memoryPath = join(localRoot, ".fusion", "memory", "MEMORY.md");
if (existsSync(memoryPath)) {
await unlink(memoryPath);
}
expect(existsSync(memoryPath)).toBe(false);
localStore.close();
localStore = undefined;
secondStore = new TaskStore(localRoot, localGlobal);
await secondStore.init();
expect(existsSync(memoryPath)).toBe(false);
} finally {
secondStore?.close();
localStore?.close();
await rm(localRoot, { recursive: true, force: true });
await rm(localGlobal, { recursive: true, force: true });
}
});
it("creates .fusion/memory/MEMORY.md when memory is toggled on via updateSettings", async () => {
const localRoot = makeTmpDir();
const localGlobal = makeTmpDir();
let localStore: TaskStore | undefined;
try {
localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true });
await localStore.init();
await localStore.updateSettings({ memoryEnabled: false } as any);
const memoryPath = join(localRoot, ".fusion", "memory", "MEMORY.md");
if (existsSync(memoryPath)) {
await unlink(memoryPath);
}
expect(existsSync(memoryPath)).toBe(false);
await localStore.updateSettings({ memoryEnabled: true } as any);
expect(existsSync(memoryPath)).toBe(true);
const content = await readFile(memoryPath, "utf-8");
expect(content).toContain("# Project Memory");
} finally {
localStore?.close();
await rm(localRoot, { recursive: true, force: true });
await rm(localGlobal, { recursive: true, force: true });
}
});
it("does not overwrite existing memory content when toggled on", async () => {
const localRoot = makeTmpDir();
const localGlobal = makeTmpDir();
let localStore: TaskStore | undefined;
try {
localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true });
await localStore.init();
const memoryPath = join(localRoot, ".fusion", "memory", "MEMORY.md");
const customContent = "# My Custom Memory\n\nImportant stuff";
await writeFile(memoryPath, customContent, "utf-8");
await localStore.updateSettings({ memoryEnabled: false } as any);
await localStore.updateSettings({ memoryEnabled: true } as any);
const content = await readFile(memoryPath, "utf-8");
expect(content).toBe(customContent);
} finally {
localStore?.close();
await rm(localRoot, { recursive: true, force: true });
await rm(localGlobal, { recursive: true, force: true });
}
});
});
describe("research document key helper", () => {
it("builds canonical research document keys", () => {
expect(buildResearchDocumentKey("RR-1")).toBe("research-RR-1");
expect(buildResearchDocumentKey("RR/1")).toBe("research-RR1");
});
it("rejects run IDs that sanitize to an empty string", () => {
expect(() => buildResearchDocumentKey("!!!")).toThrow("Invalid research run id");
});
});
// ── Title Handling Tests ────────────────────────────────────────
});

View File

@@ -0,0 +1,271 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("parseStepsFromPrompt", () => {
it("returns empty array when task directory is missing", async () => {
const task = await createTaskWithSteps();
await deleteTaskDir(task.id);
const steps = await store.parseStepsFromPrompt(task.id);
expect(steps).toEqual([]);
});
});
describe("parseDependenciesFromPrompt", () => {
it("returns single dependency from PROMPT.md", async () => {
const task = await store.createTask({ description: "Task with dep" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Task with dep
## Dependencies
- **Task:** FN-001 (must be complete first)
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual(["FN-001"]);
});
it("returns multiple dependencies in order", async () => {
const task = await store.createTask({ description: "Task with deps" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Task with deps
## Dependencies
- **Task:** FN-010 (first dep)
- **Task:** FN-020 (second dep)
- **Task:** PROJ-003 (third dep)
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual(["FN-010", "FN-020", "PROJ-003"]);
});
it("returns empty array when dependencies section says None", async () => {
const task = await store.createTask({ description: "No deps" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: No deps
## Dependencies
- **None**
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual([]);
});
it("returns empty array when no Dependencies section exists", async () => {
const task = await store.createTask({ description: "No section" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: No section
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual([]);
});
it("returns empty array when task has no PROMPT.md file", async () => {
const task = await store.createTask({ description: "No prompt" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
// Delete the PROMPT.md that createTask generates
await unlink(join(dir, "PROMPT.md"));
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual([]);
});
it("returns empty array when task directory is missing", async () => {
const task = await store.createTask({ description: "No directory" });
await deleteTaskDir(task.id);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual([]);
});
});
describe("parseFileScopeFromPrompt", () => {
it("returns paths when File Scope is followed by another heading", async () => {
const task = await store.createTask({ description: "Mid-file scope" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Mid-file scope
## File Scope
- \`packages/core/src/store.ts\`
- \`packages/core/src/store.test.ts\`
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const paths = await store.parseFileScopeFromPrompt(task.id);
expect(paths).toEqual([
"packages/core/src/store.ts",
"packages/core/src/store.test.ts",
]);
});
it("returns all paths when File Scope is the last section", async () => {
const task = await store.createTask({
description: "End-of-file scope",
});
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: End-of-file scope
## Steps
### Step 0: Preflight
- [ ] Check things
## File Scope
- \`packages/core/src/store.ts\`
- \`packages/core/src/store.test.ts\`
- \`packages/core/src/utils.ts\`
`,
);
const paths = await store.parseFileScopeFromPrompt(task.id);
expect(paths).toEqual([
"packages/core/src/store.ts",
"packages/core/src/store.test.ts",
"packages/core/src/utils.ts",
]);
});
it("returns empty array when no File Scope section exists", async () => {
const task = await store.createTask({ description: "No scope" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: No scope
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const paths = await store.parseFileScopeFromPrompt(task.id);
expect(paths).toEqual([]);
});
it("returns empty array when PROMPT.md does not exist", async () => {
const task = await store.createTask({ description: "No prompt" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await unlink(join(dir, "PROMPT.md"));
const paths = await store.parseFileScopeFromPrompt(task.id);
expect(paths).toEqual([]);
});
it("returns empty array when task directory is missing", async () => {
const task = await store.createTask({ description: "No prompt directory" });
await deleteTaskDir(task.id);
const paths = await store.parseFileScopeFromPrompt(task.id);
expect(paths).toEqual([]);
});
it("handles glob patterns in backtick-quoted paths", async () => {
const task = await store.createTask({ description: "Glob scope" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Glob scope
## File Scope
- \`packages/core/*\`
- \`packages/cli/src/commands/dashboard.ts\`
- \`packages/engine/src/**/*.ts\`
`,
);
const paths = await store.parseFileScopeFromPrompt(task.id);
expect(paths).toEqual([
"packages/core/*",
"packages/cli/src/commands/dashboard.ts",
"packages/engine/src/**/*.ts",
]);
});
});
});

View File

@@ -0,0 +1,463 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("write lock serialization", () => {
it("serializes concurrent logEntry and updateStep calls without corruption", async () => {
const task = await createTaskWithSteps();
const id = task.id;
// Fire 20 concurrent operations: 10 logEntry + 10 updateStep (alternating steps)
const promises: Promise<Task>[] = [];
for (let i = 0; i < 20; i++) {
if (i % 2 === 0) {
promises.push(store.logEntry(id, `Log entry ${i}`));
} else {
// Toggle step 0 between in-progress and done
const status = i % 4 === 1 ? "in-progress" : "done";
promises.push(store.updateStep(id, 0, status));
}
}
await Promise.all(promises);
// Read back and verify valid JSON
const taskJsonPath = join(rootDir, ".fusion", "tasks", id, "task.json");
const raw = await readFile(taskJsonPath, "utf-8");
const result = JSON.parse(raw) as Task;
// Check all 10 log entries are present (plus initial "Task created" + step update logs)
const customLogs = result.log.filter((l) => l.action.startsWith("Log entry"));
expect(customLogs).toHaveLength(10);
});
});
// ── Defensive parsing test ───────────────────────────────────────
describe("defensive JSON parsing", () => {
it("reads from SQLite even if task.json on disk is corrupted", async () => {
const task = await createTestTask();
const taskJsonPath = join(rootDir, ".fusion", "tasks", task.id, "task.json");
// Corrupt the file: append duplicate trailing content
const validJson = await readFile(taskJsonPath, "utf-8");
const corrupted = validJson + validJson.slice(validJson.length / 2);
await writeFile(taskJsonPath, corrupted);
// SQLite still has valid data — getTask should succeed
const detail = await store.getTask(task.id);
expect(detail.id).toBe(task.id);
});
it("reads from SQLite even if task.json contains invalid content", async () => {
const task = await createTestTask();
const taskJsonPath = join(rootDir, ".fusion", "tasks", task.id, "task.json");
// Write completely invalid content
await writeFile(taskJsonPath, "not json at all {{{");
// SQLite still has valid data — getTask should succeed
const detail = await store.getTask(task.id);
expect(detail.id).toBe(task.id);
});
});
// ── Atomic write test ────────────────────────────────────────────
describe("atomic writes", () => {
it("produces valid JSON after write with no .tmp files left behind", async () => {
const task = await createTestTask();
const dir = join(rootDir, ".fusion", "tasks", task.id);
// Perform a write
await store.logEntry(task.id, "atomic test");
// Verify valid JSON
const raw = await readFile(join(dir, "task.json"), "utf-8");
const parsed = JSON.parse(raw) as Task;
expect(parsed.log.some((l) => l.action === "atomic test")).toBe(true);
// Verify no .tmp files
const files = await readdir(dir);
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
});
});
// ── Atomic config writes ──────────────────────────────────────────
describe("atomic config writes", () => {
it("produces valid config.json with unique sequential IDs after 5 parallel createTask calls", async () => {
const promises = Array.from({ length: 5 }, (_, i) =>
store.createTask({ description: `Concurrent task ${i}` }),
);
const tasks = await Promise.all(promises);
// All IDs should be unique
const ids = tasks.map((t) => t.id);
expect(new Set(ids).size).toBe(5);
// IDs should be sequential (FN-001 through FN-005)
const sortedIds = [...ids].sort();
expect(sortedIds).toEqual(["FN-001", "FN-002", "FN-003", "FN-004", "FN-005"]);
// config.json should be valid JSON with nextId = 6
const configPath = join(rootDir, ".fusion", "config.json");
const raw = await readFile(configPath, "utf-8");
const config = JSON.parse(raw);
expect(config.nextId).toBe(6);
// No .tmp files left behind
const haiDir = join(rootDir, ".fusion");
const files = await readdir(haiDir);
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
});
});
// ── Attachment tests ──────────────────────────────────────────────
describe("concurrent stress", () => {
it("handles 10 parallel logEntry calls preserving all entries", async () => {
const task = await createTestTask();
const initialLogCount = task.log.length; // 1 ("Task created")
const promises = Array.from({ length: 10 }, (_, i) =>
store.logEntry(task.id, `Stress log ${i}`),
);
await Promise.all(promises);
const result = await store.getTask(task.id);
const stressLogs = result.log.filter((l) => l.action.startsWith("Stress log"));
expect(stressLogs).toHaveLength(10);
expect(result.log).toHaveLength(initialLogCount + 10);
});
});
describe("directory recreation for file-backed blobs", () => {
it("pauseTask recreates missing task directory before writing task.json", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
const paused = await store.pauseTask(task.id, true);
expect(paused.paused).toBe(true);
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "task.json"))).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.paused).toBe(true);
});
it("updateStep recreates missing task directory and persists regenerated task.json", async () => {
const task = await createTaskWithSteps();
const promptDir = join(rootDir, ".fusion", "tasks", task.id);
const prompt = await readFile(join(promptDir, "PROMPT.md"), "utf-8");
const dir = await deleteTaskDir(task.id);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), prompt);
const updated = await store.updateStep(task.id, 0, "in-progress");
expect(updated.steps[0].status).toBe("in-progress");
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "task.json"))).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.steps[0].status).toBe("in-progress");
});
it("preserves done/skipped steps when updateStep is called with in-progress", async () => {
const task = await createTaskWithSteps();
await store.updateStep(task.id, 0, "done");
await store.updateStep(task.id, 1, "done");
const beforeRegression = await store.getTask(task.id);
const currentStepBefore = beforeRegression.currentStep;
// Agent erroneously re-marks an already-done step as in-progress.
const result = await store.updateStep(task.id, 0, "in-progress");
expect(result.steps[0].status).toBe("done");
expect(result.steps[1].status).toBe("done");
expect(result.currentStep).toBe(currentStepBefore);
const fetched = await store.getTask(task.id);
expect(fetched.steps[0].status).toBe("done");
expect(fetched.currentStep).toBe(currentStepBefore);
});
it("addComment recreates missing task directory before persisting metadata", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
const updated = await store.addComment(task.id, "Please recover from missing directory");
expect(updated.comments).toHaveLength(1);
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "task.json"))).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.comments).toHaveLength(1);
});
it("addAttachment recreates missing task directory and attachment directory", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
const attachment = await store.addAttachment(task.id, "note.txt", Buffer.from("hello"), "text/plain");
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "attachments", attachment.filename))).toBe(true);
expect(existsSync(join(dir, "task.json"))).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.attachments).toHaveLength(1);
});
it("updateTask recreates missing task directory before rewriting PROMPT.md", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
const prompt = "# KB-001\n\nRecovered prompt\n";
const updated = await store.updateTask(task.id, { title: "Recovered", prompt });
expect(updated.title).toBe("Recovered");
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "PROMPT.md"))).toBe(true);
expect(await readFile(join(dir, "PROMPT.md"), "utf-8")).toBe(prompt);
const fetched = await store.getTask(task.id);
expect(fetched.title).toBe("Recovered");
expect(fetched.prompt).toBe(prompt);
});
it("duplicateTask recreates the new task directory before copying PROMPT.md", async () => {
const task = await createTestTask();
const duplicate = await store.duplicateTask(task.id);
const duplicateDir = join(rootDir, ".fusion", "tasks", duplicate.id);
expect(existsSync(duplicateDir)).toBe(true);
expect(existsSync(join(duplicateDir, "PROMPT.md"))).toBe(true);
expect(await readFile(join(duplicateDir, "PROMPT.md"), "utf-8")).toContain(task.description);
});
});
describe("pauseTask", () => {
it("sets paused flag to true and adds log entry", async () => {
const task = await createTestTask();
const paused = await store.pauseTask(task.id, true);
expect(paused.paused).toBe(true);
expect(paused.log.some((l) => l.action === "Task paused")).toBe(true);
// Verify persistence
const fetched = await store.getTask(task.id);
expect(fetched.paused).toBe(true);
});
it("unpauses a paused task and clears paused flag", async () => {
const task = await createTestTask();
await store.pauseTask(task.id, true);
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.paused).toBeUndefined();
expect(unpaused.log.some((l) => l.action === "Task unpaused")).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.paused).toBeUndefined();
});
it("emits task:updated event", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
await store.pauseTask(task.id, true);
expect(events).toHaveLength(1);
expect(events[0].paused).toBe(true);
});
it("sets status to 'paused' when pausing an in-progress task", async () => {
const task = await createTestTask();
// Move to in-progress: triage → todo → in-progress
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const paused = await store.pauseTask(task.id, true);
expect(paused.paused).toBe(true);
expect(paused.status).toBe("paused");
});
it("clears status when unpausing an in-progress task", async () => {
const task = await createTestTask();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.pauseTask(task.id, true);
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.paused).toBeUndefined();
expect(unpaused.status).toBeUndefined();
});
it("sets and clears paused status for in-review tasks", async () => {
const task = await createTestTask();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const paused = await store.pauseTask(task.id, true);
expect(paused.paused).toBe(true);
expect(paused.status).toBe("paused");
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.paused).toBeUndefined();
expect(unpaused.status).toBeUndefined();
});
it("round-trips pause/unpause correctly", async () => {
const task = await createTestTask();
await store.pauseTask(task.id, true);
let fetched = await store.getTask(task.id);
expect(fetched.paused).toBe(true);
await store.pauseTask(task.id, false);
fetched = await store.getTask(task.id);
expect(fetched.paused).toBeUndefined();
await store.pauseTask(task.id, true);
fetched = await store.getTask(task.id);
expect(fetched.paused).toBe(true);
});
it("sets pausedByAgentId and logs agent pause reason", async () => {
const task = await createTestTask();
const paused = await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-1" });
expect(paused.pausedByAgentId).toBe("agent-1");
expect(paused.log.at(-1)?.action).toBe("Task paused (agent agent-1 paused)");
});
it("clears pausedByAgentId and logs agent resume reason", async () => {
const task = await createTestTask();
await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-2" });
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.pausedByAgentId).toBeUndefined();
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused (agent agent-2 resumed)");
});
it("uses standard unpause log when task was not paused by an agent", async () => {
const task = await createTestTask();
await store.pauseTask(task.id, true);
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.pausedByAgentId).toBeUndefined();
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused");
});
it("keeps pausedByAgentId undefined when pausing without agent options", async () => {
const task = await createTestTask();
const paused = await store.pauseTask(task.id, true);
expect(paused.pausedByAgentId).toBeUndefined();
});
});
describe("clearStaleExecutionStartBranchReferences (FN-2165)", () => {
it("nulls baseBranch on live tasks that reference a deleted branch", async () => {
const upstream = await store.createTask({ description: "Upstream" });
const dependent = await store.createTask({ description: "Dependent" });
await store.updateTask(dependent.id, {
executionStartBranch: `fusion/${upstream.id.toLowerCase()}-2`,
});
const cleared = store.clearStaleExecutionStartBranchReferences([
`fusion/${upstream.id.toLowerCase()}-2`,
]);
expect(cleared).toEqual([dependent.id]);
const reloaded = await store.getTask(dependent.id);
expect(reloaded.executionStartBranch).toBeUndefined();
});
it("excludes the owner task so archival doesn't null its own baseBranch", async () => {
const upstream = await store.createTask({ description: "Upstream" });
await store.updateTask(upstream.id, { executionStartBranch: "fusion/some-base" });
const cleared = store.clearStaleExecutionStartBranchReferences(
["fusion/some-base"],
upstream.id,
);
expect(cleared).toEqual([]);
const reloaded = await store.getTask(upstream.id);
expect(reloaded.executionStartBranch).toBe("fusion/some-base");
});
it("returns [] and is a no-op when no branches given", () => {
expect(store.clearStaleExecutionStartBranchReferences([])).toEqual([]);
});
it("clears baseBranch on multiple dependents in one call", async () => {
const [a, b, c] = await Promise.all([
store.createTask({ description: "A" }),
store.createTask({ description: "B" }),
store.createTask({ description: "C" }),
]);
await store.updateTask(a.id, { executionStartBranch: "fusion/gone-a" });
await store.updateTask(b.id, { executionStartBranch: "fusion/gone-b" });
await store.updateTask(c.id, { executionStartBranch: "fusion/still-alive" });
const cleared = store.clearStaleExecutionStartBranchReferences([
"fusion/gone-a",
"fusion/gone-b",
]);
expect(cleared.sort()).toEqual([a.id, b.id].sort());
const cReloaded = await store.getTask(c.id);
expect(cReloaded.executionStartBranch).toBe("fusion/still-alive");
});
});
});

View File

@@ -0,0 +1,320 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("nodeId in-progress blocking", () => {
it("throws when updating nodeId on an in-progress task", async () => {
const task = await store.createTask({ description: "In progress task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await expect(store.updateTask(task.id, { nodeId: "node-abc" }))
.rejects.toThrow(/in progress/i);
});
it("allows updating nodeId on a todo task", async () => {
const task = await store.createTask({ description: "Todo task" });
const updated = await store.updateTask(task.id, { nodeId: "node-todo" });
expect(updated.nodeId).toBe("node-todo");
});
it("allows updating nodeId on an in-review task", async () => {
const task = await store.createTask({ description: "Review task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const updated = await store.updateTask(task.id, { nodeId: "node-review" });
expect(updated.nodeId).toBe("node-review");
});
it("allows other updates on in-progress tasks (non-nodeId)", async () => {
const task = await store.createTask({ description: "In progress title update" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const updated = await store.updateTask(task.id, { title: "Updated title" });
expect(updated.title).toBe("Updated title");
});
it("allows clearing nodeId on a done task", async () => {
const task = await store.createTask({ description: "Done task", nodeId: "node-done" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const updated = await store.updateTask(task.id, { nodeId: null });
expect(updated.nodeId).toBeUndefined();
});
it("does not throw when nodeId update is undefined on an in-progress task", async () => {
const task = await store.createTask({ description: "In progress no-op", nodeId: "node-stable" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const updated = await store.updateTask(task.id, { nodeId: undefined });
expect(updated.nodeId).toBe("node-stable");
});
it("includes task ID in nodeId override blocking error", async () => {
const task = await store.createTask({ description: "In progress blocked id" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await expect(store.updateTask(task.id, { nodeId: "node-abc" })).rejects.toThrow(task.id);
});
it("allows priority updates on in-progress tasks without changing existing nodeId", async () => {
const task = await store.createTask({ description: "In progress priority", nodeId: "node-keep" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const updated = await store.updateTask(task.id, { priority: "high" });
expect(updated.priority).toBe("high");
expect(updated.nodeId).toBe("node-keep");
});
});
describe("getTasksByAssignedAgent", () => {
it("returns only tasks assigned to the requested agent", async () => {
const mine = await store.createTask({ description: "mine", assignedAgentId: "agent-1" });
await store.createTask({ description: "other", assignedAgentId: "agent-2" });
await store.createTask({ description: "unassigned" });
const tasks = await store.getTasksByAssignedAgent("agent-1");
expect(tasks.map((task) => task.id)).toEqual([mine.id]);
});
it("supports pausedOnly filter", async () => {
const paused = await store.createTask({ description: "paused", assignedAgentId: "agent-1" });
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
await store.updateTask(paused.id, { paused: true });
const tasks = await store.getTasksByAssignedAgent("agent-1", { pausedOnly: true });
expect(tasks.map((task) => task.id)).toEqual([paused.id]);
expect(tasks.some((task) => task.id === active.id)).toBe(false);
});
it("supports excludeArchived filter", async () => {
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
const archived = await store.createTask({ description: "archived", assignedAgentId: "agent-1", column: "done" });
await store.archiveTask(archived.id, false);
const tasks = await store.getTasksByAssignedAgent("agent-1", { excludeArchived: true });
expect(tasks.map((task) => task.id)).toEqual([active.id]);
});
});
describe("selectNextTaskForAgent", () => {
it("returns null when no tasks exist", async () => {
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
});
it("returns in-progress task assigned to the agent", async () => {
const inProgress = await store.createTask({
description: "In-progress task",
column: "in-progress",
assignedAgentId: "agent-1",
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(inProgress.id);
expect(selected?.priority).toBe("in_progress");
});
it("prefers in-progress over todo when both exist for the agent", async () => {
await store.createTask({
description: "Ready todo task",
column: "todo",
assignedAgentId: "agent-1",
});
const inProgress = await store.createTask({
description: "In-progress task",
column: "in-progress",
assignedAgentId: "agent-1",
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(inProgress.id);
expect(selected?.priority).toBe("in_progress");
});
it("returns todo task with all dependencies done", async () => {
const dep = await store.createTask({ description: "Done dep", column: "done" });
const readyTodo = await store.createTask({
description: "Ready todo",
column: "todo",
assignedAgentId: "agent-1",
dependencies: [dep.id],
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(readyTodo.id);
expect(selected?.priority).toBe("todo");
});
it("skips todo task with unresolved dependencies that are not actionable", async () => {
const dep = await store.createTask({ description: "Unresolved dep", column: "todo" });
await store.createTask({
description: "Blocked todo",
column: "todo",
assignedAgentId: "agent-1",
dependencies: [dep.id],
});
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
});
it("returns blocked task with partially done dependencies when no higher-priority tasks exist", async () => {
const doneDep = await store.createTask({ description: "Done dep", column: "done" });
const blockedDep = await store.createTask({ description: "Blocked dep", column: "todo" });
const partiallyActionable = await store.createTask({
description: "Partially actionable todo",
column: "todo",
assignedAgentId: "agent-1",
dependencies: [doneDep.id, blockedDep.id],
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(partiallyActionable.id);
expect(selected?.priority).toBe("blocked");
});
it("skips paused tasks", async () => {
const pausedTodo = await store.createTask({
description: "Paused todo",
column: "todo",
assignedAgentId: "agent-1",
});
await store.updateTask(pausedTodo.id, { paused: true });
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
});
it("skips tasks assigned to a different agent", async () => {
await store.createTask({
description: "Other agent task",
column: "todo",
assignedAgentId: "agent-2",
});
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
});
it("resolves FIFO ordering within the same priority tier", async () => {
const older = await store.createTask({
description: "Older ready todo",
column: "todo",
assignedAgentId: "agent-1",
});
await new Promise((resolve) => setTimeout(resolve, 5));
await store.createTask({
description: "Newer ready todo",
column: "todo",
assignedAgentId: "agent-1",
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(older.id);
expect(selected?.priority).toBe("todo");
});
it("returns null when no tasks are assigned to the queried agent", async () => {
await store.createTask({
description: "Unassigned todo",
column: "todo",
});
await expect(store.selectNextTaskForAgent("agent-without-tasks")).resolves.toBeNull();
});
it("skips implementation todos for non-executor role agents", async () => {
await store.createTask({
description: "Assigned todo",
column: "todo",
assignedAgentId: "agent-1",
});
await expect(
store.selectNextTaskForAgent("agent-1", { id: "agent-1", role: "reviewer" }),
).resolves.toBeNull();
});
it("returns implementation todos for executor role agents", async () => {
const todo = await store.createTask({
description: "Assigned todo",
column: "todo",
assignedAgentId: "agent-1",
});
const selected = await store.selectNextTaskForAgent("agent-1", {
id: "agent-1",
role: "executor",
});
expect(selected?.task.id).toBe(todo.id);
expect(selected?.priority).toBe("todo");
});
it("allows non-executor role agents to pick assigned todos when override metadata is set", async () => {
const delegated = await store.createTask({
description: "Assigned todo override",
column: "todo",
assignedAgentId: "agent-1",
source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } },
});
const selected = await store.selectNextTaskForAgent("agent-1", {
id: "agent-1",
role: "reviewer",
});
expect(selected?.task.id).toBe(delegated.id);
expect(selected?.priority).toBe("todo");
});
});
// ── Lock serialization test ──────────────────────────────────────
});

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("SQLite-first reads when task blobs are missing", () => {
it("getTask returns metadata from SQLite with an empty prompt when the task directory is missing", async () => {
const task = await createTestTask();
await deleteTaskDir(task.id);
const fetched = await store.getTask(task.id);
expect(fetched.id).toBe(task.id);
expect(fetched.description).toBe(task.description);
expect(fetched.prompt).toBe("");
});
it("getTask syncs steps from PROMPT.md when task.steps is empty", async () => {
const task = await store.createTask({ description: "Test task" });
// task.steps should be empty in DB
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Test task
## Steps
### Step 0: Preflight
- [ ] Check something
### Step 1: Do the thing
- [ ] Do it
`,
);
const detail = await store.getTask(task.id);
expect(detail.steps).toEqual([
{ name: "Preflight", status: "pending" },
{ name: "Do the thing", status: "pending" },
]);
});
});
describe("shared mesh snapshots", () => {
it("persists and replicates extended lease metadata", async () => {
const task = await store.createTask({ description: "lease snapshot task" });
await store.updateTask(task.id, {
checkedOutBy: "agent-1",
checkedOutAt: "2026-05-01T00:00:00.000Z",
checkoutNodeId: "node-a",
checkoutRunId: "run-1",
checkoutLeaseRenewedAt: "2026-05-01T00:01:00.000Z",
checkoutLeaseEpoch: 7,
});
const snapshot = await store.getTaskMetadataSnapshot();
const replicated = snapshot.payload.tasks.find((entry) => entry.id === task.id);
expect(replicated).toMatchObject({
checkedOutBy: "agent-1",
checkedOutAt: "2026-05-01T00:00:00.000Z",
checkoutNodeId: "node-a",
checkoutRunId: "run-1",
checkoutLeaseRenewedAt: "2026-05-01T00:01:00.000Z",
checkoutLeaseEpoch: 7,
});
await store.updateTask(task.id, { checkedOutBy: null, checkoutLeaseEpoch: 8 });
const released = await store.getTask(task.id);
expect(released).toMatchObject({ checkedOutBy: undefined, checkoutLeaseEpoch: 8 });
});
it("exports and reapplies task/activity/audit snapshots deterministically", async () => {
const task = await store.createTask({ description: "snapshot task" });
await store.updateTask(task.id, { worktree: "/tmp/fn-worktree", executionStartBranch: "fn/base" });
await store.recordActivity({ type: "task:created", taskId: task.id, details: "created" });
const taskSnapshot = await store.getTaskMetadataSnapshot();
const activitySnapshot = await store.getActivityLogSnapshot();
const auditSnapshot = store.getRunAuditSnapshot();
const taskResult = await store.applyTaskMetadataSnapshot(taskSnapshot);
const activityResult = store.applyActivityLogSnapshot(activitySnapshot);
const auditResult = store.applyRunAuditSnapshot(auditSnapshot);
const taskSnapshot2 = await store.getTaskMetadataSnapshot();
const activitySnapshot2 = await store.getActivityLogSnapshot();
const auditSnapshot2 = store.getRunAuditSnapshot();
expect(taskResult.applied + taskResult.skipped).toBeGreaterThan(0);
expect(taskSnapshot2.payload).toEqual(taskSnapshot.payload);
expect(activitySnapshot2.payload).toEqual(activitySnapshot.payload);
expect(auditSnapshot2.payload).toEqual(auditSnapshot.payload);
expect(activityResult.skipped).toBeGreaterThanOrEqual(1);
expect(auditResult.skipped).toBeGreaterThanOrEqual(0);
const persisted = await store.getTask(task.id);
expect(persisted?.worktree).toBe("/tmp/fn-worktree");
expect(persisted?.executionStartBranch).toBe("fn/base");
});
});
});

View File

@@ -3,10 +3,34 @@ import { rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { vi } from "vitest"; import { vi } from "vitest";
import { TaskStore } from "../store.js";
vi.mock("node:child_process", async (importOriginal) => {
const mod = await importOriginal<typeof import("node:child_process")>();
return {
...mod,
execSync: vi.fn((...args: Parameters<typeof mod.execSync>) => mod.execSync(...args)),
};
});
vi.mock("../run-command.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("../run-command.js")>();
return {
...mod,
runCommandAsync: vi.fn((...args: Parameters<typeof mod.runCommandAsync>) => mod.runCommandAsync(...args)),
};
});
import { execSync } from "node:child_process";
import { runCommandAsync } from "../run-command.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import type { Task } from "../types.js"; import type { Task } from "../types.js";
function makeTmpDir(): string { export { TaskStore, TaskHasDependentsError };
export const mockedExecSync = vi.mocked(execSync);
export const mockedRunCommandAsync = vi.mocked(runCommandAsync);
export function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-store-test-")); return mkdtempSync(join(tmpdir(), "kb-store-test-"));
} }
@@ -75,15 +99,22 @@ export function createTaskStoreTestHarness() {
issueNumber: 2471, issueNumber: 2471,
url: "https://github.com/runfusion/fusion/issues/2471", url: "https://github.com/runfusion/fusion/issues/2471",
}), }),
insertLogEntryWithTimestamp: ( insertLogEntryWithTimestamp: (...args: any[]): void => {
taskId: string, let targetStore: TaskStore = store;
text: string, let taskId: string;
type: string, let text: string;
timestamp: string, let type: string;
detail?: string, let timestamp: string;
agent?: string, let detail: string | undefined;
): void => { let agent: string | undefined;
(store as any).db.prepare(`
if (typeof args[0] === "object") {
[targetStore, taskId, text, type, timestamp, detail, agent] = args;
} else {
[taskId, text, type, timestamp, detail, agent] = args;
}
(targetStore as any).db.prepare(`
INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(taskId, timestamp, text, type, detail ?? null, agent ?? null); `).run(taskId, timestamp, text, type, detail ?? null, agent ?? null);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,829 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
const createTestTask = () => harness.createTestTask();
const createTaskWithSteps = () => harness.createTaskWithSteps();
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
describe("upsertTask regression coverage", () => {
it("creates tasks successfully on a fresh database schema", async () => {
const freshRoot = makeTmpDir();
const freshGlobal = makeTmpDir();
const freshStore = new TaskStore(freshRoot, freshGlobal);
await freshStore.init();
const task = await freshStore.createTask({ description: "fresh schema task" });
expect(task.id).toBe("FN-001");
expect(await freshStore.getTask(task.id)).toBeDefined();
freshStore.close();
await rm(freshRoot, { recursive: true, force: true });
await rm(freshGlobal, { recursive: true, force: true });
});
it("persists createTask with nullable, array, and optional scalar fields", async () => {
const created = await store.createTask({
title: "Persist me",
description: "Create path coverage",
column: "todo",
dependencies: ["FN-999"],
enabledWorkflowSteps: ["WS-001"],
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
modelPresetId: "normal",
});
const persisted = await store.getTask(created.id);
expect(persisted.title).toBe("Persist me");
expect(persisted.column).toBe("todo");
expect(persisted.dependencies).toEqual(["FN-999"]);
expect(persisted.enabledWorkflowSteps).toEqual(["WS-001"]);
expect(persisted.modelProvider).toBe("anthropic");
expect(persisted.validatorModelProvider).toBe("openai");
expect(persisted.modelPresetId).toBe("normal");
});
it("persists updateTask changes across scalar, array, and nullable JSON-backed fields", async () => {
const task = await store.createTask({ description: "Update path coverage" });
await store.updateTask(task.id, {
title: "Updated title",
dependencies: ["FN-002", "FN-003"],
blockedBy: "FN-002",
status: "failed",
error: "boom",
summary: "summary",
workflowStepResults: [
{
workflowStepId: "WS-001",
workflowStepName: "QA",
status: "passed",
startedAt: "2026-04-01T00:00:00.000Z",
completedAt: "2026-04-01T00:01:00.000Z",
output: "ok",
},
],
modifiedFiles: ["packages/core/src/store.ts"],
});
const persisted = await store.getTask(task.id);
expect(persisted.title).toBe("Updated title");
expect(persisted.dependencies).toEqual(["FN-002", "FN-003"]);
expect(persisted.blockedBy).toBe("FN-002");
expect(persisted.status).toBe("failed");
expect(persisted.error).toBe("boom");
expect(persisted.summary).toBe("summary");
expect(persisted.workflowStepResults).toHaveLength(1);
expect(persisted.workflowStepResults?.[0].workflowStepId).toBe("WS-001");
expect(persisted.modifiedFiles).toEqual(["packages/core/src/store.ts"]);
});
});
describe("agent log persistence", () => {
it("appendAgentLog inserts into agentLogEntries and getAgentLogs reads it back", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "Hello world", "text");
await store.appendAgentLog(task.id, "Read", "tool");
(store as any).flushAgentLogBuffer();
const rows = (store as any).db.prepare(`
SELECT taskId, text, type FROM agentLogEntries
WHERE taskId = ?
ORDER BY timestamp ASC
`).all(task.id) as Array<{ taskId: string; text: string; type: string }>;
expect(rows).toEqual([
{ taskId: task.id, text: "Hello world", type: "text" },
{ taskId: task.id, text: "Read", type: "tool" },
]);
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(2);
expect(logs[0].text).toBe("Hello world");
expect(logs[0].type).toBe("text");
expect(logs[0].taskId).toBe(task.id);
expect(logs[1].text).toBe("Read");
expect(logs[1].type).toBe("tool");
});
it("getAgentLogs returns empty array when no log entries exist", async () => {
const task = await createTestTask();
const logs = await store.getAgentLogs(task.id);
expect(logs).toEqual([]);
});
it("getAgentLogs returns empty array when task directory is missing", async () => {
const task = await createTestTask();
await deleteTaskDir(task.id);
const logs = await store.getAgentLogs(task.id);
expect(logs).toEqual([]);
});
it("appendAgentLog emits agent:log event", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("agent:log", (entry) => events.push(entry));
await store.appendAgentLog(task.id, "delta text", "text");
expect(events).toHaveLength(1);
expect(events[0].text).toBe("delta text");
expect(events[0].type).toBe("text");
expect(events[0].taskId).toBe(task.id);
});
it("appendAgentLogBatch inserts all entries and emits per-entry events", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("agent:log", (entry) => events.push(entry));
await store.appendAgentLogBatch([
{ taskId: task.id, text: "batch 1", type: "text" },
{ taskId: task.id, text: "tool", type: "tool", detail: "read file", agent: "executor" },
]);
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(2);
expect(logs.map((entry) => entry.text)).toEqual(["batch 1", "tool"]);
expect(events).toHaveLength(2);
expect(events[1]).toMatchObject({ text: "tool", type: "tool", detail: "read file", agent: "executor" });
});
it("truncates oversized tool detail before persisting and emitting", async () => {
const task = await createTestTask();
const events: any[] = [];
const oversizedDetail = "X".repeat(5000);
const truncationMarker = "[tool output truncated to keep dashboard log views responsive]";
store.on("agent:log", (entry) => events.push(entry));
await store.appendAgentLogBatch([
{ taskId: task.id, text: "Bash", type: "tool_result", detail: oversizedDetail, agent: "executor" },
]);
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].detail).toContain(truncationMarker);
expect(logs[0].detail!.match(/\[tool output truncated to keep dashboard log views responsive\]/g)).toHaveLength(1);
expect(logs[0].detail!.length).toBeLessThan(oversizedDetail.length);
expect(events[0].detail).toBe(logs[0].detail);
});
it("appendAgentLogBatch with empty entries is a no-op", async () => {
const task = await createTestTask();
await store.appendAgentLogBatch([]);
expect(await store.getAgentLogCount(task.id)).toBe(0);
});
it("appendAgentLog writes detail when provided", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "Bash", "tool", "ls -la");
await store.appendAgentLog(task.id, "Read", "tool", "packages/core/src/types.ts");
await store.appendAgentLog(task.id, "some text", "text");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(3);
expect(logs[0].detail).toBe("ls -la");
expect(logs[1].detail).toBe("packages/core/src/types.ts");
expect(logs[2].detail).toBeUndefined();
});
it("appendAgentLog omits detail field when not provided", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "Bash", "tool");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0]).not.toHaveProperty("detail");
});
it("handles multiple appends correctly", async () => {
const task = await createTestTask();
for (let i = 0; i < 5; i++) {
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
}
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(5);
expect(logs[0].text).toBe("chunk 0");
expect(logs[4].text).toBe("chunk 4");
});
it("getAgentLogCount returns the number of persisted log entries", async () => {
const task = await createTestTask();
expect(await store.getAgentLogCount(task.id)).toBe(0);
await store.appendAgentLog(task.id, "chunk 0", "text");
await store.appendAgentLog(task.id, "chunk 1", "tool");
expect(await store.getAgentLogCount(task.id)).toBe(2);
});
it("returns the most recent agent log entries from SQLite in chronological order", async () => {
const task = await createTestTask();
for (let i = 0; i < 5; i++) {
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
}
const logs = await store.getAgentLogs(task.id, { limit: 2 });
expect(logs.map((entry) => entry.text)).toEqual(["chunk 3", "chunk 4"]);
});
it("returns older agent log pages when offset skips recent entries", async () => {
const task = await createTestTask();
for (let i = 0; i < 5; i++) {
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
}
await expect(store.getAgentLogs(task.id, { limit: 2 })).resolves.toMatchObject([
{ text: "chunk 3" },
{ text: "chunk 4" },
]);
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 2 })).resolves.toMatchObject([
{ text: "chunk 1" },
{ text: "chunk 2" },
]);
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 4 })).resolves.toMatchObject([
{ text: "chunk 0" },
]);
});
it("preserves insertion order when multiple entries share the same timestamp", async () => {
const task = await createTestTask();
const tiedTimestamp = "2026-04-24T12:00:00.000Z";
insertLogEntryWithTimestamp(store, task.id, "first tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "second tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "third tied", "text", tiedTimestamp);
const logs = await store.getAgentLogs(task.id);
expect(logs.map((entry) => entry.text)).toEqual([
"first tied",
"second tied",
"third tied",
]);
});
it("applies deterministic ordering for tied timestamps with limit/offset pagination", async () => {
const task = await createTestTask();
const tiedTimestamp = "2026-04-24T12:00:00.000Z";
insertLogEntryWithTimestamp(store, task.id, "first tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "second tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "third tied", "text", tiedTimestamp);
insertLogEntryWithTimestamp(store, task.id, "fourth tied", "text", tiedTimestamp);
await expect(store.getAgentLogs(task.id, { limit: 2 })).resolves.toMatchObject([
{ text: "third tied" },
{ text: "fourth tied" },
]);
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 1 })).resolves.toMatchObject([
{ text: "second tied" },
{ text: "third tied" },
]);
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 2 })).resolves.toMatchObject([
{ text: "first tied" },
{ text: "second tied" },
]);
});
it("preserves long entry fields when returning a bounded tail", async () => {
const task = await createTestTask();
const longText = [
"## Long Tail Entry",
"",
"This entry should survive a bounded tail read in full.",
"Z".repeat(800),
].join("\n");
const longDetail = "detail/".repeat(120) + "AgentLogViewer.tsx";
await store.appendAgentLog(task.id, "older entry", "text");
await store.appendAgentLog(task.id, longText, "tool", longDetail, "executor");
await store.appendAgentLog(task.id, "newest entry", "text");
const logs = await store.getAgentLogs(task.id, { limit: 2 });
expect(logs.map((entry) => entry.text)).toEqual([longText, "newest entry"]);
expect(logs[0].detail).toBe(longDetail);
expect(logs[0].agent).toBe("executor");
expect(logs[0].text.length).toBe(longText.length);
expect(logs[0].detail!.length).toBe(longDetail.length);
});
it("clips oversized historical tool detail at read time", async () => {
const task = await createTestTask();
const oversizedDetail = "Y".repeat(7000);
const truncationMarker = "[tool output truncated to keep dashboard log views responsive]";
insertLogEntryWithTimestamp(
store,
task.id,
"Bash",
"tool_result",
"2026-04-24T12:00:00.000Z",
oversizedDetail,
"executor",
);
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].detail).toContain(truncationMarker);
expect(logs[0].detail!.match(/\[tool output truncated to keep dashboard log views responsive\]/g)).toHaveLength(1);
expect(logs[0].detail!.length).toBeLessThan(oversizedDetail.length);
});
it("appendAgentLog persists and reads back the agent field", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "hello", "text", undefined, "executor");
await store.appendAgentLog(task.id, "Read", "tool", "file.ts", "triage");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(2);
expect(logs[0].agent).toBe("executor");
expect(logs[1].agent).toBe("triage");
});
it("appendAgentLog omits agent field when not provided", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "hello", "text");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0]).not.toHaveProperty("agent");
});
it("new type values (thinking, tool_result, tool_error) round-trip correctly", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "internal thought", "thinking", undefined, "executor");
await store.appendAgentLog(task.id, "Bash", "tool_result", "output summary", "executor");
await store.appendAgentLog(task.id, "Read", "tool_error", "file not found", "reviewer");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(3);
expect(logs[0].type).toBe("thinking");
expect(logs[0].text).toBe("internal thought");
expect(logs[0].agent).toBe("executor");
expect(logs[1].type).toBe("tool_result");
expect(logs[1].text).toBe("Bash");
expect(logs[1].detail).toBe("output summary");
expect(logs[2].type).toBe("tool_error");
expect(logs[2].text).toBe("Read");
expect(logs[2].detail).toBe("file not found");
expect(logs[2].agent).toBe("reviewer");
});
it("preserves long multiline text without truncation", async () => {
const task = await createTestTask();
const longText = [
"## Analysis",
"",
"After reviewing the codebase, I found several issues:",
"",
"1. The first issue is that the function `processData` does not handle",
" edge cases where the input array is empty. This can cause unexpected",
" behavior downstream when consumers expect at least one element.",
"",
"2. The second issue relates to the caching layer. The TTL is set to",
" a very low value (60 seconds) which causes excessive cache misses.",
"",
"```typescript",
"function processData(data: unknown[]): Result {",
" // This is a very long code block that should not be truncated",
" if (!data || data.length === 0) {",
" throw new Error('Data array must not be empty');",
" }",
" return data.map(item => transform(item)).filter(Boolean);",
"}",
"```",
"",
"Line " + "A".repeat(500) + " end of long line",
].join("\n");
// Total length should be well over 1000 characters
expect(longText.length).toBeGreaterThan(1000);
await store.appendAgentLog(task.id, longText, "text");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].text).toBe(longText);
});
it("preserves long detail strings without truncation", async () => {
const task = await createTestTask();
const longDetail = "path/to/a/very/deeply/nested/directory/structure/that/contains/many/segments/".repeat(20)
+ "src/components/features/dashboard/panels/AgentLogViewer.tsx";
// Total length should be well over 500 characters
expect(longDetail.length).toBeGreaterThan(500);
await store.appendAgentLog(task.id, "Read", "tool", longDetail);
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].detail).toBe(longDetail);
});
it("preserves both long text and long detail simultaneously", async () => {
const task = await createTestTask();
const longText = "X".repeat(2000);
const longDetail = "Y".repeat(2000);
await store.appendAgentLog(task.id, longText, "tool", longDetail, "executor");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].text).toBe(longText);
expect(logs[0].text.length).toBe(2000);
expect(logs[0].detail).toBe(longDetail);
expect(logs[0].detail!.length).toBe(2000);
});
it("getAgentLogsByTimeRange filters entries by start and end timestamps (inclusive)", async () => {
const task = await createTestTask();
insertLogEntryWithTimestamp(store, task.id, "before start", "text", "2024-01-01T00:00:00.000Z");
insertLogEntryWithTimestamp(store, task.id, "at start", "text", "2024-01-01T01:00:00.000Z");
insertLogEntryWithTimestamp(store, task.id, "middle", "text", "2024-01-01T02:00:00.000Z");
insertLogEntryWithTimestamp(store, task.id, "at end", "text", "2024-01-01T03:00:00.000Z");
insertLogEntryWithTimestamp(store, task.id, "after end", "text", "2024-01-01T04:00:00.000Z");
const logs = await store.getAgentLogsByTimeRange(
task.id,
"2024-01-01T01:00:00.000Z",
"2024-01-01T03:00:00.000Z",
);
expect(logs).toHaveLength(3);
expect(logs.map((l) => l.text)).toEqual(["at start", "middle", "at end"]);
});
it("getAgentLogsByTimeRange uses current time when endIso is null", async () => {
const task = await createTestTask();
insertLogEntryWithTimestamp(store, task.id, "entry1", "text", "2024-01-01T00:00:00.000Z");
insertLogEntryWithTimestamp(store, task.id, "entry2", "text", "2024-06-01T00:00:00.000Z");
const logs = await store.getAgentLogsByTimeRange(
task.id,
"2024-01-01T00:00:00.000Z",
null,
);
expect(logs).toHaveLength(2);
});
it("getAgentLogsByTimeRange returns empty array when no entries match", async () => {
const task = await createTestTask();
insertLogEntryWithTimestamp(store, task.id, "entry1", "text", "2024-01-01T00:00:00.000Z");
const logs = await store.getAgentLogsByTimeRange(
task.id,
"2025-01-01T00:00:00.000Z",
"2025-12-31T23:59:59.000Z",
);
expect(logs).toEqual([]);
});
it("getAgentLogsByTimeRange returns empty array when no entries exist", async () => {
const task = await createTestTask();
const logs = await store.getAgentLogsByTimeRange(
task.id,
"2024-01-01T00:00:00.000Z",
"2024-12-31T23:59:59.000Z",
);
expect(logs).toEqual([]);
});
it("deleteTask refuses when another live task depends on this id", async () => {
// Regression for the triage-split bug: splitting a parent into children
// used to hard-delete the parent even when a child carried the parent id
// in its dependencies array, permanently blocking the child because the
// scheduler treats missing-dep ids as unmet.
const parent = await store.createTask({ description: "Parent to be split" });
const child = await store.createTask({
description: "Child that accidentally depends on parent",
});
await store.updateTask(child.id, { dependencies: [parent.id] });
await expect(store.deleteTask(parent.id)).rejects.toBeInstanceOf(TaskHasDependentsError);
// Parent must still exist so the dependent isn't stranded.
const stillThere = await store.getTask(parent.id);
expect(stillThere.id).toBe(parent.id);
// The error must name the dependent so callers/logs can triage it.
try {
await store.deleteTask(parent.id);
} catch (err) {
expect(err).toBeInstanceOf(TaskHasDependentsError);
expect((err as TaskHasDependentsError).dependentIds).toContain(child.id);
}
// After the dependent's reference is removed, delete succeeds.
await store.updateTask(child.id, { dependencies: [] });
await expect(store.deleteTask(parent.id)).resolves.toMatchObject({ id: parent.id });
});
it("deleteTask removes incoming dependency references when explicitly requested", async () => {
const parent = await store.createTask({ description: "Parent to delete" });
const dependentOne = await store.createTask({ description: "Dependent one" });
const dependentTwo = await store.createTask({ description: "Dependent two" });
await store.updateTask(dependentOne.id, { dependencies: [parent.id, "FN-UNRELATED"] });
await store.updateTask(dependentTwo.id, { dependencies: [parent.id] });
await expect(
store.deleteTask(parent.id, { removeDependencyReferences: true }),
).resolves.toMatchObject({ id: parent.id });
const updatedOne = await store.getTask(dependentOne.id);
const updatedTwo = await store.getTask(dependentTwo.id);
expect(updatedOne.dependencies).toEqual(["FN-UNRELATED"]);
expect(updatedTwo.dependencies).toEqual([]);
expect(updatedOne.dependencies).not.toContain(parent.id);
expect(updatedTwo.dependencies).not.toContain(parent.id);
await expect(store.getTask(parent.id)).rejects.toThrow(`Task ${parent.id} not found`);
});
it("deleteTask allows deletion when a similarly-named id contains the target (substring false-positive guard)", async () => {
// The LIKE probe uses '%id%'; ensure we don't misidentify e.g. FN-1 as
// referencing FN-10 just because the id string appears inside a JSON
// array containing "FN-10".
const targetTask = await store.createTask({ description: "Target" }); // e.g. FN-001
const similarId = `${targetTask.id}X`; // definitely not a real task id
const other = await store.createTask({ description: "Other" });
await store.updateTask(other.id, { dependencies: [similarId] });
// Should NOT throw — the LIKE probe's string match is disambiguated by
// JSON.parse + array.includes.
await expect(store.deleteTask(targetTask.id)).resolves.toMatchObject({ id: targetTask.id });
});
it("deleting a task cascades agent log entry deletion", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "cascade me", "text");
(store as any).flushAgentLogBuffer();
const before = (store as any).db.prepare(
"SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?",
).get(task.id) as { count: number };
expect(before.count).toBe(1);
await store.deleteTask(task.id);
const after = (store as any).db.prepare(
"SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?",
).get(task.id) as { count: number };
expect(after.count).toBe(0);
});
it("deleteTask clears linked agent task assignments", async () => {
store.close();
store = new TaskStore(rootDir, globalDir);
await store.init();
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
try {
const task = await store.createTask({ description: "Delete me" });
const agent = await agentStore.createAgent({ name: "Delete watcher", role: "executor" });
await agentStore.assignTask(agent.id, task.id);
await store.deleteTask(task.id);
const updatedAgent = await agentStore.getAgent(agent.id);
expect(updatedAgent?.taskId).toBeUndefined();
} finally {
agentStore.close();
}
});
it("importLegacyAgentLogs imports JSONL entries from existing agent.log files", async () => {
const task = await createTestTask();
const dir = join(rootDir, ".fusion", "tasks", task.id);
const legacyEntries = [
{
timestamp: "2024-01-01T00:00:00.000Z",
taskId: task.id,
text: "legacy line 1",
type: "text",
},
{
timestamp: "2024-01-01T01:00:00.000Z",
taskId: task.id,
text: "legacy line 2",
type: "tool",
detail: "legacy detail",
agent: "executor",
},
];
await writeFile(join(dir, "agent.log"), `${legacyEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`);
const imported = await store.importLegacyAgentLogs();
expect(imported).toBe(2);
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(2);
expect(logs.map((log) => log.text)).toEqual(["legacy line 1", "legacy line 2"]);
expect(logs[1].detail).toBe("legacy detail");
expect(logs[1].agent).toBe("executor");
});
it("importLegacyAgentLogsOnce is idempotent via __meta guard", async () => {
const task = await createTestTask();
const dir = join(rootDir, ".fusion", "tasks", task.id);
const logPath = join(dir, "agent.log");
(store as any).db.prepare("DELETE FROM __meta WHERE key = ?").run("agentLogLegacyFileImportVersion");
await writeFile(logPath, `${JSON.stringify({
timestamp: "2024-01-01T00:00:00.000Z",
taskId: task.id,
text: "legacy line 1",
type: "text",
})}\n`);
await (store as any).importLegacyAgentLogsOnce();
expect(await store.getAgentLogCount(task.id)).toBe(1);
await appendFile(logPath, `${JSON.stringify({
timestamp: "2024-01-01T01:00:00.000Z",
taskId: task.id,
text: "legacy line 2",
type: "text",
})}\n`);
await (store as any).importLegacyAgentLogsOnce();
expect(await store.getAgentLogCount(task.id)).toBe(1);
const migrationRow = (store as any).db.prepare(
"SELECT value FROM __meta WHERE key = ?",
).get("agentLogLegacyFileImportVersion") as { value: string } | undefined;
expect(migrationRow?.value).toBe("1");
});
describe("agent log buffering", () => {
it("buffers entries and flushes in a single transaction when buffer is full", async () => {
const task = await createTestTask();
// Fill the buffer to its max size (50)
for (let i = 0; i < 50; i++) {
await store.appendAgentLog(task.id, `entry ${i}`, "text");
}
// Validate DB persistence without invoking read-path auto-flush helpers.
const row = (store as any).db
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
.get(task.id) as { count: number };
expect(row.count).toBe(50);
});
it("auto-flushes buffered entries when getAgentLogs is called", async () => {
const task = await createTestTask();
// Write fewer than BUFFER_SIZE entries — these stay buffered
await store.appendAgentLog(task.id, "buffered 1", "text");
await store.appendAgentLog(task.id, "buffered 2", "text");
// getAgentLogs triggers a flush
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(2);
expect(logs[0].text).toBe("buffered 1");
expect(logs[1].text).toBe("buffered 2");
});
it("auto-flushes buffered entries when getAgentLogCount is called", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "counted", "text");
const count = await store.getAgentLogCount(task.id);
expect(count).toBe(1);
});
it("auto-flushes before deleteTask so FK cascade finds the rows", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "to be cascaded", "text");
// Prove flush happens before delete
const flushSpy = vi.spyOn(store as any, "flushAgentLogBuffer");
await store.deleteTask(task.id);
expect(flushSpy).toHaveBeenCalled();
flushSpy.mockRestore();
const after = (store as any).db.prepare(
"SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?",
).get(task.id) as { count: number };
expect(after.count).toBe(0);
});
it("flushes remaining entries on close without throwing", async () => {
// Disk-backed store required — in-memory data doesn't survive close+reopen
store.close();
store = new TaskStore(rootDir, globalDir); // no inMemoryDb
await store.init();
const task = await store.createTask({ description: "Test task" });
await store.appendAgentLog(task.id, "flush on close", "text");
// close() should flush the buffer gracefully
expect(() => store.close()).not.toThrow();
// Re-open and verify the entry was persisted
store = new TaskStore(rootDir, globalDir);
await store.init();
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].text).toBe("flush on close");
});
it("close does not throw when flushing entries for already-deleted tasks", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "orphaned entry", "text");
// Flush so the entry is in the DB, then delete the task
(store as any).flushAgentLogBuffer();
await store.deleteTask(task.id);
// Now buffer another entry for the deleted task
await store.appendAgentLog(task.id, "ghost entry", "text");
// close() should not throw despite FK constraint violation on flush
expect(() => store.close()).not.toThrow();
});
it("emits agent:log event immediately even when buffered", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("agent:log", (entry) => events.push(entry));
await store.appendAgentLog(task.id, "immediate event", "text");
// Event fires immediately, even though DB write is deferred
expect(events).toHaveLength(1);
expect(events[0].text).toBe("immediate event");
expect(events[0].taskId).toBe(task.id);
});
it("flushes interleaved entries from multiple tasks correctly", async () => {
const taskA = await createTestTask();
const taskB = await store.createTask({ description: "Task B" });
// Interleave entries for two tasks
for (let i = 0; i < 25; i++) {
await store.appendAgentLog(taskA.id, `A-${i}`, "text");
await store.appendAgentLog(taskB.id, `B-${i}`, "text");
}
// 50 total = buffer full, triggers flush
const countA = await store.getAgentLogCount(taskA.id);
const countB = await store.getAgentLogCount(taskB.id);
expect(countA).toBe(25);
expect(countB).toBe(25);
});
});
});
});

File diff suppressed because it is too large Load Diff