- Add executor regression tests for fast-mode completion paths, including tool injection, workflow-step bypass, and completion enforcement checks - Expand dashboard API tests to verify executionMode payload behavior for createTask and updateTask requests - Document fast-mode behavior across architecture, task management, and workflow-step docs with explicit gate bypass/enforcement details
11772 lines
389 KiB
TypeScript
11772 lines
389 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { AgentSemaphore } from "./concurrency.js";
|
|
import { detectReviewHandoffIntent, determineRevisionResetStart } from "./executor.js";
|
|
|
|
// Mock external dependencies
|
|
vi.mock("./pi.js", () => ({
|
|
createFnAgent: vi.fn(),
|
|
describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"),
|
|
compactSessionContext: vi.fn(async (session, instructions) => {
|
|
// Delegate to session.compact if available (supports loop recovery tests)
|
|
if (typeof (session as any).compact === "function") {
|
|
return (session as any).compact(instructions);
|
|
}
|
|
return null;
|
|
}),
|
|
promptWithFallback: vi.fn(async (session, prompt, options) => {
|
|
if (options === undefined) {
|
|
await session.prompt(prompt);
|
|
} else {
|
|
await session.prompt(prompt, options);
|
|
}
|
|
}),
|
|
}));
|
|
vi.mock("./reviewer.js", () => ({
|
|
reviewStep: vi.fn(),
|
|
}));
|
|
vi.mock("./logger.js", () => {
|
|
const createMockLogger = () => ({
|
|
log: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
});
|
|
return {
|
|
createLogger: vi.fn(() => createMockLogger()),
|
|
schedulerLog: createMockLogger(),
|
|
executorLog: createMockLogger(),
|
|
triageLog: createMockLogger(),
|
|
mergerLog: createMockLogger(),
|
|
worktreePoolLog: createMockLogger(),
|
|
reviewerLog: createMockLogger(),
|
|
prMonitorLog: createMockLogger(),
|
|
runtimeLog: createMockLogger(),
|
|
ipcLog: createMockLogger(),
|
|
projectManagerLog: createMockLogger(),
|
|
hybridExecutorLog: createMockLogger(),
|
|
};
|
|
});
|
|
vi.mock("./merger.js", () => ({
|
|
aiMergeTask: vi.fn(),
|
|
findWorktreeUser: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
vi.mock("./agent-session-helpers.js", async () => {
|
|
const { createFnAgent } = await import("./pi.js");
|
|
return {
|
|
createResolvedAgentSession: async (options: any) => {
|
|
const result = await createFnAgent(options);
|
|
return {
|
|
session: result.session,
|
|
sessionFile: result.sessionFile,
|
|
runtimeId: "pi",
|
|
wasConfigured: false,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
vi.mock("./worktree-names.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./worktree-names.js")>("./worktree-names.js");
|
|
return {
|
|
...actual,
|
|
generateWorktreeName: vi.fn().mockReturnValue("swift-falcon"),
|
|
};
|
|
});
|
|
|
|
// Mock node modules used by executor.
|
|
// Route async `exec` through the `execSync` mock so existing tests that set
|
|
// up mockedExecSync.mockImplementation for user commands (worktreeInitCommand,
|
|
// workflow step scripts, etc.) keep working unchanged after the switch to
|
|
// promisify(exec) in executor.ts.
|
|
vi.mock("node:child_process", async () => {
|
|
const { promisify } = await import("node:util");
|
|
const { EventEmitter } = await import("node:events");
|
|
const execSyncFn = vi.fn();
|
|
const spawnFn = vi.fn((cmd: string, opts?: any) => {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.pid = 12345;
|
|
child.exitCode = null;
|
|
child.signalCode = null;
|
|
child.kill = vi.fn();
|
|
queueMicrotask(() => {
|
|
try {
|
|
const out = execSyncFn(cmd, opts);
|
|
const stdout = out === undefined ? "" : out.toString();
|
|
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
|
|
child.exitCode = 0;
|
|
child.emit("close", 0, null);
|
|
} catch (err) {
|
|
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
|
|
const stdout = error?.stdout?.toString?.() ?? "";
|
|
const stderr = error?.stderr?.toString?.() ?? "";
|
|
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
|
|
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
|
|
child.exitCode = error.status ?? error.code ?? 1;
|
|
child.emit("close", child.exitCode, null);
|
|
}
|
|
});
|
|
return child;
|
|
});
|
|
|
|
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
|
|
const callback = typeof opts === "function" ? opts : cb;
|
|
const forwardedOpts = typeof opts === "function" ? undefined : opts;
|
|
try {
|
|
const out = execSyncFn(cmd, forwardedOpts);
|
|
const stdout = out === undefined ? "" : out.toString();
|
|
if (typeof callback === "function") callback(null, stdout, "");
|
|
} catch (err) {
|
|
if (typeof callback === "function") {
|
|
const error = err as { stdout?: string; stderr?: string };
|
|
callback(err, error?.stdout?.toString?.() ?? "", error?.stderr?.toString?.() ?? "");
|
|
}
|
|
}
|
|
});
|
|
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
|
|
|
|
execFn[promisify.custom] = (cmd: string, opts?: any) =>
|
|
new Promise((resolve, reject) => {
|
|
|
|
execFn(cmd, opts, (err: any, stdout: string, stderr: string) => {
|
|
if (err) {
|
|
(err as Record<string, unknown>).stdout = stdout;
|
|
(err as Record<string, unknown>).stderr = stderr;
|
|
reject(err);
|
|
} else {
|
|
resolve({ stdout, stderr });
|
|
}
|
|
});
|
|
});
|
|
return { execSync: execSyncFn, exec: execFn, spawn: spawnFn };
|
|
});
|
|
vi.mock("node:fs", () => ({
|
|
existsSync: vi.fn().mockReturnValue(true),
|
|
}));
|
|
|
|
// Mock StepSessionExecutor for integration tests
|
|
const mockExecuteAll = vi.fn().mockResolvedValue([]);
|
|
const mockTerminateAllSessions = vi.fn().mockResolvedValue(undefined);
|
|
const mockCleanup = vi.fn().mockResolvedValue(undefined);
|
|
|
|
vi.mock("./step-session-executor.js", () => ({
|
|
StepSessionExecutor: vi.fn().mockImplementation(() => ({
|
|
executeAll: mockExecuteAll,
|
|
terminateAllSessions: mockTerminateAllSessions,
|
|
cleanup: mockCleanup,
|
|
})),
|
|
}));
|
|
|
|
vi.mock("./rate-limit-retry.js", () => ({
|
|
withRateLimitRetry: vi.fn((fn: () => Promise<unknown>) => fn()),
|
|
}));
|
|
vi.mock("@mariozechner/pi-coding-agent", () => {
|
|
const mockSessionManager = {};
|
|
return {
|
|
SessionManager: {
|
|
create: vi.fn().mockReturnValue(mockSessionManager),
|
|
open: vi.fn().mockReturnValue(mockSessionManager),
|
|
inMemory: vi.fn().mockReturnValue(mockSessionManager),
|
|
},
|
|
ModelRegistry: vi.fn().mockImplementation(() => ({
|
|
find: vi.fn(),
|
|
refresh: vi.fn(),
|
|
})),
|
|
AuthStorage: {
|
|
create: vi.fn().mockReturnValue({}),
|
|
},
|
|
getAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
|
|
};
|
|
});
|
|
|
|
import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
|
|
import { createFnAgent } from "./pi.js";
|
|
import { reviewStep as mockedReviewStepFn } from "./reviewer.js";
|
|
import { execSync } from "node:child_process";
|
|
import { findWorktreeUser, aiMergeTask } from "./merger.js";
|
|
import { WorktreePool } from "./worktree-pool.js";
|
|
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
|
import type { Task, TaskDetail } from "@fusion/core";
|
|
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
import { StepSessionExecutor } from "./step-session-executor.js";
|
|
import { executorLog } from "./logger.js";
|
|
import { withRateLimitRetry } from "./rate-limit-retry.js";
|
|
|
|
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
|
const mockedSessionManager = vi.mocked(SessionManager);
|
|
const mockedGenerateWorktreeName = vi.mocked(generateWorktreeName);
|
|
const mockedFindWorktreeUser = vi.mocked(findWorktreeUser);
|
|
const mockedStepSessionExecutor = vi.mocked(StepSessionExecutor);
|
|
const mockedWithRateLimitRetry = vi.mocked(withRateLimitRetry);
|
|
|
|
type EventListener = (...args: unknown[]) => void;
|
|
|
|
function createMockStore() {
|
|
const listeners = new Map<string, EventListener[]>();
|
|
const store = {
|
|
on: vi.fn((event: string, fn: EventListener) => {
|
|
const existing = listeners.get(event) || [];
|
|
existing.push(fn);
|
|
listeners.set(event, existing);
|
|
}),
|
|
/** Trigger registered listeners for an event (test helper). */
|
|
_trigger(event: string, ...args: unknown[]) {
|
|
for (const fn of listeners.get(event) || []) fn(...args);
|
|
},
|
|
emit: vi.fn(),
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
updateTask: vi.fn().mockResolvedValue({}),
|
|
moveTask: vi.fn().mockResolvedValue({}),
|
|
mergeTask: vi.fn().mockResolvedValue({}),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
addTaskComment: vi.fn().mockResolvedValue(undefined),
|
|
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
|
updateSettings: vi.fn().mockResolvedValue({}),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: undefined,
|
|
}),
|
|
updateStep: vi.fn().mockResolvedValue({}),
|
|
getWorkflowStep: vi.fn().mockResolvedValue(undefined),
|
|
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
|
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
|
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
|
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
|
|
};
|
|
return store as any;
|
|
}
|
|
|
|
describe("TaskExecutor with semaphore", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("acquires semaphore before creating agent and releases after", async () => {
|
|
const sem = new AgentSemaphore(2);
|
|
const store = createMockStore();
|
|
const acquireSpy = vi.spyOn(sem, "acquire");
|
|
const releaseSpy = vi.spyOn(sem, "release");
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(acquireSpy).toHaveBeenCalledOnce();
|
|
expect(releaseSpy).toHaveBeenCalledOnce();
|
|
expect(sem.activeCount).toBe(0);
|
|
});
|
|
|
|
it("releases semaphore on agent error", async () => {
|
|
const sem = new AgentSemaphore(1);
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("agent failed"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
semaphore: sem,
|
|
onError,
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(sem.activeCount).toBe(0);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("sets task status to 'failed' with error message when execution throws", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("agent crashed"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: expect.any(String) });
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("concurrent executions respect semaphore limit", async () => {
|
|
const sem = new AgentSemaphore(1);
|
|
const store = createMockStore();
|
|
let concurrent = 0;
|
|
let maxConcurrent = 0;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => {
|
|
concurrent++;
|
|
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
concurrent--;
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
|
|
|
|
const task = (id: string) => ({
|
|
id,
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
await Promise.all([
|
|
executor.execute(task("FN-001")),
|
|
executor.execute(task("FN-002")),
|
|
executor.execute(task("FN-003")),
|
|
]);
|
|
|
|
expect(maxConcurrent).toBe(1);
|
|
expect(sem.activeCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
const mockedExecSync = vi.mocked(execSync);
|
|
const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
|
|
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
|
|
|
describe("TaskExecutor worktreeInitCommand", () => {
|
|
const makeTask = (id = "FN-010") => ({
|
|
id,
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Default: worktree does NOT exist (new worktree)
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
it("runs worktreeInitCommand in new worktree when configured", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: "pnpm install",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// execSync is called for worktree creation + init command
|
|
const initCall = mockedExecSync.mock.calls.find(
|
|
(call) => call[0] === "pnpm install",
|
|
);
|
|
expect(initCall).toBeDefined();
|
|
expect(initCall![1]).toMatchObject({
|
|
cwd: expect.stringContaining(".worktrees/"),
|
|
timeout: 300_000,
|
|
});
|
|
|
|
// Should log success
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-010",
|
|
"Worktree init command completed",
|
|
"pnpm install",
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("does NOT run init command when worktreeInitCommand is not set", async () => {
|
|
const store = createMockStore();
|
|
// getSettings returns default (no worktreeInitCommand)
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Only worktree creation calls to execSync, no "pnpm install" etc.
|
|
const initCall = mockedExecSync.mock.calls.find(
|
|
(call) => typeof call[0] === "string" && !call[0].startsWith("git"),
|
|
);
|
|
expect(initCall).toBeUndefined();
|
|
});
|
|
|
|
it("catches init command failure and logs without aborting", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: "npm run setup",
|
|
});
|
|
|
|
// Make the init command fail (but not git worktree commands)
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (cmd === "npm run setup") {
|
|
const err: any = new Error("command failed");
|
|
err.stderr = Buffer.from("setup script error");
|
|
throw err;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should log the failure
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-010",
|
|
expect.stringContaining("Worktree init command failed"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
|
|
// The init command failure itself does not abort execution, but the mocked
|
|
// agent still exits without task_done. After the retry also fails, it reports an error.
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-010" }),
|
|
expect.objectContaining({ message: "Agent finished without calling task_done (after retry)" }),
|
|
);
|
|
|
|
// Agent should still have been created
|
|
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT run init command on worktree resume", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: "pnpm install",
|
|
});
|
|
|
|
// Worktree already exists (resume)
|
|
mockedExistsSync.mockReturnValue(true);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// getSettings is called (for project commands in execution prompt) but init command should not run
|
|
expect(store.getSettings).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor worktree naming", () => {
|
|
const makeTask = (id = "FN-030", worktree?: string) => ({
|
|
id,
|
|
title: "Test Task Title",
|
|
description: "Test description for task",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...(worktree ? { worktree } : {}),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedGenerateWorktreeName.mockReturnValue("swift-falcon");
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
it("uses generateWorktreeName for fresh worktree directories", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask());
|
|
|
|
// The worktree path stored should use the generated name, not the task ID
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-030", {
|
|
worktree: "/tmp/test/.worktrees/swift-falcon",
|
|
branch: "fusion/fn-030",
|
|
});
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
|
});
|
|
|
|
it("does NOT use task ID as worktree directory name for fresh worktrees", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask("FN-099"));
|
|
|
|
// Verify the worktree path does NOT contain the task ID
|
|
const updateCalls = store.updateTask.mock.calls;
|
|
const worktreeUpdate = updateCalls.find(
|
|
(call: any[]) => call[1]?.worktree !== undefined,
|
|
);
|
|
expect(worktreeUpdate).toBeDefined();
|
|
expect(worktreeUpdate![1].worktree).not.toContain("FN-099");
|
|
expect(worktreeUpdate![1].worktree).toContain("swift-falcon");
|
|
});
|
|
|
|
it("reuses stored worktree path for resumed tasks", async () => {
|
|
const existingPath = "/tmp/test/.worktrees/calm-river";
|
|
mockedExistsSync.mockReturnValue(true);
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (String(cmd) === "git worktree list --porcelain") {
|
|
return [
|
|
"worktree /tmp/test",
|
|
"HEAD abc123",
|
|
"branch refs/heads/main",
|
|
"",
|
|
`worktree ${existingPath}`,
|
|
"HEAD def456",
|
|
"branch refs/heads/fusion/fn-031",
|
|
"",
|
|
].join("\n") as any;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask("FN-031", existingPath));
|
|
|
|
// Should NOT generate a new name — reuse the stored path
|
|
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not reuse a stored worktree path that is not registered", async () => {
|
|
const stalePath = "/tmp/test/.worktrees/broken-wt";
|
|
mockedExistsSync.mockImplementation((path) => String(path).startsWith(stalePath));
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (String(cmd) === "git worktree list --porcelain") {
|
|
return "worktree /tmp/test\nHEAD abc123\nbranch refs/heads/main\n" as any;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask("FN-032", stalePath));
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-032", { worktree: null, branch: null });
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
describe("worktreeNaming setting", () => {
|
|
it("uses task ID as worktree name when worktreeNaming is 'task-id'", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeNaming: "task-id",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask("FN-042"));
|
|
|
|
// Should use task ID (lowercase) as worktree name
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-042", {
|
|
worktree: "/tmp/test/.worktrees/fn-042",
|
|
branch: "fusion/fn-042",
|
|
});
|
|
// Should NOT call generateWorktreeName when using task-id
|
|
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses slugified task title as worktree name when worktreeNaming is 'task-title'", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeNaming: "task-title",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
...makeTask("FN-043"),
|
|
title: "Fix login bug with OAuth",
|
|
});
|
|
|
|
// Should use slugified title as worktree name
|
|
const expectedSlug = slugify("Fix login bug with OAuth");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-043", {
|
|
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
|
|
branch: "fusion/fn-043",
|
|
});
|
|
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to description when title is empty for 'task-title' mode", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeNaming: "task-title",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const taskDescription = "Implement user authentication flow";
|
|
await executor.execute({
|
|
...makeTask("FN-044"),
|
|
title: "",
|
|
description: taskDescription,
|
|
});
|
|
|
|
// Should slugify the first 60 chars of description when title is empty
|
|
const expectedSlug = slugify(taskDescription.slice(0, 60));
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-044", {
|
|
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
|
|
branch: "fusion/fn-044",
|
|
});
|
|
});
|
|
|
|
it("uses generateWorktreeName when worktreeNaming is 'random'", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeNaming: "random",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask("FN-045"));
|
|
|
|
// Should use generateWorktreeName for random mode
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-045", {
|
|
worktree: "/tmp/test/.worktrees/swift-falcon",
|
|
branch: "fusion/fn-045",
|
|
});
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
|
});
|
|
|
|
it("defaults to random naming when worktreeNaming is undefined", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
// worktreeNaming is not set (undefined)
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask("FN-046"));
|
|
|
|
// Should default to random naming
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-046", {
|
|
worktree: "/tmp/test/.worktrees/swift-falcon",
|
|
branch: "fusion/fn-046",
|
|
});
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
|
});
|
|
|
|
it("ignores worktreeNaming setting when using pooled worktree (recycle mode)", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/pooled-warm-wt");
|
|
// Pool path exists on disk, task worktree path does not (not a resume)
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/pooled-warm-wt",
|
|
);
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
worktreeNaming: "task-id", // This should be ignored for pooled worktrees
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask("FN-047"));
|
|
|
|
// Should acquire from pool, ignoring the task-id naming preference
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-047", {
|
|
worktree: "/tmp/test/.worktrees/pooled-warm-wt",
|
|
branch: "fusion/fn-047",
|
|
});
|
|
// Should NOT call generateWorktreeName when using pooled worktree
|
|
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
|
// Should log pool acquisition
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-047",
|
|
expect.stringContaining("Acquired worktree from pool"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor worktree recovery", () => {
|
|
const makeTask = (id = "FN-050") => ({
|
|
id,
|
|
title: "Test Task",
|
|
description: "Test description",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedGenerateWorktreeName.mockReturnValue("swift-falcon");
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("creates worktree successfully on first attempt", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have logged worktree creation
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Worktree created at"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
// execSync should be called for worktree creation
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git worktree add"),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("recovers from worktree conflict and retries", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
// First call fails with conflict, second succeeds
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add") && callCount++ === 0) {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have logged cleanup and retry
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
|
|
"/tmp/test/.worktrees/swift-falcon",
|
|
);
|
|
// Should eventually succeed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("falls back to default base and clears task.baseBranch when the configured base ref is missing (FN-2165)", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git rev-parse --verify")) {
|
|
// The stored baseBranch no longer exists — simulates a dep's branch
|
|
// being deleted while this task sat queued/stuck.
|
|
const error: any = new Error("fatal: Needed a single revision");
|
|
error.stderr = Buffer.from("fatal: Needed a single revision");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
await executor.execute({ ...makeTask(), baseBranch: "fusion/missing-base" });
|
|
|
|
// Should log the soft fallback, not a terminal failure
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining('Worktree base ref "fusion/missing-base" is missing'),
|
|
expect.any(String),
|
|
);
|
|
// Should clear baseBranch on the task so retries use the default
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ baseBranch: null }),
|
|
);
|
|
// Should proceed to create a worktree from HEAD (no startPoint)
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && c[0].includes("git worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
// None of the worktree add calls should include the stale base ref
|
|
for (const call of worktreeAddCalls) {
|
|
expect(String(call[0])).not.toContain("fusion/missing-base");
|
|
}
|
|
// The task should NOT have been marked failed because of the stale baseBranch
|
|
// (downstream errors unrelated to worktree creation may still occur in this
|
|
// integration-style test — we only assert that baseBranch-missing is no
|
|
// longer a terminal failure).
|
|
const worktreeFailureCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter(
|
|
(c) => typeof c[1] === "string" && c[1].includes("Worktree creation failed"),
|
|
);
|
|
expect(worktreeFailureCalls).toHaveLength(0);
|
|
// onError may still fire from downstream step execution in this test harness;
|
|
// what matters is that the failure reason is NOT "base ref missing".
|
|
void onError;
|
|
});
|
|
|
|
it("refuses to create a worktree nested inside another worktree (FN-2165 guard)", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Simulate `git worktree list --porcelain` returning a non-root worktree
|
|
// that would be an ancestor of the target path.
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command === "git worktree list --porcelain") {
|
|
return Buffer.from(
|
|
[
|
|
"worktree /tmp/test",
|
|
"HEAD abc123",
|
|
"branch refs/heads/main",
|
|
"",
|
|
"worktree /tmp/test/.worktrees/green-finch",
|
|
"HEAD def456",
|
|
"branch refs/heads/fusion/fn-007",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
// Task has a worktree path nested inside green-finch — must be refused
|
|
await executor.execute({
|
|
...makeTask(),
|
|
worktree: "/tmp/test/.worktrees/green-finch/.worktrees/amber-panda",
|
|
});
|
|
|
|
// Should NEVER attempt a git worktree add for the nested path
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) =>
|
|
typeof c[0] === "string" &&
|
|
c[0].includes("git worktree add") &&
|
|
c[0].includes("green-finch/.worktrees/amber-panda"),
|
|
);
|
|
expect(worktreeAddCalls).toHaveLength(0);
|
|
|
|
// Should log the refusal with both the target and ancestor paths
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
"Refusing to create nested worktree",
|
|
expect.stringContaining("green-finch"),
|
|
);
|
|
});
|
|
|
|
it("fails after 3 unsuccessful attempts with detailed error", async () => {
|
|
const store = createMockStore();
|
|
|
|
// All worktree add calls fail
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
// Cleanup also fails
|
|
if (command.includes("git worktree remove")) {
|
|
throw new Error("cleanup failed");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
const executePromise = executor.execute(makeTask());
|
|
// Advance past all retry delays (100 + 500 + 1000ms)
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await executePromise;
|
|
|
|
// Should log final failure
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Worktree creation failed after 3 attempts"),
|
|
expect.any(String),
|
|
);
|
|
// Should update task as failed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("recovers from 'already used by worktree' error in createFromExistingBranch fallback", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
// First createWithBranch fails with "branch already exists" (not "already used")
|
|
// Then createFromExistingBranch fails with "already used by worktree"
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
callCount++;
|
|
if (command.includes("-b")) {
|
|
// First attempt: createWithBranch fails with branch already exists
|
|
const error: any = new Error(
|
|
"fatal: A branch named 'fusion/fn-050' already exists.",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: A branch named 'fusion/fn-050' already exists.",
|
|
);
|
|
throw error;
|
|
} else {
|
|
// Fallback createFromExistingBranch fails with already used
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
if (command.includes("git worktree remove")) {
|
|
return Buffer.from("");
|
|
}
|
|
if (command.includes("git branch -D")) {
|
|
return Buffer.from("");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Mock the second call to tryCreateWorktree to succeed
|
|
// by making subsequent calls succeed after cleanup
|
|
let secondAttempt = false;
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (secondAttempt) {
|
|
return Buffer.from(""); // Second attempt succeeds
|
|
}
|
|
if (command.includes("-b")) {
|
|
const error: any = new Error(
|
|
"fatal: A branch named 'fusion/fn-050' already exists.",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: A branch named 'fusion/fn-050' already exists.",
|
|
);
|
|
throw error;
|
|
} else {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
if (command.includes("git worktree remove")) {
|
|
secondAttempt = true; // After cleanup, next add will succeed
|
|
return Buffer.from("");
|
|
}
|
|
if (command.includes("git branch -D")) {
|
|
return Buffer.from("");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have cleaned up the conflicting worktree
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('git worktree remove "/tmp/test/.worktrees/green-sage" --force'),
|
|
expect.any(Object),
|
|
);
|
|
|
|
// Should have logged the cleanup
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
|
|
expect.any(String),
|
|
);
|
|
|
|
// Task should eventually succeed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("generates new worktree name when conflicting worktree belongs to active task", async () => {
|
|
const store = createMockStore();
|
|
store.listTasks.mockResolvedValue([
|
|
{
|
|
id: "FN-049",
|
|
title: "Other Task",
|
|
description: "Other task",
|
|
column: "in-progress",
|
|
worktree: "/tmp/test/.worktrees/green-sage",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
]);
|
|
|
|
mockedFindWorktreeUser.mockResolvedValue("FN-049");
|
|
|
|
let callCount = 0;
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
// First attempt fails with conflict
|
|
if (command.includes("git worktree add") && callCount++ === 0) {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
// Second generated name
|
|
mockedGenerateWorktreeName.mockReturnValueOnce("jade-finch");
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should log that we're trying a new path
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Conflicting worktree in use by active task, trying new path"),
|
|
expect.any(String),
|
|
);
|
|
// Should generate a new name
|
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("removes stale branch and retries when branch exists without worktree", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have removed the stale branch
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git branch -D"),
|
|
expect.any(Object),
|
|
);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removed stale branch reference, retrying"),
|
|
);
|
|
});
|
|
|
|
it("runs git worktree prune before branch deletion for stale references", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have called git worktree prune as the first recovery step
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
"git worktree prune",
|
|
expect.any(Object),
|
|
);
|
|
// Should log the prune
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Pruned stale worktree metadata"),
|
|
"fusion/fn-050",
|
|
);
|
|
// Should also call branch -D after prune
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git branch -D"),
|
|
expect.any(Object),
|
|
);
|
|
// Task should eventually succeed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("falls back to git update-ref -d when git branch -D fails on stale reference", async () => {
|
|
const store = createMockStore();
|
|
let worktreeAddCallCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (worktreeAddCallCount++ === 0) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
}
|
|
// Prune succeeds
|
|
if (command.includes("git worktree prune")) {
|
|
return Buffer.from("");
|
|
}
|
|
// branch -D fails (corrupted reference)
|
|
if (command.includes("git branch -D")) {
|
|
const error: any = new Error("error: unable to delete ref 'refs/heads/fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
// update-ref -d succeeds
|
|
if (command.includes("git update-ref -d")) {
|
|
return Buffer.from("");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have tried branch -D first
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git branch -D"),
|
|
expect.any(Object),
|
|
);
|
|
// Should have fallen back to update-ref -d
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git update-ref -d"),
|
|
expect.any(Object),
|
|
);
|
|
// Should log the fallback
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("git branch -D failed for stale branch, trying update-ref"),
|
|
expect.any(String),
|
|
);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Force-removed stale branch reference via update-ref"),
|
|
expect.any(String),
|
|
);
|
|
// Task should eventually succeed after cleanup + retry
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("bounds stale-reference cleanup retries when update-ref succeeds but the ref remains invalid", async () => {
|
|
const store = createMockStore();
|
|
let worktreeAddCallCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
worktreeAddCallCount++;
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
if (command.includes("git branch -D")) {
|
|
const error: any = new Error("error: branch 'fusion/fn-050' not found");
|
|
error.stderr = Buffer.from("error: branch 'fusion/fn-050' not found");
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
const executePromise = executor.execute(makeTask());
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await executePromise;
|
|
|
|
expect(worktreeAddCallCount).toBe(3);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Worktree creation failed after 3 attempts"),
|
|
expect.any(String),
|
|
);
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails task when all stale reference cleanup steps fail", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
// Prune fails
|
|
if (command.includes("git worktree prune")) {
|
|
throw new Error("prune failed");
|
|
}
|
|
// branch -D fails
|
|
if (command.includes("git branch -D")) {
|
|
throw new Error("branch delete failed");
|
|
}
|
|
// update-ref -d also fails
|
|
if (command.includes("git update-ref -d")) {
|
|
throw new Error("update-ref failed");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
const executePromise = executor.execute(makeTask());
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await executePromise;
|
|
|
|
// Should have logged terminal failure for the stale reference
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Failed to remove stale branch reference"),
|
|
expect.any(String),
|
|
);
|
|
// Task should be marked as failed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("recovers from stale reference in createFromExistingBranch fallback path", async () => {
|
|
const store = createMockStore();
|
|
let worktreeAddCallCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
worktreeAddCallCount++;
|
|
if (command.includes("-b")) {
|
|
// createWithBranch: fails with "already exists" (not invalid-reference)
|
|
const error: any = new Error("fatal: A branch named 'fusion/fn-050' already exists.");
|
|
error.stderr = Buffer.from("fatal: A branch named 'fusion/fn-050' already exists.");
|
|
throw error;
|
|
} else {
|
|
// createFromExistingBranch: fails with invalid reference
|
|
if (worktreeAddCallCount <= 2) {
|
|
const error: any = new Error("fatal: invalid reference: 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: invalid reference: 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
// All cleanup commands succeed
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have logged cleanup in fallback path
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Cleaned up stale reference in fallback, retrying"),
|
|
);
|
|
// Task should eventually succeed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.objectContaining({ worktree: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("recognizes 'unable to resolve reference' as invalid-reference pattern", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: unable to resolve reference 'fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: unable to resolve reference 'fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have triggered cleanup (stale branch recovery)
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("git worktree prune"),
|
|
expect.any(Object),
|
|
);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removed stale branch reference, retrying"),
|
|
);
|
|
});
|
|
|
|
it("recognizes 'stale file handle' as invalid-reference pattern", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: stale file handle");
|
|
error.stderr = Buffer.from("fatal: stale file handle");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removed stale branch reference, retrying"),
|
|
);
|
|
});
|
|
|
|
it("recognizes 'not a valid ref' as invalid-reference pattern", async () => {
|
|
const store = createMockStore();
|
|
let callCount = 0;
|
|
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add")) {
|
|
if (callCount++ === 0) {
|
|
const error: any = new Error("fatal: not a valid ref: 'refs/heads/fusion/fn-050'");
|
|
error.stderr = Buffer.from("fatal: not a valid ref: 'refs/heads/fusion/fn-050'");
|
|
throw error;
|
|
}
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removed stale branch reference, retrying"),
|
|
);
|
|
});
|
|
|
|
it("removes existing directory that is not a registered worktree", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Directory exists but is not registered
|
|
mockedExistsSync.mockReturnValue(true);
|
|
|
|
// Mock git worktree list to not include our path
|
|
let callCount = 0;
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree list")) {
|
|
return Buffer.from("/other/path/.git/worktrees/other\n");
|
|
}
|
|
if (command.includes("rm -rf")) {
|
|
return Buffer.from("");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have removed the existing directory
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("rm -rf"),
|
|
expect.any(Object),
|
|
);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("Removing existing directory (not a registered worktree)"),
|
|
);
|
|
});
|
|
|
|
it("handles locked worktree by unlocking before removal", async () => {
|
|
const store = createMockStore();
|
|
|
|
let callCount = 0;
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
|
if (command.includes("git worktree add") && callCount++ === 0) {
|
|
const error: any = new Error(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
error.stderr = Buffer.from(
|
|
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
|
|
);
|
|
throw error;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
// Should attempt to unlock the worktree before removing
|
|
const unlockCalls = mockedExecSync.mock.calls.filter((call) =>
|
|
String(call[0]).includes("git worktree unlock"),
|
|
);
|
|
expect(unlockCalls.length).toBeGreaterThanOrEqual(0); // Unlock is attempted but may fail silently
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor dependency-based worktree creation", () => {
|
|
const makeTask = (overrides: Partial<Task> = {}) => ({
|
|
id: "FN-060",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedFindWorktreeUser.mockResolvedValue(null);
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
it("creates worktree from baseBranch when set on task", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-060",
|
|
baseBranch: "fusion/fn-059",
|
|
}));
|
|
|
|
// The git worktree add command should include the startPoint
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
expect(worktreeAddCalls[0][0]).toContain("fusion/fn-059");
|
|
});
|
|
|
|
it("creates worktree from HEAD when baseBranch is not set", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-061",
|
|
// no baseBranch
|
|
}));
|
|
|
|
// The git worktree add command should NOT include a startPoint
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add -b"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
// Command format: git worktree add -b "branch" "path" (no extra ref after path)
|
|
const cmd = worktreeAddCalls[0][0] as string;
|
|
// Count quoted segments: branch + path = 2 quoted args
|
|
const quoted = cmd.match(/"[^"]+"/g) || [];
|
|
expect(quoted).toHaveLength(2);
|
|
});
|
|
|
|
it("logs base branch in worktree creation log entry", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-062",
|
|
baseBranch: "fusion/fn-061",
|
|
}));
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-062",
|
|
expect.stringContaining("based on fusion/fn-061"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("does not mention base branch in log when baseBranch is not set", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-063",
|
|
}));
|
|
|
|
// Check that log entry does NOT mention "based on"
|
|
const logCalls = store.logEntry.mock.calls.filter(
|
|
(call: any[]) => typeof call[1] === "string" && call[1].includes("Worktree created"),
|
|
);
|
|
expect(logCalls.length).toBeGreaterThan(0);
|
|
expect(logCalls[0][1]).not.toContain("based on");
|
|
});
|
|
|
|
it("retries worktree creation after cleaning up conflicting worktree", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const conflictingPath = "/tmp/test/.worktrees/sharp-stone";
|
|
|
|
let firstAttempt = true;
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b") && firstAttempt) {
|
|
firstAttempt = false;
|
|
const err: any = new Error(
|
|
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
|
);
|
|
err.stderr = Buffer.from(
|
|
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
|
);
|
|
throw err;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
await executor.execute(makeTask({ id: "FN-064" }));
|
|
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
`git worktree remove "${conflictingPath}" --force`,
|
|
expect.objectContaining({ cwd: "/tmp/test" }),
|
|
);
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
'git branch -D "fusion/fn-064"',
|
|
expect.objectContaining({ cwd: "/tmp/test" }),
|
|
);
|
|
|
|
const worktreeCreateCalls = mockedExecSync.mock.calls.filter(
|
|
(call) => typeof call[0] === "string" && call[0].includes('git worktree add') && call[0].includes("-b"),
|
|
);
|
|
expect(worktreeCreateCalls).toHaveLength(2);
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-064",
|
|
expect.stringContaining("Worktree created at /tmp/test/.worktrees/swift-falcon"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("throws original error if cleanup also fails", async () => {
|
|
vi.useFakeTimers();
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const conflictingPath = "/tmp/test/.worktrees/sharp-stone";
|
|
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b")) {
|
|
const err: any = new Error(
|
|
`fatal: 'fusion/fn-065' is already used by worktree at '${conflictingPath}'`,
|
|
);
|
|
err.stderr = Buffer.from(
|
|
`fatal: 'fusion/fn-065' is already used by worktree at '${conflictingPath}'`,
|
|
);
|
|
throw err;
|
|
}
|
|
if (cmd === `git worktree remove "${conflictingPath}" --force`) {
|
|
throw new Error("remove failed");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const executePromise = executor.execute(makeTask({ id: "FN-065" }));
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await executePromise;
|
|
vi.useRealTimers();
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
|
|
status: "failed",
|
|
error: expect.stringContaining("automatic cleanup failed"),
|
|
});
|
|
});
|
|
|
|
it("passes baseBranch to pool prepareForTask when using pooled worktree", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
|
);
|
|
|
|
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue("fusion/fn-064");
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-064",
|
|
baseBranch: "fusion/fn-063",
|
|
}));
|
|
|
|
expect(prepareSpy).toHaveBeenCalledWith(
|
|
"/tmp/test/.worktrees/idle-wt",
|
|
"fusion/fn-064",
|
|
"fusion/fn-063",
|
|
);
|
|
});
|
|
|
|
it("passes undefined to pool prepareForTask when no baseBranch", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
|
);
|
|
|
|
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue("fusion/fn-065");
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-065",
|
|
}));
|
|
|
|
expect(prepareSpy).toHaveBeenCalledWith(
|
|
"/tmp/test/.worktrees/idle-wt",
|
|
"fusion/fn-065",
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("stores suffixed branch name when pool returns a different name", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
|
);
|
|
|
|
// Pool returns a suffixed branch name due to conflict
|
|
vi.spyOn(pool, "prepareForTask").mockResolvedValue("fusion/fn-066-2");
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
|
|
await executor.execute(makeTask({
|
|
id: "FN-066",
|
|
}));
|
|
|
|
// Should store the suffixed branch name
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-066", {
|
|
worktree: "/tmp/test/.worktrees/idle-wt",
|
|
branch: "fusion/fn-066-2",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor worktree pool integration", () => {
|
|
const makeTask = (id = "FN-020") => ({
|
|
id,
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Default: worktree does NOT exist (new worktree)
|
|
mockedExistsSync.mockReturnValue(false);
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
it("acquires from pool when recycleWorktrees is true and pool has idle worktrees", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
// Pool path exists on disk, task worktree path does not (not a resume)
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
|
);
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should NOT call git worktree add (no fresh worktree)
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls).toHaveLength(0);
|
|
|
|
// Should log pool acquisition
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-020",
|
|
expect.stringContaining("Acquired worktree from pool"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
|
|
// Pool should be empty after acquire
|
|
expect(pool.size).toBe(0);
|
|
});
|
|
|
|
it("overwrites baseCommitSha when starting from a pooled worktree", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
mockedExistsSync.mockImplementation((p) => p === "/tmp/test/.worktrees/idle-wt");
|
|
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (String(cmd) === "git rev-parse HEAD") {
|
|
return "newbase123\n" as any;
|
|
}
|
|
return "" as any;
|
|
});
|
|
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-020",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
baseCommitSha: "stale-base",
|
|
});
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-020", { baseCommitSha: "newbase123" });
|
|
});
|
|
|
|
it("creates fresh worktree when pool is empty", async () => {
|
|
const pool = new WorktreePool();
|
|
// Pool is empty
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should call git worktree add (fresh worktree)
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
|
|
// Should log worktree creation, NOT pool acquisition
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-020",
|
|
expect.stringContaining("Worktree created at"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("skips worktree init command for pooled worktrees", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/warm-wt");
|
|
// Pool path exists on disk, task worktree path does not
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/warm-wt",
|
|
);
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
worktreeInitCommand: "pnpm install",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
// "pnpm install" should NOT have been called (pooled worktree has warm cache)
|
|
const initCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => c[0] === "pnpm install",
|
|
);
|
|
expect(initCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("does not use pool when recycleWorktrees is false", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
|
|
|
const store = createMockStore();
|
|
// recycleWorktrees defaults to false
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should create a fresh worktree, NOT acquire from pool
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
|
|
// Pool should still have the entry (not acquired)
|
|
expect(pool.size).toBe(1);
|
|
});
|
|
|
|
it("falls through to fresh worktree when pool prepareForTask throws", async () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/test/.worktrees/bad-wt");
|
|
// Pool path must exist on disk for acquire() to return it
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p === "/tmp/test/.worktrees/bad-wt",
|
|
);
|
|
// Make prepareForTask throw
|
|
vi.spyOn(pool, "prepareForTask").mockImplementation(() => {
|
|
throw new Error("branch conflict unrecoverable");
|
|
});
|
|
const releaseSpy = vi.spyOn(pool, "release");
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
recycleWorktrees: true,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
|
await executor.execute(makeTask());
|
|
|
|
// Should have released the bad worktree back to pool
|
|
expect(releaseSpy).toHaveBeenCalledWith("/tmp/test/.worktrees/bad-wt");
|
|
|
|
// Should have fallen through to fresh worktree creation
|
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
|
);
|
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
|
|
|
// Should log the pool failure
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-020",
|
|
expect.stringContaining("Pool worktree preparation failed"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("WorktreePool capacity", () => {
|
|
it("pool does not enforce maxWorktrees — scheduler is the capacity gatekeeper", () => {
|
|
const pool = new WorktreePool();
|
|
pool.release("/tmp/a");
|
|
pool.release("/tmp/b");
|
|
pool.release("/tmp/c");
|
|
pool.release("/tmp/d");
|
|
pool.release("/tmp/e");
|
|
expect(pool.size).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe("Merger worktree pool integration", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("passes pool option through to aiMergeTask", async () => {
|
|
const pool = new WorktreePool();
|
|
const mockedAiMergeTask = vi.mocked(aiMergeTask);
|
|
mockedAiMergeTask.mockResolvedValue({
|
|
task: { id: "FN-050" } as any,
|
|
branch: "fusion/fn-050",
|
|
merged: true,
|
|
worktreeRemoved: false,
|
|
branchDeleted: true,
|
|
});
|
|
|
|
await aiMergeTask({} as any, "/tmp/test", "FN-050", { pool });
|
|
|
|
expect(mockedAiMergeTask).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"/tmp/test",
|
|
"FN-050",
|
|
expect.objectContaining({ pool }),
|
|
);
|
|
});
|
|
|
|
// Full merger worktree pool integration tests are in merger.test.ts
|
|
// which tests aiMergeTask with real implementation
|
|
});
|
|
|
|
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
|
return {
|
|
id: "FN-001",
|
|
title: "Test Task",
|
|
description: "A test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("buildExecutionPrompt", () => {
|
|
it("includes attachment section with absolute paths for image attachments", () => {
|
|
const task = createMockTaskDetail({
|
|
attachments: [
|
|
{ filename: "abc123-screenshot.png", originalName: "screenshot.png", mimeType: "image/png", size: 2048, createdAt: new Date().toISOString() },
|
|
],
|
|
});
|
|
const result = buildExecutionPrompt(task, "/home/user/project");
|
|
|
|
expect(result).toContain("## Attachments");
|
|
expect(result).toContain("**screenshot.png** (screenshot)");
|
|
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png");
|
|
});
|
|
|
|
it("includes attachment section with absolute paths for text attachments", () => {
|
|
const task = createMockTaskDetail({
|
|
attachments: [
|
|
{ filename: "def456-error.log", originalName: "error.log", mimeType: "text/plain", size: 512, createdAt: new Date().toISOString() },
|
|
],
|
|
});
|
|
const result = buildExecutionPrompt(task, "/home/user/project");
|
|
|
|
expect(result).toContain("## Attachments");
|
|
expect(result).toContain("**error.log** (text/plain)");
|
|
expect(result).toContain("read for context");
|
|
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/def456-error.log");
|
|
});
|
|
|
|
it("includes both image and text attachments", () => {
|
|
const task = createMockTaskDetail({
|
|
attachments: [
|
|
{ filename: "abc-shot.png", originalName: "shot.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString() },
|
|
{ filename: "def-config.json", originalName: "config.json", mimeType: "application/json", size: 256, createdAt: new Date().toISOString() },
|
|
],
|
|
});
|
|
const result = buildExecutionPrompt(task, "/home/user/project");
|
|
|
|
expect(result).toContain("**shot.png** (screenshot)");
|
|
expect(result).toContain("**config.json** (application/json)");
|
|
});
|
|
|
|
it("rewrites project-root absolute paths to the active worktree", () => {
|
|
const task = createMockTaskDetail({
|
|
prompt: [
|
|
"# test",
|
|
"## Context to Read First",
|
|
"- `/home/user/project/web/app/page.tsx`",
|
|
"- `/home/user/project/.fusion/memory/`",
|
|
"## Steps",
|
|
"### Step 0: Preflight",
|
|
"- [ ] inspect `/home/user/project/web/app/layout.tsx`",
|
|
].join("\n"),
|
|
});
|
|
|
|
const result = buildExecutionPrompt(
|
|
task,
|
|
"/home/user/project",
|
|
undefined,
|
|
"/home/user/project/.worktrees/happy-robin",
|
|
);
|
|
|
|
expect(result).toContain("/home/user/project/.worktrees/happy-robin/web/app/page.tsx");
|
|
expect(result).toContain("/home/user/project/.worktrees/happy-robin/web/app/layout.tsx");
|
|
expect(result).toContain("/home/user/project/.fusion/memory/");
|
|
expect(result).not.toContain("/home/user/project/.worktrees/happy-robin/.fusion/memory/");
|
|
});
|
|
|
|
it("omits attachment section when no attachments", () => {
|
|
const task = createMockTaskDetail({ attachments: [] });
|
|
const result = buildExecutionPrompt(task, "/home/user/project");
|
|
|
|
expect(result).not.toContain("## Attachments");
|
|
});
|
|
|
|
it("omits attachment section when attachments is undefined", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task);
|
|
|
|
expect(result).not.toContain("## Attachments");
|
|
});
|
|
|
|
it("omits attachment section when rootDir is not provided", () => {
|
|
const task = createMockTaskDetail({
|
|
attachments: [
|
|
{ filename: "abc.png", originalName: "test.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString() },
|
|
],
|
|
});
|
|
const result = buildExecutionPrompt(task);
|
|
|
|
expect(result).not.toContain("## Attachments");
|
|
});
|
|
|
|
it("includes Project Commands section with test command when settings.testCommand is set", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/home/user/project", {
|
|
testCommand: "pnpm test",
|
|
} as any);
|
|
|
|
expect(result).toContain("## Project Commands");
|
|
expect(result).toContain("- **Test:** `pnpm test`");
|
|
expect(result).not.toContain("- **Build:**");
|
|
});
|
|
|
|
it("includes Project Commands section with build command when settings.buildCommand is set", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/home/user/project", {
|
|
buildCommand: "pnpm build",
|
|
} as any);
|
|
|
|
expect(result).toContain("## Project Commands");
|
|
expect(result).toContain("- **Build:** `pnpm build`");
|
|
expect(result).not.toContain("- **Test:**");
|
|
});
|
|
|
|
it("includes both commands when both are set", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/home/user/project", {
|
|
testCommand: "pnpm test",
|
|
buildCommand: "pnpm build",
|
|
} as any);
|
|
|
|
expect(result).toContain("## Project Commands");
|
|
expect(result).toContain("- **Test:** `pnpm test`");
|
|
expect(result).toContain("- **Build:** `pnpm build`");
|
|
});
|
|
|
|
it("tells executors to fix quality-gate failures even outside initial file scope", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/home/user/project", {
|
|
testCommand: "pnpm test",
|
|
buildCommand: "pnpm build",
|
|
} as any);
|
|
|
|
expect(result).toContain("fix failures even when that requires edits outside the original File Scope");
|
|
expect(result).toContain("If the repo has a typecheck command, run it before `task_done()`");
|
|
expect(result).toContain("not for fixes required to get tests, build, or typecheck back to green");
|
|
});
|
|
|
|
it("requires resolving ALL test failures, including unrelated or pre-existing ones", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/home/user/project", {
|
|
testCommand: "pnpm test",
|
|
buildCommand: "pnpm build",
|
|
} as any);
|
|
|
|
// The stricter language must be present to prevent "unrelated failure" deferrals
|
|
expect(result).toContain("Resolve ALL test failures");
|
|
expect(result).toContain("even if they appear unrelated or pre-existing");
|
|
expect(result).toContain("accumulate technical debt");
|
|
expect(result).toContain("Investigate and fix or suppress them");
|
|
expect(result).toContain("do not defer them to a separate task");
|
|
});
|
|
|
|
it("omits Project Commands section when neither command is set", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/home/user/project", {} as any);
|
|
|
|
expect(result).not.toContain("## Project Commands");
|
|
});
|
|
|
|
it("omits Project Commands section when settings is undefined", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task);
|
|
|
|
expect(result).not.toContain("## Project Commands");
|
|
});
|
|
|
|
it("includes Steering Comments section when steeringComments has entries", () => {
|
|
const task = createMockTaskDetail({
|
|
steeringComments: [
|
|
{
|
|
id: "1",
|
|
text: "Please handle the edge case",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user" as const,
|
|
},
|
|
],
|
|
});
|
|
const result = buildExecutionPrompt(task);
|
|
|
|
expect(result).toContain("## Steering Comments");
|
|
expect(result).toContain("**user**");
|
|
expect(result).toContain("> Please handle the edge case");
|
|
expect(result).toContain("The following comments were added by the user during execution");
|
|
});
|
|
|
|
it("formats multiple steering comments correctly", () => {
|
|
const now = new Date();
|
|
const task = createMockTaskDetail({
|
|
steeringComments: [
|
|
{
|
|
id: "1",
|
|
text: "First comment",
|
|
createdAt: new Date(now.getTime() - 60000).toISOString(), // 1 minute ago
|
|
author: "user" as const,
|
|
},
|
|
{
|
|
id: "2",
|
|
text: "Second comment",
|
|
createdAt: now.toISOString(),
|
|
author: "agent" as const,
|
|
},
|
|
],
|
|
});
|
|
const result = buildExecutionPrompt(task);
|
|
|
|
expect(result).toContain("**user**");
|
|
expect(result).toContain("**agent**");
|
|
expect(result).toContain("> First comment");
|
|
expect(result).toContain("> Second comment");
|
|
});
|
|
|
|
it("omits Steering Comments section when steeringComments is empty", () => {
|
|
const task = createMockTaskDetail({ steeringComments: [] });
|
|
const result = buildExecutionPrompt(task);
|
|
|
|
expect(result).not.toContain("## Steering Comments");
|
|
});
|
|
|
|
it("omits Steering Comments section when steeringComments is undefined", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task);
|
|
|
|
expect(result).not.toContain("## Steering Comments");
|
|
});
|
|
|
|
it("includes only the 10 most recent steering comments", () => {
|
|
const steeringComments = Array.from({ length: 15 }, (_, i) => ({
|
|
id: `${i}`,
|
|
text: `Comment ${i}`,
|
|
createdAt: new Date().toISOString(),
|
|
author: "user" as const,
|
|
}));
|
|
|
|
const task = createMockTaskDetail({ steeringComments });
|
|
const result = buildExecutionPrompt(task);
|
|
|
|
// Should include comments 5-14 (the 10 most recent), not 0-4
|
|
expect(result).toContain("> Comment 5");
|
|
expect(result).toContain("> Comment 14");
|
|
expect(result).not.toContain("> Comment 0");
|
|
expect(result).not.toContain("> Comment 4");
|
|
});
|
|
|
|
it("end-to-end: steering comments are fully injected into execution prompt with correct format", () => {
|
|
const now = new Date();
|
|
const task = createMockTaskDetail({
|
|
id: "FN-123",
|
|
title: "Verify Steering Feature",
|
|
steeringComments: [
|
|
{
|
|
id: "sc-001",
|
|
text: "Please ensure all edge cases are handled in the validation logic",
|
|
createdAt: new Date(now.getTime() - 120000).toISOString(),
|
|
author: "user" as const,
|
|
},
|
|
{
|
|
id: "sc-002",
|
|
text: "Consider adding unit tests for the new utility function",
|
|
createdAt: new Date(now.getTime() - 60000).toISOString(),
|
|
author: "agent" as const,
|
|
},
|
|
{
|
|
id: "sc-003",
|
|
text: "Don't forget to update the documentation before completing",
|
|
createdAt: now.toISOString(),
|
|
author: "user" as const,
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = buildExecutionPrompt(task, "/project", { testCommand: "pnpm test" } as any);
|
|
|
|
// Verify section header exists
|
|
expect(result).toContain("## Steering Comments");
|
|
|
|
// Verify explanatory header text
|
|
expect(result).toContain("The following comments were added by the user during execution");
|
|
expect(result).toContain("Consider adjusting your approach or replanning remaining steps based on this feedback");
|
|
|
|
// Verify all three comments appear with correct author badges
|
|
expect(result).toContain("**user**");
|
|
expect(result).toContain("**agent**");
|
|
|
|
// Verify quoted text format
|
|
expect(result).toContain("> Please ensure all edge cases are handled in the validation logic");
|
|
expect(result).toContain("> Consider adding unit tests for the new utility function");
|
|
expect(result).toContain("> Don't forget to update the documentation before completing");
|
|
|
|
// Verify timestamp formatting appears (either relative like "2m ago" or absolute)
|
|
// The formatTimestamp function returns relative times for recent comments
|
|
expect(result).toMatch(/\*\*user\*\* — \d+m? ago/);
|
|
|
|
// Verify the section appears in the expected location (after progress section, before review level)
|
|
const steeringSectionIndex = result.indexOf("## Steering Comments");
|
|
const reviewLevelIndex = result.indexOf("## Review level");
|
|
expect(steeringSectionIndex).toBeGreaterThan(0);
|
|
expect(reviewLevelIndex).toBeGreaterThan(steeringSectionIndex);
|
|
});
|
|
|
|
it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
testCommand: "npm test",
|
|
buildCommand: "npm run build",
|
|
});
|
|
|
|
const mockPrompt = vi.fn().mockResolvedValue(undefined);
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: mockPrompt,
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Called twice: initial execution + retry when agent finishes without task_done
|
|
expect(mockPrompt).toHaveBeenCalledTimes(2);
|
|
const agentPrompt = mockPrompt.mock.calls[0][0];
|
|
expect(agentPrompt).toContain("## Project Commands");
|
|
expect(agentPrompt).toContain("- **Test:** `npm test`");
|
|
expect(agentPrompt).toContain("- **Build:** `npm run build`");
|
|
});
|
|
|
|
describe("memoryEnabled setting", () => {
|
|
it("includes memory instructions when memoryEnabled: true", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
memoryEnabled: true,
|
|
} as any);
|
|
expect(result).toContain("Execute this task.");
|
|
expect(result).toContain("## Project Memory");
|
|
expect(result).toContain(".fusion/memory/");
|
|
});
|
|
|
|
it("excludes memory instructions when memoryEnabled: false", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
memoryEnabled: false,
|
|
} as any);
|
|
expect(result).toContain("Execute this task.");
|
|
expect(result).not.toContain("## Project Memory");
|
|
});
|
|
|
|
it("includes memory instructions when memoryEnabled is undefined (default enabled)", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {} as any);
|
|
expect(result).toContain("Execute this task.");
|
|
expect(result).toContain("## Project Memory");
|
|
expect(result).toContain(".fusion/memory/");
|
|
});
|
|
|
|
it("includes selective memory write instruction for durable learnings at end of execution", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
memoryEnabled: true,
|
|
} as any);
|
|
// Should instruct selective writes, not unconditional appends
|
|
expect(result).toMatch(/skip.*memory.*update|selectively|durable.*learnings/i);
|
|
expect(result).toMatch(/end of execution|before calling.*task_done/i);
|
|
// Should forbid task-specific trivia
|
|
expect(result).toMatch(/avoid.*trivia|task-specific.*trivia|per-task.*log/i);
|
|
// Should allow consolidation/editing
|
|
expect(result).toMatch(/consolidate|update.*refine.*existing|edit.*existing/i);
|
|
});
|
|
|
|
it("uses project-root memory path not worktree-local path", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
memoryEnabled: true,
|
|
} as any);
|
|
expect(result).toContain("`.fusion/memory/`");
|
|
});
|
|
});
|
|
|
|
describe("memoryBackendType setting", () => {
|
|
it("includes .fusion/memory/ for file backend", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
memoryEnabled: true,
|
|
memoryBackendType: "file",
|
|
} as any);
|
|
expect(result).toContain("## Project Memory");
|
|
// Check that the Project Memory section contains .fusion/memory/
|
|
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
|
|
expect(memorySectionMatch).toBeTruthy();
|
|
expect(memorySectionMatch![1]).toContain(".fusion/memory/");
|
|
});
|
|
|
|
it("includes read-only wording for readonly backend without write directives in memory section", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
memoryEnabled: true,
|
|
memoryBackendType: "readonly",
|
|
} as any);
|
|
expect(result).toContain("## Project Memory");
|
|
// Extract the Project Memory section
|
|
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
|
|
expect(memorySectionMatch).toBeTruthy();
|
|
const memorySection = memorySectionMatch![1];
|
|
// Should NOT contain write/update directives in the memory section
|
|
expect(memorySection).not.toMatch(/write.*memory|update.*memory/i);
|
|
// Should NOT contain the specific file path in the memory section
|
|
expect(memorySection).not.toContain(".fusion/memory/");
|
|
});
|
|
|
|
it("does not include .fusion/memory/ in Project Memory section for qmd backend", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
memoryEnabled: true,
|
|
memoryBackendType: "qmd",
|
|
} as any);
|
|
expect(result).toContain("## Project Memory");
|
|
// Extract the Project Memory section
|
|
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
|
|
expect(memorySectionMatch).toBeTruthy();
|
|
const memorySection = memorySectionMatch![1];
|
|
// QMD should NOT unconditionally reference .fusion/memory/ in the memory section
|
|
expect(memorySection).not.toContain(".fusion/memory/");
|
|
expect(memorySection).toContain("memory_search");
|
|
expect(memorySection).toContain("memory_get");
|
|
});
|
|
|
|
it("QMD prompt has actionable memory instructions", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
memoryEnabled: true,
|
|
memoryBackendType: "qmd",
|
|
} as any);
|
|
expect(result).toContain("## Project Memory");
|
|
// Extract the Project Memory section
|
|
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
|
|
expect(memorySectionMatch).toBeTruthy();
|
|
const memorySection = memorySectionMatch![1];
|
|
// QMD should NOT contain .fusion/memory/
|
|
expect(memorySection).not.toContain(".fusion/memory/");
|
|
expect(memorySection).toContain("memory_search");
|
|
// Contains "end of execution" write guidance
|
|
expect(memorySection).toMatch(/end of execution/i);
|
|
});
|
|
|
|
it("excludes memory section when memoryEnabled: false regardless of backend", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
memoryEnabled: false,
|
|
memoryBackendType: "file",
|
|
} as any);
|
|
expect(result).toContain("Execute this task.");
|
|
expect(result).not.toContain("## Project Memory");
|
|
});
|
|
});
|
|
|
|
describe("commit author attribution", () => {
|
|
it("includes default author in commit instruction when commitAuthorEnabled is true", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
commitAuthorEnabled: true,
|
|
} as any);
|
|
expect(result).toContain('--author="Fusion <noreply@runfusion.ai>"');
|
|
});
|
|
|
|
it("includes custom author name and email in commit instruction", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
commitAuthorEnabled: true,
|
|
commitAuthorName: "CustomBot",
|
|
commitAuthorEmail: "bot@example.com",
|
|
} as any);
|
|
expect(result).toContain('--author="CustomBot <bot@example.com>"');
|
|
});
|
|
|
|
it("omits author from commit instruction when commitAuthorEnabled is false", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
commitAuthorEnabled: false,
|
|
} as any);
|
|
expect(result).not.toContain("--author");
|
|
// Should still contain commit instruction without author
|
|
expect(result).toContain("git commit -m");
|
|
});
|
|
|
|
it("uses default author when commitAuthorEnabled is true but name/email are undefined", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {
|
|
commitAuthorEnabled: true,
|
|
commitAuthorName: undefined,
|
|
commitAuthorEmail: undefined,
|
|
} as any);
|
|
expect(result).toContain('--author="Fusion <noreply@runfusion.ai>"');
|
|
});
|
|
|
|
it("uses default author when settings is undefined", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project");
|
|
expect(result).toContain('--author="Fusion <noreply@runfusion.ai>"');
|
|
});
|
|
|
|
it("uses default author when settings is empty object", () => {
|
|
const task = createMockTaskDetail();
|
|
const result = buildExecutionPrompt(task, "/project", {} as any);
|
|
expect(result).toContain('--author="Fusion <noreply@runfusion.ai>"');
|
|
});
|
|
});
|
|
});
|
|
|
|
// Import the summarizeToolArgs helper directly (not affected by mocks above)
|
|
describe("summarizeToolArgs", () => {
|
|
// Dynamic import to avoid mock interference
|
|
let summarizeToolArgs: (name: string, args?: Record<string, unknown>) => string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
const mod = await vi.importActual<typeof import("./executor.js")>("./executor.js");
|
|
summarizeToolArgs = mod.summarizeToolArgs;
|
|
});
|
|
|
|
it("returns command for bash tool", () => {
|
|
expect(summarizeToolArgs("Bash", { command: "ls -la" })).toBe("ls -la");
|
|
expect(summarizeToolArgs("bash", { command: "echo hello" })).toBe("echo hello");
|
|
});
|
|
|
|
it("returns long bash commands in full without truncation", () => {
|
|
const longCmd = "a".repeat(100);
|
|
const result = summarizeToolArgs("Bash", { command: longCmd });
|
|
expect(result).toBe(longCmd);
|
|
});
|
|
|
|
it("returns path for read/edit/write tools", () => {
|
|
expect(summarizeToolArgs("Read", { path: "src/types.ts" })).toBe("src/types.ts");
|
|
expect(summarizeToolArgs("edit", { path: "src/store.ts" })).toBe("src/store.ts");
|
|
expect(summarizeToolArgs("Write", { path: "out.txt", content: "data" })).toBe("out.txt");
|
|
});
|
|
|
|
it("returns first string arg for unknown tools", () => {
|
|
expect(summarizeToolArgs("task_update", { step: 1, status: "done" })).toBe("done");
|
|
});
|
|
|
|
it("returns undefined when no args provided", () => {
|
|
expect(summarizeToolArgs("Bash")).toBeUndefined();
|
|
expect(summarizeToolArgs("Bash", {})).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined when no string args found", () => {
|
|
expect(summarizeToolArgs("unknown", { count: 42, flag: true })).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor pause behavior", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("terminates agent and moves task to todo when paused during execution", async () => {
|
|
const store = createMockStore();
|
|
const disposeFn = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate pause happening during agent execution
|
|
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
|
|
// Simulate the dispose causing an error (session terminated)
|
|
throw new Error("Session terminated");
|
|
}),
|
|
dispose: disposeFn,
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should move to todo, NOT mark as failed
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
|
|
});
|
|
|
|
it("does not move to in-review when paused during execution (graceful session end)", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate pause — session ends gracefully (no throw)
|
|
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should NOT move to in-review (paused tasks skip that logic)
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
|
|
// Should move to todo instead (regression: was stranding in in-progress)
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
|
|
});
|
|
|
|
it("moves paused task to todo when session ends gracefully (regression for FN-827)", async () => {
|
|
const store = createMockStore();
|
|
const disposeFn = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate pause during execution — session ends gracefully (no throw)
|
|
store._trigger("task:updated", { id: "FN-805", paused: true, column: "in-progress" });
|
|
// No error thrown — this is the "graceful exit" path
|
|
}),
|
|
dispose: disposeFn,
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const stuckTaskDetector = { trackTask: vi.fn(), untrackTask: vi.fn(), recordActivity: vi.fn() } as any;
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector });
|
|
await executor.execute({
|
|
id: "FN-805",
|
|
title: "Stranded task",
|
|
description: "A task that was paused and stranded",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// The critical fix: task must end in todo, not stranded in in-progress
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-805", "todo");
|
|
// Should NOT be marked as failed
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-805", expect.objectContaining({ status: "failed" }));
|
|
// Should log the pause event
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-805", expect.stringContaining("Execution paused"));
|
|
// Session should be disposed
|
|
expect(disposeFn).toHaveBeenCalled();
|
|
// Stuck detector should have untracked the task
|
|
expect(stuckTaskDetector.untrackTask).toHaveBeenCalledWith("FN-805");
|
|
});
|
|
|
|
it("handles rapid pause→unpause without duplicate executor runs", async () => {
|
|
const store = createMockStore();
|
|
const disposeFn = vi.fn();
|
|
let promptCallCount = 0;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
promptCallCount++;
|
|
// Simulate pause during execution
|
|
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
|
|
// Simulate rapid unpause while executor is still handling the pause
|
|
store._trigger("task:updated", { id: "FN-001", paused: undefined, column: "in-progress" });
|
|
// Session ends gracefully (no throw)
|
|
}),
|
|
dispose: disposeFn,
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Rapid pause/unpause",
|
|
description: "Test rapid pause then unpause",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// The task should still be moved to todo exactly once (the pause took effect)
|
|
// Even if unpause happened rapidly, the session was already disposed
|
|
const todoCalls = store.moveTask.mock.calls.filter(
|
|
(call: any[]) => call[0] === "FN-001" && call[1] === "todo",
|
|
);
|
|
expect(todoCalls.length).toBe(1);
|
|
// Should NOT have duplicate in-review calls
|
|
const inReviewCalls = store.moveTask.mock.calls.filter(
|
|
(call: any[]) => call[0] === "FN-001" && call[1] === "in-review",
|
|
);
|
|
expect(inReviewCalls.length).toBe(0);
|
|
// Agent should only have been prompted once
|
|
expect(promptCallCount).toBe(1);
|
|
});
|
|
|
|
it("skips paused tasks during resumeOrphaned", async () => {
|
|
const store = createMockStore();
|
|
store.listTasks.mockResolvedValue([
|
|
{ id: "FN-001", column: "in-progress", paused: true, title: "Paused task", steps: [], description: "", dependencies: [] },
|
|
{ id: "FN-002", column: "in-progress", paused: false, title: "Active task", steps: [], description: "", dependencies: [] },
|
|
]);
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.resumeOrphaned();
|
|
|
|
// Only KB-002 should be resumed (KB-001 is paused)
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-002", "Resumed after engine restart");
|
|
expect(store.logEntry).not.toHaveBeenCalledWith("FN-001", expect.anything());
|
|
});
|
|
|
|
it("resumes unpaused in-progress task with no active session", async () => {
|
|
const store = createMockStore();
|
|
const disposeFn = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: disposeFn,
|
|
},
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Simulate unpause of an in-progress task that has no active session
|
|
// (e.g., engine restarted while task was paused in-progress)
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
paused: undefined,
|
|
column: "in-progress",
|
|
description: "Test task",
|
|
title: "Resumed task",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait for async execution to start
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
// Agent created at least twice: initial resume + retry when agent finishes without task_done
|
|
// (async worktree validation may allow additional retry cycles within the timeout)
|
|
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resuming execution after unpause", undefined, undefined);
|
|
});
|
|
|
|
it("does not recursively resume when resume logging emits task updated", async () => {
|
|
const store = createMockStore();
|
|
const task = {
|
|
id: "FN-001",
|
|
paused: undefined,
|
|
column: "in-progress",
|
|
description: "Test task",
|
|
title: "Resumed task",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
}) as any);
|
|
|
|
let emittedUpdateFromLog = false;
|
|
store.logEntry.mockImplementation(async (_id: string, action: string) => {
|
|
if (action === "Resuming execution after unpause" && !emittedUpdateFromLog) {
|
|
emittedUpdateFromLog = true;
|
|
store._trigger("task:updated", { ...task, updatedAt: new Date().toISOString() });
|
|
}
|
|
});
|
|
|
|
new TaskExecutor(store, "/tmp/test");
|
|
store._trigger("task:updated", task);
|
|
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
const resumeLogCalls = store.logEntry.mock.calls.filter(
|
|
([id, action]: [string, string]) => id === "FN-001" && action === "Resuming execution after unpause",
|
|
);
|
|
expect(resumeLogCalls).toHaveLength(1);
|
|
});
|
|
|
|
it("clears stale failed state before resuming unpaused in-progress task", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
paused: undefined,
|
|
column: "in-progress",
|
|
status: "failed",
|
|
error: "Request was aborted.",
|
|
description: "Test task",
|
|
title: "Resumed task",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resuming execution after unpause", undefined, undefined);
|
|
});
|
|
|
|
it("clears stale failed state before resuming orphaned in-progress task", async () => {
|
|
const store = createMockStore();
|
|
store.listTasks.mockResolvedValue([
|
|
{
|
|
id: "FN-001",
|
|
column: "in-progress",
|
|
paused: false,
|
|
status: "failed",
|
|
error: "Request was aborted.",
|
|
title: "Active task",
|
|
steps: [],
|
|
description: "",
|
|
dependencies: [],
|
|
},
|
|
]);
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.resumeOrphaned();
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resumed after engine restart");
|
|
});
|
|
|
|
it("does not duplicate execution when unpausing already-executing task", async () => {
|
|
const store = createMockStore();
|
|
const disposeFn = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate rapid unpause during execution — should NOT start a second run
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
paused: undefined,
|
|
column: "in-progress",
|
|
});
|
|
// Wait a bit to let the unpause handler run
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}),
|
|
dispose: disposeFn,
|
|
},
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Already executing",
|
|
description: "Test no duplicate",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// At least two agent creations (initial + retry without task_done), but no duplicate from the unpause event
|
|
// (async worktree validation may allow additional retry cycles)
|
|
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("does not resume unpaused task that is not in-progress", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Unpause a todo task — executor should NOT try to execute it
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
paused: undefined,
|
|
column: "todo",
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
// No agent should have been created
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not resume unpaused task that still has an active session", async () => {
|
|
const store = createMockStore();
|
|
const disposeFn = vi.fn();
|
|
|
|
let promptResolve: () => void;
|
|
const promptPromise = new Promise<void>((r) => { promptResolve = r; });
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate unpause while session is still active (should be a no-op)
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
paused: undefined,
|
|
column: "in-progress",
|
|
});
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}),
|
|
dispose: disposeFn,
|
|
},
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Start execution — session will be active
|
|
const executePromise = executor.execute({
|
|
id: "FN-001",
|
|
title: "Active session",
|
|
description: "Test active session unpause",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
await executePromise;
|
|
|
|
// Two agent sessions (initial + retry without task_done) — the unpause during active session was a no-op
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("uses SessionManager.create for fresh execution and persists sessionFile", async () => {
|
|
const store = createMockStore();
|
|
const sessionFilePath = "/tmp/sessions/session_123.jsonl";
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
sessionFile: sessionFilePath,
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Fresh task",
|
|
description: "Test fresh session",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should use SessionManager.create for fresh execution
|
|
expect(mockedSessionManager.create).toHaveBeenCalledWith(
|
|
expect.stringContaining(".worktrees"),
|
|
);
|
|
expect(mockedSessionManager.open).not.toHaveBeenCalled();
|
|
|
|
// Should persist the session file path on the task
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { sessionFile: sessionFilePath });
|
|
});
|
|
|
|
it("uses SessionManager.open to resume session when task has sessionFile", async () => {
|
|
const store = createMockStore();
|
|
const sessionFilePath = "/tmp/sessions/session_123.jsonl";
|
|
const resumePromptFn = vi.fn().mockResolvedValue(undefined);
|
|
|
|
// existsSync must return true for the session file
|
|
mockedExistsSync.mockReturnValue(true);
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: resumePromptFn,
|
|
dispose: vi.fn(),
|
|
},
|
|
sessionFile: sessionFilePath,
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Resumed task",
|
|
description: "Test session resume",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
sessionFile: sessionFilePath,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should use SessionManager.open for the initial resumed execution
|
|
expect(mockedSessionManager.open).toHaveBeenCalledWith(sessionFilePath);
|
|
|
|
// The first createFnAgent call should use the opened session manager
|
|
const firstCall = mockedCreateFnAgent.mock.calls[0][0] as any;
|
|
expect(firstCall.sessionManager).toBeDefined();
|
|
|
|
// The log should indicate resume
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("Resumed agent session after unpause"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("preserves sessionFile when task is paused (graceful exit)", async () => {
|
|
const store = createMockStore();
|
|
const sessionFilePath = "/tmp/sessions/session_456.jsonl";
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate pause — session ends gracefully
|
|
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
sessionFile: sessionFilePath,
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Pauseable task",
|
|
description: "Test session file preserved on pause",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Session file should NOT be cleared when paused
|
|
const clearCalls = store.updateTask.mock.calls.filter(
|
|
(call: any[]) => call[0] === "FN-001" && call[1]?.sessionFile === null,
|
|
);
|
|
expect(clearCalls.length).toBe(0);
|
|
|
|
// Task should be moved to todo (ready for resume)
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
});
|
|
|
|
it("falls back to fresh session when sessionFile no longer exists on disk", async () => {
|
|
const store = createMockStore();
|
|
const staleSessionFile = "/tmp/sessions/deleted_session.jsonl";
|
|
|
|
// Session file does NOT exist on disk
|
|
mockedExistsSync.mockImplementation(
|
|
(p) => p !== staleSessionFile,
|
|
);
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
sessionFile: "/tmp/sessions/new_session.jsonl",
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Stale session",
|
|
description: "Test stale session file fallback",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
sessionFile: staleSessionFile,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should fall back to SessionManager.create (not open)
|
|
expect(mockedSessionManager.create).toHaveBeenCalled();
|
|
expect(mockedSessionManager.open).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("swallowed async store failure observability", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedWithRateLimitRetry.mockImplementation((fn: () => Promise<unknown>) => fn());
|
|
});
|
|
|
|
it("logs warning when rate-limit retry logEntry fails in step-session mode", async () => {
|
|
const warnSpy = vi.spyOn(executorLog, "warn");
|
|
const store = createMockStore();
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
runStepsInNewSessions: true,
|
|
maxParallelSteps: 2,
|
|
});
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Rate-limit step-session task",
|
|
description: "Rate-limit diagnostics",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
baseCommitSha: "abc123",
|
|
enabledWorkflowSteps: [],
|
|
});
|
|
|
|
mockExecuteAll.mockResolvedValue([{ stepIndex: 0, success: true, retries: 0 }]);
|
|
|
|
store.logEntry.mockImplementation(async (_taskId: string, message: string) => {
|
|
if (message.includes("Rate limited — retry")) {
|
|
throw new Error("step-session retry log failure");
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
mockedWithRateLimitRetry.mockImplementationOnce((async (
|
|
fn: () => Promise<unknown>,
|
|
options?: { onRetry?: (attempt: number, delayMs: number, error: Error) => void },
|
|
) => {
|
|
options?.onRetry?.(2, 4_000, new Error("rate limit"));
|
|
return fn();
|
|
}) as typeof withRateLimitRetry);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await expect(executor.execute({
|
|
id: "FN-001",
|
|
title: "Rate-limit step-session task",
|
|
description: "Rate-limit diagnostics",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})).resolves.toBeUndefined();
|
|
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("FN-001 failed to log rate-limit retry: step-session retry log failure"),
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("logs warning when rate-limit retry logEntry fails in main-agent mode", async () => {
|
|
const warnSpy = vi.spyOn(executorLog, "warn");
|
|
const store = createMockStore();
|
|
let capturedCustomTools: Array<{ name: string; execute: (callId: string, args: Record<string, unknown>) => Promise<unknown> }> = [];
|
|
|
|
store.logEntry.mockImplementation(async (_taskId: string, message: string) => {
|
|
if (message.includes("Rate limited — retry")) {
|
|
throw new Error("main-agent retry log failure");
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
mockedCreateFnAgent.mockImplementation((async (opts: { customTools?: typeof capturedCustomTools }) => {
|
|
capturedCustomTools = opts.customTools ?? [];
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = capturedCustomTools.find((tool) => tool.name === "task_done");
|
|
if (taskDoneTool) {
|
|
await taskDoneTool.execute("call-1", { summary: "done" });
|
|
}
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
},
|
|
sessionFile: "/tmp/sessions/main-agent-rate-limit.jsonl",
|
|
};
|
|
}) as any);
|
|
|
|
mockedWithRateLimitRetry.mockImplementationOnce((async (
|
|
fn: () => Promise<unknown>,
|
|
options?: { onRetry?: (attempt: number, delayMs: number, error: Error) => void },
|
|
) => {
|
|
options?.onRetry?.(1, 3_000, new Error("rate limit"));
|
|
return fn();
|
|
}) as typeof withRateLimitRetry);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await expect(executor.execute({
|
|
id: "FN-001",
|
|
title: "Rate-limit main-agent task",
|
|
description: "Rate-limit diagnostics",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})).resolves.toBeUndefined();
|
|
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("FN-001 failed to log rate-limit retry: main-agent retry log failure"),
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("logs warning when sessionFile update fails during retry", async () => {
|
|
const warnSpy = vi.spyOn(executorLog, "warn");
|
|
const store = createMockStore();
|
|
const retrySessionFilePath = "/tmp/sessions/retry-failed.jsonl";
|
|
|
|
store.updateTask.mockImplementation(async (_taskId: string, patch: Record<string, unknown>) => {
|
|
if (patch?.sessionFile === retrySessionFilePath) {
|
|
throw new Error("retry sessionFile write failed");
|
|
}
|
|
return {};
|
|
});
|
|
|
|
mockedCreateFnAgent
|
|
.mockResolvedValueOnce({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
sessionFile: "/tmp/sessions/initial.jsonl",
|
|
} as any)
|
|
.mockResolvedValueOnce({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
sessionFile: retrySessionFilePath,
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await expect(executor.execute({
|
|
id: "FN-001",
|
|
title: "Retry session task",
|
|
description: "Session retry diagnostics",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})).resolves.toBeUndefined();
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("FN-001 failed to persist retry sessionFile: retry sessionFile write failed"),
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("logs warning when sessionFile clear fails on completion", async () => {
|
|
const warnSpy = vi.spyOn(executorLog, "warn");
|
|
const store = createMockStore();
|
|
|
|
store.updateTask.mockImplementation(async (_taskId: string, patch: Record<string, unknown>) => {
|
|
if (patch?.sessionFile === null) {
|
|
throw new Error("session clear failed");
|
|
}
|
|
return {};
|
|
});
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
sessionFile: "/tmp/sessions/clear-test.jsonl",
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await expect(executor.execute({
|
|
id: "FN-001",
|
|
title: "Session clear task",
|
|
description: "Session clear diagnostics",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})).resolves.toBeUndefined();
|
|
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("FN-001 failed to clear sessionFile: session clear failed"),
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("logs warning when child agent deletion fails during cleanup", async () => {
|
|
vi.useFakeTimers();
|
|
const warnSpy = vi.spyOn(executorLog, "warn");
|
|
|
|
try {
|
|
const store = createMockStore();
|
|
const agentStore = {
|
|
updateAgentState: vi.fn().mockResolvedValue(undefined),
|
|
deleteAgent: vi.fn().mockRejectedValue(new Error("delete failed")),
|
|
};
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
agentStore: agentStore as any,
|
|
});
|
|
|
|
(executor as any).childSessions.set("child-007", {
|
|
dispose: vi.fn(),
|
|
});
|
|
|
|
await (executor as any).terminateChildAgent("child-007");
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
await Promise.resolve();
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to delete spawned agent child-007: delete failed"),
|
|
);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor executor model hot-swap", () => {
|
|
const buildUpdatedTask = (overrides: Partial<Task> = {}): Task => ({
|
|
id: "FN-001",
|
|
title: "Model task",
|
|
description: "Test model updates",
|
|
column: "in-progress",
|
|
paused: false,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
});
|
|
|
|
const flushTaskUpdated = async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("hot-swaps executor model on active session when modelProvider/modelId change", async () => {
|
|
const store = createMockStore();
|
|
const setModel = vi.fn().mockResolvedValue(undefined);
|
|
const findModel = vi.fn().mockReturnValue({
|
|
provider: { name: "openai" },
|
|
id: "gpt-4o",
|
|
name: "GPT-4o",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
(executor as any)._modelRegistry = { find: findModel };
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: { setModel, dispose: vi.fn() },
|
|
seenSteeringIds: new Set(),
|
|
lastModelProvider: "anthropic",
|
|
lastModelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
store._trigger("task:updated", buildUpdatedTask({
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
}));
|
|
|
|
await flushTaskUpdated();
|
|
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
expect(setModel).toHaveBeenCalledWith(expect.objectContaining({
|
|
provider: expect.objectContaining({ name: "openai" }),
|
|
id: "gpt-4o",
|
|
}));
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Model changed to openai/gpt-4o", undefined, undefined);
|
|
});
|
|
|
|
it("does not attempt hot-swap when no active session exists", async () => {
|
|
const store = createMockStore();
|
|
const setModel = vi.fn().mockResolvedValue(undefined);
|
|
|
|
new TaskExecutor(store, "/tmp/test");
|
|
|
|
store._trigger("task:updated", buildUpdatedTask({
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
}));
|
|
|
|
await flushTaskUpdated();
|
|
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not hot-swap when model fields are unchanged", async () => {
|
|
const store = createMockStore();
|
|
const setModel = vi.fn().mockResolvedValue(undefined);
|
|
const findModel = vi.fn();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
(executor as any)._modelRegistry = { find: findModel };
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: { setModel, dispose: vi.fn() },
|
|
seenSteeringIds: new Set(),
|
|
lastModelProvider: "anthropic",
|
|
lastModelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
store._trigger("task:updated", buildUpdatedTask({
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
}));
|
|
|
|
await flushTaskUpdated();
|
|
|
|
expect(findModel).not.toHaveBeenCalled();
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hot-swaps to settings default when override is cleared", async () => {
|
|
const store = createMockStore();
|
|
const setModel = vi.fn().mockResolvedValue(undefined);
|
|
const findModel = vi.fn().mockReturnValue({
|
|
provider: { name: "openai" },
|
|
id: "gpt-4o",
|
|
name: "GPT-4o",
|
|
});
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
(executor as any)._modelRegistry = { find: findModel };
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: { setModel, dispose: vi.fn() },
|
|
seenSteeringIds: new Set(),
|
|
lastModelProvider: "anthropic",
|
|
lastModelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
store._trigger("task:updated", buildUpdatedTask({
|
|
modelProvider: undefined,
|
|
modelId: undefined,
|
|
}));
|
|
|
|
await flushTaskUpdated();
|
|
|
|
expect(findModel).toHaveBeenCalledWith("openai", "gpt-4o");
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("logs error and continues when setModel fails", async () => {
|
|
const store = createMockStore();
|
|
const setModel = vi.fn().mockRejectedValue(new Error("API key not found"));
|
|
const findModel = vi.fn().mockReturnValue({
|
|
provider: { name: "openai" },
|
|
id: "gpt-4o",
|
|
name: "GPT-4o",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
(executor as any)._modelRegistry = { find: findModel };
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: { setModel, dispose: vi.fn() },
|
|
seenSteeringIds: new Set(),
|
|
lastModelProvider: "anthropic",
|
|
lastModelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
store._trigger("task:updated", buildUpdatedTask({
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
}));
|
|
|
|
await flushTaskUpdated();
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Model change failed: API key not found", undefined, undefined);
|
|
expect((executor as any).activeSessions.has("FN-001")).toBe(true);
|
|
});
|
|
|
|
it("does not attempt hot-swap on paused task", async () => {
|
|
const store = createMockStore();
|
|
const setModel = vi.fn().mockResolvedValue(undefined);
|
|
const dispose = vi.fn();
|
|
const findModel = vi.fn();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
(executor as any)._modelRegistry = { find: findModel };
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: { setModel, dispose },
|
|
seenSteeringIds: new Set(),
|
|
lastModelProvider: "anthropic",
|
|
lastModelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
store._trigger("task:updated", buildUpdatedTask({
|
|
paused: true,
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
}));
|
|
|
|
await flushTaskUpdated();
|
|
|
|
expect(dispose).toHaveBeenCalledTimes(1);
|
|
expect(findModel).not.toHaveBeenCalled();
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor task:updated listener guards", () => {
|
|
it("catches and logs errors from async task:updated operations", async () => {
|
|
const store = createMockStore();
|
|
const terminateError = new Error("terminate failed");
|
|
const terminateAllSessions = vi.fn().mockRejectedValue(terminateError);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
(executor as any).activeStepExecutors.set("FN-001", {
|
|
terminateAllSessions,
|
|
});
|
|
|
|
const taskUpdatedHandler = (store.on as unknown as ReturnType<typeof vi.fn>).mock.calls
|
|
.find((call: any[]) => call[0] === "task:updated")?.[1];
|
|
|
|
expect(taskUpdatedHandler).toBeTypeOf("function");
|
|
|
|
await expect(taskUpdatedHandler({
|
|
id: "FN-001",
|
|
title: "Guard test",
|
|
description: "Guard test",
|
|
column: "in-progress",
|
|
paused: true,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} satisfies Task)).resolves.toBeUndefined();
|
|
|
|
expect(terminateAllSessions).toHaveBeenCalledTimes(1);
|
|
expect(executorLog.error).toHaveBeenCalledWith("Uncaught error in task:updated listener:", terminateError);
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor global pause behavior", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("disposes all active sessions when settings:updated fires with globalPause: true", async () => {
|
|
const store = createMockStore();
|
|
const disposeFn1 = vi.fn();
|
|
const disposeFn2 = vi.fn();
|
|
let callCount = 0;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => {
|
|
callCount++;
|
|
const dispose = callCount === 1 ? disposeFn1 : disposeFn2;
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Wait for the global pause to fire; only fire once for first task
|
|
if (callCount === 2) {
|
|
store._trigger("settings:updated", {
|
|
settings: { globalPause: true },
|
|
previous: { globalPause: false },
|
|
});
|
|
}
|
|
throw new Error("Session terminated");
|
|
}),
|
|
dispose,
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Execute two tasks concurrently
|
|
await Promise.all([
|
|
executor.execute({
|
|
id: "FN-001", title: "T1", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
}),
|
|
executor.execute({
|
|
id: "FN-002", title: "T2", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
}),
|
|
]);
|
|
|
|
// Both tasks should be moved to todo (not marked as failed)
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-002", "todo");
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", { status: "failed" });
|
|
});
|
|
|
|
it("moves paused tasks to todo (not marked as failed)", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
store._trigger("settings:updated", {
|
|
settings: { globalPause: true },
|
|
previous: { globalPause: false },
|
|
});
|
|
throw new Error("Session terminated");
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001", title: "Test", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
|
|
});
|
|
|
|
it("finalizes to in-review when global pause hits after task_done", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
const customTools = opts.customTools || [];
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) {
|
|
await taskDoneTool.execute("tool-1", {});
|
|
}
|
|
store._trigger("settings:updated", {
|
|
settings: { globalPause: true },
|
|
previous: { globalPause: false },
|
|
});
|
|
throw new Error("Session terminated");
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001", title: "Test", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [{ name: "Step 1", status: "pending" }], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
});
|
|
|
|
it("takes no action when globalPause remains false", async () => {
|
|
const store = createMockStore();
|
|
const disposeFn = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Trigger settings:updated but globalPause stays false
|
|
store._trigger("settings:updated", {
|
|
settings: { globalPause: false },
|
|
previous: { globalPause: false },
|
|
});
|
|
}),
|
|
dispose: disposeFn,
|
|
},
|
|
} as any));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001", title: "Test", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should move to in-review (normal completion), not todo
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
});
|
|
|
|
it("takes no action when globalPause transitions from true to true", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Trigger settings:updated but globalPause is already true
|
|
store._trigger("settings:updated", {
|
|
settings: { globalPause: true },
|
|
previous: { globalPause: true },
|
|
});
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001", title: "Test", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should move to in-review (normal completion), not todo
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor enginePaused soft pause (no agent termination)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("does NOT dispose active sessions when enginePaused transitions false→true", async () => {
|
|
const store = createMockStore();
|
|
const disposeFn = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Trigger engine pause while the session is active
|
|
store._trigger("settings:updated", {
|
|
settings: { enginePaused: true },
|
|
previous: { enginePaused: false },
|
|
});
|
|
// Session continues normally — no error thrown
|
|
}),
|
|
dispose: disposeFn,
|
|
},
|
|
} as any));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001", title: "Test", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// dispose called twice: initial session + retry session (both cleaned up normally),
|
|
// NOT by an engine pause listener
|
|
expect(disposeFn).toHaveBeenCalledTimes(2);
|
|
// Task should complete normally and move to in-review, not todo
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
|
|
});
|
|
|
|
it("does NOT move tasks to todo when enginePaused transitions false→true", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
store._trigger("settings:updated", {
|
|
settings: { enginePaused: true },
|
|
previous: { enginePaused: false },
|
|
});
|
|
// Session continues normally
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001", title: "Test", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Task should complete normally (in-review), not be moved to todo
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
});
|
|
|
|
it("takes no action when enginePaused stays false (false→false)", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
store._trigger("settings:updated", {
|
|
settings: { enginePaused: false },
|
|
previous: { enginePaused: false },
|
|
});
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001", title: "Test", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should move to in-review (normal completion), not todo
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
});
|
|
|
|
it("takes no action when enginePaused stays true (true→true)", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
store._trigger("settings:updated", {
|
|
settings: { enginePaused: true },
|
|
previous: { enginePaused: true },
|
|
});
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001", title: "Test", description: "T", column: "in-progress",
|
|
dependencies: [], steps: [], currentStep: 0, log: [],
|
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should move to in-review (normal completion), not todo
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
});
|
|
});
|
|
|
|
// ── Code review verdict enforcement tests ────────────────────────────
|
|
|
|
const mockedReviewStep = vi.mocked(mockedReviewStepFn);
|
|
|
|
/**
|
|
* Helper: executes a task and captures the custom tools passed to createFnAgent.
|
|
* Returns a map of tool name → tool execute function for direct testing.
|
|
*/
|
|
async function captureTools(): Promise<Record<string, (id: string, params: any) => Promise<any>>> {
|
|
const store = createMockStore();
|
|
store.updateStep.mockResolvedValue({
|
|
steps: [
|
|
{ name: "Preflight", status: "done" },
|
|
{ name: "Implement", status: "in-progress" },
|
|
{ name: "Testing", status: "pending" },
|
|
],
|
|
});
|
|
mockedExistsSync.mockReturnValue(true);
|
|
|
|
let capturedTools: any[] = [];
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedTools = opts.customTools || [];
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-TEST",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const tools: Record<string, any> = {};
|
|
for (const t of capturedTools) {
|
|
tools[t.name] = t.execute;
|
|
}
|
|
return tools;
|
|
}
|
|
|
|
describe("Code review verdict tracking", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("code review REVISE sets tracking state", async () => {
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "REVISE",
|
|
review: "Fix the bug",
|
|
summary: "Needs fixes",
|
|
});
|
|
|
|
const tools = await captureTools();
|
|
const result = await tools.review_step("call1", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Implement",
|
|
baseline: "abc123",
|
|
});
|
|
|
|
expect(result.content[0].text).toContain("REVISE");
|
|
expect(result.content[0].text).toContain("cannot be marked done");
|
|
|
|
// Now task_update(step=1, status="done") should be blocked
|
|
const updateResult = await tools.task_update("call2", { step: 1, status: "done" });
|
|
expect(updateResult.content[0].text).toContain("Cannot mark Step 1 as done");
|
|
expect(updateResult.content[0].text).toContain("REVISE");
|
|
});
|
|
|
|
it("code review APPROVE clears tracking state", async () => {
|
|
// First: REVISE
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "REVISE",
|
|
review: "Fix the bug",
|
|
summary: "Needs fixes",
|
|
});
|
|
|
|
const tools = await captureTools();
|
|
await tools.review_step("call1", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Implement",
|
|
baseline: "abc123",
|
|
});
|
|
|
|
// Verify it's blocked
|
|
const blocked = await tools.task_update("call2", { step: 1, status: "done" });
|
|
expect(blocked.content[0].text).toContain("Cannot mark Step 1 as done");
|
|
|
|
// Now: APPROVE
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "APPROVE",
|
|
review: "Looks good",
|
|
summary: "All good",
|
|
});
|
|
|
|
await tools.review_step("call3", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Implement",
|
|
baseline: "def456",
|
|
});
|
|
|
|
// Now task_update should succeed
|
|
const updateResult = await tools.task_update("call4", { step: 1, status: "done" });
|
|
expect(updateResult.content[0].text).toContain("→ done");
|
|
});
|
|
|
|
it("plan review REVISE does NOT set tracking state", async () => {
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "REVISE",
|
|
review: "Reconsider approach",
|
|
summary: "Plan issues",
|
|
});
|
|
|
|
const tools = await captureTools();
|
|
const result = await tools.review_step("call1", {
|
|
step: 1,
|
|
type: "plan",
|
|
step_name: "Implement",
|
|
});
|
|
|
|
// Plan REVISE should use the non-enforced text format
|
|
expect(result.content[0].text).toContain("REVISE");
|
|
expect(result.content[0].text).not.toContain("cannot be marked done");
|
|
|
|
// task_update should still work (plan reviews are advisory)
|
|
const updateResult = await tools.task_update("call2", { step: 1, status: "done" });
|
|
expect(updateResult.content[0].text).toContain("→ done");
|
|
});
|
|
});
|
|
|
|
describe("Code review verdict enforcement - task_update blocking", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("task_update(status='done') is rejected when last code review was REVISE", async () => {
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "REVISE",
|
|
review: "Fix issues",
|
|
summary: "Needs work",
|
|
});
|
|
|
|
const tools = await captureTools();
|
|
await tools.review_step("call1", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Implement",
|
|
baseline: "abc",
|
|
});
|
|
|
|
const result = await tools.task_update("call2", { step: 1, status: "done" });
|
|
expect(result.content[0].text).toContain("Cannot mark Step 1 as done");
|
|
expect(result.content[0].text).toContain("review_step");
|
|
});
|
|
|
|
it("task_update succeeds after a subsequent APPROVE", async () => {
|
|
const tools = await captureTools();
|
|
|
|
// REVISE first
|
|
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix", summary: "Bad" });
|
|
await tools.review_step("c1", { step: 1, type: "code", step_name: "Impl", baseline: "a" });
|
|
|
|
// Then APPROVE
|
|
mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "OK", summary: "Good" });
|
|
await tools.review_step("c2", { step: 1, type: "code", step_name: "Impl", baseline: "b" });
|
|
|
|
const result = await tools.task_update("c3", { step: 1, status: "done" });
|
|
expect(result.content[0].text).toContain("→ done");
|
|
});
|
|
|
|
it("task_update succeeds when no code review was requested (review level 0)", async () => {
|
|
const tools = await captureTools();
|
|
|
|
// No review_step calls at all
|
|
const result = await tools.task_update("c1", { step: 1, status: "done" });
|
|
expect(result.content[0].text).toContain("→ done");
|
|
});
|
|
|
|
it("plan-only REVISE does NOT block advancement", async () => {
|
|
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Rethink", summary: "Plan issue" });
|
|
|
|
const tools = await captureTools();
|
|
await tools.review_step("c1", { step: 1, type: "plan", step_name: "Impl" });
|
|
|
|
const result = await tools.task_update("c2", { step: 1, status: "done" });
|
|
expect(result.content[0].text).toContain("→ done");
|
|
});
|
|
|
|
it("multiple steps tracked independently (REVISE on step 1 doesn't block step 2)", async () => {
|
|
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix", summary: "Bad" });
|
|
|
|
const tools = await captureTools();
|
|
await tools.review_step("c1", { step: 1, type: "code", step_name: "Step1", baseline: "a" });
|
|
|
|
// Step 1 is blocked
|
|
const blocked = await tools.task_update("c2", { step: 1, status: "done" });
|
|
expect(blocked.content[0].text).toContain("Cannot mark Step 1 as done");
|
|
|
|
// Step 2 is NOT blocked (no review for step 2)
|
|
const allowed = await tools.task_update("c3", { step: 2, status: "done" });
|
|
expect(allowed.content[0].text).toContain("→ done");
|
|
});
|
|
|
|
it("REVISE tool response text includes re-review instructions", async () => {
|
|
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Bug found", summary: "Issues" });
|
|
|
|
const tools = await captureTools();
|
|
const result = await tools.review_step("c1", { step: 1, type: "code", step_name: "Implement", baseline: "abc" });
|
|
|
|
expect(result.content[0].text).toContain("cannot be marked done");
|
|
expect(result.content[0].text).toContain("review_step");
|
|
expect(result.content[0].text).toContain('type="code"');
|
|
});
|
|
|
|
it("EXECUTOR_SYSTEM_PROMPT contains code review enforcement language", async () => {
|
|
// Capture the system prompt passed to createFnAgent
|
|
let capturedSystemPrompt = "";
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedSystemPrompt = opts.systemPrompt || "";
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn(), branchWithSummary: vi.fn() },
|
|
navigateTree: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-SYS",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Verify enforcement language is present in system prompt
|
|
expect(capturedSystemPrompt).toContain("enforced");
|
|
expect(capturedSystemPrompt).toContain("will be rejected until the code review passes");
|
|
expect(capturedSystemPrompt).toContain("REVISE (plan review)");
|
|
expect(capturedSystemPrompt).toContain("advisory");
|
|
});
|
|
|
|
// Note: The EXECUTOR_SYSTEM_PROMPT constant is tested indirectly via the buildExecutionPrompt test.
|
|
// The direct test for EXECUTOR_SYSTEM_PROMPT is skipped because of module caching issues in vitest.
|
|
// The buildExecutionPrompt test verifies the CRITICAL language is included in execution prompts.
|
|
|
|
it("task_update with non-done status is not blocked by REVISE", async () => {
|
|
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix", summary: "Bad" });
|
|
|
|
const tools = await captureTools();
|
|
await tools.review_step("c1", { step: 1, type: "code", step_name: "Step1", baseline: "a" });
|
|
|
|
// "in-progress" should still work even with REVISE
|
|
const result = await tools.task_update("c2", { step: 1, status: "in-progress" });
|
|
expect(result.content[0].text).toContain("→ in-progress");
|
|
});
|
|
});
|
|
|
|
// ── RETHINK verdict handling tests ───────────────────────────────────
|
|
|
|
describe("RETHINK verdict handling", () => {
|
|
const makeTask = (id = "FN-040") => ({
|
|
id,
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
/** Return value for store.updateStep that satisfies the task_update tool. */
|
|
function makeStepResult(stepIndex: number, status: string) {
|
|
const steps = Array.from({ length: Math.max(stepIndex + 1, 3) }, (_, i) => ({
|
|
name: `Step ${i}`,
|
|
status: i === stepIndex ? status : "pending",
|
|
}));
|
|
return { steps };
|
|
}
|
|
|
|
/**
|
|
* Helper: run executor and capture custom tools from createFnAgent mock.
|
|
* Returns the tools map keyed by tool name.
|
|
*/
|
|
async function captureRethinkTools(store: any, options?: any) {
|
|
let capturedTools: any[] = [];
|
|
const mockSessionManager = {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-checkpoint-123"),
|
|
branchWithSummary: vi.fn().mockReturnValue("new-branch-id"),
|
|
};
|
|
const mockNavigateTree = vi.fn().mockResolvedValue({ cancelled: false });
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: mockSessionManager,
|
|
navigateTree: mockNavigateTree,
|
|
};
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedTools = opts.customTools || [];
|
|
return { session: mockSession } as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", options);
|
|
await executor.execute(makeTask());
|
|
|
|
const toolMap = new Map<string, any>();
|
|
for (const tool of capturedTools) {
|
|
toolMap.set(tool.name, tool);
|
|
}
|
|
return { toolMap, mockSession, mockSessionManager, mockNavigateTree };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("RETHINK verdict triggers git reset --hard to baseline SHA", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Wrong approach, try something else",
|
|
summary: "Rejected approach",
|
|
});
|
|
|
|
const { toolMap } = await captureRethinkTools(store);
|
|
const reviewTool = toolMap.get("review_step");
|
|
|
|
// First call task_update to set in-progress (captures checkpoint)
|
|
const updateTool = toolMap.get("task_update");
|
|
await updateTool.execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
// Now call review_step with a baseline
|
|
const result = await reviewTool.execute("call-2", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Test Step",
|
|
baseline: "abc123def",
|
|
});
|
|
|
|
// Verify git reset was called
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
"git reset --hard abc123def",
|
|
expect.objectContaining({ cwd: expect.stringContaining(".worktrees/") }),
|
|
);
|
|
});
|
|
|
|
it("RETHINK verdict rewinds session to pre-step checkpoint", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Fundamentally wrong",
|
|
summary: "Bad approach",
|
|
});
|
|
|
|
const { toolMap, mockNavigateTree } = await captureRethinkTools(store);
|
|
|
|
// Capture checkpoint
|
|
const updateTool = toolMap.get("task_update");
|
|
await updateTool.execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
// Trigger RETHINK
|
|
await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Test Step",
|
|
baseline: "abc123",
|
|
});
|
|
|
|
// Verify navigateTree was called with checkpoint and summarize: false
|
|
expect(mockNavigateTree).toHaveBeenCalledWith("leaf-checkpoint-123", { summarize: false });
|
|
});
|
|
|
|
it("RETHINK verdict resets step status to pending", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Try again",
|
|
summary: "Rejected",
|
|
});
|
|
|
|
const { toolMap } = await captureRethinkTools(store);
|
|
|
|
const updateTool = toolMap.get("task_update");
|
|
await updateTool.execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Test Step",
|
|
baseline: "abc123",
|
|
});
|
|
|
|
// updateStep should be called: once for in-progress, once for pending (reset)
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-040", 1, "pending");
|
|
});
|
|
|
|
it("RETHINK re-prompt includes reviewer feedback", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Your approach uses polling when it should use events",
|
|
summary: "Wrong architecture",
|
|
});
|
|
|
|
const { toolMap } = await captureRethinkTools(store);
|
|
|
|
const updateTool = toolMap.get("task_update");
|
|
await updateTool.execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
const result = await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Test Step",
|
|
baseline: "abc123",
|
|
});
|
|
|
|
const text = result.content[0].text;
|
|
expect(text).toContain("RETHINK");
|
|
expect(text).toContain("Your approach uses polling when it should use events");
|
|
expect(text).toContain("Take a different approach");
|
|
expect(text).toContain("Do NOT repeat the rejected strategy");
|
|
});
|
|
|
|
it("RETHINK without baseline SHA skips git reset but still rewinds conversation", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Wrong approach",
|
|
summary: "Rejected",
|
|
});
|
|
|
|
const { toolMap, mockNavigateTree } = await captureRethinkTools(store);
|
|
|
|
const updateTool = toolMap.get("task_update");
|
|
await updateTool.execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
// Call review_step WITHOUT baseline
|
|
await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Test Step",
|
|
// no baseline
|
|
});
|
|
|
|
// git reset should NOT be called (no baseline)
|
|
const gitResetCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard"),
|
|
);
|
|
expect(gitResetCalls).toHaveLength(0);
|
|
|
|
// But session rewind should still happen
|
|
expect(mockNavigateTree).toHaveBeenCalledWith("leaf-checkpoint-123", { summarize: false });
|
|
});
|
|
|
|
it("RETHINK without session checkpoint falls back gracefully", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Bad approach",
|
|
summary: "Rejected",
|
|
});
|
|
|
|
const { toolMap, mockNavigateTree } = await captureRethinkTools(store);
|
|
|
|
// Do NOT call task_update for step 2, so no checkpoint exists
|
|
|
|
// Call review_step for step 2 — should not crash
|
|
const result = await toolMap.get("review_step").execute("call-2", {
|
|
step: 2,
|
|
type: "code",
|
|
step_name: "Test Step",
|
|
baseline: "abc123",
|
|
});
|
|
|
|
// navigateTree should NOT be called (no checkpoint)
|
|
expect(mockNavigateTree).not.toHaveBeenCalled();
|
|
|
|
// Should still return RETHINK feedback
|
|
expect(result.content[0].text).toContain("RETHINK");
|
|
});
|
|
|
|
it("pre-step checkpoint is captured when task_update sets status to in-progress", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
const { toolMap, mockSessionManager } = await captureRethinkTools(store);
|
|
|
|
const updateTool = toolMap.get("task_update");
|
|
await updateTool.execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
// Verify getLeafId was called
|
|
expect(mockSessionManager.getLeafId).toHaveBeenCalled();
|
|
});
|
|
|
|
it("RETHINK falls back to branchWithSummary when navigateTree fails", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Wrong approach",
|
|
summary: "Rejected",
|
|
});
|
|
|
|
// Create tools but make navigateTree throw
|
|
let capturedTools: any[] = [];
|
|
const mockSessionManager = {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-checkpoint-456"),
|
|
branchWithSummary: vi.fn().mockReturnValue("new-branch-id"),
|
|
};
|
|
const mockNavigateTree = vi.fn().mockRejectedValue(new Error("navigateTree not available"));
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: mockSessionManager,
|
|
navigateTree: mockNavigateTree,
|
|
};
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedTools = opts.customTools || [];
|
|
return { session: mockSession } as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
const toolMap = new Map<string, any>();
|
|
for (const tool of capturedTools) toolMap.set(tool.name, tool);
|
|
|
|
// Capture checkpoint
|
|
await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
// Trigger RETHINK
|
|
await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "code",
|
|
step_name: "Test Step",
|
|
baseline: "abc123",
|
|
});
|
|
|
|
// navigateTree was called but failed → should fall back to branchWithSummary
|
|
expect(mockNavigateTree).toHaveBeenCalled();
|
|
expect(mockSessionManager.branchWithSummary).toHaveBeenCalledWith(
|
|
"leaf-checkpoint-456",
|
|
expect.stringContaining("RETHINK"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Plan RETHINK verdict handling tests ──────────────────────────────
|
|
|
|
describe("Plan RETHINK verdict handling", () => {
|
|
const makeTask = (id = "FN-050") => ({
|
|
id,
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
function makeStepResult(stepIndex: number, status: string) {
|
|
const steps = Array.from({ length: Math.max(stepIndex + 1, 3) }, (_, i) => ({
|
|
name: `Step ${i}`,
|
|
status: i === stepIndex ? status : "pending",
|
|
}));
|
|
return { steps };
|
|
}
|
|
|
|
async function capturePlanRethinkTools(store: any) {
|
|
let capturedTools: any[] = [];
|
|
const mockSessionManager = {
|
|
getLeafId: vi.fn().mockReturnValue("plan-checkpoint-789"),
|
|
branchWithSummary: vi.fn().mockReturnValue("new-branch-id"),
|
|
};
|
|
const mockNavigateTree = vi.fn().mockResolvedValue({ cancelled: false });
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: mockSessionManager,
|
|
navigateTree: mockNavigateTree,
|
|
};
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedTools = opts.customTools || [];
|
|
return { session: mockSession } as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute(makeTask());
|
|
|
|
const toolMap = new Map<string, any>();
|
|
for (const tool of capturedTools) {
|
|
toolMap.set(tool.name, tool);
|
|
}
|
|
return { toolMap, mockSession, mockSessionManager, mockNavigateTree };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("plan RETHINK verdict rewinds session to pre-step checkpoint", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Plan is fundamentally flawed",
|
|
summary: "Bad plan",
|
|
});
|
|
|
|
const { toolMap, mockNavigateTree } = await capturePlanRethinkTools(store);
|
|
|
|
// Capture checkpoint by starting step
|
|
await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
// Trigger plan RETHINK
|
|
await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "plan",
|
|
step_name: "Test Step",
|
|
});
|
|
|
|
// Session should be rewound to checkpoint
|
|
expect(mockNavigateTree).toHaveBeenCalledWith("plan-checkpoint-789", { summarize: false });
|
|
});
|
|
|
|
it("plan RETHINK verdict does NOT trigger git reset", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Wrong plan",
|
|
summary: "Rejected",
|
|
});
|
|
|
|
const { toolMap } = await capturePlanRethinkTools(store);
|
|
|
|
await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
// Even if baseline is passed, plan RETHINK should NOT git reset
|
|
await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "plan",
|
|
step_name: "Test Step",
|
|
baseline: "some-sha-that-should-be-ignored",
|
|
});
|
|
|
|
// git reset should NOT be called for plan reviews
|
|
const gitResetCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard"),
|
|
);
|
|
expect(gitResetCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("plan RETHINK verdict resets step status to pending", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Try another plan",
|
|
summary: "Rejected plan",
|
|
});
|
|
|
|
const { toolMap } = await capturePlanRethinkTools(store);
|
|
|
|
await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "plan",
|
|
step_name: "Test Step",
|
|
});
|
|
|
|
// updateStep should be called with "pending" to reset the step
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-050", 1, "pending");
|
|
});
|
|
|
|
it("plan RETHINK re-prompt includes reviewer feedback and plan-specific language", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "This plan overlooks critical edge cases in error handling",
|
|
summary: "Insufficient plan",
|
|
});
|
|
|
|
const { toolMap } = await capturePlanRethinkTools(store);
|
|
|
|
await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
const result = await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "plan",
|
|
step_name: "Test Step",
|
|
});
|
|
|
|
const text = result.content[0].text;
|
|
expect(text).toContain("RETHINK");
|
|
expect(text).toContain("Your plan was rejected");
|
|
expect(text).toContain("This plan overlooks critical edge cases in error handling");
|
|
expect(text).toContain("Take a different approach to planning this step");
|
|
expect(text).toContain("Do NOT repeat the rejected strategy");
|
|
});
|
|
|
|
it("plan RETHINK without session checkpoint falls back gracefully", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Bad plan",
|
|
summary: "Rejected",
|
|
});
|
|
|
|
const { toolMap, mockNavigateTree } = await capturePlanRethinkTools(store);
|
|
|
|
// Do NOT call task_update for step 2, so no checkpoint exists
|
|
|
|
// Call review_step for step 2 — should not crash
|
|
const result = await toolMap.get("review_step").execute("call-2", {
|
|
step: 2,
|
|
type: "plan",
|
|
step_name: "Test Step",
|
|
});
|
|
|
|
// navigateTree should NOT be called (no checkpoint)
|
|
expect(mockNavigateTree).not.toHaveBeenCalled();
|
|
|
|
// Should still return RETHINK feedback with plan-specific text
|
|
expect(result.content[0].text).toContain("RETHINK");
|
|
expect(result.content[0].text).toContain("Your plan was rejected");
|
|
});
|
|
|
|
it("plan RETHINK logs correctly without git reset info", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK",
|
|
review: "Wrong plan",
|
|
summary: "Plan rejected",
|
|
});
|
|
|
|
const { toolMap } = await capturePlanRethinkTools(store);
|
|
|
|
await toolMap.get("task_update").execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
await toolMap.get("review_step").execute("call-2", {
|
|
step: 1,
|
|
type: "plan",
|
|
step_name: "Test Step",
|
|
});
|
|
|
|
// Verify log entry uses plan-specific message (no git reset reference)
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-050",
|
|
expect.stringContaining("plan rewound"),
|
|
"Plan rejected",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── E2E review pipeline sequence tests ─────────────────────────────
|
|
|
|
describe("E2E review pipeline — multi-verdict sequence", () => {
|
|
/**
|
|
* Exercises the full review pipeline within a single task execution:
|
|
* plan review → APPROVE
|
|
* code review → REVISE (blocked)
|
|
* code review → APPROVE (unblocked)
|
|
* step done → success
|
|
*
|
|
* Verifies that verdicts compose correctly across the full lifecycle.
|
|
*/
|
|
|
|
function makeStepResult(stepIndex: number, status: string) {
|
|
const steps = Array.from({ length: 3 }, (_, i) => ({
|
|
name: [`Preflight`, `Implement`, `Tests`][i],
|
|
status: i === stepIndex ? status : i < stepIndex ? "done" : "pending",
|
|
}));
|
|
return { steps };
|
|
}
|
|
|
|
async function captureE2ETools(store: any) {
|
|
let capturedTools: any[] = [];
|
|
const mockSessionManager = {
|
|
getLeafId: vi.fn().mockReturnValue("e2e-checkpoint"),
|
|
branchWithSummary: vi.fn(),
|
|
};
|
|
const mockNavigateTree = vi.fn().mockResolvedValue({ cancelled: false });
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: mockSessionManager,
|
|
navigateTree: mockNavigateTree,
|
|
};
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedTools = opts.customTools || [];
|
|
return { session: mockSession } as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-E2E",
|
|
title: "E2E Test",
|
|
description: "E2E pipeline test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const tools: Record<string, any> = {};
|
|
for (const t of capturedTools) {
|
|
tools[t.name] = t.execute;
|
|
}
|
|
return { tools, mockNavigateTree, mockSessionManager };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("full sequence: plan APPROVE → code REVISE (blocked) → code APPROVE (unblocked) → done", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
const { tools } = await captureE2ETools(store);
|
|
|
|
// Step 1: Start the step
|
|
await tools.task_update("u1", { step: 1, status: "in-progress" });
|
|
|
|
// Step 2: Plan review → APPROVE (advisory, no blocking)
|
|
mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Good plan", summary: "Approved" });
|
|
const planResult = await tools.review_step("r1", {
|
|
step: 1, type: "plan", step_name: "Implement",
|
|
});
|
|
expect(planResult.content[0].text).toBe("APPROVE");
|
|
|
|
// Step 3: Code review → REVISE (should block advancement)
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "REVISE", review: "Missing error handling in fetchUser()", summary: "Needs fixes",
|
|
});
|
|
const reviseResult = await tools.review_step("r2", {
|
|
step: 1, type: "code", step_name: "Implement", baseline: "sha-1",
|
|
});
|
|
expect(reviseResult.content[0].text).toContain("cannot be marked done");
|
|
|
|
// Step 4: Attempt to mark done — should be blocked
|
|
const blockedResult = await tools.task_update("u2", { step: 1, status: "done" });
|
|
expect(blockedResult.content[0].text).toContain("Cannot mark Step 1 as done");
|
|
|
|
// Step 5: Fix issues, re-submit code review → APPROVE
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "APPROVE", review: "Error handling added correctly", summary: "All good",
|
|
});
|
|
const approveResult = await tools.review_step("r3", {
|
|
step: 1, type: "code", step_name: "Implement", baseline: "sha-2",
|
|
});
|
|
expect(approveResult.content[0].text).toBe("APPROVE");
|
|
|
|
// Step 6: Now marking done should succeed
|
|
const doneResult = await tools.task_update("u3", { step: 1, status: "done" });
|
|
expect(doneResult.content[0].text).toContain("→ done");
|
|
});
|
|
|
|
it("full sequence: code RETHINK → git reset + session rewind → retry with APPROVE → done", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
const { tools, mockNavigateTree } = await captureE2ETools(store);
|
|
|
|
// Step 1: Start the step (captures checkpoint)
|
|
await tools.task_update("u1", { step: 1, status: "in-progress" });
|
|
|
|
// Step 2: Code review → RETHINK (rewind everything)
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK", review: "Using polling instead of events is wrong", summary: "Bad approach",
|
|
});
|
|
const rethinkResult = await tools.review_step("r1", {
|
|
step: 1, type: "code", step_name: "Implement", baseline: "sha-bad",
|
|
});
|
|
|
|
// Verify RETHINK outcomes
|
|
expect(rethinkResult.content[0].text).toContain("RETHINK");
|
|
expect(rethinkResult.content[0].text).toContain("Do NOT repeat the rejected strategy");
|
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
"git reset --hard sha-bad",
|
|
expect.objectContaining({ cwd: expect.any(String) }),
|
|
);
|
|
expect(mockNavigateTree).toHaveBeenCalledWith("e2e-checkpoint", { summarize: false });
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-E2E", 1, "pending");
|
|
|
|
// Step 3: Restart the step (new approach)
|
|
await tools.task_update("u2", { step: 1, status: "in-progress" });
|
|
|
|
// Step 4: Code review → APPROVE on second attempt
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "APPROVE", review: "Event-driven approach is correct", summary: "Approved",
|
|
});
|
|
const approveResult = await tools.review_step("r2", {
|
|
step: 1, type: "code", step_name: "Implement", baseline: "sha-good",
|
|
});
|
|
expect(approveResult.content[0].text).toBe("APPROVE");
|
|
|
|
// Step 5: Mark done — should succeed (no REVISE blocking)
|
|
const doneResult = await tools.task_update("u3", { step: 1, status: "done" });
|
|
expect(doneResult.content[0].text).toContain("→ done");
|
|
});
|
|
|
|
it("multi-step pipeline: step 1 APPROVE, step 2 REVISE, step 1 remains unaffected", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
const { tools } = await captureE2ETools(store);
|
|
|
|
// Step 1: Complete with APPROVE
|
|
await tools.task_update("u1", { step: 1, status: "in-progress" });
|
|
mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "OK", summary: "Good" });
|
|
await tools.review_step("r1", { step: 1, type: "code", step_name: "Implement", baseline: "sha-1" });
|
|
const step1Done = await tools.task_update("u2", { step: 1, status: "done" });
|
|
expect(step1Done.content[0].text).toContain("→ done");
|
|
|
|
// Step 2: Gets REVISE
|
|
await tools.task_update("u3", { step: 2, status: "in-progress" });
|
|
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Tests insufficient", summary: "Bad" });
|
|
await tools.review_step("r2", { step: 2, type: "code", step_name: "Tests", baseline: "sha-2" });
|
|
|
|
// Step 2 blocked
|
|
const step2Blocked = await tools.task_update("u4", { step: 2, status: "done" });
|
|
expect(step2Blocked.content[0].text).toContain("Cannot mark Step 2 as done");
|
|
|
|
// Step 1 remains unaffected — if agent tries to re-update step 1, it still works
|
|
// (step isolation: REVISE on step 2 does not affect step 1)
|
|
});
|
|
|
|
it("plan RETHINK followed by plan APPROVE allows code phase to proceed", async () => {
|
|
const store = createMockStore();
|
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
|
makeStepResult(step, status),
|
|
);
|
|
|
|
const { tools, mockNavigateTree } = await captureE2ETools(store);
|
|
|
|
// Start step
|
|
await tools.task_update("u1", { step: 1, status: "in-progress" });
|
|
|
|
// Plan review → RETHINK
|
|
mockedReviewStep.mockResolvedValue({
|
|
verdict: "RETHINK", review: "Plan ignores edge cases", summary: "Bad plan",
|
|
});
|
|
const rethinkResult = await tools.review_step("r1", {
|
|
step: 1, type: "plan", step_name: "Implement",
|
|
});
|
|
expect(rethinkResult.content[0].text).toContain("Your plan was rejected");
|
|
|
|
// Verify plan RETHINK does NOT trigger git reset
|
|
const gitResetCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard"),
|
|
);
|
|
expect(gitResetCalls).toHaveLength(0);
|
|
|
|
// Session was rewound
|
|
expect(mockNavigateTree).toHaveBeenCalled();
|
|
|
|
// Restart step with new plan
|
|
await tools.task_update("u2", { step: 1, status: "in-progress" });
|
|
|
|
// Plan review → APPROVE
|
|
mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Good plan", summary: "Approved" });
|
|
await tools.review_step("r2", { step: 1, type: "plan", step_name: "Implement" });
|
|
|
|
// Code phase: APPROVE directly
|
|
mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Clean code", summary: "Good" });
|
|
await tools.review_step("r3", { step: 1, type: "code", step_name: "Implement", baseline: "sha-1" });
|
|
|
|
// Mark done — should succeed (plan reviews are advisory, code APPROVE clears the path)
|
|
const doneResult = await tools.task_update("u3", { step: 1, status: "done" });
|
|
expect(doneResult.content[0].text).toContain("→ done");
|
|
});
|
|
});
|
|
|
|
// ── task_add_dep tool tests ──────────────────────────────────────────
|
|
|
|
describe("task_add_dep tool", () => {
|
|
/**
|
|
* Helper: run executor with a customized mock store and capture custom tools.
|
|
* The mock store's getTask is configured to:
|
|
* - Return the executing task (KB-TEST) with configurable dependencies
|
|
* - Return a target task (KB-OTHER) when requested
|
|
* - Throw for unknown task IDs
|
|
*/
|
|
async function captureAddDepTools(opts?: { existingDeps?: string[]; targetExists?: boolean }) {
|
|
const existingDeps = opts?.existingDeps ?? [];
|
|
const targetExists = opts?.targetExists ?? true;
|
|
|
|
const store = createMockStore();
|
|
store.getTask.mockImplementation(async (id: string) => {
|
|
if (id === "FN-TEST") {
|
|
return {
|
|
id: "FN-TEST",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: existingDeps,
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
if (id === "FN-OTHER" && targetExists) {
|
|
return {
|
|
id: "FN-OTHER",
|
|
title: "Other task",
|
|
description: "Another task",
|
|
column: "todo",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
throw new Error(`Task ${id} not found`);
|
|
});
|
|
|
|
store.updateStep.mockResolvedValue({
|
|
steps: [
|
|
{ name: "Preflight", status: "done" },
|
|
{ name: "Implement", status: "in-progress" },
|
|
],
|
|
});
|
|
|
|
mockedExistsSync.mockReturnValue(true);
|
|
|
|
let capturedTools: any[] = [];
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedTools = opts.customTools || [];
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-TEST",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: existingDeps,
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const tools: Record<string, any> = {};
|
|
for (const t of capturedTools) {
|
|
tools[t.name] = t.execute;
|
|
}
|
|
return { tools, store };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("adds a valid dependency via store.updateTask when confirm=true", async () => {
|
|
const { tools, store } = await captureAddDepTools();
|
|
|
|
const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER", confirm: true });
|
|
|
|
expect(result.content[0].text).toContain("Added dependency");
|
|
expect(result.content[0].text).toContain("triage");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-TEST", {
|
|
dependencies: ["FN-OTHER"],
|
|
});
|
|
});
|
|
|
|
it("returns error for self-dependency", async () => {
|
|
const { tools, store } = await captureAddDepTools();
|
|
|
|
const result = await tools.task_add_dep("call1", { task_id: "FN-TEST" });
|
|
|
|
expect(result.content[0].text).toContain("Cannot add self-dependency");
|
|
expect(result.content[0].text).toContain("FN-TEST cannot depend on itself");
|
|
// store.updateTask should NOT have been called for dependency update
|
|
// (it may be called for worktree path updates, so we check specifically for dependencies)
|
|
const depUpdateCalls = store.updateTask.mock.calls.filter(
|
|
(call: any[]) => call[1]?.dependencies !== undefined,
|
|
);
|
|
expect(depUpdateCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("returns error for non-existent target task", async () => {
|
|
const { tools, store } = await captureAddDepTools({ targetExists: false });
|
|
|
|
const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER" });
|
|
|
|
expect(result.content[0].text).toContain("FN-OTHER not found");
|
|
expect(result.content[0].text).toContain("Cannot add dependency on a non-existent task");
|
|
const depUpdateCalls = store.updateTask.mock.calls.filter(
|
|
(call: any[]) => call[1]?.dependencies !== undefined,
|
|
);
|
|
expect(depUpdateCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("returns informational message for duplicate dependency without duplicating", async () => {
|
|
const { tools, store } = await captureAddDepTools({ existingDeps: ["FN-OTHER"] });
|
|
|
|
const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER" });
|
|
|
|
expect(result.content[0].text).toContain("already a dependency");
|
|
expect(result.content[0].text).toContain("No changes made");
|
|
const depUpdateCalls = store.updateTask.mock.calls.filter(
|
|
(call: any[]) => call[1]?.dependencies !== undefined,
|
|
);
|
|
expect(depUpdateCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("logs the dependency addition via store.logEntry when confirm=true", async () => {
|
|
const { tools, store } = await captureAddDepTools();
|
|
|
|
await tools.task_add_dep("call1", { task_id: "FN-OTHER", confirm: true });
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on FN-OTHER — stopping execution for re-specification");
|
|
});
|
|
|
|
it("appends to existing dependencies without overwriting when confirm=true", async () => {
|
|
const { tools, store } = await captureAddDepTools({ existingDeps: ["FN-001"] });
|
|
|
|
const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER", confirm: true });
|
|
|
|
expect(result.content[0].text).toContain("Added dependency");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-TEST", {
|
|
dependencies: ["FN-001", "FN-OTHER"],
|
|
});
|
|
});
|
|
|
|
it("is registered in customTools array", async () => {
|
|
const { tools } = await captureAddDepTools();
|
|
|
|
expect(tools.task_add_dep).toBeDefined();
|
|
expect(typeof tools.task_add_dep).toBe("function");
|
|
});
|
|
|
|
it("returns warning without confirm=true and does NOT add dependency", async () => {
|
|
const { tools, store } = await captureAddDepTools();
|
|
|
|
const result = await tools.task_add_dep("call1", { task_id: "FN-OTHER" });
|
|
|
|
expect(result.content[0].text).toContain("stop execution and discard current work");
|
|
expect(result.content[0].text).toContain("confirm=true");
|
|
// Should NOT have updated dependencies
|
|
const depUpdateCalls = store.updateTask.mock.calls.filter(
|
|
(call: any[]) => call[1]?.dependencies !== undefined,
|
|
);
|
|
expect(depUpdateCalls).toHaveLength(0);
|
|
// Should NOT have logged any dep addition
|
|
const logCalls = store.logEntry.mock.calls.filter(
|
|
(call: any[]) => typeof call[1] === "string" && call[1].includes("Added dependency"),
|
|
);
|
|
expect(logCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("validation errors (self-dep, not-found, dedup) return immediately without requiring confirm", async () => {
|
|
// Self-dep — no confirm needed
|
|
const { tools: tools1 } = await captureAddDepTools();
|
|
const selfResult = await tools1.task_add_dep("call1", { task_id: "FN-TEST" });
|
|
expect(selfResult.content[0].text).toContain("Cannot add self-dependency");
|
|
|
|
// Not found — no confirm needed
|
|
const { tools: tools2 } = await captureAddDepTools({ targetExists: false });
|
|
const notFoundResult = await tools2.task_add_dep("call1", { task_id: "FN-OTHER" });
|
|
expect(notFoundResult.content[0].text).toContain("not found");
|
|
|
|
// Dedup — no confirm needed
|
|
const { tools: tools3 } = await captureAddDepTools({ existingDeps: ["FN-OTHER"] });
|
|
const dedupResult = await tools3.task_add_dep("call1", { task_id: "FN-OTHER" });
|
|
expect(dedupResult.content[0].text).toContain("already a dependency");
|
|
});
|
|
|
|
it("with confirm=true triggers depAborted and disposes session", async () => {
|
|
const store = createMockStore();
|
|
store.getTask.mockImplementation(async (id: string) => {
|
|
if (id === "FN-DEP") {
|
|
return {
|
|
id: "FN-DEP",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
if (id === "FN-TARGET") {
|
|
return {
|
|
id: "FN-TARGET",
|
|
title: "Target",
|
|
description: "Target task",
|
|
column: "todo",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
throw new Error(`Task ${id} not found`);
|
|
});
|
|
|
|
mockedExistsSync.mockReturnValue(true);
|
|
|
|
const disposeFn = vi.fn();
|
|
let capturedTools: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedTools = opts.customTools || [];
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// The agent calls task_add_dep with confirm=true during execution
|
|
const addDepTool = capturedTools.find((t: any) => t.name === "task_add_dep");
|
|
await addDepTool.execute("call1", { task_id: "FN-TARGET", confirm: true });
|
|
// After dispose is called, session.prompt throws
|
|
throw new Error("Session terminated");
|
|
}),
|
|
dispose: disposeFn,
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-DEP",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Worktree removal should have been attempted
|
|
const worktreeRemoveCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
|
|
);
|
|
expect(worktreeRemoveCalls.length).toBeGreaterThan(0);
|
|
|
|
// Branch deletion should have been attempted
|
|
const branchDeleteCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("fusion/fn-dep"),
|
|
);
|
|
expect(branchDeleteCalls.length).toBeGreaterThan(0);
|
|
|
|
// Task should be moved to triage
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-DEP", "triage");
|
|
|
|
// Worktree and status should be cleared
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-DEP", { worktree: null, status: null });
|
|
|
|
// Task should NOT be marked as failed
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-DEP", { status: "failed" });
|
|
});
|
|
});
|
|
|
|
// ── Usage limit detection in executor ────────────────────────────────
|
|
|
|
import { UsageLimitPauser } from "./usage-limit-detector.js";
|
|
|
|
describe("TaskExecutor usage limit detection", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("triggers global pause when executor catches a usage-limit error", async () => {
|
|
const store = createMockStore();
|
|
const pauser = new UsageLimitPauser(store);
|
|
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
onError,
|
|
usageLimitPauser: pauser,
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
|
|
"executor",
|
|
"FN-001",
|
|
"rate_limit_error: Rate limit exceeded",
|
|
);
|
|
expect(store.updateSettings).toHaveBeenCalledWith({
|
|
globalPause: true,
|
|
globalPauseReason: "rate-limit",
|
|
});
|
|
// Task should still be marked as failed
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" });
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT trigger global pause for transient non-usage-limit errors", async () => {
|
|
const store = createMockStore();
|
|
const pauser = new UsageLimitPauser(store);
|
|
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
|
const onError = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("connection refused"));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
onError,
|
|
usageLimitPauser: pauser,
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(onUsageLimitHitSpy).not.toHaveBeenCalled();
|
|
// Recovery policy: first transient error → retry 1/3 with backoff
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", expect.stringContaining("Transient error (retry 1/3"), undefined, expect.objectContaining({ agentId: "executor" }));
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
|
recoveryRetryCount: 1,
|
|
nextRecoveryAt: expect.any(String),
|
|
}));
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.updateTask).not.toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("works without usageLimitPauser (backward compatible)", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should not crash — just mark as failed
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" });
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("triggers global pause when session.prompt() resolves with exhausted-retry error on state.error", async () => {
|
|
const store = createMockStore();
|
|
const pauser = new UsageLimitPauser(store);
|
|
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
|
|
|
// session.prompt() resolves normally, but session.state.error is set
|
|
// (this is what happens when pi-coding-agent exhausts retries)
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: { error: "rate_limit_error: Rate limit exceeded" },
|
|
};
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession } as any);
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
onError,
|
|
usageLimitPauser: pauser,
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// UsageLimitPauser should be called
|
|
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
|
|
"executor",
|
|
"FN-001",
|
|
"rate_limit_error: Rate limit exceeded",
|
|
);
|
|
// Task should be marked as failed
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" });
|
|
// onError callback should fire
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("triggers global pause for overloaded error", async () => {
|
|
const store = createMockStore();
|
|
const pauser = new UsageLimitPauser(store);
|
|
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("overloaded_error: Overloaded"));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
usageLimitPauser: pauser,
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-002",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
|
|
"executor",
|
|
"FN-002",
|
|
"overloaded_error: Overloaded",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor bounded recovery retries", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("increments recoveryRetryCount on successive transient failures", async () => {
|
|
const store = createMockStore();
|
|
const onError = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("upstream connect error"));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
// First failure: count goes from undefined to 1
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
|
recoveryRetryCount: 1,
|
|
nextRecoveryAt: expect.any(String),
|
|
}));
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(onError).not.toHaveBeenCalled();
|
|
|
|
// Second failure: count goes from 1 to 2
|
|
vi.clearAllMocks();
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("upstream connect error"));
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
recoveryRetryCount: 1,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
|
recoveryRetryCount: 2,
|
|
}));
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("moves task to in-review when transient retries are exhausted (single-session)", async () => {
|
|
const store = createMockStore();
|
|
const onError = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("socket hang up"));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
// Task already has 3 retries (max) — next failure should escalate
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
recoveryRetryCount: 3,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
|
status: "failed",
|
|
error: "socket hang up",
|
|
recoveryRetryCount: null,
|
|
nextRecoveryAt: null,
|
|
});
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT consume retry budget for paused tasks", async () => {
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
// Simulate a paused abort — the executor checks pausedAborted set
|
|
const task = {
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
recoveryRetryCount: 1,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Simulate: task gets paused mid-execution → abort error
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("Aborted"));
|
|
(executor as any).pausedAborted.add("FN-001");
|
|
|
|
await executor.execute(task);
|
|
|
|
// Should NOT update recoveryRetryCount
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
|
recoveryRetryCount: expect.any(Number),
|
|
}));
|
|
});
|
|
|
|
it("does NOT consume retry budget for stuck-task-detector kills", async () => {
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("Aborted"));
|
|
(executor as any).stuckAborted.set("FN-001", true);
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
recoveryRetryCount: 2,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should NOT update recoveryRetryCount
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
|
recoveryRetryCount: expect.any(Number),
|
|
}));
|
|
});
|
|
|
|
it("requeues to todo when a stuck-killed session resolves without throwing", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn(async () => {
|
|
executor.markStuckAborted("FN-001", true);
|
|
}),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
}) as any);
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
|
expect(store.updateTask).not.toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
|
|
// Executor now handles the requeue in its finally block
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "stuck-killed", worktree: null, branch: null });
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
});
|
|
|
|
it("does not requeue when stuck-kill budget is exhausted", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn(async () => {
|
|
// Budget exhausted — shouldRequeue=false
|
|
executor.markStuckAborted("FN-001", false);
|
|
}),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
}) as any);
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should NOT requeue or mark as failed (budget handler already did that)
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "stuck-killed", worktree: null, branch: null });
|
|
expect(store.updateTask).not.toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
});
|
|
|
|
it("skips moveTask when task is already in todo at execute start", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn(async () => {
|
|
// Simulate stuck kill
|
|
executor.markStuckAborted("FN-001", true);
|
|
throw new Error("Stuck task");
|
|
}),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
}) as any);
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "todo", // Task was already in todo when execute started
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should NOT call moveTask because task is already in todo
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
|
// Should still clean up and mark as stuck-killed
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "stuck-killed", worktree: null, branch: null });
|
|
});
|
|
|
|
it("clears recovery metadata after successful run completes", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Mock successful agent session
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: { error: undefined },
|
|
};
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession } as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
recoveryRetryCount: 2,
|
|
nextRecoveryAt: new Date().toISOString(),
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// moveTask to in-review clears recovery metadata (via store's column transition logic)
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
});
|
|
});
|
|
|
|
describe("Per-task model overrides", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("uses per-task model overrides when both provider and modelId are set", async () => {
|
|
const store = createMockStore();
|
|
const capturedOptions: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedOptions.push(opts);
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Override getTask to return task with model overrides
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
// Should use per-task model overrides
|
|
expect(capturedOptions[0].defaultProvider).toBe("anthropic");
|
|
expect(capturedOptions[0].defaultModelId).toBe("claude-sonnet-4-5");
|
|
});
|
|
|
|
it("falls back to global settings when per-task model is not fully specified", async () => {
|
|
const store = createMockStore();
|
|
const capturedOptions: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedOptions.push(opts);
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: undefined,
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
// No modelProvider/modelId set
|
|
});
|
|
|
|
// Should use global settings (not task overrides)
|
|
expect(capturedOptions[0].defaultProvider).toBe("openai");
|
|
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
|
|
});
|
|
|
|
it("falls back to global settings when only modelProvider is set (missing modelId)", async () => {
|
|
const store = createMockStore();
|
|
const capturedOptions: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedOptions.push(opts);
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: undefined,
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Override getTask to return task with only modelProvider set
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
modelProvider: "anthropic",
|
|
// modelId is missing
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
modelProvider: "anthropic",
|
|
// modelId is missing
|
|
});
|
|
|
|
// Should fall back to global settings since modelId is not set
|
|
expect(capturedOptions[0].defaultProvider).toBe("openai");
|
|
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
|
|
});
|
|
});
|
|
|
|
// ── Lane hierarchy model resolution tests ─────────────────────────────────────
|
|
|
|
describe("Executor lane hierarchy model resolution", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("resolves task override when both provider and modelId are set", async () => {
|
|
const store = createMockStore();
|
|
const capturedOptions: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedOptions.push(opts);
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
executionGlobalProvider: "google",
|
|
executionGlobalModelId: "gemini-2.5",
|
|
executionProvider: undefined,
|
|
executionModelId: undefined,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
// Task override takes precedence
|
|
expect(capturedOptions[0].defaultProvider).toBe("anthropic");
|
|
expect(capturedOptions[0].defaultModelId).toBe("claude-sonnet-4-5");
|
|
});
|
|
|
|
it("resolves project execution override when task override is not set", async () => {
|
|
const store = createMockStore();
|
|
const capturedOptions: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedOptions.push(opts);
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
executionGlobalProvider: "google",
|
|
executionGlobalModelId: "gemini-2.5",
|
|
executionProvider: "anthropic",
|
|
executionModelId: "claude-opus-4",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
// No task-level model override
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
// No task-level model override
|
|
});
|
|
|
|
// Project execution override takes precedence over global lane
|
|
expect(capturedOptions[0].defaultProvider).toBe("anthropic");
|
|
expect(capturedOptions[0].defaultModelId).toBe("claude-opus-4");
|
|
});
|
|
|
|
it("resolves global execution lane when project override is not set", async () => {
|
|
const store = createMockStore();
|
|
const capturedOptions: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedOptions.push(opts);
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
executionGlobalProvider: "google",
|
|
executionGlobalModelId: "gemini-2.5",
|
|
executionProvider: undefined,
|
|
executionModelId: undefined,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
// No task-level model override
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
// No task-level model override
|
|
});
|
|
|
|
// Global execution lane takes precedence over default
|
|
expect(capturedOptions[0].defaultProvider).toBe("google");
|
|
expect(capturedOptions[0].defaultModelId).toBe("gemini-2.5");
|
|
});
|
|
|
|
it("falls back to default when no lane overrides are set", async () => {
|
|
const store = createMockStore();
|
|
const capturedOptions: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedOptions.push(opts);
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {},
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
executionGlobalProvider: undefined,
|
|
executionGlobalModelId: undefined,
|
|
executionProvider: undefined,
|
|
executionModelId: undefined,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
// No task-level model override
|
|
});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
// No task-level model override
|
|
});
|
|
|
|
// Default takes precedence when no lane overrides are set
|
|
expect(capturedOptions[0].defaultProvider).toBe("openai");
|
|
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
|
|
});
|
|
});
|
|
|
|
// ── Per-task thinkingLevel override tests ───────────────────────────
|
|
|
|
describe("Per-task thinkingLevel override", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("uses per-task thinkingLevel when set on the task", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
|
|
// Override getTask to return task with thinkingLevel override
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
thinkingLevel: "high",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
thinkingLevel: "high",
|
|
});
|
|
|
|
// Should use per-task thinkingLevel override
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0];
|
|
expect(callArgs).toBeDefined();
|
|
expect(callArgs[0].defaultThinkingLevel).toBe("high");
|
|
});
|
|
|
|
it("falls back to global defaultThinkingLevel when task has no thinkingLevel", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: undefined,
|
|
defaultThinkingLevel: "medium",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
// No thinkingLevel set
|
|
});
|
|
|
|
// Should fall back to global defaultThinkingLevel
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0];
|
|
expect(callArgs).toBeDefined();
|
|
expect(callArgs[0].defaultThinkingLevel).toBe("medium");
|
|
});
|
|
|
|
it("uses explicit 'off' thinkingLevel from task over global setting", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any);
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: undefined,
|
|
defaultThinkingLevel: "high",
|
|
});
|
|
|
|
// Override getTask to return task with thinkingLevel: "off"
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
thinkingLevel: "off",
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
thinkingLevel: "off",
|
|
});
|
|
|
|
// Should use task's explicit "off" instead of global "high"
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0];
|
|
expect(callArgs).toBeDefined();
|
|
expect(callArgs[0].defaultThinkingLevel).toBe("off");
|
|
});
|
|
});
|
|
|
|
// ── Invalid transition error handling tests ─────────────────────────
|
|
|
|
describe("Invalid transition error handling", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("does not mark task as failed when invalid transition error occurs on completion", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Mock moveTask to throw invalid transition error (task already moved to done)
|
|
store.moveTask.mockRejectedValue(
|
|
new Error("Invalid transition: 'done' → 'in-review'. Valid targets: none"),
|
|
);
|
|
|
|
// Mock agent that completes successfully
|
|
mockedCreateFnAgent.mockImplementation(async () => {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Agent completes work but moveTask will fail
|
|
}),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn(),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// A missing task_done triggers a retry. Both attempts fail to call task_done,
|
|
// then the moveTask in the retry path throws the Invalid transition error,
|
|
// which is caught by the outer handler.
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
|
status: "failed",
|
|
error: "Agent finished without calling task_done (after retry)",
|
|
});
|
|
|
|
// Should log informative message from the outer catch for Invalid transition
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
"Task already moved from 'done' — skipping transition to 'in-review'",
|
|
expect.stringContaining("Invalid transition"),
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
});
|
|
|
|
it("calls onComplete when invalid transition occurs after successful execution", async () => {
|
|
const store = createMockStore();
|
|
const onComplete = vi.fn();
|
|
|
|
// Mock moveTask to throw invalid transition error
|
|
store.moveTask.mockRejectedValue(
|
|
new Error("Invalid transition: 'in-progress' → 'in-review'. Valid targets: todo, triage"),
|
|
);
|
|
|
|
mockedCreateFnAgent.mockImplementation(async () => {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn(),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
await executor.execute({
|
|
id: "FN-002",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// onComplete should be called even when invalid transition occurs
|
|
expect(onComplete).toHaveBeenCalled();
|
|
expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-002" }));
|
|
});
|
|
|
|
it("finalizes an already-reviewed task when it is ready to merge", async () => {
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-003",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-review",
|
|
paused: false,
|
|
status: null,
|
|
error: null,
|
|
worktree: "/tmp/test/.worktrees/fn-003",
|
|
dependencies: [],
|
|
steps: [{ name: "Done", status: "done" }],
|
|
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const result = await (executor as any).finalizeAlreadyReviewedTask("FN-003");
|
|
|
|
expect(result).toBe("merged");
|
|
expect(store.mergeTask).toHaveBeenCalledWith("FN-003");
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-003",
|
|
"Task already in-review after completion — finalizing merge",
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor task_done with summary", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("accepts and saves summary parameter when task is completed", async () => {
|
|
const store = createMockStore();
|
|
let capturedTool: any = null;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => {
|
|
// Capture the task_done tool
|
|
capturedTool = customTools?.find((t: any) => t.name === "task_done");
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Execute a task
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 1", status: "in-progress" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Verify task_done tool was created
|
|
expect(capturedTool).toBeDefined();
|
|
expect(capturedTool.name).toBe("task_done");
|
|
|
|
// Verify the tool accepts summary parameter
|
|
expect(capturedTool.parameters).toBeDefined();
|
|
|
|
// Execute the tool with a summary
|
|
const result = await capturedTool.execute("tool-1", { summary: "Test summary of changes" });
|
|
|
|
// Verify the task was updated with the summary
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { summary: "Test summary of changes" });
|
|
|
|
// Verify success message includes summary mention
|
|
expect(result.content[0].text).toContain("summary");
|
|
});
|
|
|
|
it("works without summary parameter (backward compatible)", async () => {
|
|
const store = createMockStore();
|
|
let capturedTool: any = null;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => {
|
|
capturedTool = customTools?.find((t: any) => t.name === "task_done");
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute({
|
|
id: "FN-002",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 1", status: "in-progress" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Execute the tool without summary
|
|
const result = await capturedTool.execute("tool-1", {});
|
|
|
|
// Verify summary was not updated
|
|
const summaryUpdateCalls = store.updateTask.mock.calls.filter(
|
|
(call: any[]) => call[1]?.summary !== undefined
|
|
);
|
|
expect(summaryUpdateCalls).toHaveLength(0);
|
|
|
|
// Verify standard success message
|
|
expect(result.content[0].text).toBe("Task marked complete. All steps done. Moving to in-review.");
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor task_done blockers", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("rejects task_done when the task is explicitly blocked", async () => {
|
|
const store = createMockStore();
|
|
let capturedTool: any = null;
|
|
|
|
store.getTask.mockImplementation(async (taskId: string) => {
|
|
if (taskId === "FN-001") {
|
|
return {
|
|
id: "FN-001",
|
|
title: "Blocked task",
|
|
description: "Blocked task",
|
|
column: "in-progress",
|
|
blockedBy: "FN-DEP-1",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 1", status: "in-progress" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
return {
|
|
id: taskId,
|
|
column: "done",
|
|
};
|
|
});
|
|
|
|
mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => {
|
|
capturedTool = customTools?.find((t: any) => t.name === "task_done");
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Blocked task",
|
|
description: "Blocked task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 1", status: "in-progress" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(capturedTool).toBeDefined();
|
|
|
|
store.updateStep.mockClear();
|
|
store.updateTask.mockClear();
|
|
|
|
const result = await capturedTool.execute("tool-1", {});
|
|
|
|
expect(result.content[0].text).toContain("Cannot mark task done yet");
|
|
expect(store.updateStep).not.toHaveBeenCalled();
|
|
expect(store.updateTask).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Workflow Steps Execution", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
/**
|
|
* Create a mock agent that auto-triggers the task_done tool when prompt is called.
|
|
* This simulates a successful task execution where the agent calls task_done().
|
|
*/
|
|
function createAgentWithTaskDone() {
|
|
let capturedCustomTools: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
capturedCustomTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Find and execute task_done tool to set taskDone = true
|
|
const taskDoneTool = capturedCustomTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) {
|
|
await taskDoneTool.execute("tool-1", {});
|
|
}
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
}) as any);
|
|
}
|
|
|
|
it("marks the task failed when the agent exits without calling task_done", async () => {
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "in-progress" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
},
|
|
} as any);
|
|
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "in-progress" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should have been called twice: initial + retry
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
|
|
// Retry still didn't call task_done, so it fails with the retry message
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
|
status: "failed",
|
|
error: "Agent finished without calling task_done (after retry)",
|
|
});
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
"Agent finished without calling task_done (after retry) — moved to in-review for inspection",
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-001" }),
|
|
expect.objectContaining({ message: "Agent finished without calling task_done (after retry)" }),
|
|
);
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("runs workflow steps after main task execution", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Task has workflow steps enabled
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Docs Review",
|
|
description: "Check documentation",
|
|
prompt: "Review all docs and verify they are complete.",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// First call: main agent with task_done, subsequent calls: simple mocks for workflow step agents
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
// Main execution — find and trigger task_done
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
} else {
|
|
// Workflow step agent (no custom tools, uses readonly tools)
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
state: {},
|
|
},
|
|
};
|
|
}
|
|
}) as any);
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// createFnAgent called twice: main agent + workflow step agent
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
|
|
// Second call should be the workflow step with readonly tools
|
|
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
|
expect(secondCall[0].tools).toBe("readonly");
|
|
expect(secondCall[0].systemPrompt).toContain("Docs Review");
|
|
expect(secondCall[0].systemPrompt).toContain("Review all docs and verify they are complete.");
|
|
|
|
// Task should move to in-review
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
});
|
|
|
|
it("runs browser verification workflow steps with coding tools", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Browser task",
|
|
description: "Verify browser behavior",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
templateId: "browser-verification",
|
|
name: "Browser Verification",
|
|
description: "Verify with browser automation",
|
|
mode: "prompt",
|
|
toolMode: "coding",
|
|
prompt: "Use browser automation to verify the app.",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
}
|
|
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
state: {},
|
|
},
|
|
};
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Browser task",
|
|
description: "Verify browser behavior",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
|
expect(secondCall[0].tools).toBe("coding");
|
|
});
|
|
|
|
it("runs QA workflow steps with coding tools", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "QA task",
|
|
description: "Verify tests pass",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
templateId: "qa-check",
|
|
name: "QA Check",
|
|
description: "Run tests and verify they pass",
|
|
mode: "prompt",
|
|
toolMode: "coding",
|
|
prompt: "Run the test suite and report results.",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
}
|
|
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
state: {},
|
|
},
|
|
};
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "QA task",
|
|
description: "Verify tests pass",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
|
expect(secondCall[0].tools).toBe("coding");
|
|
});
|
|
|
|
it("skips workflow steps with no prompt", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Empty Step",
|
|
description: "No prompt",
|
|
prompt: "",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
createAgentWithTaskDone();
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should only call createFnAgent once (main execution), skip workflow step
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
|
|
|
// Should log that it was skipped
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("has no prompt"),
|
|
);
|
|
|
|
// Task should still move to in-review
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
});
|
|
|
|
it("handles tasks with no workflow steps", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
createAgentWithTaskDone();
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Only main agent call
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
|
// Task should still move to in-review
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
});
|
|
|
|
it("uses workflow step model override when both provider and modelId are set", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Security Audit",
|
|
description: "Check security",
|
|
prompt: "Scan for vulnerabilities.",
|
|
enabled: true,
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
// Main execution agent
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
} else {
|
|
// Workflow step agent
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
state: {},
|
|
},
|
|
};
|
|
}
|
|
}) as any);
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// createFnAgent called twice: main agent + workflow step agent
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
|
|
// Second call should use the workflow step's model override
|
|
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
|
expect(secondCall[0].defaultProvider).toBe("anthropic");
|
|
expect(secondCall[0].defaultModelId).toBe("claude-sonnet-4-5");
|
|
|
|
// Log should indicate the override
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("workflow step override"),
|
|
);
|
|
});
|
|
|
|
it("uses global defaults when workflow step has no model override", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Workflow step without model override
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Docs Review",
|
|
description: "Check documentation",
|
|
prompt: "Review all docs.",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
} else {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
state: {},
|
|
},
|
|
};
|
|
}
|
|
}) as any);
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
|
|
// Second call should use settings defaults (no override indicator)
|
|
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
|
// defaults come from the mock store's getSettings
|
|
expect(secondCall[0].defaultProvider).toBeUndefined();
|
|
expect(secondCall[0].defaultModelId).toBeUndefined();
|
|
|
|
// Log should NOT indicate override
|
|
expect(store.logEntry).not.toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("workflow step override"),
|
|
);
|
|
});
|
|
|
|
it("executes script-mode workflow step successfully", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
scripts: { test: "echo 'all tests passed'" },
|
|
});
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Run Tests",
|
|
description: "Execute test suite",
|
|
mode: "script",
|
|
prompt: "",
|
|
scriptName: "test",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Mock execSync to succeed for the script command
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
if (typeof cmd === "string" && cmd.includes("echo")) {
|
|
return Buffer.from("all tests passed\n");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
// Main agent with task_done
|
|
createAgentWithTaskDone();
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should only call createFnAgent once (main execution — no agent for script mode)
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
|
|
|
// Should log script execution
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("executing script 'test'"),
|
|
);
|
|
|
|
// Task should move to in-review
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
|
|
// Should record a passed result
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({
|
|
workflowStepResults: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
workflowStepId: "WS-001",
|
|
workflowStepName: "Run Tests",
|
|
status: "passed",
|
|
output: "Script 'test' completed successfully",
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
const updatePayloads = store.updateTask.mock.calls.map((call: any[]) => call[1]);
|
|
expect(JSON.stringify(updatePayloads)).not.toContain("all tests passed");
|
|
});
|
|
|
|
it("sends task back to in-progress when script-mode workflow step fails with exhausted retries", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
scripts: { lint: "pnpm lint" },
|
|
});
|
|
|
|
// Mutable task object to track step changes
|
|
const mutableTask = {
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress" as const,
|
|
dependencies: [] as string[],
|
|
steps: [{ name: "Preflight", status: "pending" as const }],
|
|
currentStep: 0,
|
|
log: [] as string[],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
store.getTask.mockResolvedValue(mutableTask);
|
|
|
|
// Make updateStep track changes in the mutable task
|
|
store.updateStep.mockImplementation(async (taskId: string, stepIndex: number, status: string) => {
|
|
if (mutableTask.steps[stepIndex]) {
|
|
mutableTask.steps[stepIndex].status = status as any;
|
|
}
|
|
return {};
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Lint Check",
|
|
description: "Run linter",
|
|
mode: "script",
|
|
prompt: "",
|
|
scriptName: "lint",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Mock execSync to throw for the lint command
|
|
const scriptErr = new Error("Command failed: pnpm lint");
|
|
(scriptErr as any).status = 1;
|
|
(scriptErr as any).stderr = Buffer.from("syntax error on line 42\n");
|
|
(scriptErr as any).stdout = Buffer.from("");
|
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
|
if (typeof cmd === "string" && cmd.includes("lint")) {
|
|
throw scriptErr;
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
// Use createAgentWithTaskDone to properly set up the agent mock
|
|
createAgentWithTaskDone();
|
|
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
|
|
|
// Use fake timers to control the setTimeout in sendTaskBackForFix
|
|
vi.useFakeTimers();
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should record a failed result with exit code and stderr
|
|
// (This may not be the first call, so check if any call has workflowStepResults)
|
|
const updateTaskCalls = store.updateTask.mock.calls;
|
|
const hasWorkflowStepFailure = updateTaskCalls.some(
|
|
(call: any[]) =>
|
|
call[0] === "FN-001" &&
|
|
call[1]?.workflowStepResults?.some(
|
|
(r: any) =>
|
|
r.workflowStepId === "WS-001" &&
|
|
r.workflowStepName === "Lint Check" &&
|
|
r.status === "failed" &&
|
|
r.output?.includes("Exit code: 1")
|
|
)
|
|
);
|
|
expect(hasWorkflowStepFailure).toBe(true);
|
|
|
|
// Task should be cleared and reset for retry (not failed + in-review)
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({ status: null, error: null, sessionFile: null, workflowStepRetries: 0 }),
|
|
);
|
|
|
|
// Should add a comment with failure feedback
|
|
// This will fail if sendTaskBackForFix is not called
|
|
expect(store.addTaskComment).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("Workflow step failed"),
|
|
"agent",
|
|
);
|
|
|
|
// Should reset all steps to pending
|
|
// Check that updateStep was called with "pending" for step 0
|
|
// (There may be multiple calls - first from task_done marking it done, second from sendTaskBackForFix resetting it)
|
|
const updateStepCalls = store.updateStep.mock.calls;
|
|
const hasResetToPending = updateStepCalls.some(
|
|
(call: any[]) => call[0] === "FN-001" && call[1] === 0 && call[2] === "pending"
|
|
);
|
|
expect(hasResetToPending).toBe(true);
|
|
|
|
// Advance timers to trigger the setTimeout that moves task to todo then in-progress
|
|
vi.advanceTimersByTime(0);
|
|
// Run any pending microtasks (the async code in setTimeout)
|
|
await vi.runAllTimersAsync();
|
|
|
|
// Task should move to todo then in-progress (not in-review)
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
|
|
|
// onComplete should NOT be called (task is being retried, not completed)
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
|
|
// onError should NOT be called (task is being retried, not permanently failed)
|
|
expect(onError).not.toHaveBeenCalled();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("sends task back to in-progress when script is missing from settings.scripts", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
scripts: { other: "echo other" },
|
|
});
|
|
|
|
// Mutable task object to track step changes
|
|
const mutableTask = {
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress" as const,
|
|
dependencies: [] as string[],
|
|
steps: [{ name: "Preflight", status: "pending" as const }],
|
|
currentStep: 0,
|
|
log: [] as string[],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
store.getTask.mockResolvedValue(mutableTask);
|
|
|
|
// Make updateStep track changes in the mutable task
|
|
store.updateStep.mockImplementation(async (taskId: string, stepIndex: number, status: string) => {
|
|
if (mutableTask.steps[stepIndex]) {
|
|
mutableTask.steps[stepIndex].status = status as any;
|
|
}
|
|
return {};
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Missing Script",
|
|
description: "Uses nonexistent script",
|
|
mode: "script",
|
|
prompt: "",
|
|
scriptName: "nonexistent",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Use createAgentWithTaskDone to properly set up the agent mock
|
|
createAgentWithTaskDone();
|
|
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
|
|
|
// Use fake timers to control the setTimeout in sendTaskBackForFix
|
|
vi.useFakeTimers();
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should log that the script was not found
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("not found in project settings"),
|
|
);
|
|
|
|
// Should record a failed result
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({
|
|
workflowStepResults: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
workflowStepId: "WS-001",
|
|
status: "failed",
|
|
output: expect.stringContaining("not found in project settings"),
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
|
|
// Task should be cleared and reset for retry (not failed + in-review)
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({ status: null, error: null, sessionFile: null, workflowStepRetries: 0 }),
|
|
);
|
|
|
|
// Should add a comment with failure feedback
|
|
expect(store.addTaskComment).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("Workflow step failed"),
|
|
"agent",
|
|
);
|
|
|
|
// Should reset all steps to pending
|
|
// Check that updateStep was called with "pending" for step 0
|
|
// (There may be multiple calls - first from task_done marking it done, second from sendTaskBackForFix resetting it)
|
|
const updateStepCalls = store.updateStep.mock.calls;
|
|
const hasResetToPending = updateStepCalls.some(
|
|
(call: any[]) => call[0] === "FN-001" && call[1] === 0 && call[2] === "pending"
|
|
);
|
|
expect(hasResetToPending).toBe(true);
|
|
|
|
// Advance timers to trigger the setTimeout that moves task to todo then in-progress
|
|
vi.advanceTimersByTime(0);
|
|
// Run any pending microtasks (the async code in setTimeout)
|
|
await vi.runAllTimersAsync();
|
|
|
|
// Task should move to todo then in-progress (not in-review)
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
|
|
|
// onComplete should NOT be called (task is being retried, not completed)
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
|
|
// onError should NOT be called (task is being retried, not permanently failed)
|
|
expect(onError).not.toHaveBeenCalled();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("skips script-mode step when scriptName is missing", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
scripts: {},
|
|
});
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "No Script",
|
|
description: "Script step without scriptName",
|
|
mode: "script",
|
|
prompt: "",
|
|
scriptName: undefined,
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
createAgentWithTaskDone();
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Should only call createFnAgent once (main execution)
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
|
|
|
// Should log that it was skipped
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("no scriptName"),
|
|
);
|
|
|
|
// Task should move to in-review (skipped step doesn't block)
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
});
|
|
|
|
it("treats legacy steps without mode as prompt-mode", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Legacy step without mode field
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Legacy Review",
|
|
description: "Old step without mode",
|
|
prompt: "Review the code changes.",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as any); // mode field intentionally omitted
|
|
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
} else {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
state: {},
|
|
},
|
|
};
|
|
}
|
|
}) as any);
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// createFnAgent called twice: main agent + workflow step agent (prompt mode)
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
|
|
// Second call should use prompt mode (readonly tools, agent-based)
|
|
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
|
expect(secondCall[0].tools).toBe("readonly");
|
|
expect(secondCall[0].systemPrompt).toContain("Legacy Review");
|
|
|
|
// Task should move to in-review
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
});
|
|
|
|
// ── Workflow Step Phase Filtering ────────────────────────────────────
|
|
|
|
it("skips post-merge workflow steps during executor pre-merge execution", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockImplementation(async (id: string) => {
|
|
if (id === "WS-001") {
|
|
return {
|
|
id: "WS-001",
|
|
name: "Pre-merge Check",
|
|
description: "Before merge",
|
|
prompt: "Run pre-merge checks",
|
|
phase: "pre-merge",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
if (id === "WS-002") {
|
|
return {
|
|
id: "WS-002",
|
|
name: "Post-merge Notify",
|
|
description: "After merge",
|
|
prompt: "Send notifications",
|
|
phase: "post-merge",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
// Main agent calls task_done, then a workflow step agent for pre-merge only
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
} else {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
state: {},
|
|
},
|
|
};
|
|
}
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// createFnAgent called twice: main agent + 1 pre-merge step (post-merge skipped)
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
|
|
// Verify the workflow step results only contain pre-merge
|
|
const updateCalls = store.updateTask.mock.calls;
|
|
const resultsCall = updateCalls.find((c: any) =>
|
|
c[1]?.workflowStepResults?.length > 0
|
|
);
|
|
expect(resultsCall).toBeDefined();
|
|
const results = resultsCall![1].workflowStepResults;
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].workflowStepId).toBe("WS-001");
|
|
expect(results[0].phase).toBe("pre-merge");
|
|
});
|
|
|
|
it("normalizes legacy workflow steps without phase as pre-merge", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Legacy step without phase field
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Legacy Check",
|
|
description: "No phase field",
|
|
prompt: "Run checks",
|
|
// phase is undefined — should be treated as pre-merge
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
} else {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
state: {},
|
|
},
|
|
};
|
|
}
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Legacy step should have been executed (treated as pre-merge)
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
|
|
// Verify result has phase: "pre-merge"
|
|
const updateCalls = store.updateTask.mock.calls;
|
|
const resultsCall = updateCalls.find((c: any) =>
|
|
c[1]?.workflowStepResults?.some((r: any) => r.workflowStepId === "WS-001")
|
|
);
|
|
expect(resultsCall).toBeDefined();
|
|
const results = resultsCall![1].workflowStepResults;
|
|
expect(results[0].phase).toBe("pre-merge");
|
|
});
|
|
|
|
it("only runs post-merge steps when all are post-merge (skips all in executor)", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Post-merge Notify",
|
|
description: "After merge",
|
|
prompt: "Send notifications",
|
|
phase: "post-merge",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
createAgentWithTaskDone();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Only main agent called (no workflow step agent since all are post-merge)
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
|
|
|
// Task should still move to in-review
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
});
|
|
|
|
// ── Workflow Step Revision Request ──────────────────────────────────
|
|
|
|
it("workflow step agent returns revisionRequested when output starts with REQUEST REVISION", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Security Audit",
|
|
description: "Check for vulnerabilities",
|
|
prompt: "Scan for security issues.",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// First call: main agent with task_done
|
|
// Second call: workflow step agent that returns REQUEST REVISION
|
|
let callIdx = 0;
|
|
let subscribeHandler: any;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
// Main execution
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
} else {
|
|
// Workflow step agent that requests revision
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Call the subscribe handler to simulate agent outputting REQUEST REVISION
|
|
if (subscribeHandler) {
|
|
subscribeHandler({
|
|
type: "message_update",
|
|
assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix the SQL injection vulnerability in src/auth.ts" },
|
|
});
|
|
}
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn((handler: any) => {
|
|
subscribeHandler = handler;
|
|
}),
|
|
state: {},
|
|
};
|
|
return { session };
|
|
}
|
|
}) as any);
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Both agents should be called
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
|
|
// Log should show revision was requested
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("requested revision"),
|
|
expect.stringContaining("SQL injection"),
|
|
);
|
|
|
|
// Workflow step result should be marked as failed
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({
|
|
workflowStepResults: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
workflowStepId: "WS-001",
|
|
status: "failed",
|
|
output: expect.stringContaining("SQL injection"),
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
|
|
// Task should NOT be marked as failed (revision requested, not hard failure)
|
|
expect(store.updateTask).not.toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({ status: "failed", error: "Workflow step failed" }),
|
|
);
|
|
});
|
|
|
|
it("passing workflow step moves task to in-review normally", async () => {
|
|
const store = createMockStore();
|
|
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "QA Check",
|
|
description: "Run tests",
|
|
prompt: "Run the test suite.",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
if (callIdx === 1) {
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
} else {
|
|
// Workflow step agent that passes (no REQUEST REVISION)
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
state: {},
|
|
},
|
|
};
|
|
}
|
|
}) as any);
|
|
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Both agents called
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
|
|
// Log should show workflow step passed
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("Workflow step completed"),
|
|
);
|
|
|
|
// Task should move to in-review (not revision loop)
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
|
|
// onComplete should be called
|
|
expect(onComplete).toHaveBeenCalled();
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Real-time steering injection", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("initializes seenSteeringIds with existing comments at session start", async () => {
|
|
const store = createMockStore();
|
|
const steerFn = vi.fn().mockResolvedValue(undefined);
|
|
|
|
// Mock session with steer method
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate execution running
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}),
|
|
dispose: vi.fn(),
|
|
steer: steerFn,
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const existingComment = {
|
|
id: "1234567890-abc123",
|
|
text: "Existing comment",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user" as const,
|
|
};
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
steeringComments: [existingComment],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait for execution to complete
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// No steer calls should be made for existing comments
|
|
expect(steerFn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("injects new steering comments via session.steer() on task:updated", async () => {
|
|
const store = createMockStore();
|
|
const steerFn = vi.fn().mockResolvedValue(undefined);
|
|
let promptResolve: () => void;
|
|
const promptPromise = new Promise<void>(resolve => { promptResolve = resolve; });
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Wait for signal to complete
|
|
await promptPromise;
|
|
}),
|
|
dispose: vi.fn(),
|
|
steer: steerFn,
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Start execution
|
|
const executePromise = executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait for agent to start
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
// Simulate adding a steering comment mid-execution
|
|
const newComment = {
|
|
id: "9876543210-def456",
|
|
text: "Please use a different approach",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user" as const,
|
|
};
|
|
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
steeringComments: [newComment],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait for steer to be called
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
// Verify steer was called with the formatted message
|
|
expect(steerFn).toHaveBeenCalledOnce();
|
|
expect(steerFn.mock.calls[0][0]).toContain("📣 **New feedback**");
|
|
expect(steerFn.mock.calls[0][0]).toContain("Please use a different approach");
|
|
|
|
// Verify log entry was created
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("Comment received mid-execution"),
|
|
"by user"
|
|
);
|
|
|
|
// Complete the execution
|
|
promptResolve!();
|
|
await executePromise;
|
|
});
|
|
|
|
it("does not re-inject already seen steering comments", async () => {
|
|
const store = createMockStore();
|
|
const steerFn = vi.fn().mockResolvedValue(undefined);
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
steer: steerFn,
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const commentId = "1111111111-aaa111";
|
|
|
|
// Start execution with one comment
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
steeringComments: [{
|
|
id: commentId,
|
|
text: "Original comment",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user" as const,
|
|
}],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait for execution to start
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
// Trigger task:updated with the SAME comment (simulating a non-steering update)
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
steeringComments: [{
|
|
id: commentId,
|
|
text: "Original comment",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user" as const,
|
|
}],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait and verify steer was not called again
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
expect(steerFn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("marks comment as seen even if steer() throws", async () => {
|
|
const store = createMockStore();
|
|
const steerFn = vi.fn().mockRejectedValue(new Error("Session disconnected"));
|
|
let resolvePrompt: () => void;
|
|
const promptPromise = new Promise<void>(resolve => { resolvePrompt = resolve; });
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(() => promptPromise),
|
|
dispose: vi.fn(),
|
|
steer: steerFn,
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const commentId = "2222222222-bbb222";
|
|
|
|
// Start execution (don't await yet)
|
|
const executePromise = executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
steeringComments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait for execution to start
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
// Add a new comment that will fail to inject
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
steeringComments: [{
|
|
id: commentId,
|
|
text: "Comment that fails",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user" as const,
|
|
}],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait for processing
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
// Verify steer was called (and failed)
|
|
expect(steerFn).toHaveBeenCalledOnce();
|
|
|
|
// Trigger task:updated again with the same comment
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
steeringComments: [{
|
|
id: commentId,
|
|
text: "Comment that fails",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user" as const,
|
|
}],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait and verify steer was NOT called again (comment marked as seen)
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
expect(steerFn).toHaveBeenCalledTimes(1);
|
|
|
|
// Complete execution
|
|
resolvePrompt!();
|
|
await executePromise;
|
|
});
|
|
|
|
it("does not inject steering comments for tasks not in activeSessions", async () => {
|
|
const store = createMockStore();
|
|
const steerFn = vi.fn().mockResolvedValue(undefined);
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
steer: steerFn,
|
|
},
|
|
} as any);
|
|
|
|
new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Trigger task:updated for a task that is not in activeSessions
|
|
store._trigger("task:updated", {
|
|
id: "FN-NOT-EXECUTING",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
steeringComments: [{
|
|
id: "3333333333-ccc333",
|
|
text: "Should not be injected",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user" as const,
|
|
}],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait and verify steer was not called
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
expect(steerFn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles multiple new steering comments in a single task:updated", async () => {
|
|
const store = createMockStore();
|
|
const steerFn = vi.fn().mockResolvedValue(undefined);
|
|
let resolvePrompt: () => void;
|
|
const promptPromise = new Promise<void>(resolve => { resolvePrompt = resolve; });
|
|
|
|
// Set up getTask to return the task with existing comment in comments (used for seenSteeringIds init)
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
comments: [{
|
|
id: "existing-comment",
|
|
text: "Original",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user",
|
|
}],
|
|
steeringComments: [{
|
|
id: "existing-comment",
|
|
text: "Original",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user",
|
|
}],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(() => promptPromise),
|
|
dispose: vi.fn(),
|
|
steer: steerFn,
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Start execution (don't await yet)
|
|
const executePromise = executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait for execution to start
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
// Add two new comments at once
|
|
store._trigger("task:updated", {
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
steeringComments: [
|
|
{
|
|
id: "existing-comment",
|
|
text: "Original",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user",
|
|
},
|
|
{
|
|
id: "new-comment-1",
|
|
text: "First new comment",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user",
|
|
},
|
|
{
|
|
id: "new-comment-2",
|
|
text: "Second new comment",
|
|
createdAt: new Date().toISOString(),
|
|
author: "user",
|
|
},
|
|
],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Wait for processing
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
// Verify steer was called twice (once for each new comment)
|
|
expect(steerFn).toHaveBeenCalledTimes(2);
|
|
|
|
// Complete execution
|
|
resolvePrompt!();
|
|
await executePromise;
|
|
});
|
|
});
|
|
|
|
// ── Loop recovery (compact-and-resume) integration tests ────────────
|
|
|
|
describe("TaskExecutor loop recovery", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function createMockSessionForLoopRecovery(overrides?: { compactResult?: any }) {
|
|
const defaultResult = {
|
|
summary: "Compacted conversation",
|
|
tokensBefore: 150000,
|
|
};
|
|
const compactRetVal = overrides && "compactResult" in overrides ? overrides.compactResult : defaultResult;
|
|
const compact = vi.fn(async () => compactRetVal);
|
|
const steer = vi.fn(async () => {});
|
|
|
|
return {
|
|
prompt: vi.fn(async () => {}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
setThinkingLevel: vi.fn(),
|
|
steer,
|
|
compact,
|
|
sessionFile: "/tmp/test-session.json",
|
|
model: { provider: "mock", id: "mock-model", name: "Mock" },
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
}
|
|
|
|
function setupExecutorWithActiveSession(mockSession: ReturnType<typeof createMockSessionForLoopRecovery>) {
|
|
const store = createMockStore();
|
|
(store.getSettings as any).mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test-root");
|
|
|
|
// Directly inject an active session (avoids full execute() chain)
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: mockSession,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
return { store, executor, mockSession };
|
|
}
|
|
|
|
it("handleLoopDetected returns true and compacts session when active session exists", async () => {
|
|
const mockSession = createMockSessionForLoopRecovery();
|
|
const { store, executor } = setupExecutorWithActiveSession(mockSession);
|
|
|
|
const result = await executor.handleLoopDetected({
|
|
taskId: "FN-001",
|
|
reason: "loop",
|
|
noProgressMs: 600000,
|
|
inactivityMs: 0,
|
|
activitySinceProgress: 100,
|
|
shouldRequeue: true,
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockSession.compact).toHaveBeenCalled();
|
|
expect(mockSession.steer).toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.stringContaining("compact-and-resume"),
|
|
);
|
|
});
|
|
|
|
it("handleLoopDetected returns false when no active session", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test-root");
|
|
|
|
// No session active (activeSessions is empty)
|
|
const result = await executor.handleLoopDetected({
|
|
taskId: "FN-001",
|
|
reason: "loop",
|
|
noProgressMs: 600000,
|
|
inactivityMs: 0,
|
|
activitySinceProgress: 100,
|
|
shouldRequeue: true,
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("handleLoopDetected returns false when attempt ceiling reached", async () => {
|
|
const mockSession = createMockSessionForLoopRecovery();
|
|
const { executor } = setupExecutorWithActiveSession(mockSession);
|
|
|
|
// First call succeeds
|
|
const result1 = await executor.handleLoopDetected({
|
|
taskId: "FN-001",
|
|
reason: "loop",
|
|
noProgressMs: 600000,
|
|
inactivityMs: 0,
|
|
activitySinceProgress: 100,
|
|
shouldRequeue: true,
|
|
});
|
|
expect(result1).toBe(true);
|
|
|
|
// Second call hits ceiling (max 1 attempt per execute() lifecycle)
|
|
const result2 = await executor.handleLoopDetected({
|
|
taskId: "FN-001",
|
|
reason: "loop",
|
|
noProgressMs: 600000,
|
|
inactivityMs: 0,
|
|
activitySinceProgress: 200,
|
|
shouldRequeue: true,
|
|
});
|
|
expect(result2).toBe(false);
|
|
});
|
|
|
|
it("handleLoopDetected returns false when compaction fails", async () => {
|
|
const mockSession = createMockSessionForLoopRecovery({ compactResult: null });
|
|
const { executor } = setupExecutorWithActiveSession(mockSession);
|
|
|
|
const result = await executor.handleLoopDetected({
|
|
taskId: "FN-001",
|
|
reason: "loop",
|
|
noProgressMs: 600000,
|
|
inactivityMs: 0,
|
|
activitySinceProgress: 100,
|
|
shouldRequeue: true,
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Context limit error recovery tests ────────────────────────────────
|
|
|
|
describe("TaskExecutor context limit error recovery", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function createMockSessionForContextRecovery() {
|
|
return {
|
|
prompt: vi.fn(async () => {}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
setThinkingLevel: vi.fn(),
|
|
steer: vi.fn(async () => {}),
|
|
sessionFile: "/tmp/test-session.json",
|
|
model: { provider: "mock", id: "mock-model", name: "Mock" },
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
}
|
|
|
|
it("does NOT mark task as failed when context limit error is detected and recovery succeeds", async () => {
|
|
const mockSession = createMockSessionForContextRecovery();
|
|
|
|
// Mock compactSessionContext to succeed
|
|
const { compactSessionContext } = await import("./pi.js");
|
|
vi.mocked(compactSessionContext).mockResolvedValueOnce({
|
|
summary: "Compacted conversation",
|
|
tokensBefore: 150000,
|
|
});
|
|
|
|
const store = createMockStore();
|
|
(store.getSettings as any).mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Directly inject an active session
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: mockSession,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
// Simulate the catch block being invoked with a context limit error
|
|
// This would normally happen when prompt() throws
|
|
const contextError = new Error("invalid params, context window exceeds limit (2013)");
|
|
|
|
// The executor should catch this error and attempt recovery
|
|
// We can't directly test the catch block, but we can test that isContextLimitError
|
|
// now correctly identifies this error
|
|
const { isContextLimitError } = await import("./context-limit-detector.js");
|
|
expect(isContextLimitError(contextError.message)).toBe(true);
|
|
});
|
|
|
|
it("recognizes 'context window exceeds limit' as context limit error", async () => {
|
|
const { isContextLimitError } = await import("./context-limit-detector.js");
|
|
|
|
// These are the specific error formats that should be recognized
|
|
expect(isContextLimitError("invalid params, context window exceeds limit (2013)")).toBe(true);
|
|
expect(isContextLimitError("context window exceeds limit")).toBe(true);
|
|
expect(isContextLimitError("context window exceeds limit (2003)")).toBe(true);
|
|
expect(isContextLimitError("Context Window Exceeds limit")).toBe(true);
|
|
});
|
|
|
|
it("does NOT recognize generic 'limit exceeded' without context keywords", async () => {
|
|
const { isContextLimitError } = await import("./context-limit-detector.js");
|
|
|
|
// These should NOT be recognized as context limit errors
|
|
expect(isContextLimitError("limit exceeded")).toBe(false);
|
|
expect(isContextLimitError("quota exceeded")).toBe(false);
|
|
expect(isContextLimitError("rate limit exceeded")).toBe(false);
|
|
});
|
|
|
|
it("reduced-prompt retry is attempted when compact returns null", async () => {
|
|
// This test verifies the recovery flow when compactSessionContext returns null
|
|
// (no history to compact) - the code should fall through to reduced-prompt retry
|
|
const { isContextLimitError } = await import("./context-limit-detector.js");
|
|
|
|
// These error formats should trigger reduced-prompt recovery
|
|
const contextError = "context window exceeds limit (2013)";
|
|
expect(isContextLimitError(contextError)).toBe(true);
|
|
// The test passes if isContextLimitError returns true, which means
|
|
// the reduced-prompt retry path would be triggered
|
|
});
|
|
|
|
it("reduced-prompt retry prompt focuses on completing efficiently", async () => {
|
|
// Verify the reduced prompt template includes key instructions
|
|
const reducedPrompt = [
|
|
"Your previous attempt hit the context window limit.",
|
|
"Focus on completing the task efficiently with minimal context:",
|
|
"1. Review git status and git log to see what's been done",
|
|
"2. Identify the most critical remaining work",
|
|
"3. Complete it with a simpler, more focused approach",
|
|
"",
|
|
"Do not repeat what's already been done. Just complete the task and call task_done.",
|
|
].join("\n");
|
|
|
|
expect(reducedPrompt).toContain("context window limit");
|
|
expect(reducedPrompt).toContain("git status");
|
|
expect(reducedPrompt).toContain("task_done");
|
|
expect(reducedPrompt).toContain("Do not repeat what's already been done");
|
|
});
|
|
});
|
|
|
|
// ── Agent Spawning Tests ─────────────────────────────────────────────────
|
|
|
|
function createMockAgentStore() {
|
|
let nextId = 1;
|
|
const agents = new Map<string, any>();
|
|
|
|
return {
|
|
createAgent: vi.fn(async (input: any) => {
|
|
const agentId = `agent-${String(nextId++).padStart(8, "0")}`;
|
|
const agent = {
|
|
id: agentId,
|
|
name: input.name,
|
|
role: input.role,
|
|
state: "idle" as string,
|
|
reportsTo: input.reportsTo,
|
|
metadata: input.metadata ?? {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
agents.set(agentId, agent);
|
|
return agent;
|
|
}),
|
|
updateAgentState: vi.fn(async (agentId: string, newState: string) => {
|
|
const agent = agents.get(agentId);
|
|
if (agent) {
|
|
agent.state = newState;
|
|
agent.updatedAt = new Date().toISOString();
|
|
}
|
|
return agent;
|
|
}),
|
|
_agents: agents,
|
|
};
|
|
}
|
|
|
|
async function captureToolsWithAgentStore(agentStore?: any, settingsOverride?: any): Promise<{
|
|
tools: Record<string, (id: string, params: any) => Promise<any>>;
|
|
store: ReturnType<typeof createMockStore>;
|
|
executor: TaskExecutor;
|
|
}> {
|
|
const store = createMockStore();
|
|
store.updateStep.mockResolvedValue({
|
|
steps: [
|
|
{ name: "Preflight", status: "done" },
|
|
{ name: "Implement", status: "in-progress" },
|
|
{ name: "Testing", status: "pending" },
|
|
],
|
|
});
|
|
const mergedSettings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: undefined,
|
|
...settingsOverride,
|
|
};
|
|
store.getSettings.mockResolvedValue(mergedSettings);
|
|
|
|
let capturedTools: any[] = [];
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedTools = opts.customTools || [];
|
|
// Child agent sessions get a never-resolving prompt so runSpawnedChild
|
|
// doesn't complete and decrement totalSpawnedCount before limit checks.
|
|
const isChildAgent = opts.systemPrompt?.includes("child agent spawned");
|
|
return {
|
|
session: {
|
|
prompt: isChildAgent ? vi.fn(() => new Promise(() => {})) : vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
mockedExistsSync.mockReturnValue(true);
|
|
// Mock execSync for worktree operations
|
|
vi.mocked(execSync).mockReturnValue("");
|
|
|
|
const options: any = {};
|
|
if (agentStore) {
|
|
options.agentStore = agentStore;
|
|
}
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", options);
|
|
|
|
await executor.execute({
|
|
id: "FN-SPAWN",
|
|
title: "Spawn Test",
|
|
description: "Spawn test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const tools: Record<string, any> = {};
|
|
for (const t of capturedTools) {
|
|
tools[t.name] = t.execute;
|
|
}
|
|
return { tools, store, executor };
|
|
}
|
|
|
|
describe("Agent Spawning", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
vi.mocked(execSync).mockReturnValue("");
|
|
});
|
|
|
|
it("spawn_agent tool is registered in customTools", async () => {
|
|
const { tools } = await captureToolsWithAgentStore();
|
|
expect(tools.spawn_agent).toBeDefined();
|
|
expect(typeof tools.spawn_agent).toBe("function");
|
|
});
|
|
|
|
it("returns error when AgentStore is not configured", async () => {
|
|
const { tools } = await captureToolsWithAgentStore(undefined);
|
|
const result = await tools.spawn_agent("call1", {
|
|
name: "researcher",
|
|
role: "engineer",
|
|
task: "Research something",
|
|
});
|
|
|
|
expect(result.content[0].text).toContain("not available");
|
|
expect(result.content[0].text).toContain("no AgentStore configured");
|
|
expect(result.details.state).toBe("error");
|
|
});
|
|
|
|
it("creates agent in AgentStore with correct reportsTo", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
const result = await tools.spawn_agent("call1", {
|
|
name: "researcher",
|
|
role: "engineer",
|
|
task: "Research authentication patterns",
|
|
});
|
|
|
|
expect(agentStore.createAgent).toHaveBeenCalledOnce();
|
|
const createInput = agentStore.createAgent.mock.calls[0][0];
|
|
expect(createInput.name).toBe("researcher");
|
|
expect(createInput.role).toBe("engineer");
|
|
expect(createInput.reportsTo).toBe("FN-SPAWN");
|
|
expect(createInput.metadata.type).toBe("spawned");
|
|
expect(createInput.metadata.parentTaskId).toBe("FN-SPAWN");
|
|
});
|
|
|
|
it("returns correct SpawnAgentResult structure with state", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
const result = await tools.spawn_agent("call1", {
|
|
name: "researcher",
|
|
role: "engineer",
|
|
task: "Research authentication patterns",
|
|
});
|
|
|
|
// Parse the JSON from the text content
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
expect(parsed).toHaveProperty("agentId");
|
|
expect(parsed).toHaveProperty("name", "researcher");
|
|
expect(parsed).toHaveProperty("state", "running");
|
|
expect(parsed).toHaveProperty("role", "engineer");
|
|
expect(parsed).toHaveProperty("message");
|
|
expect(parsed.message).toContain("researcher");
|
|
expect(parsed.message).toContain("Research authentication patterns");
|
|
|
|
// Also check details object
|
|
expect(result.details.agentId).toBe(parsed.agentId);
|
|
expect(result.details.state).toBe("running");
|
|
});
|
|
|
|
it("transitions agent to active state after creation", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
await tools.spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "custom",
|
|
task: "Do some work",
|
|
});
|
|
|
|
// Agent is created in idle, then transitioned to active
|
|
const agentId = agentStore.createAgent.mock.calls[0][0];
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
"active"
|
|
);
|
|
});
|
|
|
|
it("creates child agent session via createFnAgent", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
await tools.spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "engineer",
|
|
task: "Do some work",
|
|
});
|
|
|
|
// createFnAgent is called at least twice: once for parent, once for child
|
|
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// Find the child session call
|
|
const childCall = mockedCreateFnAgent.mock.calls.find(
|
|
(call: any) => call[0].systemPrompt?.includes("child agent spawned")
|
|
);
|
|
expect(childCall).toBeDefined();
|
|
expect(childCall![0].tools).toBe("coding");
|
|
expect(childCall![0].systemPrompt).toContain("FN-SPAWN");
|
|
});
|
|
|
|
it("respects per-parent maxSpawnedAgentsPerParent limit", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore, {
|
|
maxSpawnedAgentsPerParent: 2,
|
|
});
|
|
|
|
// Spawn 2 agents (limit)
|
|
await tools.spawn_agent("call1", { name: "a1", role: "engineer", task: "task 1" });
|
|
await tools.spawn_agent("call2", { name: "a2", role: "engineer", task: "task 2" });
|
|
|
|
// 3rd should be rejected
|
|
const result = await tools.spawn_agent("call3", { name: "a3", role: "engineer", task: "task 3" });
|
|
expect(result.content[0].text).toContain("Per-parent spawn limit reached");
|
|
expect(result.content[0].text).toContain("2/2");
|
|
expect(result.details.state).toBe("error");
|
|
});
|
|
|
|
it("respects global maxSpawnedAgentsGlobal limit", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore, {
|
|
maxSpawnedAgentsGlobal: 3,
|
|
});
|
|
|
|
// Spawn 3 agents (global limit)
|
|
await tools.spawn_agent("call1", { name: "a1", role: "engineer", task: "task 1" });
|
|
await tools.spawn_agent("call2", { name: "a2", role: "engineer", task: "task 2" });
|
|
await tools.spawn_agent("call3", { name: "a3", role: "engineer", task: "task 3" });
|
|
|
|
// 4th should hit global limit
|
|
const result = await tools.spawn_agent("call4", { name: "a4", role: "engineer", task: "task 4" });
|
|
expect(result.content[0].text).toContain("Global spawn limit reached");
|
|
expect(result.content[0].text).toContain("3/3");
|
|
expect(result.details.state).toBe("error");
|
|
});
|
|
|
|
it("uses default limits when settings are not specified", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
// No spawn settings in the store — defaults should apply
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
// Should be able to spawn (defaults: 5 per parent, 20 global)
|
|
const result = await tools.spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "engineer",
|
|
task: "task 1",
|
|
});
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
expect(parsed.state).toBe("running");
|
|
});
|
|
|
|
it("handles errors during agent creation gracefully", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
agentStore.createAgent.mockRejectedValue(new Error("DB connection failed"));
|
|
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
const result = await tools.spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "engineer",
|
|
task: "task 1",
|
|
});
|
|
|
|
expect(result.content[0].text).toContain("Failed to spawn agent");
|
|
expect(result.content[0].text).toContain("DB connection failed");
|
|
expect(result.details.state).toBe("error");
|
|
});
|
|
|
|
it("trims whitespace from agent name", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
await tools.spawn_agent("call1", {
|
|
name: " researcher ",
|
|
role: "engineer",
|
|
task: "task 1",
|
|
});
|
|
|
|
const createInput = agentStore.createAgent.mock.calls[0][0];
|
|
expect(createInput.name).toBe("researcher");
|
|
});
|
|
|
|
it("truncates long task descriptions in result message", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
const longTask = "A".repeat(200);
|
|
const result = await tools.spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "engineer",
|
|
task: longTask,
|
|
});
|
|
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
expect(parsed.message).toContain("...");
|
|
// The message should contain the first 100 chars
|
|
expect(parsed.message.length).toBeLessThan(longTask.length + 50);
|
|
});
|
|
});
|
|
|
|
describe("Agent Spawning - Child Termination", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
vi.mocked(execSync).mockReturnValue("");
|
|
});
|
|
|
|
it("parent termination triggers all child terminations", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const mockDispose = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: mockDispose,
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
|
|
await executor.execute({
|
|
id: "FN-PARENT",
|
|
title: "Parent Task",
|
|
description: "Parent",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// execute() should have completed, disposing the parent session
|
|
// and any child sessions that were spawned
|
|
expect(mockDispose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("terminateChildAgent cleans up maps and decrements count", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
});
|
|
|
|
const mockDispose = vi.fn();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: mockDispose,
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
|
|
// Access internal state via any for testing
|
|
const internals = executor as any;
|
|
|
|
// Simulate spawned agent tracking state
|
|
const childId = "agent-test-child";
|
|
const mockSession = { dispose: vi.fn() };
|
|
internals.childSessions.set(childId, mockSession);
|
|
internals.spawnedAgents.set("FN-PARENT", new Set([childId]));
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Terminate the child
|
|
await internals.terminateChildAgent(childId);
|
|
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
expect(internals.childSessions.has(childId)).toBe(false);
|
|
// Note: spawnedAgents cleanup is done by terminateAllChildren, not terminateChildAgent
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith(childId, "terminated");
|
|
});
|
|
|
|
it("terminateChildAgent handles missing session gracefully", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Terminate a child that doesn't have a session in the map
|
|
await internals.terminateChildAgent("nonexistent-agent");
|
|
|
|
// Should still decrement counter and attempt state update
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("nonexistent-agent", "terminated");
|
|
});
|
|
|
|
it("terminateAllChildren handles no children gracefully", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
// Should not throw when there are no children
|
|
await internals.terminateAllChildren("FN-NONE");
|
|
expect(agentStore.updateAgentState).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("terminateAllChildren terminates all children and cleans up", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
// Set up multiple children
|
|
const child1 = { dispose: vi.fn() };
|
|
const child2 = { dispose: vi.fn() };
|
|
internals.childSessions.set("c1", child1);
|
|
internals.childSessions.set("c2", child2);
|
|
internals.spawnedAgents.set("FN-PARENT", new Set(["c1", "c2"]));
|
|
internals.totalSpawnedCount = 2;
|
|
|
|
await internals.terminateAllChildren("FN-PARENT");
|
|
|
|
expect(child1.dispose).toHaveBeenCalled();
|
|
expect(child2.dispose).toHaveBeenCalled();
|
|
expect(internals.spawnedAgents.has("FN-PARENT")).toBe(false);
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c1", "terminated");
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c2", "terminated");
|
|
});
|
|
|
|
it("terminateChildAgent handles AgentStore errors gracefully", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
agentStore.updateAgentState.mockRejectedValue(new Error("DB error"));
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn() };
|
|
internals.childSessions.set("c1", mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Should not throw even when AgentStore fails
|
|
await internals.terminateChildAgent("c1");
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
});
|
|
|
|
it("terminateChildAgent auto-deletes agent after 5 second delay", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
const agentStore = createMockAgentStore() as any;
|
|
// Add deleteAgent mock to the agent store
|
|
agentStore.deleteAgent = vi.fn().mockResolvedValue(undefined);
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn() };
|
|
const childId = "agent-auto-delete-test";
|
|
internals.childSessions.set(childId, mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Terminate the child
|
|
const terminatePromise = internals.terminateChildAgent(childId);
|
|
|
|
// Session should be disposed immediately
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
|
|
// deleteAgent should not be called yet (before 5 seconds)
|
|
expect(agentStore.deleteAgent).not.toHaveBeenCalled();
|
|
|
|
// Advance timers by 5 seconds
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
|
|
// Now deleteAgent should have been called
|
|
expect(agentStore.deleteAgent).toHaveBeenCalledTimes(1);
|
|
expect(agentStore.deleteAgent).toHaveBeenCalledWith(childId);
|
|
|
|
// Should not throw even when delete fails
|
|
await terminatePromise;
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Agent Spawning - runSpawnedChild", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("updates agent state to running then active on success", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined) };
|
|
internals.childSessions.set("agent-test", mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");
|
|
|
|
// Should transition: running → active
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "running");
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "active");
|
|
// Should clean up
|
|
expect(internals.childSessions.has("agent-test")).toBe(false);
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
});
|
|
|
|
it("updates agent state to error on failure", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn() };
|
|
internals.childSessions.set("agent-test", mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Make promptWithFallback throw
|
|
const { promptWithFallback } = await import("./pi.js");
|
|
vi.mocked(promptWithFallback).mockRejectedValueOnce(new Error("API error"));
|
|
|
|
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");
|
|
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "running");
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "error");
|
|
// Should still clean up
|
|
expect(internals.childSessions.has("agent-test")).toBe(false);
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
});
|
|
|
|
it("cleans up even when state update fails", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
agentStore.updateAgentState.mockRejectedValue(new Error("DB down"));
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn() };
|
|
internals.childSessions.set("agent-test", mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Should not throw even when state updates fail
|
|
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");
|
|
|
|
expect(internals.childSessions.has("agent-test")).toBe(false);
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Agent Execution Flow Integration Tests (FN-978) ────────────────────────────
|
|
//
|
|
// These tests verify the complete execution flow: event listener registration,
|
|
// session creation, stuck detector tracking, and heartbeat recording.
|
|
describe("TaskExecutor agent execution flow (FN-978)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("registers task:moved event listener in constructor", () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
// Verify the store.on was called with "task:moved"
|
|
expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function));
|
|
});
|
|
|
|
it("executes task when task:moved event fires with to='in-progress'", async () => {
|
|
const store = createMockStore();
|
|
const session = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
};
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger the task:moved event manually
|
|
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
|
|
|
|
// Wait for async execution to complete
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
// Verify the agent was created and prompt was called
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cwd: expect.any(String),
|
|
systemPrompt: expect.any(String),
|
|
tools: "coding",
|
|
}),
|
|
);
|
|
expect(session.prompt).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not execute task when task:moved event fires with to!='in-progress'", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: { prompt: vi.fn(), dispose: vi.fn() },
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger the task:moved event with to='done' (should not execute)
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "done" });
|
|
|
|
// Wait for async
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
// Verify no agent was created
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe("when task is moved away from in-progress", () => {
|
|
it("terminates active session and removes from activeSessions map", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const disposeSpy = vi.fn();
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: disposeSpy,
|
|
steer: vi.fn(),
|
|
};
|
|
|
|
// Simulate an active session
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: mockSession,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-001",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger task:moved away from in-progress
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "todo" });
|
|
|
|
// Allow async handlers to complete
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
// Verify session was disposed and removed from map
|
|
expect(disposeSpy).toHaveBeenCalled();
|
|
expect((executor as any).activeSessions.has("FN-001")).toBe(false);
|
|
// Verify task was added to pausedAborted set
|
|
expect((executor as any).pausedAborted.has("FN-001")).toBe(true);
|
|
});
|
|
|
|
it("terminates active step executor when task is moved away", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const mockTerminateAllSessions = vi.fn().mockResolvedValue(undefined);
|
|
const mockStepExecutor = {
|
|
executeAll: vi.fn().mockResolvedValue([]),
|
|
terminateAllSessions: mockTerminateAllSessions,
|
|
cleanup: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
// Simulate an active step executor
|
|
(executor as any).activeStepExecutors.set("FN-002", mockStepExecutor as any);
|
|
|
|
const task = {
|
|
id: "FN-002",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger task:moved away from in-progress
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
|
|
|
|
// Allow async handlers to complete
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
// Verify terminateAllSessions was called
|
|
expect(mockTerminateAllSessions).toHaveBeenCalled();
|
|
// Verify removed from map
|
|
expect((executor as any).activeStepExecutors.has("FN-002")).toBe(false);
|
|
});
|
|
|
|
it("handles graceful no-op when no active session exists", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// No active session set
|
|
|
|
const task = {
|
|
id: "FN-003",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Should not throw
|
|
expect(() => {
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("untracks task from stuck detector when moved away", async () => {
|
|
const store = createMockStore();
|
|
const untrackSpy = vi.fn();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
recordActivity: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
untrackTask: untrackSpy,
|
|
};
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
});
|
|
|
|
const disposeSpy = vi.fn();
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: disposeSpy,
|
|
steer: vi.fn(),
|
|
};
|
|
|
|
(executor as any).activeSessions.set("FN-004", {
|
|
session: mockSession,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-004",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "todo" });
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
expect(untrackSpy).toHaveBeenCalledWith("FN-004");
|
|
});
|
|
|
|
it("adds task to pausedAborted set to prevent re-execution", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const disposeSpy = vi.fn();
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: disposeSpy,
|
|
steer: vi.fn(),
|
|
};
|
|
|
|
(executor as any).activeSessions.set("FN-005", {
|
|
session: mockSession,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-005",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "triage" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
expect((executor as any).pausedAborted.has("FN-005")).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("tracks task with stuck detector after session creation", async () => {
|
|
const store = createMockStore();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
recordActivity: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
};
|
|
|
|
const session = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
};
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await executor.execute(task);
|
|
|
|
// Verify trackTask was called with task ID
|
|
expect(stuckDetector.trackTask).toHaveBeenCalledWith("FN-978", expect.anything());
|
|
// Verify recordActivity was called (heartbeat on prompt start)
|
|
expect(stuckDetector.recordActivity).toHaveBeenCalledWith("FN-978");
|
|
// Verify untrackTask was called in the finally block
|
|
expect(stuckDetector.untrackTask).toHaveBeenCalledWith("FN-978");
|
|
});
|
|
|
|
it("records activity via AgentLogger onText callbacks", async () => {
|
|
const store = createMockStore();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
recordActivity: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
};
|
|
|
|
let capturedOnText: ((delta: string) => void) | undefined;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
// Capture the onText callback that's passed to createFnAgent
|
|
capturedOnText = opts.onText;
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate the agent producing text output
|
|
if (capturedOnText) {
|
|
capturedOnText("Hello world");
|
|
}
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const onAgentText = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
onAgentText,
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await executor.execute(task);
|
|
|
|
// Verify that recordActivity was called (at least once for the initial heartbeat
|
|
// and possibly more for the simulated text output)
|
|
expect(stuckDetector.recordActivity).toHaveBeenCalledWith("FN-978");
|
|
// The initial recordActivity + text callback should result in multiple calls
|
|
expect(stuckDetector.recordActivity.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
// Verify onAgentText callback was called with the delta
|
|
expect(onAgentText).toHaveBeenCalledWith("FN-978", "Hello world");
|
|
});
|
|
|
|
it("records activity via AgentLogger onToolStart callbacks", async () => {
|
|
const store = createMockStore();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
recordActivity: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
};
|
|
|
|
let capturedOnToolStart: ((name: string, args?: Record<string, unknown>) => void) | undefined;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedOnToolStart = opts.onToolStart;
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate the agent calling a tool
|
|
if (capturedOnToolStart) {
|
|
capturedOnToolStart("bash", { command: "echo test" });
|
|
}
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const onAgentTool = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
onAgentTool,
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await executor.execute(task);
|
|
|
|
// Verify that recordActivity was called for the tool usage
|
|
expect(stuckDetector.recordActivity).toHaveBeenCalledWith("FN-978");
|
|
// Verify onAgentTool callback was called with the tool name
|
|
expect(onAgentTool).toHaveBeenCalledWith("FN-978", "bash");
|
|
});
|
|
|
|
it("prevents duplicate execution when task:moved fires twice for same task", async () => {
|
|
const store = createMockStore();
|
|
const session = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
};
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger the event twice quickly
|
|
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
|
|
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
|
|
|
|
// Wait for completion
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
// The executing guard prevents duplicate execution from the event handler.
|
|
// Note: createFnAgent may be called a second time if the agent finishes
|
|
// without calling task_done (retry path), but the initial trigger should
|
|
// only cause one execution, not two.
|
|
// Verify that store.on was called with task:moved (listener registered)
|
|
expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function));
|
|
// Verify the event handler initiated execute() (not twice from events)
|
|
// The executing set guard works — both triggers don't cause double execution
|
|
});
|
|
|
|
it("logs error when execute() fails in task:moved handler", async () => {
|
|
const store = createMockStore();
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("model not found"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger the event
|
|
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
|
|
|
|
// Wait for async
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
// Verify the error handler was called
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-978" }),
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── StepSessionExecutor integration tests ──────────────────────────────────
|
|
|
|
describe("StepSessionExecutor integration", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockExecuteAll.mockResolvedValue([]);
|
|
mockTerminateAllSessions.mockResolvedValue(undefined);
|
|
mockCleanup.mockResolvedValue(undefined);
|
|
});
|
|
|
|
/** Helper to create a task with steps for step-session mode testing */
|
|
function createTaskWithSteps(overrides: Partial<Task> = {}): Task {
|
|
return {
|
|
id: "FN-200",
|
|
title: "Step-session test task",
|
|
description: "Test task with steps for step-session execution",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Step 0", status: "pending" },
|
|
{ name: "Step 1", status: "pending" },
|
|
],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Helper to create a store configured for step-session mode */
|
|
function createStepSessionStore() {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
runStepsInNewSessions: true,
|
|
maxParallelSteps: 2,
|
|
});
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-200",
|
|
title: "Step-session test task",
|
|
description: "Test task with steps for step-session execution",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Step 0", status: "pending" },
|
|
{ name: "Step 1", status: "pending" },
|
|
],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check\n### Step 1: Implement\n- [ ] code",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
baseCommitSha: "abc123",
|
|
enabledWorkflowSteps: [],
|
|
});
|
|
return store;
|
|
}
|
|
|
|
it("uses step-session path when runStepsInNewSessions is true", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// StepSessionExecutor constructor should have been called
|
|
expect(mockedStepSessionExecutor).toHaveBeenCalled();
|
|
// executeAll should have been called
|
|
expect(mockExecuteAll).toHaveBeenCalledOnce();
|
|
// createFnAgent should NOT have been called for step-session path
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses single-session path when runStepsInNewSessions is false (default)", async () => {
|
|
const store = createMockStore();
|
|
// Default settings: no runStepsInNewSessions
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
});
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-200",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// Should NOT use step-session executor
|
|
expect(mockedStepSessionExecutor).not.toHaveBeenCalled();
|
|
// Should use the traditional single-session agent
|
|
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
|
});
|
|
|
|
it("success path moves task to in-review and calls onComplete", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
{ stepIndex: 1, success: true, retries: 0 },
|
|
]);
|
|
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
expect(mockExecuteAll).toHaveBeenCalledOnce();
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-200" }));
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("failure path marks task as failed with step error summary", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
{ stepIndex: 1, success: false, error: "compilation error", retries: 3 },
|
|
]);
|
|
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-200", {
|
|
status: "failed",
|
|
error: "Step 1: compilation error",
|
|
});
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-200" }),
|
|
expect.objectContaining({ message: "Step 1: compilation error" }),
|
|
);
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("moves task to in-review when step-session execution fails", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockRejectedValue(new Error("Infrastructure failure"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-200", expect.objectContaining({
|
|
status: "failed",
|
|
error: "Infrastructure failure",
|
|
}));
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("moves task to in-review when transient retries are exhausted (step-session)", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockRejectedValue(new Error("socket hang up"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
await executor.execute(createTaskWithSteps({ recoveryRetryCount: 3 }));
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-200", {
|
|
status: "failed",
|
|
error: "socket hang up",
|
|
recoveryRetryCount: null,
|
|
nextRecoveryAt: null,
|
|
});
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-200", "todo");
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("pause terminates step sessions", async () => {
|
|
const store = createStepSessionStore();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
} as any;
|
|
|
|
// Make executeAll hang until we trigger pause
|
|
let resolveExecuteAll: () => void;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector });
|
|
|
|
const task = createTaskWithSteps();
|
|
|
|
// Start execution (don't await — it will hang)
|
|
const executePromise = executor.execute(task);
|
|
|
|
// Give it time to set up the step executor
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
// Trigger pause
|
|
store._trigger("task:updated", { ...task, paused: true });
|
|
|
|
// Resolve executeAll so the execution can complete
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
expect(mockTerminateAllSessions).toHaveBeenCalled();
|
|
expect(stuckDetector.untrackTask).toHaveBeenCalledWith("FN-200");
|
|
});
|
|
|
|
it("stuck-kill terminates step sessions", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
// Make executeAll hang
|
|
let resolveExecuteAll: () => void;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
// Give it time to set up the step executor
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
// Trigger stuck kill
|
|
executor.markStuckAborted("FN-200");
|
|
|
|
// Resolve executeAll so the execution can complete
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
expect(mockTerminateAllSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
// ── FN-1461: Step-session stuck retry regression tests ─────────────────────────────────────
|
|
|
|
it("REGRESSION: stuck-kill with bare task ID properly requeues step-session task to todo", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
// Make executeAll hang initially
|
|
let resolveExecuteAll: (() => void) | null = null;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
// Give it time to set up the step executor
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
// Verify step executor is registered
|
|
expect((executor as any).activeStepExecutors.has("FN-200")).toBe(true);
|
|
|
|
// Trigger stuck kill with bare task ID (as StuckTaskDetector.onStuck would call)
|
|
executor.markStuckAborted("FN-200", true);
|
|
|
|
// Resolve executeAll to complete the execution
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
// Verify: task should be marked stuck-killed and moved to todo for retry
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-200", expect.objectContaining({
|
|
status: "stuck-killed",
|
|
worktree: null,
|
|
branch: null,
|
|
}));
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "todo");
|
|
});
|
|
|
|
it("REGRESSION: stuck-kill with exhausted budget does not requeue step-session task", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
let resolveExecuteAll: (() => void) | null = null;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
// Budget exhausted — should NOT requeue
|
|
executor.markStuckAborted("FN-200", false);
|
|
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
// Should NOT move to todo or mark as stuck-killed
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-200", "todo");
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-200", expect.objectContaining({
|
|
status: "stuck-killed",
|
|
}));
|
|
});
|
|
|
|
it("REGRESSION: untrackTask called with bare task ID during pause in step-session mode", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
} as any;
|
|
|
|
let resolveExecuteAll: (() => void) | null = null;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector });
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
// Trigger pause
|
|
store._trigger("task:updated", { ...task, paused: true });
|
|
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
// In step-session mode, untrackTask is called with bare task ID "FN-200"
|
|
// But tracking was done with step-scoped keys like "FN-200-step-0"
|
|
// BUG: This test captures that the current implementation passes bare ID,
|
|
// which won't match the step-scoped tracking keys
|
|
expect(stuckDetector.untrackTask).toHaveBeenCalledWith("FN-200");
|
|
// After fix: untrackTask should also clean up any step-scoped entries
|
|
});
|
|
|
|
it("REGRESSION: StepSessionExecutor should pass bare task ID for recordProgress in onStepStart", async () => {
|
|
// Note: This test verifies the expected contract between StepSessionExecutor and StuckTaskDetector.
|
|
// In step-session mode:
|
|
// - StepSessionExecutor tracks with step-scoped keys (e.g., "FN-200-step-0")
|
|
// - But recordProgress should be called with the bare task ID for consistency
|
|
// - StuckTaskDetector.recordProgress should handle this by finding the canonical task ID
|
|
//
|
|
// Currently, StepSessionExecutor calls recordProgress(task.id) where task.id is "FN-200"
|
|
// But the entry was tracked with key "FN-200-step-0", so the lookup fails.
|
|
//
|
|
// After fix: recordProgress should handle both bare task IDs and step-scoped keys
|
|
// by extracting the canonical task ID from step-scoped keys.
|
|
//
|
|
// This test is informational - the actual fix will be in StuckTaskDetector.recordProgress()
|
|
// which should find entries by canonicalTaskId when looking up by bare task ID.
|
|
expect(true).toBe(true); // Placeholder - real test verifies StuckTaskDetector behavior
|
|
});
|
|
|
|
it("cleanup called in finally block even on error", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockRejectedValue(new Error("Fatal error"));
|
|
mockCleanup.mockResolvedValue(undefined);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// cleanup should still have been called despite the error
|
|
expect(mockCleanup).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("respects semaphore for concurrency control", async () => {
|
|
const store = createStepSessionStore();
|
|
const sem = new AgentSemaphore(2);
|
|
const runSpy = vi.spyOn(sem, "run");
|
|
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
]);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
expect(runSpy).toHaveBeenCalledOnce();
|
|
expect(sem.activeCount).toBe(0);
|
|
});
|
|
|
|
it("runs without semaphore when not provided", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
]);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// Should still work fine without a semaphore
|
|
expect(mockExecuteAll).toHaveBeenCalledOnce();
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
});
|
|
|
|
it("dep-abort during step-session execution triggers cleanup", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
// Make executeAll hang so we can trigger dep-abort mid-execution
|
|
let resolveExecuteAll: () => void;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
// Give it time to set up the step executor
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
// Simulate dep-abort by directly triggering the task_add_dep cleanup logic
|
|
// The dep-abort flag should cause the step-session path to handle cleanup
|
|
// We can test this by checking that when depAborted is set, cleanup is called
|
|
// For now, just resolve and verify cleanup runs
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
// Verify cleanup was called in finally
|
|
expect(mockCleanup).toHaveBeenCalled();
|
|
});
|
|
|
|
it("workflow steps run on success and block on failure", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
// Enable a workflow step
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-200",
|
|
title: "Step-session test task",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Step 0", status: "pending" },
|
|
],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
baseCommitSha: "abc123",
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Test Workflow",
|
|
description: "Test",
|
|
mode: "script",
|
|
phase: "pre-merge",
|
|
scriptName: "test-script",
|
|
prompt: undefined,
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Steps succeed, but workflow step will fail
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
]);
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
// Use fake timers to control the setTimeout in sendTaskBackForFix
|
|
vi.useFakeTimers();
|
|
|
|
// Exhaust retries so workflow step failure is immediate
|
|
await executor.execute(createTaskWithSteps({ steps: [{ name: "Step 0", status: "pending" }], workflowStepRetries: 3, enabledWorkflowSteps: ["WS-001"] }));
|
|
|
|
// Should have called getWorkflowStep to look up the workflow step
|
|
expect(store.getWorkflowStep).toHaveBeenCalledWith("WS-001");
|
|
// With script mode and no scripts configured, the step should fail (script not found)
|
|
// Task should be sent back to in-progress for remediation, NOT call onError
|
|
expect(store.addTaskComment).toHaveBeenCalledWith(
|
|
"FN-200",
|
|
expect.stringContaining("Workflow step failed"),
|
|
"agent",
|
|
);
|
|
// onError should NOT be called (task is being retried, not permanently failed)
|
|
expect(onError).not.toHaveBeenCalled();
|
|
|
|
// Advance timers to trigger the setTimeout that moves task to todo then in-progress
|
|
vi.advanceTimersByTime(0);
|
|
// Run any pending microtasks (the async code in setTimeout)
|
|
await vi.runAllTimersAsync();
|
|
|
|
// Task should move to todo then in-progress (not in-review)
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "todo");
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-progress");
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("onStepStart callback updates step status to in-progress", async () => {
|
|
const store = createStepSessionStore();
|
|
store.updateStep.mockResolvedValue({} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// Capture the StepSessionExecutor constructor options
|
|
expect(mockedStepSessionExecutor).toHaveBeenCalled();
|
|
const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0];
|
|
|
|
// Invoke the onStepStart callback
|
|
ctorOptions.onStepStart!(0);
|
|
|
|
// Should update step status in store
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-200", 0, "in-progress");
|
|
});
|
|
|
|
it("onStepComplete callback updates step status to done on success", async () => {
|
|
const store = createStepSessionStore();
|
|
store.updateStep.mockResolvedValue({} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0];
|
|
|
|
ctorOptions.onStepComplete!(0, { stepIndex: 0, success: true, retries: 0 });
|
|
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-200", 0, "done");
|
|
});
|
|
|
|
it("onStepComplete callback updates step status to skipped on failure", async () => {
|
|
const store = createStepSessionStore();
|
|
store.updateStep.mockResolvedValue({} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0];
|
|
|
|
ctorOptions.onStepComplete!(1, { stepIndex: 1, success: false, retries: 3 });
|
|
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-200", 1, "skipped");
|
|
});
|
|
|
|
it("step status update errors do not block execution", async () => {
|
|
const store = createStepSessionStore();
|
|
store.updateStep.mockRejectedValue(new Error("DB error"));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
// Should not throw even when updateStep rejects
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0];
|
|
|
|
// Invoking callbacks should not throw
|
|
expect(() => ctorOptions.onStepStart!(0)).not.toThrow();
|
|
expect(() => ctorOptions.onStepComplete!(0, { stepIndex: 0, success: true, retries: 0 })).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("detectReviewHandoffIntent", () => {
|
|
it("returns true for 'send it back to me'", () => {
|
|
expect(detectReviewHandoffIntent("Please send it back to me for review")).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'hand off to user'", () => {
|
|
expect(detectReviewHandoffIntent("I need to hand off to user")).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'needs human review'", () => {
|
|
expect(detectReviewHandoffIntent("This needs human review")).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'assign to user'", () => {
|
|
expect(detectReviewHandoffIntent("Please assign to user")).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'return to user'", () => {
|
|
expect(detectReviewHandoffIntent("Return to user for final approval")).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'user review needed'", () => {
|
|
expect(detectReviewHandoffIntent("User review needed")).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'requesting user review'", () => {
|
|
expect(detectReviewHandoffIntent("I am requesting user review")).toBe(true);
|
|
});
|
|
|
|
it("is case-insensitive", () => {
|
|
expect(detectReviewHandoffIntent("SEND IT BACK TO ME")).toBe(true);
|
|
expect(detectReviewHandoffIntent("Send It Back To Me")).toBe(true);
|
|
});
|
|
|
|
it("returns false for regular comments without handoff intent", () => {
|
|
expect(detectReviewHandoffIntent("Good progress on the implementation")).toBe(false);
|
|
expect(detectReviewHandoffIntent("Please add more tests")).toBe(false);
|
|
expect(detectReviewHandoffIntent("The code looks great")).toBe(false);
|
|
});
|
|
|
|
it("returns false for empty strings", () => {
|
|
expect(detectReviewHandoffIntent("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("buildExecutionPrompt", () => {
|
|
it("includes worktree boundary guidance in the execution prompt", () => {
|
|
const task: any = {
|
|
id: "FN-TEST",
|
|
title: "Test task",
|
|
dependencies: [],
|
|
prompt: "# Test task\n## Steps\n- Step 1",
|
|
steps: [],
|
|
currentStep: 0,
|
|
attachments: [],
|
|
};
|
|
|
|
const prompt = buildExecutionPrompt(task, "/project");
|
|
|
|
expect(prompt).toContain("## Worktree Boundaries");
|
|
expect(prompt).toContain("isolated git worktree");
|
|
expect(prompt).toContain("All code changes must be made inside the current worktree directory");
|
|
});
|
|
|
|
it("mentions project memory exception in worktree boundary guidance", () => {
|
|
const task: any = {
|
|
id: "FN-TEST",
|
|
title: "Test task",
|
|
dependencies: [],
|
|
prompt: "# Test task\n## Steps\n- Step 1",
|
|
steps: [],
|
|
currentStep: 0,
|
|
attachments: [],
|
|
};
|
|
|
|
const prompt = buildExecutionPrompt(task, "/project");
|
|
|
|
expect(prompt).toContain(".fusion/memory/");
|
|
expect(prompt).toContain("memory");
|
|
expect(prompt).toContain("durable");
|
|
});
|
|
|
|
it("mentions task attachments exception in worktree boundary guidance", () => {
|
|
const task: any = {
|
|
id: "FN-TEST",
|
|
title: "Test task",
|
|
dependencies: [],
|
|
prompt: "# Test task\n## Steps\n- Step 1",
|
|
steps: [],
|
|
currentStep: 0,
|
|
attachments: [],
|
|
};
|
|
|
|
const prompt = buildExecutionPrompt(task, "/project");
|
|
|
|
expect(prompt).toContain("attachments");
|
|
expect(prompt).toContain("context");
|
|
});
|
|
|
|
it("includes worktree boundary guidance regardless of review level", () => {
|
|
const task: any = {
|
|
id: "FN-TEST",
|
|
title: "Test task",
|
|
dependencies: [],
|
|
prompt: "# Test task\n## Review Level: 0\n## Steps\n- Step 1",
|
|
steps: [],
|
|
currentStep: 0,
|
|
attachments: [],
|
|
};
|
|
|
|
const prompt = buildExecutionPrompt(task, "/project");
|
|
|
|
expect(prompt).toContain("## Worktree Boundaries");
|
|
});
|
|
});
|
|
|
|
// ── Skill Selection Regression Tests (FN-1514) ──────────────────────────
|
|
|
|
describe("TaskExecutor skillSelection regression (FN-1511)", () => {
|
|
const projectRoot = "/tmp/test-project";
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
/**
|
|
* Helper: execute a task and capture createFnAgent call arguments.
|
|
*/
|
|
async function captureCreateFnAgentArgs(options?: {
|
|
assignedAgentId?: string;
|
|
assignedAgentSkills?: string[];
|
|
settings?: Record<string, unknown>;
|
|
}) {
|
|
const { assignedAgentId, assignedAgentSkills } = options || {};
|
|
|
|
const mockAgentStore = {
|
|
getAgent: vi.fn().mockImplementation(async (id: string) => {
|
|
if (id === assignedAgentId) {
|
|
return {
|
|
id,
|
|
name: "Test Agent",
|
|
role: "executor",
|
|
state: "idle",
|
|
metadata: { skills: assignedAgentSkills || [] },
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-SKILL",
|
|
title: "Skill Test",
|
|
description: "Test skill selection",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
assignedAgentId,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
let capturedArgs: any = null;
|
|
mockedCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedArgs = opts;
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, projectRoot, { agentStore: mockAgentStore as any });
|
|
await executor.execute({
|
|
id: "FN-SKILL",
|
|
title: "Skill Test",
|
|
description: "Test skill selection",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
assignedAgentId,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
return capturedArgs;
|
|
}
|
|
|
|
describe("single-session mode (runStepsInNewSessions: false)", () => {
|
|
it("passes skillSelection to createFnAgent when assigned agent has skills", async () => {
|
|
const args = await captureCreateFnAgentArgs({
|
|
assignedAgentId: "agent-001",
|
|
assignedAgentSkills: ["triage", "executor"],
|
|
});
|
|
|
|
expect(args).not.toBeNull();
|
|
expect(args).toHaveProperty("skillSelection");
|
|
// The agent's skills are passed directly; filtering happens at skill resolver level
|
|
expect(args.skillSelection).toMatchObject({
|
|
projectRootDir: projectRoot,
|
|
requestedSkillNames: expect.arrayContaining(["triage", "executor"]),
|
|
sessionPurpose: "executor",
|
|
});
|
|
});
|
|
|
|
it("normalizes whitespace in requestedSkillNames", async () => {
|
|
const args = await captureCreateFnAgentArgs({
|
|
assignedAgentId: "agent-001",
|
|
assignedAgentSkills: [" triage ", " executor ", "reviewer"],
|
|
});
|
|
|
|
expect(args).not.toBeNull();
|
|
expect(args.skillSelection).toMatchObject({
|
|
projectRootDir: projectRoot,
|
|
requestedSkillNames: expect.arrayContaining(["triage", "executor", "reviewer"]),
|
|
});
|
|
});
|
|
|
|
it("deduplicates requestedSkillNames while preserving first occurrence", async () => {
|
|
const args = await captureCreateFnAgentArgs({
|
|
assignedAgentId: "agent-001",
|
|
assignedAgentSkills: ["triage", "executor", "triage", "reviewer", "executor"],
|
|
});
|
|
|
|
expect(args).not.toBeNull();
|
|
// Should contain triage, executor, reviewer in that order (first occurrence)
|
|
expect(args.skillSelection).toMatchObject({
|
|
projectRootDir: projectRoot,
|
|
requestedSkillNames: ["triage", "executor", "reviewer"],
|
|
});
|
|
});
|
|
|
|
it("omits skillSelection when assigned agent has no skills", async () => {
|
|
const args = await captureCreateFnAgentArgs({
|
|
assignedAgentId: "agent-001",
|
|
assignedAgentSkills: [],
|
|
});
|
|
|
|
expect(args).not.toBeNull();
|
|
// When no skills, skillSelection may be undefined or executor uses role fallback
|
|
// The key is it doesn't crash and handles the case gracefully
|
|
});
|
|
|
|
it("omits skillSelection when no assigned agent", async () => {
|
|
const args = await captureCreateFnAgentArgs({});
|
|
|
|
expect(args).not.toBeNull();
|
|
// Legacy fallback: no skillSelection when no assigned agent
|
|
});
|
|
});
|
|
|
|
describe("step-session mode (runStepsInNewSessions: true)", () => {
|
|
// Note: These tests verify that skillSelection flows from executor to
|
|
// StepSessionExecutor. The full integration is complex due to mock setup,
|
|
// so we verify the contract indirectly through the step-session-executor tests.
|
|
// The executor tests focus on verifying skillSelection is present in createFnAgent calls.
|
|
// See StepSessionExecutor skillSelection tests in step-session-executor.test.ts.
|
|
|
|
// Skipped: Integration tests for step-session skill selection are covered
|
|
// in step-session-executor.test.ts where StepSessionExecutor is tested directly.
|
|
it.skip("step-session skill selection covered in step-session-executor.test.ts", () => {});
|
|
});
|
|
});
|
|
|
|
// ── Agent Messaging Tool Tests ────────────────────────────────────────
|
|
|
|
describe("TaskExecutor messaging tools", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any);
|
|
});
|
|
|
|
/**
|
|
* Helper: execute a task and capture the customTools array passed to createFnAgent.
|
|
*/
|
|
async function captureCustomTools(options?: {
|
|
messageStore?: unknown;
|
|
agentStore?: unknown;
|
|
assignedAgentId?: string;
|
|
executionMode?: "standard" | "fast";
|
|
}): Promise<any[]> {
|
|
const { messageStore, agentStore, assignedAgentId, executionMode } = options || {};
|
|
let captured: any[] = [];
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
captured = opts.customTools || [];
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const store = createMockStore();
|
|
// Override getTask to return the correct assignedAgentId and executionMode
|
|
store.getTask.mockImplementation(async (id: string) => ({
|
|
id,
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
assignedAgentId,
|
|
executionMode,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}));
|
|
|
|
const taskExecutor = new TaskExecutor(store, "/tmp/test", {
|
|
messageStore: messageStore as any,
|
|
agentStore: agentStore as any,
|
|
});
|
|
|
|
await taskExecutor.execute({
|
|
id: "FN-MSG",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
assignedAgentId,
|
|
executionMode,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
return captured;
|
|
}
|
|
|
|
it("includes send_message when messageStore and assignedAgentId are available", async () => {
|
|
const mockMessageStore = {
|
|
sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }),
|
|
};
|
|
const tools = await captureCustomTools({
|
|
messageStore: mockMessageStore,
|
|
assignedAgentId: "agent-001",
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).toContain("send_message");
|
|
});
|
|
|
|
it("includes read_messages when messageStore and assignedAgentId are available", async () => {
|
|
const mockMessageStore = {
|
|
sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }),
|
|
getInbox: vi.fn().mockReturnValue([]),
|
|
};
|
|
const tools = await captureCustomTools({
|
|
messageStore: mockMessageStore,
|
|
assignedAgentId: "agent-001",
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).toContain("read_messages");
|
|
});
|
|
|
|
it("excludes read_messages when messageStore is not provided", async () => {
|
|
const tools = await captureCustomTools({
|
|
assignedAgentId: "agent-001",
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).not.toContain("read_messages");
|
|
});
|
|
|
|
it("excludes read_messages when assignedAgentId is not provided", async () => {
|
|
const mockMessageStore = {
|
|
sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }),
|
|
getInbox: vi.fn().mockReturnValue([]),
|
|
};
|
|
const tools = await captureCustomTools({
|
|
messageStore: mockMessageStore,
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).not.toContain("read_messages");
|
|
});
|
|
|
|
it("excludes messaging tools when messageStore is not provided", async () => {
|
|
const tools = await captureCustomTools({
|
|
assignedAgentId: "agent-001",
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).not.toContain("send_message");
|
|
expect(toolNames).not.toContain("read_messages");
|
|
});
|
|
|
|
it("includes list_agents and delegate_task when agentStore is available", async () => {
|
|
const mockAgentStore = {
|
|
listAgents: vi.fn().mockResolvedValue([]),
|
|
getAgent: vi.fn().mockResolvedValue(null),
|
|
};
|
|
const tools = await captureCustomTools({
|
|
agentStore: mockAgentStore,
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).toContain("list_agents");
|
|
expect(toolNames).toContain("delegate_task");
|
|
});
|
|
|
|
it("excludes delegation tools when agentStore is not provided", async () => {
|
|
const tools = await captureCustomTools({});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).not.toContain("list_agents");
|
|
expect(toolNames).not.toContain("delegate_task");
|
|
});
|
|
|
|
describe("fast mode", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("excludes review_step tool when executionMode is 'fast'", async () => {
|
|
const tools = await captureCustomTools({
|
|
executionMode: "fast",
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).not.toContain("review_step");
|
|
});
|
|
|
|
it("includes task_update and task_done tools in fast mode", async () => {
|
|
const tools = await captureCustomTools({
|
|
executionMode: "fast",
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).toContain("task_update");
|
|
expect(toolNames).toContain("task_done");
|
|
});
|
|
|
|
it("includes review_step tool when executionMode is 'standard'", async () => {
|
|
const tools = await captureCustomTools({
|
|
executionMode: "standard",
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).toContain("review_step");
|
|
});
|
|
|
|
it("includes review_step tool when executionMode is undefined (defaults to standard)", async () => {
|
|
const tools = await captureCustomTools({});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).toContain("review_step");
|
|
});
|
|
|
|
it("logs executor model usage when execution starts", async () => {
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
executionMode: "fast",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
executionMode: "fast",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Verify logEntry was called (indicates executor is running)
|
|
expect(store.logEntry).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Fast mode completion path", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(false);
|
|
});
|
|
|
|
it("skips workflow steps in fast mode when task completes", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Task with workflow steps enabled AND fast mode
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
executionMode: "fast",
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Mock workflow step exists
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Docs Review",
|
|
description: "Check documentation",
|
|
prompt: "Review docs.",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Mock agent with task_done
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
}) as any);
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
executionMode: "fast",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Verify task moved to in-review
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
|
|
// Verify onComplete was called
|
|
expect(onComplete).toHaveBeenCalled();
|
|
|
|
// Verify workflow step was NOT called (fast mode skips workflow steps)
|
|
// The agent should only be called once (main execution), not twice (main + workflow)
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("still runs workflow steps in standard mode when task completes", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Task with workflow steps enabled in standard mode
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
executionMode: "standard",
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Mock workflow step exists
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Docs Review",
|
|
description: "Check documentation",
|
|
prompt: "Review docs.",
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Track agent calls
|
|
let callIdx = 0;
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
callIdx++;
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
if (callIdx === 1) {
|
|
// Main execution — find and trigger task_done
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
} else {
|
|
// Workflow step — no task_done needed
|
|
}
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
executionMode: "standard",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Verify task moved to in-review
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
|
|
|
// Verify workflow step WAS called (standard mode runs workflow steps)
|
|
// Agent should be called twice: main execution + workflow step
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("still enforces task_done requirement in fast mode", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Task in fast mode
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
executionMode: "fast",
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Mock agent that exits WITHOUT calling task_done
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
},
|
|
} as any);
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
executionMode: "fast",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Fast mode should still enforce task_done requirement
|
|
// Should fail after retry and call onError
|
|
expect(onError).toHaveBeenCalled();
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({ status: "failed" }),
|
|
);
|
|
});
|
|
|
|
it("still checks completion blockers in fast mode", async () => {
|
|
const store = createMockStore();
|
|
|
|
// Task in fast mode with no workflow steps
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
executionMode: "fast",
|
|
prompt: "# test\n## Steps\n",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Mock agent with task_done
|
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
|
const customTools = opts.customTools || [];
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
return { session };
|
|
}) as any);
|
|
|
|
const onComplete = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
|
|
|
await executor.execute({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
executionMode: "fast",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Verify task completed normally even without workflow steps
|
|
// Completion blockers (test/build/typecheck) are checked via getTaskCompletionBlocker
|
|
// which is called before finalizing
|
|
expect(onComplete).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("determineRevisionResetStart", () => {
|
|
const steps = [
|
|
{ name: "Preflight" },
|
|
{ name: "Reposition the agent badge in TaskCard.tsx" },
|
|
{ name: "Restyle `.card-agent-badge` and add `.card-agent-row` in styles.css" },
|
|
{ name: "Update tests" },
|
|
{ name: "Testing & Verification" },
|
|
{ name: "Documentation & Delivery" },
|
|
];
|
|
|
|
it("skips Preflight when feedback targets a later step", () => {
|
|
// Feedback is phrased to hit step 2's "restyle" without also mentioning
|
|
// step 1's distinctive tokens (reposition / badge / taskcard / agent).
|
|
const feedback = "The card styling needs more contrast — restyle the class tokens.";
|
|
expect(determineRevisionResetStart(steps, feedback)).toBe(2);
|
|
});
|
|
|
|
it("matches earliest step when multiple step names are mentioned", () => {
|
|
const feedback = "The reposition logic is off, and the restyle tokens also need a second pass.";
|
|
expect(determineRevisionResetStart(steps, feedback)).toBe(1);
|
|
});
|
|
|
|
it("falls back to first non-Preflight step when feedback matches nothing", () => {
|
|
const feedback = "Please improve overall polish and typography hierarchy.";
|
|
expect(determineRevisionResetStart(steps, feedback)).toBe(1);
|
|
});
|
|
|
|
it("never resets a Preflight step even if feedback somehow mentions preflight", () => {
|
|
const feedback = "Preflight context looks wrong; restyle the row as well.";
|
|
// Step 0 is Preflight → skipped. Earliest remaining match is step 2 (restyle).
|
|
expect(determineRevisionResetStart(steps, feedback)).toBe(2);
|
|
});
|
|
|
|
it("is case-insensitive across both feedback and step names", () => {
|
|
const feedback = "RESTYLE the component, please.";
|
|
expect(determineRevisionResetStart(steps, feedback)).toBe(2);
|
|
});
|
|
|
|
it("returns 0 when there is no Preflight and no match", () => {
|
|
const noPreflight = [{ name: "Apply Fix" }, { name: "Testing & Verification" }];
|
|
expect(determineRevisionResetStart(noPreflight, "please improve polish")).toBe(0);
|
|
});
|
|
|
|
it("returns steps.length for an empty step list (nothing to reset)", () => {
|
|
expect(determineRevisionResetStart([], "anything")).toBe(0);
|
|
});
|
|
|
|
it("returns steps.length when only a Preflight step exists (nothing to reset)", () => {
|
|
expect(determineRevisionResetStart([{ name: "Preflight" }], "anything")).toBe(1);
|
|
});
|
|
|
|
it("ignores short tokens like 'test' to avoid matching 'Update tests' for generic feedback", () => {
|
|
// "test" is 4 chars — below the 5+ char threshold — so generic feedback
|
|
// mentioning "test" alone should not target the "Update tests" step.
|
|
const feedback = "Please test this by clicking.";
|
|
expect(determineRevisionResetStart(steps, feedback)).toBe(1);
|
|
});
|
|
});
|