feat(KB-332): rename task prefix from KB to FN and branches from kb/ to fusion/

- Change default task prefix from KB to FN across all packages

- Rename branch naming pattern from kb/{id} to fusion/{id}

- Update all test assertions and fixtures to use new prefixes

- Add changeset for the breaking change

- Resolve merge conflicts in TaskCard.test.tsx touch gesture tests
This commit is contained in:
gsxdsm
2026-03-31 19:29:47 -07:00
parent 204604ec43
commit 5647e500cf
75 changed files with 2303 additions and 2189 deletions

View File

@@ -62,7 +62,7 @@ describe("AgentLogger", () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-001",
taskId: "FN-001",
flushSizeBytes: 10,
flushIntervalMs: 500,
});
@@ -75,14 +75,14 @@ describe("AgentLogger", () => {
logger.onText("worldextra");
// Allow async flush
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-001", "helloworldextra", "text", undefined, undefined);
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-001", "helloworldextra", "text", undefined, undefined);
});
it("flushes on timer when under size threshold", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-002",
taskId: "FN-002",
flushSizeBytes: 1024,
flushIntervalMs: 500,
});
@@ -91,14 +91,14 @@ describe("AgentLogger", () => {
expect(store.appendAgentLog).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(500);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-002", "small", "text", undefined, undefined);
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-002", "small", "text", undefined, undefined);
});
it("flushes text before logging tool start", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-003",
taskId: "FN-003",
flushSizeBytes: 1024,
});
@@ -110,36 +110,36 @@ describe("AgentLogger", () => {
const calls = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.length).toBe(2);
// Text flushed first
expect(calls[0]).toEqual(["KB-003", "pending text", "text", undefined, undefined]);
expect(calls[0]).toEqual(["FN-003", "pending text", "text", undefined, undefined]);
// Tool logged second with detail
expect(calls[1]).toEqual(["KB-003", "Bash", "tool", "ls", undefined]);
expect(calls[1]).toEqual(["FN-003", "Bash", "tool", "ls", undefined]);
});
it("logs tool detail using summarizeToolArgs", async () => {
const store = createMockStore();
const logger = new AgentLogger({ store, taskId: "KB-004" });
const logger = new AgentLogger({ store, taskId: "FN-004" });
logger.onToolStart("Read", { path: "src/index.ts" });
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-004", "Read", "tool", "src/index.ts", undefined);
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-004", "Read", "tool", "src/index.ts", undefined);
});
it("logs tool with undefined detail for unknown args", async () => {
const store = createMockStore();
const logger = new AgentLogger({ store, taskId: "KB-005" });
const logger = new AgentLogger({ store, taskId: "FN-005" });
logger.onToolStart("task_done", { count: 42 });
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-005", "task_done", "tool", undefined, undefined);
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-005", "task_done", "tool", undefined, undefined);
});
it("flush() clears timer and writes remaining text", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-006",
taskId: "FN-006",
flushSizeBytes: 1024,
flushIntervalMs: 500,
});
@@ -147,12 +147,12 @@ describe("AgentLogger", () => {
logger.onText("remaining");
await logger.flush();
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-006", "remaining", "text", undefined, undefined);
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-006", "remaining", "text", undefined, undefined);
});
it("flush() is safe to call when buffer is empty", async () => {
const store = createMockStore();
const logger = new AgentLogger({ store, taskId: "KB-007" });
const logger = new AgentLogger({ store, taskId: "FN-007" });
await logger.flush();
expect(store.appendAgentLog).not.toHaveBeenCalled();
@@ -164,23 +164,23 @@ describe("AgentLogger", () => {
const onAgentTool = vi.fn();
const logger = new AgentLogger({
store,
taskId: "KB-008",
taskId: "FN-008",
onAgentText,
onAgentTool,
});
logger.onText("delta");
expect(onAgentText).toHaveBeenCalledWith("KB-008", "delta");
expect(onAgentText).toHaveBeenCalledWith("FN-008", "delta");
logger.onToolStart("Bash", { command: "echo hi" });
expect(onAgentTool).toHaveBeenCalledWith("KB-008", "Bash");
expect(onAgentTool).toHaveBeenCalledWith("FN-008", "Bash");
});
it("does not schedule multiple timers for consecutive small writes", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-009",
taskId: "FN-009",
flushSizeBytes: 1024,
flushIntervalMs: 500,
});
@@ -193,7 +193,7 @@ describe("AgentLogger", () => {
// All text should be flushed in a single call
expect(store.appendAgentLog).toHaveBeenCalledTimes(1);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-009", "abc", "text", undefined, undefined);
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-009", "abc", "text", undefined, undefined);
});
// ── Agent field propagation ──────────────────────────────────────
@@ -202,7 +202,7 @@ describe("AgentLogger", () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-010",
taskId: "FN-010",
agent: "executor",
flushSizeBytes: 5,
});
@@ -210,13 +210,13 @@ describe("AgentLogger", () => {
// Text flush
logger.onText("hello world");
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-010", "hello world", "text", undefined, "executor");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-010", "hello world", "text", undefined, "executor");
// Tool start
(store.appendAgentLog as ReturnType<typeof vi.fn>).mockClear();
logger.onToolStart("Bash", { command: "ls" });
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-010", "Bash", "tool", "ls", "executor");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-010", "Bash", "tool", "ls", "executor");
});
// ── Thinking buffer/flush ────────────────────────────────────────
@@ -225,7 +225,7 @@ describe("AgentLogger", () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-011",
taskId: "FN-011",
agent: "executor",
flushSizeBytes: 1024,
flushIntervalMs: 500,
@@ -236,14 +236,14 @@ describe("AgentLogger", () => {
expect(store.appendAgentLog).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(500);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-011", "thought 1 thought 2", "thinking", undefined, "executor");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-011", "thought 1 thought 2", "thinking", undefined, "executor");
});
it("flushes thinking on size threshold", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-012",
taskId: "FN-012",
agent: "triage",
flushSizeBytes: 10,
});
@@ -253,28 +253,28 @@ describe("AgentLogger", () => {
logger.onThinking("enough to flush");
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-012", "shortenough to flush", "thinking", undefined, "triage");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-012", "shortenough to flush", "thinking", undefined, "triage");
});
it("flushes thinking buffer on flush()", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-013",
taskId: "FN-013",
agent: "reviewer",
flushSizeBytes: 1024,
});
logger.onThinking("remaining thinking");
await logger.flush();
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-013", "remaining thinking", "thinking", undefined, "reviewer");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-013", "remaining thinking", "thinking", undefined, "reviewer");
});
it("flushes thinking buffer before tool start", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-014",
taskId: "FN-014",
agent: "executor",
flushSizeBytes: 1024,
});
@@ -284,8 +284,8 @@ describe("AgentLogger", () => {
await vi.advanceTimersByTimeAsync(0);
const calls = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls;
expect(calls[0]).toEqual(["KB-014", "pre-tool thought", "thinking", undefined, "executor"]);
expect(calls[1]).toEqual(["KB-014", "Read", "tool", "file.ts", "executor"]);
expect(calls[0]).toEqual(["FN-014", "pre-tool thought", "thinking", undefined, "executor"]);
expect(calls[1]).toEqual(["FN-014", "Read", "tool", "file.ts", "executor"]);
});
// ── onToolEnd ────────────────────────────────────────────────────
@@ -294,33 +294,33 @@ describe("AgentLogger", () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-015",
taskId: "FN-015",
agent: "executor",
});
logger.onToolEnd("Bash", false, "command output");
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-015", "Bash", "tool_result", "command output", "executor");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-015", "Bash", "tool_result", "command output", "executor");
});
it("logs tool_error on failed tool end", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-016",
taskId: "FN-016",
agent: "executor",
});
logger.onToolEnd("Read", true, "file not found");
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-016", "Read", "tool_error", "file not found", "executor");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-016", "Read", "tool_error", "file not found", "executor");
});
it("truncates long tool results to 500 chars", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-017",
taskId: "FN-017",
agent: "executor",
});
@@ -336,12 +336,12 @@ describe("AgentLogger", () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "KB-018",
taskId: "FN-018",
agent: "merger",
});
logger.onToolEnd("Bash", false);
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-018", "Bash", "tool_result", undefined, "merger");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-018", "Bash", "tool_result", undefined, "merger");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -350,7 +350,7 @@ export class TaskExecutor {
* than being named after the task ID. This decouples directory names from
* tasks, enabling worktree reuse across dependency chains. When resuming
* a task that already has `task.worktree` set, the existing path is used
* as-is. Branches remain task-scoped (`kb/{task-id}`).
* as-is. Branches remain task-scoped (`fusion/{task-id}`).
*/
async execute(task: Task): Promise<void> {
if (this.executing.has(task.id)) return;
@@ -399,7 +399,7 @@ export class TaskExecutor {
}
// Create or reuse worktree — try pool first when recycling is enabled
const branchName = `kb/${task.id.toLowerCase()}`;
const branchName = `fusion/${task.id.toLowerCase()}`;
// Use generateWorktreeName for human-friendly directory names (adjective-noun pattern)
// instead of task.id, so worktrees are named like ".worktrees/swift-falcon"
let isResume = existsSync(worktreePath);
@@ -1049,7 +1049,7 @@ export class TaskExecutor {
}
// Delete the branch
const branch = `kb/${taskId.toLowerCase()}`;
const branch = `fusion/${taskId.toLowerCase()}`;
try {
execSync(`git branch -D "${branch}"`, { cwd: this.rootDir, stdio: "pipe" });
} catch {
@@ -1070,9 +1070,9 @@ export class TaskExecutor {
/**
* Create a git worktree at `path` on a new branch.
*
* @param branch — Branch name (e.g., `kb/kb-042`)
* @param branch — Branch name (e.g., `fusion/fn-042`)
* @param path — Absolute worktree directory path
* @param startPoint — Optional git ref to branch from (e.g., `kb/kb-041`).
* @param startPoint — Optional git ref to branch from (e.g., `fusion/fn-041`).
* When provided, the worktree starts from that ref instead of HEAD.
*/
/**
@@ -1268,7 +1268,7 @@ If issues are found that need attention, describe them clearly.`;
* Create a git worktree with automatic recovery from conflicts.
* Implements retry logic with exponential backoff for transient failures.
*
* @param branch - The branch name to create (e.g., "kb/kb-123")
* @param branch - The branch name to create (e.g., "fusion/fn-123")
* @param path - The desired worktree path
* @param taskId - The task ID for logging
* @param startPoint - Optional base branch/commit for new branch

View File

@@ -42,7 +42,7 @@ const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
const baseTask: Task = {
id: "KB-050",
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
@@ -95,27 +95,27 @@ function setupHappyPathExecSync() {
describe("findWorktreeUser", () => {
it("returns null when no other task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "KB-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "FN-050", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "KB-050");
const result = await findWorktreeUser(store, "/tmp/wt", "FN-050");
expect(result).toBeNull();
});
it("returns task ID when another non-done task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "KB-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "KB-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
{ id: "FN-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "FN-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "KB-050");
expect(result).toBe("KB-051");
const result = await findWorktreeUser(store, "/tmp/wt", "FN-050");
expect(result).toBe("FN-051");
});
it("ignores done tasks", async () => {
const store = createMockStore({}, [
{ id: "KB-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "KB-051", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "FN-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "FN-051", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "KB-050");
const result = await findWorktreeUser(store, "/tmp/wt", "FN-050");
expect(result).toBeNull();
});
});
@@ -136,14 +136,14 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
it("does NOT remove worktree when another task references the same path", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "KB-050", worktree: worktreePath },
{ id: "FN-050", worktree: worktreePath },
[
{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "KB-051", worktree: worktreePath, column: "in-progress" } as Task,
{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "FN-051", worktree: worktreePath, column: "in-progress" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Worktree should NOT be removed
const removeCall = mockedExecSync.mock.calls.find(
@@ -156,13 +156,13 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
it("removes worktree when no other task references it", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "KB-050", worktree: worktreePath },
{ id: "FN-050", worktree: worktreePath },
[
{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
const removeCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("worktree remove"),
@@ -174,14 +174,14 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
it("always deletes the branch regardless of worktree sharing", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "KB-050", worktree: worktreePath },
{ id: "FN-050", worktree: worktreePath },
[
{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "KB-051", worktree: worktreePath, column: "in-progress" } as Task,
{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "FN-051", worktree: worktreePath, column: "in-progress" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Branch should be deleted even though worktree is shared
const branchDeleteCall = mockedExecSync.mock.calls.find(
@@ -194,14 +194,14 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
it("result.worktreeRemoved is false when worktree is retained", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "KB-050", worktree: worktreePath },
{ id: "FN-050", worktree: worktreePath },
[
{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "KB-051", worktree: worktreePath, column: "todo" } as Task,
{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "FN-051", worktree: worktreePath, column: "todo" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.worktreeRemoved).toBe(false);
expect(result.merged).toBe(true);
});
@@ -228,13 +228,13 @@ describe("aiMergeTask — empty squash merge (branch already merged via dep)", (
});
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Agent should NOT have been spawned
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
// Task should still be moved to done
expect(store.moveTask).toHaveBeenCalledWith("KB-050", "done");
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("still cleans up branch and worktree when squash is empty", async () => {
@@ -251,7 +251,7 @@ describe("aiMergeTask — empty squash merge (branch already merged via dep)", (
});
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Branch should be deleted
const branchDeleteCall = mockedExecSync.mock.calls.find(
@@ -284,11 +284,11 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
it("includes task ID in system prompt by default (includeTaskIdInCommit: true)", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "KB-050");
await aiMergeTask(store, "/tmp/root", "FN-050");
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
expect(agentCall.systemPrompt).toContain("<type>(<scope>): <summary>");
@@ -297,15 +297,15 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
it("omits task ID scope in system prompt when includeTaskIdInCommit is false", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
includeTaskIdInCommit: false,
});
await aiMergeTask(store, "/tmp/root", "KB-050");
await aiMergeTask(store, "/tmp/root", "FN-050");
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
expect(agentCall.systemPrompt).toContain("<type>: <summary>");
@@ -329,11 +329,11 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
});
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "KB-050");
await aiMergeTask(store, "/tmp/root", "FN-050");
const commitCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("git commit"),
@@ -357,15 +357,15 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
});
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
includeTaskIdInCommit: false,
});
await aiMergeTask(store, "/tmp/root", "KB-050");
await aiMergeTask(store, "/tmp/root", "FN-050");
const commitCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("git commit"),
@@ -391,8 +391,8 @@ describe("aiMergeTask — model settings threading", () => {
it("passes defaultProvider and defaultModelId from settings to createKbAgent", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
@@ -400,7 +400,7 @@ describe("aiMergeTask — model settings threading", () => {
defaultModelId: "gpt-4o",
});
await aiMergeTask(store, "/tmp/root", "KB-050");
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0] as any;
@@ -410,11 +410,11 @@ describe("aiMergeTask — model settings threading", () => {
it("does not set model fields when settings omit them", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "KB-050");
await aiMergeTask(store, "/tmp/root", "FN-050");
const opts = mockedCreateHaiAgent.mock.calls[0][0] as any;
expect(opts.defaultProvider).toBeUndefined();
@@ -447,13 +447,13 @@ describe("aiMergeTask — agent log persistence", () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "KB-050", worktree: worktreePath },
[{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task],
{ id: "FN-050", worktree: worktreePath },
[{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "KB-050");
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-050", "Hello merge", "text", undefined, "merger");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Hello merge", "text", undefined, "merger");
});
it("logs tool invocations to store.appendAgentLog", async () => {
@@ -473,13 +473,13 @@ describe("aiMergeTask — agent log persistence", () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "KB-050", worktree: worktreePath },
[{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task],
{ id: "FN-050", worktree: worktreePath },
[{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "KB-050");
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-050", "Bash", "tool", "git status", "merger");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Bash", "tool", "git status", "merger");
});
it("still fires onAgentText callback alongside logging", async () => {
@@ -500,14 +500,14 @@ describe("aiMergeTask — agent log persistence", () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "KB-050", worktree: worktreePath },
[{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task],
{ id: "FN-050", worktree: worktreePath },
[{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "KB-050", { onAgentText });
await aiMergeTask(store, "/tmp/root", "FN-050", { onAgentText });
expect(onAgentText).toHaveBeenCalledWith("hi");
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-050", "hi", "text", undefined, "merger");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "hi", "text", undefined, "merger");
});
});
@@ -524,8 +524,8 @@ describe("aiMergeTask — usage limit detection", () => {
it("triggers global pause when merger catches a usage-limit error", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const pauser = new UsageLimitPauser(store);
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
@@ -538,12 +538,12 @@ describe("aiMergeTask — usage limit detection", () => {
} as any);
await expect(
aiMergeTask(store, "/tmp/root", "KB-050", { usageLimitPauser: pauser }),
aiMergeTask(store, "/tmp/root", "FN-050", { usageLimitPauser: pauser }),
).rejects.toThrow("AI merge failed");
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
"merger",
"KB-050",
"FN-050",
"rate_limit_error: Rate limit exceeded",
);
expect(store.updateSettings).toHaveBeenCalledWith({ globalPause: true });
@@ -551,8 +551,8 @@ describe("aiMergeTask — usage limit detection", () => {
it("triggers global pause when session.prompt() resolves with exhausted-retry error on state.error", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const pauser = new UsageLimitPauser(store);
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
@@ -566,13 +566,13 @@ describe("aiMergeTask — usage limit detection", () => {
mockedCreateHaiAgent.mockResolvedValue({ session: mockSession } as any);
await expect(
aiMergeTask(store, "/tmp/root", "KB-050", { usageLimitPauser: pauser }),
aiMergeTask(store, "/tmp/root", "FN-050", { usageLimitPauser: pauser }),
).rejects.toThrow("AI merge failed");
// UsageLimitPauser should be called with "merger" agent type
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
"merger",
"KB-050",
"FN-050",
"429 Too Many Requests",
);
// git reset --merge should be called to abort the merge
@@ -584,8 +584,8 @@ describe("aiMergeTask — usage limit detection", () => {
it("does NOT trigger global pause for non-usage-limit errors", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const pauser = new UsageLimitPauser(store);
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
@@ -598,7 +598,7 @@ describe("aiMergeTask — usage limit detection", () => {
} as any);
await expect(
aiMergeTask(store, "/tmp/root", "KB-050", { usageLimitPauser: pauser }),
aiMergeTask(store, "/tmp/root", "FN-050", { usageLimitPauser: pauser }),
).rejects.toThrow("AI merge failed");
expect(onUsageLimitHitSpy).not.toHaveBeenCalled();
@@ -606,8 +606,8 @@ describe("aiMergeTask — usage limit detection", () => {
it("works without usageLimitPauser (backward compatible)", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
mockedCreateHaiAgent.mockResolvedValue({
@@ -619,14 +619,14 @@ describe("aiMergeTask — usage limit detection", () => {
// Should not crash — just re-throw
await expect(
aiMergeTask(store, "/tmp/root", "KB-050"),
aiMergeTask(store, "/tmp/root", "FN-050"),
).rejects.toThrow("AI merge failed");
});
it("triggers global pause for overloaded error", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const pauser = new UsageLimitPauser(store);
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
@@ -639,12 +639,12 @@ describe("aiMergeTask — usage limit detection", () => {
} as any);
await expect(
aiMergeTask(store, "/tmp/root", "KB-050", { usageLimitPauser: pauser }),
aiMergeTask(store, "/tmp/root", "FN-050", { usageLimitPauser: pauser }),
).rejects.toThrow("AI merge failed");
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
"merger",
"KB-050",
"FN-050",
"overloaded_error: Overloaded",
);
});
@@ -669,11 +669,11 @@ describe("aiMergeTask — onSession callback", () => {
const onSession = vi.fn();
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "KB-050", { onSession });
await aiMergeTask(store, "/tmp/root", "FN-050", { onSession });
expect(onSession).toHaveBeenCalledTimes(1);
expect(onSession).toHaveBeenCalledWith(mockSession);
@@ -688,12 +688,12 @@ describe("aiMergeTask — onSession callback", () => {
} as any);
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Should not crash without onSession
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).resolves.toBeDefined();
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).resolves.toBeDefined();
});
});
@@ -1108,8 +1108,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("attempt 1 success: sets resolutionStrategy to 'ai' and attemptsMade to 1", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Clean merge with no conflicts - simulate empty diff for conflicts
@@ -1129,7 +1129,7 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ai");
@@ -1138,8 +1138,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("with autoResolveConflicts disabled: only makes 1 attempt on conflict", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
@@ -1185,7 +1185,7 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
} as any);
});
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).rejects.toThrow();
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow();
// Should have called agent exactly once (no retries since autoResolve is disabled)
expect(agentCallCount).toBe(1);
@@ -1193,8 +1193,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("attempt 1 fails, attempt 2 auto-resolves lock files: sets resolutionStrategy to 'auto-resolve'", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
let mergeCallCount = 0;
@@ -1230,7 +1230,7 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
});
// Agent should not be called since all conflicts are auto-resolved
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("auto-resolve");
@@ -1239,8 +1239,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("attempt 3 uses -X theirs strategy: sets resolutionStrategy to 'theirs'", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
let squashCallCount = 0;
@@ -1302,7 +1302,7 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
} as any);
});
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("theirs");
@@ -1313,8 +1313,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("all 3 attempts fail: throws error and calls git reset --merge", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const resetCalls: string[] = [];
@@ -1350,7 +1350,7 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
},
} as any);
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).rejects.toThrow(
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"all 3 attempts exhausted",
);
@@ -1360,8 +1360,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("tracks resolutionStrategy as 'ai' when attempt 1 succeeds even with autoResolve enabled", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Clean merge with no conflicts
@@ -1379,7 +1379,7 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ai");

View File

@@ -538,7 +538,7 @@ export async function aiMergeTask(
);
}
const branch = `kb/${taskId.toLowerCase()}`;
const branch = `fusion/${taskId.toLowerCase()}`;
const worktreePath = task.worktree;
const result: MergeResult = {
task,

View File

@@ -88,7 +88,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
// Wait for any async operations
await new Promise(resolve => setTimeout(resolve, 10));
@@ -101,7 +101,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
@@ -119,7 +119,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
@@ -129,7 +129,7 @@ describe("NtfyNotifier", () => {
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Title": "Task KB-001 completed",
"Title": "Task FN-001 completed",
"Priority": "default",
}),
body: 'Task "Test Task" is ready for review',
@@ -141,7 +141,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-review", "done");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-review", "done");
await new Promise(resolve => setTimeout(resolve, 10));
@@ -153,7 +153,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const failedTask = createTask("KB-001", "Test Task", "failed");
const failedTask = createTask("FN-001", "Test Task", "failed");
store.triggerTaskUpdated(failedTask);
await new Promise(resolve => setTimeout(resolve, 10));
@@ -164,7 +164,7 @@ describe("NtfyNotifier", () => {
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Title": "Task KB-001 failed",
"Title": "Task FN-001 failed",
"Priority": "high",
}),
body: 'Task "Test Task" has failed and needs attention',
@@ -177,8 +177,8 @@ describe("NtfyNotifier", () => {
await notifier.start();
const mergeResult: MergeResult = {
task: createTask("KB-001", "Test Task"),
branch: "kb/kb-001",
task: createTask("FN-001", "Test Task"),
branch: "fusion/fn-001",
merged: true,
worktreeRemoved: true,
branchDeleted: true,
@@ -193,7 +193,7 @@ describe("NtfyNotifier", () => {
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Title": "Task KB-001 merged",
"Title": "Task FN-001 merged",
"Priority": "default",
}),
body: 'Task "Test Task" has been merged to main',
@@ -206,8 +206,8 @@ describe("NtfyNotifier", () => {
await notifier.start();
const mergeResult: MergeResult = {
task: createTask("KB-001", "Test Task"),
branch: "kb/kb-001",
task: createTask("FN-001", "Test Task"),
branch: "fusion/fn-001",
merged: false,
worktreeRemoved: false,
branchDeleted: false,
@@ -224,7 +224,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const taskWithoutTitle = { ...createTask("KB-001"), description: "Implement user authentication flow" };
const taskWithoutTitle = { ...createTask("FN-001"), description: "Implement user authentication flow" };
store.triggerTaskMoved(taskWithoutTitle, "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
@@ -232,7 +232,7 @@ describe("NtfyNotifier", () => {
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
body: 'Task "KB-001: Implement user authentication flow" is ready for review',
body: 'Task "FN-001: Implement user authentication flow" is ready for review',
})
);
});
@@ -242,7 +242,7 @@ describe("NtfyNotifier", () => {
await notifier.start();
const longDescription = "A".repeat(250);
const taskWithoutTitle = { ...createTask("KB-001"), description: longDescription };
const taskWithoutTitle = { ...createTask("FN-001"), description: longDescription };
store.triggerTaskMoved(taskWithoutTitle, "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
@@ -251,7 +251,7 @@ describe("NtfyNotifier", () => {
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
body: `Task "KB-001: ${expectedSnippet}" is ready for review`,
body: `Task "FN-001: ${expectedSnippet}" is ready for review`,
})
);
});
@@ -261,7 +261,7 @@ describe("NtfyNotifier", () => {
await notifier.start();
const exactDescription = "B".repeat(200);
const taskWithoutTitle = { ...createTask("KB-001"), description: exactDescription };
const taskWithoutTitle = { ...createTask("FN-001"), description: exactDescription };
store.triggerTaskMoved(taskWithoutTitle, "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
@@ -269,7 +269,7 @@ describe("NtfyNotifier", () => {
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
body: `Task "KB-001: ${exactDescription}" is ready for review`,
body: `Task "FN-001: ${exactDescription}" is ready for review`,
})
);
});
@@ -282,7 +282,7 @@ describe("NtfyNotifier", () => {
await notifier.start();
// Initially disabled
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).not.toHaveBeenCalled();
@@ -290,7 +290,7 @@ describe("NtfyNotifier", () => {
fetchMock.mockResolvedValue({ ok: true });
store.setSettings({ ntfyEnabled: true });
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
@@ -302,14 +302,14 @@ describe("NtfyNotifier", () => {
await notifier.start();
// Initially enabled
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).toHaveBeenCalledTimes(1);
// Disable at runtime
store.setSettings({ ntfyEnabled: false });
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls
});
@@ -320,14 +320,14 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object));
// Change topic
store.setSettings({ ntfyTopic: "new-topic" });
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object));
});
@@ -342,7 +342,7 @@ describe("NtfyNotifier", () => {
await notifier.start();
// Should not throw
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).toHaveBeenCalled();
@@ -356,7 +356,7 @@ describe("NtfyNotifier", () => {
await notifier.start();
// Should not throw
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).toHaveBeenCalled();
@@ -373,7 +373,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const task = createTask("KB-001", "Test Task");
const task = createTask("FN-001", "Test Task");
// Multiple in-review events for the same task
store.triggerTaskMoved(task, "in-progress", "in-review");
@@ -390,7 +390,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const task = createTask("KB-001", "Test Task");
const task = createTask("FN-001", "Test Task");
// First: in-review notification
store.triggerTaskMoved(task, "in-progress", "in-review");
@@ -400,7 +400,7 @@ describe("NtfyNotifier", () => {
// Second: merged notification (different event type - should be allowed)
const mergeResult: MergeResult = {
task,
branch: "kb/kb-001",
branch: "fusion/fn-001",
merged: true,
worktreeRemoved: true,
branchDeleted: true,
@@ -416,10 +416,10 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const task = createTask("KB-001", "Test Task");
const task = createTask("FN-001", "Test Task");
const mergeResult: MergeResult = {
task,
branch: "kb/kb-001",
branch: "fusion/fn-001",
merged: true,
worktreeRemoved: true,
branchDeleted: true,
@@ -437,7 +437,7 @@ describe("NtfyNotifier", () => {
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Title": "Task KB-001 merged",
"Title": "Task FN-001 merged",
"Priority": "default",
}),
body: 'Task "Test Task" has been merged to main',
@@ -449,10 +449,10 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const task = createTask("KB-001", "Test Task");
const task = createTask("FN-001", "Test Task");
const mergeResult: MergeResult = {
task,
branch: "kb/kb-001",
branch: "fusion/fn-001",
merged: true,
worktreeRemoved: true,
branchDeleted: true,
@@ -473,8 +473,8 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const task1 = createTask("KB-001", "Test Task 1");
const task2 = createTask("KB-002", "Test Task 2");
const task1 = createTask("FN-001", "Test Task 1");
const task2 = createTask("FN-002", "Test Task 2");
store.triggerTaskMoved(task1, "in-progress", "in-review");
store.triggerTaskMoved(task2, "in-progress", "in-review");
@@ -494,7 +494,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" });
await notifier.start();
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
@@ -513,13 +513,13 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).toHaveBeenCalledTimes(1);
notifier.stop();
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
// Should not increase after stop
@@ -534,7 +534,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const task = createTask("KB-001", "Test Task");
const task = createTask("FN-001", "Test Task");
// First: in-review notification
store.triggerTaskMoved(task, "in-progress", "in-review");
@@ -557,15 +557,15 @@ describe("NtfyNotifier", () => {
await notifier.start();
// Move to todo - should not notify
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "triage", "todo");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo");
await new Promise(resolve => setTimeout(resolve, 10));
// Move to in-progress - should not notify
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "todo", "in-progress");
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress");
await new Promise(resolve => setTimeout(resolve, 10));
// Move to done - should not notify (merged notification comes from task:merged)
store.triggerTaskMoved(createTask("KB-003", "Test Task 3"), "in-review", "done");
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done");
await new Promise(resolve => setTimeout(resolve, 10));
expect(fetchMock).not.toHaveBeenCalled();
@@ -577,7 +577,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const task = createTask("KB-001", "Test Task", "in-progress");
const task = createTask("FN-001", "Test Task", "in-progress");
store.triggerTaskUpdated(task);
await new Promise(resolve => setTimeout(resolve, 10));
@@ -590,7 +590,7 @@ describe("NtfyNotifier", () => {
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await new Promise(resolve => setTimeout(resolve, 10));
// Empty topic should be treated as no topic

View File

@@ -4,7 +4,7 @@ import type { TaskStore, Task } from "@fusion/core";
const mockStore = {
addSteeringComment: vi.fn<(id: string, text: string, author?: "user" | "agent") => Promise<Task>>(),
createTask: vi.fn<(input: Parameters<TaskStore["createTask"]>[0]) => Promise<Task>>().mockResolvedValue({ id: "KB-123" } as Task),
createTask: vi.fn<(input: Parameters<TaskStore["createTask"]>[0]) => Promise<Task>>().mockResolvedValue({ id: "FN-123" } as Task),
} as unknown as TaskStore;
describe("PrCommentHandler", () => {
@@ -20,7 +20,7 @@ describe("PrCommentHandler", () => {
number: 42,
status: "open" as const,
title: "Test PR",
headBranch: "kb/kb-001",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
};
@@ -38,7 +38,7 @@ describe("PrCommentHandler", () => {
"👍",
"✅",
])("filters out non-actionable comment: %s", async (body) => {
await handler.handleNewComments("KB-001", mockPrInfo, [
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body,
@@ -66,7 +66,7 @@ describe("PrCommentHandler", () => {
{ body: "I suggest renaming this", keyword: "suggest" },
{ body: "Recommend adding tests", keyword: "recommend" },
])("creates steering comment for actionable feedback containing '$keyword': $body", async ({ body }) => {
await handler.handleNewComments("KB-001", mockPrInfo, [
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body,
@@ -83,7 +83,7 @@ describe("PrCommentHandler", () => {
describe("code suggestions", () => {
it("creates steering comment for comments with code blocks", async () => {
await handler.handleNewComments("KB-001", mockPrInfo, [
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: "```typescript\nconst x = 1;\n```",
@@ -98,7 +98,7 @@ describe("PrCommentHandler", () => {
});
it("creates steering comment for inline code suggestions", async () => {
await handler.handleNewComments("KB-001", mockPrInfo, [
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: "Use `const` instead of `let`",
@@ -115,7 +115,7 @@ describe("PrCommentHandler", () => {
describe("steering comment content", () => {
it("includes PR info and comment details", async () => {
await handler.handleNewComments("KB-001", mockPrInfo, [
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: "Please fix the bug",
@@ -140,7 +140,7 @@ describe("PrCommentHandler", () => {
it("truncates long comments", async () => {
const longBody = "Please fix this issue: " + "a".repeat(1000);
await handler.handleNewComments("KB-001", mockPrInfo, [
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: longBody,
@@ -159,7 +159,7 @@ describe("PrCommentHandler", () => {
});
it("marks as agent-authored", async () => {
await handler.handleNewComments("KB-001", mockPrInfo, [
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: "Please fix this",
@@ -171,14 +171,14 @@ describe("PrCommentHandler", () => {
]);
expect(mockStore.addSteeringComment).toHaveBeenCalledWith(
"KB-001",
"FN-001",
expect.any(String),
"agent"
);
});
it("calls out follow-up context when feedback arrives after PR is merged", async () => {
await handler.handleNewComments("KB-001", { ...mockPrInfo, status: "merged" }, [
await handler.handleNewComments("FN-001", { ...mockPrInfo, status: "merged" }, [
{
id: 1,
body: "Please add one more regression test",
@@ -197,7 +197,7 @@ describe("PrCommentHandler", () => {
describe("createFollowUpTask", () => {
it("creates follow-up task for unaddressed feedback", async () => {
await handler.createFollowUpTask("KB-001", mockPrInfo, [
await handler.createFollowUpTask("FN-001", mockPrInfo, [
{
id: 1,
body: "This needs fixing",
@@ -210,20 +210,20 @@ describe("PrCommentHandler", () => {
expect(mockStore.createTask).toHaveBeenCalledWith({
title: "Follow-up: Address PR #42 feedback",
description: expect.stringContaining("KB-001"),
description: expect.stringContaining("FN-001"),
column: "triage",
dependencies: ["KB-001"],
dependencies: ["FN-001"],
});
});
it("does nothing when no unaddressed comments", async () => {
await handler.createFollowUpTask("KB-001", mockPrInfo, []);
await handler.createFollowUpTask("FN-001", mockPrInfo, []);
expect(mockStore.createTask).not.toHaveBeenCalled();
});
it("summarizes multiple comments", async () => {
await handler.createFollowUpTask("KB-001", mockPrInfo, [
await handler.createFollowUpTask("FN-001", mockPrInfo, [
{
id: 1,
body: "First issue to fix",

View File

@@ -20,50 +20,50 @@ describe("PrMonitor", () => {
number: 42,
status: "open" as const,
title: "Test PR",
headBranch: "kb/kb-001",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
};
describe("startMonitoring", () => {
it("starts monitoring a PR", () => {
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
monitor.startMonitoring("FN-001", "owner", "repo", mockPrInfo);
const tracked = monitor.getTrackedPrs();
expect(tracked.has("KB-001")).toBe(true);
expect(tracked.get("KB-001")?.prInfo.number).toBe(42);
expect(tracked.has("FN-001")).toBe(true);
expect(tracked.get("FN-001")?.prInfo.number).toBe(42);
});
it("replaces existing monitoring for same task", () => {
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
monitor.startMonitoring("FN-001", "owner", "repo", mockPrInfo);
const newPrInfo = { ...mockPrInfo, number: 43 };
monitor.startMonitoring("KB-001", "owner", "repo", newPrInfo);
monitor.startMonitoring("FN-001", "owner", "repo", newPrInfo);
const tracked = monitor.getTrackedPrs();
expect(tracked.get("KB-001")?.prInfo.number).toBe(43);
expect(tracked.get("FN-001")?.prInfo.number).toBe(43);
});
});
describe("updatePrInfo", () => {
it("updates tracked PR metadata without restarting monitoring", () => {
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
monitor.startMonitoring("FN-001", "owner", "repo", mockPrInfo);
const updatedPrInfo = { ...mockPrInfo, status: "merged" as const };
monitor.updatePrInfo("KB-001", updatedPrInfo);
monitor.updatePrInfo("FN-001", updatedPrInfo);
const tracked = monitor.getTrackedPrs();
expect(tracked.get("KB-001")?.prInfo.status).toBe("merged");
expect(tracked.get("KB-001")?.owner).toBe("owner");
expect(tracked.get("FN-001")?.prInfo.status).toBe("merged");
expect(tracked.get("FN-001")?.owner).toBe("owner");
});
});
describe("stopMonitoring", () => {
it("stops monitoring a task", () => {
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
monitor.stopMonitoring("KB-001");
monitor.startMonitoring("FN-001", "owner", "repo", mockPrInfo);
monitor.stopMonitoring("FN-001");
const tracked = monitor.getTrackedPrs();
expect(tracked.has("KB-001")).toBe(false);
expect(tracked.has("FN-001")).toBe(false);
});
it("does nothing for untracked task", () => {
@@ -73,8 +73,8 @@ describe("PrMonitor", () => {
describe("stopAll", () => {
it("stops all monitoring", () => {
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
monitor.startMonitoring("KB-002", "owner", "repo", mockPrInfo);
monitor.startMonitoring("FN-001", "owner", "repo", mockPrInfo);
monitor.startMonitoring("FN-002", "owner", "repo", mockPrInfo);
monitor.stopAll();

View File

@@ -64,7 +64,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
}),
emit: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn().mockResolvedValue(makeTaskDetail("KB-001", "in-progress")),
getTask: vi.fn().mockResolvedValue(makeTaskDetail("FN-001", "in-progress")),
updateTask: vi.fn().mockResolvedValue({}),
moveTask: vi.fn().mockImplementation(async (id: string, col: Column) => {
return makeTask(id, col);
@@ -77,7 +77,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
return makeTaskDetail(id, "in-progress");
}),
createTask: vi.fn().mockImplementation(async (input: any) => {
return makeTask("KB-NEW", "triage");
return makeTask("FN-NEW", "triage");
}),
deleteTask: vi.fn().mockResolvedValue(undefined),
_trigger(event: string, ...args: any[]) {
@@ -145,9 +145,9 @@ beforeEach(() => {
describe("In-progress task resume after restart", () => {
it("resumeOrphaned() calls execute() for each in-progress task not already executing", async () => {
const store = createMockStore();
const task1 = makeTask("KB-001", "in-progress");
const task2 = makeTask("KB-002", "in-progress");
const taskDone = makeTask("KB-003", "done");
const task1 = makeTask("FN-001", "in-progress");
const task2 = makeTask("FN-002", "in-progress");
const taskDone = makeTask("FN-003", "done");
store.listTasks.mockResolvedValue([task1, task2, taskDone]);
mockAgentSuccess();
@@ -164,11 +164,11 @@ describe("In-progress task resume after restart", () => {
it("resumed task reuses existing worktree — no git worktree add called", async () => {
const store = createMockStore();
const task = makeTask("KB-010", "in-progress", {
const task = makeTask("FN-010", "in-progress", {
worktree: "/tmp/wt/KB-010",
});
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("KB-010", "in-progress", {
store.getTask.mockResolvedValue(makeTaskDetail("FN-010", "in-progress", {
worktree: "/tmp/wt/KB-010",
}));
@@ -190,9 +190,9 @@ describe("In-progress task resume after restart", () => {
it("resumed task with step progress includes RESUMING section in agent prompt", async () => {
const store = createMockStore();
const steps = makeSteps("done", "done", "done", "in-progress", "pending");
const task = makeTask("KB-020", "in-progress", { steps, currentStep: 3 });
const task = makeTask("FN-020", "in-progress", { steps, currentStep: 3 });
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("KB-020", "in-progress", {
store.getTask.mockResolvedValue(makeTaskDetail("FN-020", "in-progress", {
steps,
currentStep: 3,
}));
@@ -223,9 +223,9 @@ describe("In-progress task resume after restart", () => {
...DEFAULT_SETTINGS,
worktreeInitCommand: "pnpm install",
});
const task = makeTask("KB-030", "in-progress");
const task = makeTask("FN-030", "in-progress");
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("KB-030", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-030", "in-progress"));
mockedExistsSync.mockReturnValue(true); // worktree exists
mockAgentSuccess();
@@ -246,8 +246,8 @@ describe("In-progress task resume after restart", () => {
it("resumeOrphaned() logs 'Resumed after engine restart' for each orphaned task", async () => {
const store = createMockStore();
const task1 = makeTask("KB-040", "in-progress");
const task2 = makeTask("KB-041", "in-progress");
const task1 = makeTask("FN-040", "in-progress");
const task2 = makeTask("FN-041", "in-progress");
store.listTasks.mockResolvedValue([task1, task2]);
store.getTask.mockImplementation(async (id: string) =>
makeTaskDetail(id, "in-progress"),
@@ -259,8 +259,8 @@ describe("In-progress task resume after restart", () => {
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
expect(store.logEntry).toHaveBeenCalledWith("KB-040", "Resumed after engine restart");
expect(store.logEntry).toHaveBeenCalledWith("KB-041", "Resumed after engine restart");
expect(store.logEntry).toHaveBeenCalledWith("FN-040", "Resumed after engine restart");
expect(store.logEntry).toHaveBeenCalledWith("FN-041", "Resumed after engine restart");
});
});
@@ -274,9 +274,9 @@ describe("In-progress task resume after restart", () => {
describe("In-review merge handling after restart", () => {
it("aiMergeTask validates task is in 'in-review' before merging", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("KB-050", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-050", "in-progress"));
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).rejects.toThrow(
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Cannot merge KB-050: task is in 'in-progress', must be in 'in-review'",
);
@@ -286,8 +286,8 @@ describe("In-review merge handling after restart", () => {
it("aiMergeTask sets status to 'merging' during execution and clears on success", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("KB-051", "in-review"));
store.moveTask.mockResolvedValue(makeTask("KB-051", "done"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-051", "in-review"));
store.moveTask.mockResolvedValue(makeTask("FN-051", "done"));
// Branch exists, merge succeeds, no conflicts
mockedExecSync.mockImplementation((cmd: any) => {
@@ -301,16 +301,16 @@ describe("In-review merge handling after restart", () => {
mockAgentSuccess();
await aiMergeTask(store, "/tmp/root", "KB-051");
await aiMergeTask(store, "/tmp/root", "FN-051");
// Should have set status to "merging"
expect(store.updateTask).toHaveBeenCalledWith("KB-051", { status: "merging" });
expect(store.updateTask).toHaveBeenCalledWith("FN-051", { status: "merging" });
// Should have cleared status via completeTask (status: null before moveTask)
expect(store.updateTask).toHaveBeenCalledWith("KB-051", { status: null });
expect(store.updateTask).toHaveBeenCalledWith("FN-051", { status: null });
});
it("sequential aiMergeTask calls for multiple in-review tasks all succeed", async () => {
const taskIds = ["KB-052", "KB-053", "KB-054"];
const taskIds = ["FN-052", "FN-053", "FN-054"];
for (const taskId of taskIds) {
const store = createMockStore();
@@ -333,7 +333,7 @@ describe("In-review merge handling after restart", () => {
it("aiMergeTask throws on agent failure during session.prompt and calls git reset --merge", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("KB-055", "in-review"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-055", "in-review"));
// Branch exists, merge starts, agent creates but prompt fails
mockedExecSync.mockImplementation((cmd: any) => {
@@ -350,7 +350,7 @@ describe("In-review merge handling after restart", () => {
},
} as any);
await expect(aiMergeTask(store, "/tmp/root", "KB-055")).rejects.toThrow(
await expect(aiMergeTask(store, "/tmp/root", "FN-055")).rejects.toThrow(
"AI merge failed for KB-055: all 3 attempts exhausted",
);
@@ -361,13 +361,13 @@ describe("In-review merge handling after restart", () => {
expect(resetCalls.length).toBeGreaterThan(0);
// Status was set to "merging" but NOT cleared by aiMergeTask (that's the dashboard's job)
expect(store.updateTask).toHaveBeenCalledWith("KB-055", { status: "merging" });
expect(store.updateTask).toHaveBeenCalledWith("FN-055", { status: "merging" });
});
it("aiMergeTask moves task to done when branch does not exist", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("KB-056", "in-review"));
store.moveTask.mockResolvedValue(makeTask("KB-056", "done"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-056", "in-review"));
store.moveTask.mockResolvedValue(makeTask("FN-056", "done"));
// git rev-parse --verify throws (branch not found)
mockedExecSync.mockImplementation((cmd: any) => {
@@ -377,11 +377,11 @@ describe("In-review merge handling after restart", () => {
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "KB-056");
const result = await aiMergeTask(store, "/tmp/root", "FN-056");
expect(result.merged).toBe(false);
expect(result.error).toContain("Branch");
expect(store.moveTask).toHaveBeenCalledWith("KB-056", "done");
expect(store.moveTask).toHaveBeenCalledWith("FN-056", "done");
});
});
@@ -390,8 +390,8 @@ describe("In-review merge handling after restart", () => {
describe("Triage re-pick after restart", () => {
it("TriageProcessor.start() after restart picks up triage tasks (processing set is fresh)", async () => {
const store = createMockStore();
const triageTask1 = makeTask("KB-060", "triage");
const triageTask2 = makeTask("KB-061", "triage");
const triageTask1 = makeTask("FN-060", "triage");
const triageTask2 = makeTask("FN-061", "triage");
store.listTasks.mockResolvedValue([triageTask1, triageTask2]);
store.getTask.mockImplementation(async (id: string) =>
makeTaskDetail(id, "triage"),
@@ -409,15 +409,15 @@ describe("Triage re-pick after restart", () => {
triage.stop();
// Both triage tasks should have been picked up for specification
expect(store.updateTask).toHaveBeenCalledWith("KB-060", { status: "specifying" });
expect(store.updateTask).toHaveBeenCalledWith("KB-061", { status: "specifying" });
expect(store.updateTask).toHaveBeenCalledWith("FN-060", { status: "specifying" });
expect(store.updateTask).toHaveBeenCalledWith("FN-061", { status: "specifying" });
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
});
it("specifyTask() skips task already in processing set (no double-specification)", async () => {
const store = createMockStore();
const task = makeTask("KB-062", "triage");
store.getTask.mockResolvedValue(makeTaskDetail("KB-062", "triage"));
const task = makeTask("FN-062", "triage");
store.getTask.mockResolvedValue(makeTaskDetail("FN-062", "triage"));
// Slow agent to keep task in processing
let resolvePrompt: Function;
@@ -451,7 +451,7 @@ describe("Triage re-pick after restart", () => {
describe("Scheduler after restart", () => {
it("schedule() moves todo tasks to in-progress when deps are satisfied", async () => {
const store = createMockStore();
const todoTask = makeTask("KB-070", "todo");
const todoTask = makeTask("FN-070", "todo");
store.listTasks.mockResolvedValue([todoTask]);
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS });
@@ -470,16 +470,16 @@ describe("Scheduler after restart", () => {
await new Promise((r) => setTimeout(r, 50));
scheduler.stop();
expect(store.moveTask).toHaveBeenCalledWith("KB-070", "in-progress");
expect(store.updateTask).toHaveBeenCalledWith("KB-070", { status: null, blockedBy: null });
expect(store.moveTask).toHaveBeenCalledWith("FN-070", "in-progress");
expect(store.updateTask).toHaveBeenCalledWith("FN-070", { status: null, blockedBy: null });
expect(onSchedule).toHaveBeenCalledWith(todoTask);
});
it("schedule() respects dependency ordering — blocked tasks stay in todo", async () => {
const store = createMockStore();
const depTask = makeTask("KB-071", "in-progress");
const blockedTask = makeTask("KB-072", "todo", {
dependencies: ["KB-071"],
const depTask = makeTask("FN-071", "in-progress");
const blockedTask = makeTask("FN-072", "todo", {
dependencies: ["FN-071"],
});
store.listTasks.mockResolvedValue([depTask, blockedTask]);
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS });
@@ -496,19 +496,19 @@ describe("Scheduler after restart", () => {
scheduler.stop();
// Task should NOT have been moved
expect(store.moveTask).not.toHaveBeenCalledWith("KB-072", "in-progress");
expect(onBlocked).toHaveBeenCalledWith(blockedTask, ["KB-071"]);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-072", "in-progress");
expect(onBlocked).toHaveBeenCalledWith(blockedTask, ["FN-071"]);
});
it("full column coverage: restart with tasks in every column", async () => {
const store = createMockStore();
// Tasks across all columns
const triageTask = makeTask("KB-080", "triage");
const todoTask = makeTask("KB-081", "todo");
const inProgressTask = makeTask("KB-082", "in-progress");
const inReviewTask = makeTask("KB-083", "in-review");
const doneTask = makeTask("KB-084", "done");
const triageTask = makeTask("FN-080", "triage");
const todoTask = makeTask("FN-081", "todo");
const inProgressTask = makeTask("FN-082", "in-progress");
const inReviewTask = makeTask("FN-083", "in-review");
const doneTask = makeTask("FN-084", "done");
const allTasks = [triageTask, todoTask, inProgressTask, inReviewTask, doneTask];
store.listTasks.mockResolvedValue(allTasks);
@@ -528,7 +528,7 @@ describe("Scheduler after restart", () => {
await new Promise((r) => setTimeout(r, 100));
triage.stop();
expect(store.updateTask).toHaveBeenCalledWith("KB-080", { status: "specifying" });
expect(store.updateTask).toHaveBeenCalledWith("FN-080", { status: "specifying" });
// 2. Scheduler moves todo → in-progress
vi.clearAllMocks();
@@ -540,7 +540,7 @@ describe("Scheduler after restart", () => {
await new Promise((r) => setTimeout(r, 50));
scheduler.stop();
expect(store.moveTask).toHaveBeenCalledWith("KB-081", "in-progress");
expect(store.moveTask).toHaveBeenCalledWith("FN-081", "in-progress");
// 3. Executor resumes in-progress tasks
vi.clearAllMocks();
@@ -555,13 +555,13 @@ describe("Scheduler after restart", () => {
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
expect(store.logEntry).toHaveBeenCalledWith("KB-082", "Resumed after engine restart");
expect(store.logEntry).toHaveBeenCalledWith("FN-082", "Resumed after engine restart");
// 4. Done tasks are untouched (no operations on KB-084)
const doneCalls = [
...store.updateTask.mock.calls,
...store.moveTask.mock.calls,
].filter((call) => call[0] === "KB-084");
].filter((call) => call[0] === "FN-084");
expect(doneCalls).toHaveLength(0);
});
});
@@ -572,9 +572,9 @@ describe("Crash scenario edge cases", () => {
it("agent dies mid-step — onError is called, semaphore slot released, task eligible for resume", async () => {
const sem = new AgentSemaphore(2);
const store = createMockStore();
const task = makeTask("KB-090", "in-progress");
const task = makeTask("FN-090", "in-progress");
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("KB-090", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-090", "in-progress"));
// Agent session.prompt rejects (simulating crash mid-step)
mockedCreateHaiAgent.mockResolvedValue({
@@ -603,7 +603,7 @@ describe("Crash scenario edge cases", () => {
// Verify by calling resumeOrphaned again — it should try to execute again
vi.clearAllMocks();
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("KB-090", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-090", "in-progress"));
mockAgentSuccess();
await executor.resumeOrphaned();
@@ -615,7 +615,7 @@ describe("Crash scenario edge cases", () => {
it("engine killed during merge — git reset --merge cleanup, task stays in-review", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("KB-091", "in-review"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-091", "in-review"));
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
@@ -632,7 +632,7 @@ describe("Crash scenario edge cases", () => {
},
} as any);
await expect(aiMergeTask(store, "/tmp/root", "KB-091")).rejects.toThrow();
await expect(aiMergeTask(store, "/tmp/root", "FN-091")).rejects.toThrow();
// git reset --merge should have been called
const resetCalls = mockedExecSync.mock.calls.filter(
@@ -641,17 +641,17 @@ describe("Crash scenario edge cases", () => {
expect(resetCalls.length).toBeGreaterThan(0);
// Task should NOT have been moved to done
expect(store.moveTask).not.toHaveBeenCalledWith("KB-091", "done");
expect(store.moveTask).not.toHaveBeenCalledWith("FN-091", "done");
// Status was set to "merging" during execution
expect(store.updateTask).toHaveBeenCalledWith("KB-091", { status: "merging" });
expect(store.updateTask).toHaveBeenCalledWith("FN-091", { status: "merging" });
});
it("concurrent resumeOrphaned() calls don't double-execute the same task", async () => {
const store = createMockStore();
const task = makeTask("KB-092", "in-progress");
const task = makeTask("FN-092", "in-progress");
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("KB-092", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-092", "in-progress"));
let resolvePrompt: Function;
mockedCreateHaiAgent.mockResolvedValue({
@@ -688,9 +688,9 @@ describe("Crash scenario edge cases", () => {
await sem.acquire();
expect(sem.activeCount).toBe(1);
const task = makeTask("KB-093", "in-progress");
const task = makeTask("FN-093", "in-progress");
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("KB-093", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-093", "in-progress"));
// Agent creation itself fails
mockedCreateHaiAgent.mockRejectedValue(new Error("cannot create agent"));
@@ -731,8 +731,8 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
const store = createMockStore();
store.listTasks.mockResolvedValue([
makeTask("KB-100", "in-progress", { worktree: "/root/.worktrees/swift-falcon" }),
makeTask("KB-101", "done", { worktree: "/root/.worktrees/calm-river" }),
makeTask("FN-100", "in-progress", { worktree: "/root/.worktrees/swift-falcon" }),
makeTask("FN-101", "done", { worktree: "/root/.worktrees/calm-river" }),
]);
// Simulate startup rehydration
@@ -761,7 +761,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
...DEFAULT_SETTINGS,
recycleWorktrees: true,
});
store.getTask.mockResolvedValue(makeTaskDetail("KB-110", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-110", "in-progress"));
const pool = new WorktreePool();
const idlePaths = await scanIdleWorktrees("/root", store);
@@ -777,7 +777,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
mockAgentSuccess();
const executor = new TaskExecutor(store, "/root", { pool });
await executor.execute(makeTask("KB-110", "in-progress"));
await executor.execute(makeTask("FN-110", "in-progress"));
await new Promise((r) => setTimeout(r, 50));
// Pool should be empty (worktree acquired)
@@ -791,7 +791,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
// Should log pool acquisition
expect(store.logEntry).toHaveBeenCalledWith(
"KB-110",
"FN-110",
expect.stringContaining("Acquired worktree from pool"),
);
});
@@ -925,8 +925,8 @@ describe("Edge case: worktree deleted between scan and acquire", () => {
describe("Engine pause/unpause cycle", () => {
it("executor: agents continue running on enginePaused (soft pause), complete normally", async () => {
const store = createMockStore();
const task = makeTask("KB-EP1", "in-progress");
store.getTask.mockResolvedValue(makeTaskDetail("KB-EP1", "in-progress"));
const task = makeTask("FN-EP1", "in-progress");
store.getTask.mockResolvedValue(makeTaskDetail("FN-EP1", "in-progress"));
// Agent triggers engine pause mid-flight but continues normally (soft pause)
mockedCreateHaiAgent.mockImplementation(async () => ({
@@ -947,9 +947,9 @@ describe("Engine pause/unpause cycle", () => {
await executor.execute(task);
// Task should complete normally (in-review), NOT moved to todo
expect(store.moveTask).toHaveBeenCalledWith("KB-EP1", "in-review");
expect(store.moveTask).not.toHaveBeenCalledWith("KB-EP1", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("KB-EP1", { status: "failed" });
expect(store.moveTask).toHaveBeenCalledWith("FN-EP1", "in-review");
expect(store.moveTask).not.toHaveBeenCalledWith("FN-EP1", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("FN-EP1", { status: "failed" });
});
it("triage: agents NOT terminated on enginePaused (soft pause), session continues", async () => {
@@ -977,7 +977,7 @@ describe("Engine pause/unpause cycle", () => {
} as any));
const triage = new TriageProcessor(store, "/tmp/test");
await triage.specifyTask(makeTask("KB-EP2", "triage"));
await triage.specifyTask(makeTask("FN-EP2", "triage"));
// Session should have continued past the enginePaused event
expect(sessionContinued).toBe(true);
@@ -987,7 +987,7 @@ describe("Engine pause/unpause cycle", () => {
it("scheduler resumes on unpause: schedule() runs when enginePaused goes true→false", async () => {
const store = createMockStore();
const todoTask = makeTask("KB-EP3", "todo");
const todoTask = makeTask("FN-EP3", "todo");
store.listTasks.mockResolvedValue([todoTask]);
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS, enginePaused: false });
store.parseFileScopeFromPrompt.mockResolvedValue([]);
@@ -1003,7 +1003,7 @@ describe("Engine pause/unpause cycle", () => {
await new Promise((r) => setTimeout(r, 50));
// Scheduler should have moved todo task to in-progress
expect(store.moveTask).toHaveBeenCalledWith("KB-EP3", "in-progress");
expect(store.moveTask).toHaveBeenCalledWith("FN-EP3", "in-progress");
// Now simulate engine pause then unpause
store.moveTask.mockClear();
@@ -1013,7 +1013,7 @@ describe("Engine pause/unpause cycle", () => {
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS, enginePaused: true });
// Add a new todo task
const newTask = makeTask("KB-EP4", "todo");
const newTask = makeTask("FN-EP4", "todo");
store.listTasks.mockResolvedValue([newTask]);
// Unpause — trigger settings:updated to wake the scheduler
@@ -1027,13 +1027,13 @@ describe("Engine pause/unpause cycle", () => {
scheduler.stop();
// The new task should have been scheduled after unpause
expect(store.moveTask).toHaveBeenCalledWith("KB-EP4", "in-progress");
expect(store.moveTask).toHaveBeenCalledWith("FN-EP4", "in-progress");
});
it("concurrency slots freed after agent completes during enginePaused (soft pause)", async () => {
const sem = new AgentSemaphore(1); // Only 1 concurrent slot
const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("KB-EP5", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-EP5", "in-progress"));
mockedCreateHaiAgent.mockImplementation(async () => ({
session: {
@@ -1052,7 +1052,7 @@ describe("Engine pause/unpause cycle", () => {
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
// Execute — agent runs to completion despite engine pause
await executor.execute(makeTask("KB-EP5", "in-progress"));
await executor.execute(makeTask("FN-EP5", "in-progress"));
// After completion, the semaphore slot should be freed.
// Verify by running a new task through the semaphore — it should not block.

View File

@@ -36,7 +36,7 @@ describe("reviewStep — model settings threading", () => {
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "anthropic",
@@ -56,7 +56,7 @@ describe("reviewStep — model settings threading", () => {
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{},
);
@@ -73,7 +73,7 @@ describe("reviewStep — model settings threading", () => {
);
const result = await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
);
expect(result.verdict).toBe("APPROVE");
@@ -91,7 +91,7 @@ describe("reviewStep — spec review type", () => {
);
const result = await reviewStep(
"/tmp/worktree", "KB-050", 0, "Spec Review", "spec", "# Task: KB-050\n\n## Mission\nDo something",
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec", "# Task: KB-050\n\n## Mission\nDo something",
);
expect(result.verdict).toBe("APPROVE");
@@ -104,7 +104,7 @@ describe("reviewStep — spec review type", () => {
);
const result = await reviewStep(
"/tmp/worktree", "KB-050", 0, "Spec Review", "spec", "# Task: KB-050",
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec", "# Task: KB-050",
);
expect(result.verdict).toBe("REVISE");
@@ -116,7 +116,7 @@ describe("reviewStep — spec review type", () => {
);
const result = await reviewStep(
"/tmp/worktree", "KB-050", 0, "Spec Review", "spec", "# Task: KB-050",
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec", "# Task: KB-050",
);
expect(result.verdict).toBe("RETHINK");
@@ -128,7 +128,7 @@ describe("reviewStep — spec review type", () => {
);
await reviewStep(
"/tmp/worktree", "KB-050", 0, "Spec Review", "spec", "# Task: KB-050",
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec", "# Task: KB-050",
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
@@ -156,7 +156,7 @@ describe("reviewStep — spec review type", () => {
} as any);
await reviewStep(
"/tmp/worktree", "KB-050", 0, "Spec Review", "spec",
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec",
"# Task: KB-050\n\n## Mission\nDo something great",
);
@@ -186,7 +186,7 @@ describe("reviewStep — spec review type", () => {
// Pass a baseline — should be ignored for spec reviews
await reviewStep(
"/tmp/worktree", "KB-050", 0, "Spec Review", "spec",
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec",
"# Task: KB-050", "abc123",
);
@@ -211,7 +211,7 @@ describe("reviewStep — exhausted-retry error detection", () => {
mockedCreateHaiAgent.mockResolvedValue({ session: mockSession } as any);
await expect(
reviewStep("/tmp/worktree", "KB-100", 1, "Test Step", "code", "# prompt"),
reviewStep("/tmp/worktree", "FN-100", 1, "Test Step", "code", "# prompt"),
).rejects.toThrow("rate_limit_error: Rate limit exceeded");
});
@@ -226,7 +226,7 @@ describe("reviewStep — exhausted-retry error detection", () => {
mockedCreateHaiAgent.mockResolvedValue({ session: mockSession } as any);
await expect(
reviewStep("/tmp/worktree", "KB-100", 1, "Test Step", "code", "# prompt"),
reviewStep("/tmp/worktree", "FN-100", 1, "Test Step", "code", "# prompt"),
).rejects.toThrow();
// Session should be disposed in the finally block
@@ -239,7 +239,7 @@ describe("reviewStep — exhausted-retry error detection", () => {
);
const result = await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
);
expect(result.verdict).toBe("APPROVE");
@@ -257,7 +257,7 @@ describe("reviewStep — validator model overrides", () => {
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
@@ -279,7 +279,7 @@ describe("reviewStep — validator model overrides", () => {
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
@@ -301,7 +301,7 @@ describe("reviewStep — validator model overrides", () => {
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
@@ -323,7 +323,7 @@ describe("reviewStep — validator model overrides", () => {
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",

View File

@@ -25,7 +25,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
// Helper to create mock tasks
function createMockTask(overrides: Partial<Task> = {}): Task {
return {
id: "KB-001",
id: "FN-001",
description: "Test task",
column: "todo",
dependencies: [],
@@ -152,10 +152,10 @@ describe("Scheduler", () => {
describe("schedule() concurrency limits", () => {
it("respects maxConcurrent limit", async () => {
const tasks = [
createMockTask({ id: "KB-001", column: "in-progress" }),
createMockTask({ id: "KB-002", column: "in-progress" }),
createMockTask({ id: "KB-003", column: "todo" }),
createMockTask({ id: "KB-004", column: "todo" }),
createMockTask({ id: "FN-001", column: "in-progress" }),
createMockTask({ id: "FN-002", column: "in-progress" }),
createMockTask({ id: "FN-003", column: "todo" }),
createMockTask({ id: "FN-004", column: "todo" }),
];
const store = createMockStore({
@@ -175,11 +175,11 @@ describe("Scheduler", () => {
it("respects maxWorktrees limit", async () => {
const tasks = [
createMockTask({ id: "KB-001", column: "in-progress" }),
createMockTask({ id: "KB-002", column: "in-progress" }),
createMockTask({ id: "KB-003", column: "in-progress" }),
createMockTask({ id: "KB-004", column: "in-progress" }),
createMockTask({ id: "KB-005", column: "todo" }),
createMockTask({ id: "FN-001", column: "in-progress" }),
createMockTask({ id: "FN-002", column: "in-progress" }),
createMockTask({ id: "FN-003", column: "in-progress" }),
createMockTask({ id: "FN-004", column: "in-progress" }),
createMockTask({ id: "FN-005", column: "todo" }),
];
const store = createMockStore({
@@ -206,9 +206,9 @@ describe("Scheduler", () => {
} as unknown as AgentSemaphore;
const tasks = [
createMockTask({ id: "KB-001", column: "in-progress" }),
createMockTask({ id: "KB-002", column: "in-progress" }),
createMockTask({ id: "KB-003", column: "todo" }),
createMockTask({ id: "FN-001", column: "in-progress" }),
createMockTask({ id: "FN-002", column: "in-progress" }),
createMockTask({ id: "FN-003", column: "todo" }),
];
const store = createMockStore({
@@ -227,7 +227,7 @@ describe("Scheduler", () => {
describe("global pause", () => {
it("halts scheduling when globalPause is active", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "KB-001", column: "todo" })]),
listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-001", column: "todo" })]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
@@ -246,7 +246,7 @@ describe("Scheduler", () => {
describe("engine pause", () => {
it("halts new scheduling when enginePaused is active", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "KB-001", column: "todo" })]),
listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-001", column: "todo" })]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
@@ -265,7 +265,7 @@ describe("Scheduler", () => {
describe("filesystem validation", () => {
it("moves task to triage when task directory is missing", async () => {
const tasks = [
createMockTask({ id: "KB-001", column: "todo", dependencies: [] }),
createMockTask({ id: "FN-001", column: "todo", dependencies: [] }),
];
const store = createMockStore({
@@ -292,20 +292,20 @@ describe("Scheduler", () => {
await new Promise(resolve => setTimeout(resolve, 0));
// Task should be moved to triage
expect(moveTask).toHaveBeenCalledWith("KB-001", "triage");
expect(moveTask).toHaveBeenCalledWith("FN-001", "triage");
// Log entry should be written with reason
expect(logEntry).toHaveBeenCalledWith(
"KB-001",
"FN-001",
"Task moved to triage — filesystem validation failed",
"missing directory"
);
// Task should not be moved to in-progress
expect(moveTask).not.toHaveBeenCalledWith("KB-001", "in-progress");
expect(moveTask).not.toHaveBeenCalledWith("FN-001", "in-progress");
});
it("moves task to triage when PROMPT.md is missing", async () => {
const tasks = [
createMockTask({ id: "KB-002", column: "todo", dependencies: [] }),
createMockTask({ id: "FN-002", column: "todo", dependencies: [] }),
];
const store = createMockStore({
@@ -322,7 +322,7 @@ describe("Scheduler", () => {
// Mock directory exists but PROMPT.md doesn't
vi.mocked(existsSync).mockImplementation((path) => {
if (typeof path === "string" && path.includes("KB-002") && !path.endsWith("PROMPT.md")) {
if (typeof path === "string" && path.includes("FN-002") && !path.endsWith("PROMPT.md")) {
return true; // Directory exists
}
return false; // PROMPT.md missing
@@ -335,9 +335,9 @@ describe("Scheduler", () => {
// Flush any remaining microtasks
await new Promise(resolve => setTimeout(resolve, 0));
expect(moveTask).toHaveBeenCalledWith("KB-002", "triage");
expect(moveTask).toHaveBeenCalledWith("FN-002", "triage");
expect(logEntry).toHaveBeenCalledWith(
"KB-002",
"FN-002",
"Task moved to triage — filesystem validation failed",
"missing or empty PROMPT.md"
);
@@ -345,7 +345,7 @@ describe("Scheduler", () => {
it("moves task to triage when PROMPT.md is empty", async () => {
const tasks = [
createMockTask({ id: "KB-003", column: "todo", dependencies: [] }),
createMockTask({ id: "FN-003", column: "todo", dependencies: [] }),
];
const store = createMockStore({
@@ -372,9 +372,9 @@ describe("Scheduler", () => {
// Flush any remaining microtasks
await new Promise(resolve => setTimeout(resolve, 0));
expect(moveTask).toHaveBeenCalledWith("KB-003", "triage");
expect(moveTask).toHaveBeenCalledWith("FN-003", "triage");
expect(logEntry).toHaveBeenCalledWith(
"KB-003",
"FN-003",
"Task moved to triage — filesystem validation failed",
"missing or empty PROMPT.md"
);
@@ -382,7 +382,7 @@ describe("Scheduler", () => {
it("proceeds with scheduling when filesystem is valid", async () => {
const tasks = [
createMockTask({ id: "KB-004", column: "todo", dependencies: [] }),
createMockTask({ id: "FN-004", column: "todo", dependencies: [] }),
];
const store = createMockStore({
@@ -409,21 +409,21 @@ describe("Scheduler", () => {
await new Promise(resolve => setTimeout(resolve, 0));
// Should NOT move to triage
expect(moveTask).not.toHaveBeenCalledWith("KB-004", "triage");
expect(moveTask).not.toHaveBeenCalledWith("FN-004", "triage");
// Should NOT log validation failure
expect(logEntry).not.toHaveBeenCalledWith(
"KB-004",
"FN-004",
"Task moved to triage — filesystem validation failed",
expect.any(String)
);
// Should move to in-progress (since deps are satisfied and concurrency allows)
expect(moveTask).toHaveBeenCalledWith("KB-004", "in-progress");
expect(moveTask).toHaveBeenCalledWith("FN-004", "in-progress");
});
it("does not validate filesystem for tasks with unmet dependencies", async () => {
const tasks = [
createMockTask({ id: "KB-005", column: "todo", dependencies: ["KB-006"] }),
createMockTask({ id: "KB-006", column: "todo", dependencies: [] }), // Unsatisfied dep
createMockTask({ id: "FN-005", column: "todo", dependencies: ["FN-006"] }),
createMockTask({ id: "FN-006", column: "todo", dependencies: [] }), // Unsatisfied dep
];
const store = createMockStore({
@@ -450,9 +450,9 @@ describe("Scheduler", () => {
// Task with unmet deps should be queued, not validated
// Since KB-006 is not done, KB-005 should not be validated
expect(updateTask).toHaveBeenCalledWith("KB-005", { status: "queued" });
expect(updateTask).toHaveBeenCalledWith("FN-005", { status: "queued" });
// No filesystem validation should occur (no move to triage)
expect(moveTask).not.toHaveBeenCalledWith("KB-005", "triage");
expect(moveTask).not.toHaveBeenCalledWith("FN-005", "triage");
});
});
});

View File

@@ -253,7 +253,7 @@ export class Scheduler {
for (const depId of task.dependencies) {
const dep = allTasks.find((t) => t.id === depId);
if (dep && dep.column === "in-review" && dep.worktree) {
return `kb/${dep.id.toLowerCase()}`;
return `fusion/${dep.id.toLowerCase()}`;
}
}
@@ -261,7 +261,7 @@ export class Scheduler {
if (task.blockedBy) {
const blocker = allTasks.find((t) => t.id === task.blockedBy);
if (blocker && blocker.column === "in-review" && blocker.worktree) {
return `kb/${blocker.id.toLowerCase()}`;
return `fusion/${blocker.id.toLowerCase()}`;
}
}

View File

@@ -73,40 +73,40 @@ describe("StuckTaskDetector", () => {
describe("trackTask", () => {
it("adds task to tracking", () => {
const session = createMockSession();
detector.trackTask("KB-001", session);
detector.trackTask("FN-001", session);
expect(detector.trackedCount).toBe(1);
});
it("sets initial activity timestamp", () => {
const session = createMockSession();
const before = Date.now();
detector.trackTask("KB-001", session);
detector.trackTask("FN-001", session);
const after = Date.now();
const lastActivity = detector.getLastActivity("KB-001");
const lastActivity = detector.getLastActivity("FN-001");
expect(lastActivity).toBeDefined();
expect(lastActivity).toBeGreaterThanOrEqual(before);
expect(lastActivity).toBeLessThanOrEqual(after);
});
it("can track multiple tasks", () => {
detector.trackTask("KB-001", createMockSession());
detector.trackTask("KB-002", createMockSession());
detector.trackTask("FN-001", createMockSession());
detector.trackTask("FN-002", createMockSession());
expect(detector.trackedCount).toBe(2);
});
});
describe("untrackTask", () => {
it("removes task from tracking", () => {
detector.trackTask("KB-001", createMockSession());
detector.trackTask("FN-001", createMockSession());
expect(detector.trackedCount).toBe(1);
detector.untrackTask("KB-001");
detector.untrackTask("FN-001");
expect(detector.trackedCount).toBe(0);
});
it("is safe to untrack untracked task", () => {
detector.untrackTask("KB-001");
detector.untrackTask("FN-001");
expect(detector.trackedCount).toBe(0);
});
});
@@ -116,14 +116,14 @@ describe("StuckTaskDetector", () => {
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
detector.trackTask("KB-001", session);
const initialActivity = detector.getLastActivity("KB-001")!;
detector.trackTask("FN-001", session);
const initialActivity = detector.getLastActivity("FN-001")!;
// Advance time
vi.advanceTimersByTime(10);
detector.recordActivity("KB-001");
detector.recordActivity("FN-001");
const newActivity = detector.getLastActivity("KB-001")!;
const newActivity = detector.getLastActivity("FN-001")!;
expect(newActivity).toBeGreaterThanOrEqual(initialActivity);
vi.useRealTimers();
@@ -131,44 +131,44 @@ describe("StuckTaskDetector", () => {
it("does nothing for untracked task", () => {
// Should not throw
detector.recordActivity("KB-001");
detector.recordActivity("FN-001");
});
});
describe("isStuck", () => {
it("returns false when no timeout exceeded", () => {
const session = createMockSession();
detector.trackTask("KB-001", session);
detector.trackTask("FN-001", session);
expect(detector.isStuck("KB-001", 60000)).toBe(false);
expect(detector.isStuck("FN-001", 60000)).toBe(false);
});
it("returns true when timeout exceeded", () => {
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
detector.trackTask("KB-001", session);
detector.trackTask("FN-001", session);
vi.advanceTimersByTime(61000); // Advance 61 seconds
expect(detector.isStuck("KB-001", 60000)).toBe(true);
expect(detector.isStuck("FN-001", 60000)).toBe(true);
vi.useRealTimers();
});
it("returns false for untracked task", () => {
expect(detector.isStuck("KB-001", 60000)).toBe(false);
expect(detector.isStuck("FN-001", 60000)).toBe(false);
});
});
describe("killAndRetry", () => {
it("disposes the session", async () => {
const session = createMockSession();
detector.trackTask("KB-001", session);
detector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await detector.killAndRetry("KB-001", 60000);
await detector.killAndRetry("FN-001", 60000);
expect(session.dispose).toHaveBeenCalled();
@@ -177,13 +177,13 @@ describe("StuckTaskDetector", () => {
it("removes task from tracking", async () => {
const session = createMockSession();
detector.trackTask("KB-001", session);
detector.trackTask("FN-001", session);
expect(detector.trackedCount).toBe(1);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await detector.killAndRetry("KB-001", 60000);
await detector.killAndRetry("FN-001", 60000);
expect(detector.trackedCount).toBe(0);
@@ -192,15 +192,15 @@ describe("StuckTaskDetector", () => {
it("logs to task log", async () => {
const session = createMockSession();
detector.trackTask("KB-001", session);
detector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await detector.killAndRetry("KB-001", 60000);
await detector.killAndRetry("FN-001", 60000);
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"FN-001",
expect.stringContaining("Task terminated due to stuck agent session")
);
@@ -209,15 +209,15 @@ describe("StuckTaskDetector", () => {
it("updates task status and moves to todo", async () => {
const session = createMockSession();
detector.trackTask("KB-001", session);
detector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await detector.killAndRetry("KB-001", 60000);
await detector.killAndRetry("FN-001", 60000);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "stuck-killed" });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "stuck-killed" });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
vi.useRealTimers();
});
@@ -227,20 +227,20 @@ describe("StuckTaskDetector", () => {
const customDetector = new StuckTaskDetector(store, { onStuck });
const session = createMockSession();
customDetector.trackTask("KB-001", session);
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await customDetector.killAndRetry("KB-001", 60000);
await customDetector.killAndRetry("FN-001", 60000);
expect(onStuck).toHaveBeenCalledWith("KB-001");
expect(onStuck).toHaveBeenCalledWith("FN-001");
vi.useRealTimers();
});
it("does nothing for untracked task", async () => {
await detector.killAndRetry("KB-001", 60000);
await detector.killAndRetry("FN-001", 60000);
// Should not throw
expect(store.moveTask).not.toHaveBeenCalled();
});
@@ -254,14 +254,14 @@ describe("StuckTaskDetector", () => {
const customDetector = new StuckTaskDetector(store);
const session = createMockSession();
customDetector.trackTask("KB-001", session);
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await customDetector.checkNow();
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
vi.useRealTimers();
});
@@ -292,7 +292,7 @@ describe("StuckTaskDetector", () => {
const customDetector = new StuckTaskDetector(store);
const session = createMockSession();
customDetector.trackTask("KB-001", session);
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
@@ -311,7 +311,7 @@ describe("StuckTaskDetector", () => {
const customDetector = new StuckTaskDetector(store);
const session = createMockSession();
customDetector.trackTask("KB-001", session);
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
@@ -330,7 +330,7 @@ describe("StuckTaskDetector", () => {
const customDetector = new StuckTaskDetector(store);
const session = createMockSession();
customDetector.trackTask("KB-001", session);
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);

View File

@@ -41,7 +41,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
}
const mockTaskDetail: TaskDetail = {
id: "KB-001",
id: "FN-001",
description: "Test task description",
column: "triage",
dependencies: [],
@@ -67,7 +67,7 @@ describe("buildSpecificationPrompt", () => {
);
expect(prompt).toContain("Specify this task");
expect(prompt).toContain("KB-001");
expect(prompt).toContain("FN-001");
expect(prompt).toContain("Test Task");
expect(prompt).toContain("Test task description");
expect(prompt).toContain(".kb/tasks/KB-001/PROMPT.md");
@@ -147,7 +147,7 @@ describe("buildSpecificationPrompt", () => {
it("includes dependencies when present", () => {
const taskWithDeps: TaskDetail = {
...baseTask,
dependencies: ["KB-002", "KB-003"],
dependencies: ["FN-002", "FN-003"],
};
const prompt = buildSpecificationPrompt(
@@ -156,7 +156,7 @@ describe("buildSpecificationPrompt", () => {
);
expect(prompt).toContain("Dependencies");
expect(prompt).toContain("KB-002, KB-003");
expect(prompt).toContain("FN-002, FN-003");
});
it("handles task without title", () => {
@@ -216,7 +216,7 @@ describe("TRIAGE_SYSTEM_PROMPT", () => {
describe("readAttachmentContents", () => {
const testDir = join(__dirname, "test-attachments");
const taskId = "KB-TEST";
const taskId = "FN-TEST";
beforeEach(async () => {
// Clean up and create test directory
@@ -376,7 +376,7 @@ describe("TriageProcessor", () => {
describe("Re-specification flow", () => {
const taskWithRevisionRequest: Task = {
id: "KB-001",
id: "FN-001",
description: "Test task",
column: "triage",
dependencies: [],
@@ -444,12 +444,12 @@ describe("requirePlanApproval setting", () => {
});
it("sets awaiting-approval status instead of moving to todo when requirePlanApproval is true", async () => {
const taskDir = join(rootDir, ".kb", "tasks", "KB-001");
const taskDir = join(rootDir, ".kb", "tasks", "FN-001");
await mkdir(taskDir, { recursive: true });
await writeFile(
join(taskDir, "task.json"),
JSON.stringify({
id: "KB-001",
id: "FN-001",
description: "Test task",
column: "triage",
dependencies: [],
@@ -480,7 +480,7 @@ describe("requirePlanApproval setting", () => {
}),
listTasks: vi.fn().mockResolvedValue([
{
id: "KB-001",
id: "FN-001",
description: "Test task",
column: "triage",
dependencies: [],
@@ -539,7 +539,7 @@ describe("requirePlanApproval setting", () => {
describe("taskCreate tool model inheritance", () => {
it("inherits parent task model settings when creating subtasks", async () => {
const parentTask: Task = {
id: "KB-001",
id: "FN-001",
description: "Parent task",
column: "triage",
dependencies: [],
@@ -555,7 +555,7 @@ describe("taskCreate tool model inheritance", () => {
};
const createdSubtask: Task = {
id: "KB-002",
id: "FN-002",
description: "Child task description",
column: "triage",
dependencies: [],
@@ -572,7 +572,7 @@ describe("taskCreate tool model inheritance", () => {
});
// Simulate the taskCreate tool behavior
const parentTaskId = "KB-001";
const parentTaskId = "FN-001";
const parentTaskResult = await store.getTask(parentTaskId);
await store.createTask({
@@ -586,7 +586,7 @@ describe("taskCreate tool model inheritance", () => {
validatorModelId: parentTaskResult?.validatorModelId,
});
expect(store.getTask).toHaveBeenCalledWith("KB-001");
expect(store.getTask).toHaveBeenCalledWith("FN-001");
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "Child Task",
modelProvider: "anthropic",
@@ -598,7 +598,7 @@ describe("taskCreate tool model inheritance", () => {
it("handles missing parent task gracefully when creating subtasks", async () => {
const createdSubtask: Task = {
id: "KB-002",
id: "FN-002",
description: "Child task description",
column: "triage",
dependencies: [],
@@ -615,7 +615,7 @@ describe("taskCreate tool model inheritance", () => {
});
// Simulate the taskCreate tool behavior with missing parent
const parentTaskId = "KB-NONEXISTENT";
const parentTaskId = "FN-NONEXISTENT";
let parentTask;
try {
parentTask = await store.getTask(parentTaskId);
@@ -634,7 +634,7 @@ describe("taskCreate tool model inheritance", () => {
validatorModelId: parentTask?.validatorModelId,
});
expect(store.getTask).toHaveBeenCalledWith("KB-NONEXISTENT");
expect(store.getTask).toHaveBeenCalledWith("FN-NONEXISTENT");
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
modelProvider: undefined,
modelId: undefined,

View File

@@ -145,7 +145,7 @@ describe("UsageLimitPauser", () => {
const store = createMockStore();
const pauser = new UsageLimitPauser(store);
await pauser.onUsageLimitHit("executor", "KB-001", "rate_limit_error: Rate limit exceeded");
await pauser.onUsageLimitHit("executor", "FN-001", "rate_limit_error: Rate limit exceeded");
expect(store.updateSettings).toHaveBeenCalledWith({ globalPause: true });
});
@@ -154,10 +154,10 @@ describe("UsageLimitPauser", () => {
const store = createMockStore();
const pauser = new UsageLimitPauser(store);
await pauser.onUsageLimitHit("triage", "KB-002", "overloaded_error");
await pauser.onUsageLimitHit("triage", "FN-002", "overloaded_error");
expect(store.logEntry).toHaveBeenCalledWith(
"KB-002",
"FN-002",
"Usage limit detected (triage): overloaded_error",
);
});
@@ -169,9 +169,9 @@ describe("UsageLimitPauser", () => {
const pauser = new UsageLimitPauser(store);
await pauser.onUsageLimitHit("executor", "KB-001", "rate limit");
await pauser.onUsageLimitHit("triage", "KB-002", "rate limit");
await pauser.onUsageLimitHit("merger", "KB-003", "rate limit");
await pauser.onUsageLimitHit("executor", "FN-001", "rate limit");
await pauser.onUsageLimitHit("triage", "FN-002", "rate limit");
await pauser.onUsageLimitHit("merger", "FN-003", "rate limit");
// updateSettings should only be called once
expect(store.updateSettings).toHaveBeenCalledTimes(1);
@@ -183,14 +183,14 @@ describe("UsageLimitPauser", () => {
// First hit — triggers pause
store.getSettings.mockResolvedValue({ globalPause: true });
await pauser.onUsageLimitHit("executor", "KB-001", "rate limit");
await pauser.onUsageLimitHit("executor", "FN-001", "rate limit");
expect(store.updateSettings).toHaveBeenCalledTimes(1);
// External reset: globalPause set to false
store.getSettings.mockResolvedValue({ globalPause: false });
// Second hit — should trigger again since it was reset
await pauser.onUsageLimitHit("executor", "KB-004", "rate limit again");
await pauser.onUsageLimitHit("executor", "FN-004", "rate limit again");
expect(store.updateSettings).toHaveBeenCalledTimes(2);
});
@@ -198,10 +198,10 @@ describe("UsageLimitPauser", () => {
const store = createMockStore();
const pauser = new UsageLimitPauser(store);
await pauser.onUsageLimitHit("merger", "KB-005", "quota exceeded");
await pauser.onUsageLimitHit("merger", "FN-005", "quota exceeded");
expect(store.logEntry).toHaveBeenCalledWith(
"KB-005",
"FN-005",
expect.stringContaining("merger"),
);
});

View File

@@ -123,7 +123,7 @@ describe("WorktreePool", () => {
describe("prepareForTask", () => {
it("cleans dirty working tree before checkout", () => {
pool.prepareForTask("/tmp/wt", "kb/kb-042");
pool.prepareForTask("/tmp/wt", "fusion/fn-042");
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
expect(calls).toContain("git checkout -- .");
@@ -131,18 +131,18 @@ describe("WorktreePool", () => {
});
it("creates branch from main with force-reset", () => {
pool.prepareForTask("/tmp/wt", "kb/kb-042");
pool.prepareForTask("/tmp/wt", "fusion/fn-042");
const checkoutCall = mockedExecSync.mock.calls.find(
(c) => typeof c[0] === "string" && (c[0] as string).includes("checkout -B"),
);
expect(checkoutCall).toBeDefined();
expect(checkoutCall![0]).toBe('git checkout -B "kb/kb-042" main');
expect(checkoutCall![0]).toBe('git checkout -B "fusion/fn-042" main');
expect(checkoutCall![1]).toMatchObject({ cwd: "/tmp/wt" });
});
it("runs all commands in the correct worktree directory", () => {
pool.prepareForTask("/tmp/my-worktree", "kb/kb-099");
pool.prepareForTask("/tmp/my-worktree", "fusion/fn-099");
for (const call of mockedExecSync.mock.calls) {
expect(call[1]).toMatchObject({ cwd: "/tmp/my-worktree" });
@@ -150,13 +150,13 @@ describe("WorktreePool", () => {
});
it("creates branch from custom startPoint when provided", () => {
pool.prepareForTask("/tmp/wt", "kb/kb-042", "kb/kb-041");
pool.prepareForTask("/tmp/wt", "fusion/fn-042", "fusion/fn-041");
const checkoutCall = mockedExecSync.mock.calls.find(
(c) => typeof c[0] === "string" && (c[0] as string).includes("checkout -B"),
);
expect(checkoutCall).toBeDefined();
expect(checkoutCall![0]).toBe('git checkout -B "kb/kb-042" kb/kb-041');
expect(checkoutCall![0]).toBe('git checkout -B "fusion/fn-042" kb/kb-041');
});
it("tolerates git checkout -- . failure (already clean)", () => {
@@ -166,12 +166,12 @@ describe("WorktreePool", () => {
});
// Should not throw
expect(() => pool.prepareForTask("/tmp/wt", "kb/kb-001")).not.toThrow();
expect(() => pool.prepareForTask("/tmp/wt", "fusion/fn-001")).not.toThrow();
// Should still run clean and branch creation
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
expect(calls).toContain("git clean -fd");
expect(calls).toContain('git checkout -B "kb/kb-001" main');
expect(calls).toContain('git checkout -B "fusion/fn-001" main');
});
});
@@ -251,8 +251,8 @@ describe("scanIdleWorktrees", () => {
] as any);
const store = createMockStore([
makeTask("KB-001", "in-progress", "/root/.worktrees/swift-falcon"),
makeTask("KB-002", "done", "/root/.worktrees/calm-river"),
makeTask("FN-001", "in-progress", "/root/.worktrees/swift-falcon"),
makeTask("FN-002", "done", "/root/.worktrees/calm-river"),
]);
const idle = await scanIdleWorktrees("/root", store);
@@ -287,7 +287,7 @@ describe("scanIdleWorktrees", () => {
] as any);
const store = createMockStore([
makeTask("KB-010", "in-review", "/root/.worktrees/review-wt"),
makeTask("FN-010", "in-review", "/root/.worktrees/review-wt"),
]);
const idle = await scanIdleWorktrees("/root", store);
@@ -354,7 +354,7 @@ describe("cleanupOrphanedWorktrees", () => {
] as any);
const store = createMockStore([
makeTask("KB-001", "in-progress", "/root/.worktrees/active-wt"),
makeTask("FN-001", "in-progress", "/root/.worktrees/active-wt"),
]);
const cleaned = await cleanupOrphanedWorktrees("/root", store);
@@ -405,8 +405,8 @@ describe("cleanupOrphanedWorktrees", () => {
] as any);
const store = createMockStore([
makeTask("KB-001", "in-progress", "/root/.worktrees/active-1"),
makeTask("KB-002", "in-review", "/root/.worktrees/active-2"),
makeTask("FN-001", "in-progress", "/root/.worktrees/active-1"),
makeTask("FN-002", "in-review", "/root/.worktrees/active-2"),
]);
const cleaned = await cleanupOrphanedWorktrees("/root", store);

View File

@@ -111,8 +111,8 @@ export class WorktreePool {
* 3. `git checkout -B <branchName> <startPoint>` — create/reset branch from start point
*
* @param worktreePath — Absolute path to the recycled worktree
* @param branchName — Branch name for the new task (e.g., `kb/kb-042`)
* @param startPoint — Git ref to branch from (e.g., `kb/kb-041`). Defaults to `main`.
* @param branchName — Branch name for the new task (e.g., `fusion/fn-042`)
* @param startPoint — Git ref to branch from (e.g., `fusion/fn-041`). Defaults to `main`.
*/
prepareForTask(worktreePath: string, branchName: string, startPoint?: string): void {
// Clean tracked modifications