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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user