Files
fusion/packages/engine/src/__tests__/step-session-executor.test.ts
Fusion (runfusion.ai) 837f07e6ca feat(FN-4948): complete Step 2 — wire identity guard into worktree provisioning
Fusion-Task-Id: FN-4948
Fusion-Task-Lineage: dc622643-4c3e-4217-9219-a6a6e5424427
2026-05-17 17:40:46 -07:00

2498 lines
79 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
parseStepFileScopes,
buildConflictMatrix,
determineParallelWaves,
buildStepPrompt,
StepSessionExecutor,
} from "../step-session-executor.js";
import { AgentLogger } from "../agent-logger.js";
import * as worktreeBackendModule from "../worktree-backend.js";
import type { TaskDetail, Settings, TaskStore } from "@fusion/core";
import { installTaskWorktreeIdentityGuard } from "../worktree-hooks.js";
vi.mock("../worktree-hooks.js", () => ({
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../worktree-hooks.js", () => ({
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
}));
// ── Shared test fixtures ──────────────────────────────────────────────
function makePrompt(steps: string[]): string {
return `# Task: FN-001 - Test Task
## Mission
Do the thing.
## Steps
${steps.join("\n\n")}
## Completion Criteria
- All done
`;
}
function makeTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-001",
title: "Test Task",
description: "A test task",
column: "in-progress",
dependencies: [],
steps: [
{ name: "Preflight", status: "pending" },
{ name: "Implement", status: "pending" },
{ name: "Test", status: "pending" },
],
currentStep: 0,
log: [],
prompt: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
// ── parseStepFileScopes tests ──────────────────────────────────────────
describe("parseStepFileScopes", () => {
it("extracts paths from a realistic multi-step PROMPT.md", () => {
const prompt = makePrompt([
`### Step 0: Preflight
- [ ] Required files exist
**Artifacts:**
- \`packages/core/src/types.ts\`
- \`packages/engine/src/pi.ts\``,
`### Step 1: Implement
- [ ] Create the module
**Artifacts:**
- \`packages/engine/src/new-module.ts\` (new)
- \`packages/engine/src/existing.ts\` (modified)`,
`### Step 2: Test
- [ ] Write tests
**Artifacts:**
- \`packages/engine/src/new-module.test.ts\``,
]);
const scopes = parseStepFileScopes(prompt);
expect(scopes.get(0)).toEqual([
"packages/core/src/types.ts",
"packages/engine/src/pi.ts",
]);
expect(scopes.get(1)).toEqual([
"packages/engine/src/new-module.ts",
"packages/engine/src/existing.ts",
]);
expect(scopes.get(2)).toEqual([
"packages/engine/src/new-module.test.ts",
]);
});
it("returns empty arrays for steps with no file scope", () => {
const prompt = makePrompt([
`### Step 0: Preflight
- [ ] Check things`,
`### Step 1: Implement
- [ ] Do the work`,
]);
const scopes = parseStepFileScopes(prompt);
expect(scopes.get(0)).toEqual([]);
expect(scopes.get(1)).toEqual([]);
});
it("handles steps with - \\`path\\` in artifacts sections", () => {
const prompt = `### Step 0: Setup
**Artifacts:**
- \`src/foo.ts\`
- \`src/bar.ts\`
### Step 1: Build
**Artifacts:**
- \`dist/bundle.js\``;
const scopes = parseStepFileScopes(prompt);
expect(scopes.get(0)).toEqual(["src/foo.ts", "src/bar.ts"]);
expect(scopes.get(1)).toEqual(["dist/bundle.js"]);
});
it("normalizes glob patterns (strips /* suffix)", () => {
const prompt = `### Step 0: Implement
- \`packages/core/*\`
- \`packages/engine/src/*\``;
const scopes = parseStepFileScopes(prompt);
expect(scopes.get(0)).toEqual(["packages/core", "packages/engine/src"]);
});
it("returns empty Map for empty prompts", () => {
expect(parseStepFileScopes("")).toEqual(new Map());
expect(parseStepFileScopes("Just some text")).toEqual(new Map());
});
it("returns empty Map for prompts with no step sections", () => {
const prompt = `# Task: FN-001\n\n## Mission\nDo stuff.\n\nNo steps here.`;
expect(parseStepFileScopes(prompt)).toEqual(new Map());
});
it("strips (new | modified) suffix from paths", () => {
const prompt = `### Step 0: Do things
**Artifacts:**
- \`src/foo.ts\` (new)
- \`src/bar.ts\` (modified)`;
const result = parseStepFileScopes(prompt);
expect(result.get(0)).toEqual(["src/foo.ts", "src/bar.ts"]);
});
it("strips trailing slashes from paths", () => {
const prompt = `### Step 0: Do things
- \`src/foo/\``;
const result = parseStepFileScopes(prompt);
expect(result.get(0)).toEqual(["src/foo"]);
});
it("handles mixed paths with and without suffixes", () => {
const prompt = `### Step 0: Do things
- \`src/a.ts\`
- \`src/b.ts\` (new)
- \`src/c.ts\` (modified)
- \`packages/core/*\``;
const result = parseStepFileScopes(prompt);
expect(result.get(0)).toEqual([
"src/a.ts",
"src/b.ts",
"src/c.ts",
"packages/core",
]);
});
it("produces sequential 0-based keys", () => {
const prompt = `### Step 0: A
- \`a.ts\`
### Step 1: B
- \`b.ts\`
### Step 2: C
- \`c.ts\``;
const result = parseStepFileScopes(prompt);
expect([...result.keys()]).toEqual([0, 1, 2]);
});
});
// ── buildConflictMatrix tests ──────────────────────────────────────────
describe("buildConflictMatrix", () => {
it("returns empty matrix for empty scopes", () => {
const scopes = new Map<number, string[]>();
const matrix = buildConflictMatrix(scopes);
expect(matrix).toEqual([]);
});
it("diagonal is always true", () => {
const scopes = new Map<number, string[]>([
[0, ["src/a.ts"]],
[1, ["src/b.ts"]],
]);
const matrix = buildConflictMatrix(scopes);
expect(matrix[0][0]).toBe(true);
expect(matrix[1][1]).toBe(true);
});
it("detects exact path overlap as conflict", () => {
const scopes = new Map<number, string[]>([
[0, ["src/a.ts"]],
[1, ["src/a.ts"]],
]);
const matrix = buildConflictMatrix(scopes);
expect(matrix[0][1]).toBe(true);
expect(matrix[1][0]).toBe(true);
});
it("detects prefix overlap as conflict", () => {
const scopes = new Map<number, string[]>([
[0, ["packages/core"]],
[1, ["packages/core/src/types.ts"]],
]);
const matrix = buildConflictMatrix(scopes);
expect(matrix[0][1]).toBe(true);
expect(matrix[1][0]).toBe(true);
});
it("no conflict when paths are completely separate", () => {
const scopes = new Map<number, string[]>([
[0, ["packages/core/src/types.ts"]],
[1, ["packages/engine/src/pi.ts"]],
]);
const matrix = buildConflictMatrix(scopes);
expect(matrix[0][1]).toBe(false);
expect(matrix[1][0]).toBe(false);
});
it("returns symmetric matrix", () => {
const scopes = new Map<number, string[]>([
[0, ["src/a.ts", "src/b.ts"]],
[1, ["src/c.ts"]],
[2, ["src/b.ts", "src/d.ts"]],
]);
const matrix = buildConflictMatrix(scopes);
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
expect(matrix[i][j]).toBe(matrix[j][i]);
}
}
});
it("empty scope steps do not conflict with anything", () => {
const scopes = new Map<number, string[]>([
[0, []],
[1, ["src/a.ts"]],
[2, []],
]);
const matrix = buildConflictMatrix(scopes);
// Empty scopes don't conflict with anything (except themselves on diagonal)
expect(matrix[0][1]).toBe(false);
expect(matrix[0][2]).toBe(false);
expect(matrix[1][2]).toBe(false);
});
});
// ── determineParallelWaves tests ───────────────────────────────────────
describe("determineParallelWaves", () => {
it("maxParallel=1 → all steps sequential (one per wave)", () => {
const scopes = new Map<number, string[]>([
[0, ["src/a.ts"]],
[1, ["src/b.ts"]],
[2, ["src/c.ts"]],
]);
const waves = determineParallelWaves(scopes, 1);
expect(waves).toHaveLength(3);
expect(waves[0]).toEqual({ indices: [0], waveNumber: 0 });
expect(waves[1]).toEqual({ indices: [1], waveNumber: 1 });
expect(waves[2]).toEqual({ indices: [2], waveNumber: 2 });
});
it("no conflicts → all steps in wave 0 (capped by maxParallel)", () => {
const scopes = new Map<number, string[]>([
[0, ["src/a.ts"]],
[1, ["src/b.ts"]],
[2, ["src/c.ts"]],
[3, ["src/d.ts"]],
]);
const waves = determineParallelWaves(scopes, 2);
// All non-conflicting, capped at 2 per wave → 2 waves
expect(waves).toHaveLength(2);
expect(waves[0].indices).toEqual([0, 1]);
expect(waves[1].indices).toEqual([2, 3]);
});
it("all steps conflict → each step in its own wave", () => {
const scopes = new Map<number, string[]>([
[0, ["src/a.ts"]],
[1, ["src/a.ts"]],
[2, ["src/a.ts"]],
]);
const waves = determineParallelWaves(scopes, 4);
// All conflict with each other → each in own wave
expect(waves).toHaveLength(3);
expect(waves[0].indices).toEqual([0]);
expect(waves[1].indices).toEqual([1]);
expect(waves[2].indices).toEqual([2]);
});
it("mixed — steps 0,1 touch core (parent/child), steps 2,3 touch engine (parent/child) → wave grouping", () => {
// Use directory + file paths so prefix overlap creates real conflicts
const scopes = new Map<number, string[]>([
[0, ["packages/core"]],
[1, ["packages/core/src/store.ts"]],
[2, ["packages/engine"]],
[3, ["packages/engine/src/executor.ts"]],
]);
const waves = determineParallelWaves(scopes, 2);
// 0 and 1 conflict (0 is prefix of 1), 2 and 3 conflict (2 is prefix of 3)
// 0 and 2 don't conflict → wave 0: [0, 2]
// 1 and 3 don't conflict → wave 1: [1, 3]
expect(waves).toHaveLength(2);
expect(waves[0].indices).toEqual([0, 2]);
expect(waves[1].indices).toEqual([1, 3]);
});
it("empty scopes → no conflicts, all in wave 0", () => {
const scopes = new Map<number, string[]>([
[0, []],
[1, []],
]);
const waves = determineParallelWaves(scopes, 4);
expect(waves).toHaveLength(1);
expect(waves[0].indices).toEqual([0, 1]);
});
it("maxParallel=2 with 4 non-conflicting steps → 2 waves of 2", () => {
const scopes = new Map<number, string[]>([
[0, ["a.ts"]],
[1, ["b.ts"]],
[2, ["c.ts"]],
[3, ["d.ts"]],
]);
const waves = determineParallelWaves(scopes, 2);
expect(waves).toHaveLength(2);
expect(waves[0].indices).toHaveLength(2);
expect(waves[1].indices).toHaveLength(2);
});
it("produces ascending wave numbers", () => {
const scopes = new Map<number, string[]>([
[0, ["a.ts"]],
[1, ["b.ts"]],
]);
const waves = determineParallelWaves(scopes, 1);
for (let i = 0; i < waves.length; i++) {
expect(waves[i].waveNumber).toBe(i);
}
});
it("respects maxParallel cap even when no conflicts exist", () => {
const scopes = new Map<number, string[]>([
[0, ["a.ts"]],
[1, ["b.ts"]],
[2, ["c.ts"]],
]);
const waves = determineParallelWaves(scopes, 2);
// 3 non-conflicting steps, max 2 → wave 0: [0,1], wave 1: [2]
expect(waves).toHaveLength(2);
expect(waves[0].indices).toEqual([0, 1]);
expect(waves[1].indices).toEqual([2]);
});
});
// ── buildStepPrompt tests ─────────────────────────────────────────────
describe("buildStepPrompt", () => {
const fullPrompt = `# Task: FN-001 - Test Task
## Mission
Do important work.
## Context to Read First
- \`src/types.ts\`
## File Scope
- \`packages/engine/src/new-module.ts\`
## Do NOT
- Delete existing files
- Skip tests
## Steps
### Step 0: Preflight
- [ ] Check files exist
- [ ] Verify settings
### Step 1: Implement
- [ ] Create new-module.ts
- [ ] Add exports
**Artifacts:**
- \`packages/engine/src/new-module.ts\` (new)
### Step 2: Test
- [ ] Write unit tests
- [ ] Run pnpm test
**Artifacts:**
- \`packages/engine/src/new-module.test.ts\` (new)
## Completion Criteria
- All tests pass
## Git Commit Convention
- feat(FN-001): description
## Review level: 2`;
it("includes step-specific section text", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const result = buildStepPrompt(task, 1);
expect(result).toContain("Create new-module.ts");
expect(result).toContain("Add exports");
});
it("includes task ID and step number in preamble", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const result = buildStepPrompt(task, 1);
expect(result).toContain("Step 1");
expect(result).toContain("FN-001");
});
it("includes File Scope and Do NOT sections from original prompt", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const result = buildStepPrompt(task, 1);
expect(result).toContain("packages/engine/src/new-module.ts");
expect(result).toContain("Do NOT");
expect(result).toContain("Delete existing files");
});
it("rewrites project-root absolute paths to the active worktree", () => {
const prompt = fullPrompt.replace(
"`packages/engine/src/new-module.ts`",
"`/repo/project/packages/engine/src/new-module.ts`",
).replace(
"- `src/types.ts`",
"- `/repo/project/.fusion/memory/MEMORY.md`",
);
const task = makeTaskDetail({ prompt });
const result = buildStepPrompt(
task,
1,
"/repo/project",
undefined,
"/repo/project/.worktrees/happy-robin",
);
expect(result).toContain("/repo/project/.worktrees/happy-robin/packages/engine/src/new-module.ts");
expect(result).toContain("/repo/project/.fusion/memory/");
expect(result).not.toContain("/repo/project/.worktrees/happy-robin/.fusion/memory/");
});
it("handles step 0 (preflight) correctly", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const result = buildStepPrompt(task, 0);
expect(result).toContain("Step 0");
expect(result).toContain("Check files exist");
expect(result).toContain("Verify settings");
});
it("handles the last step correctly", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const result = buildStepPrompt(task, 2);
expect(result).toContain("Step 2");
expect(result).toContain("Write unit tests");
});
it("handles a step with no checkboxes", () => {
const minimalPrompt = `# Task: FN-001
### Step 0: Just Do It
Some freeform text without checkboxes.`;
const task = makeTaskDetail({ prompt: minimalPrompt });
const result = buildStepPrompt(task, 0);
expect(result).toContain("Just Do It");
expect(result).toContain("Some freeform text");
});
it("includes project commands when settings provide them", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const settings = {
testCommand: "pnpm test",
buildCommand: "pnpm build",
} as Partial<Settings> as Settings;
const result = buildStepPrompt(task, 1, undefined, settings);
expect(result).toContain("pnpm test");
expect(result).toContain("pnpm build");
});
it("does not include project commands when settings lack them", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const result = buildStepPrompt(task, 1);
expect(result).not.toContain("Project Commands");
});
it("includes fn_task_done instruction at the end", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const result = buildStepPrompt(task, 1);
expect(result).toContain("fn_task_done()");
});
it("does not include content from other steps", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const result = buildStepPrompt(task, 0);
// Step 1 content should not appear
expect(result).not.toContain("Create new-module.ts");
});
});
// ── StepSessionExecutor test helpers ───────────────────────────────────
// Mock pi.js for StepSessionExecutor tests
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
promptWithFallback: vi.fn(async (session: any, prompt: string) => {
await session.prompt(prompt);
}),
describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"),
compactSessionContext: vi.fn(),
}));
vi.mock("../agent-session-helpers.js", async () => {
const pi = await import("../pi.js");
return {
createResolvedAgentSession: vi.fn(async (options: any) => {
const result = await pi.createFnAgent(options);
return {
session: result.session,
sessionFile: result.sessionFile,
runtimeId: "pi",
wasConfigured: false,
};
}),
promptWithAutoRetry: vi.fn(async (session: any, prompt: string, options?: unknown) =>
pi.promptWithFallback(session, prompt, options as any),
),
describeAgentModel: vi.fn(async (session: any) => pi.describeModel(session)),
resolveExecutorSessionModel: vi.fn((taskModelProvider?: string, taskModelId?: string, settings?: any, assignedAgentRuntimeConfig?: Record<string, unknown>) => {
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
if (settings?.executionProvider && settings?.executionModelId) {
return { provider: settings.executionProvider, modelId: settings.executionModelId };
}
if (settings?.executionGlobalProvider && settings?.executionGlobalModelId) {
return { provider: settings.executionGlobalProvider, modelId: settings.executionGlobalModelId };
}
if (settings?.defaultProviderOverride && settings?.defaultModelIdOverride) {
return { provider: settings.defaultProviderOverride, modelId: settings.defaultModelIdOverride };
}
if (settings?.defaultProvider && settings?.defaultModelId) {
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
}
return { provider: undefined, modelId: undefined };
}),
};
});
// Mock logger
vi.mock("../logger.js", () => {
const createMockLogger = () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
});
const loggers = new Map<string, ReturnType<typeof createMockLogger>>();
const getLogger = (prefix: string) => {
if (!loggers.has(prefix)) {
loggers.set(prefix, createMockLogger());
}
return loggers.get(prefix)!;
};
return {
createLogger: vi.fn((prefix: string) => getLogger(prefix)),
schedulerLog: getLogger("scheduler"),
executorLog: getLogger("executor"),
planLog: getLogger("plan"),
mergerLog: getLogger("merger"),
worktreePoolLog: getLogger("worktree-pool"),
reviewerLog: getLogger("reviewer"),
prMonitorLog: getLogger("pr-monitor"),
runtimeLog: getLogger("runtime"),
stepExecLog: getLogger("step-session-executor"),
ipcLog: getLogger("ipc"),
projectManagerLog: getLogger("project-manager"),
autopilotLog: getLogger("autopilot"),
};
});
// Mock context-limit-detector
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn().mockImplementation((msg: string) =>
/context\s+window\s+exceeds/i.test(msg),
),
}));
// Mock usage-limit-detector
vi.mock("../usage-limit-detector.js", () => ({
checkSessionError: vi.fn(),
}));
// Mock worktree-names
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("test-worktree"),
};
});
// Route async `exec` through the `execSync` mock so existing tests keep working.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execSyncFn = vi.fn();
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : (opts ?? {});
try {
const out = execSyncFn(cmd, { ...options, stdio: ["pipe", "pipe", "pipe"] });
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?.() ?? "");
}
}
});
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 };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
}));
import { createFnAgent } from "../pi.js";
import { generateWorktreeName } from "../worktree-names.js";
import { execSync } from "node:child_process";
import { AgentSemaphore } from "../concurrency.js";
import { createLogger } from "../logger.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedExecSync = vi.mocked(execSync);
const mockedInstallTaskWorktreeIdentityGuard = vi.mocked(installTaskWorktreeIdentityGuard);
const mockedGenerateWorktreeName = vi.mocked(generateWorktreeName);
const mockedCreateLogger = vi.mocked(createLogger);
const getStepSessionLogger = () => mockedCreateLogger("step-session-executor") as {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
function makeMockSession(promptFn?: () => Promise<void>) {
return {
prompt: promptFn ?? vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
model: { provider: "mock", id: "mock-model" },
};
}
function makeSettings(overrides: Record<string, any> = {}): Settings {
return {
maxConcurrent: 2,
maxWorktrees: 4,
maxParallelSteps: 1,
...overrides,
} as Settings;
}
function makeStepPrompt(taskId: string, numSteps: number): string {
const steps = [];
for (let i = 0; i < numSteps; i++) {
steps.push(`### Step ${i}: Step ${i}\n- [ ] Do step ${i}`);
}
return `# Task: ${taskId}\n\n## Steps\n\n${steps.join("\n\n")}`;
}
// ── StepSessionExecutor: Sequential Execution ──────────────────────────
describe("StepSessionExecutor", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
// Default: generateWorktreeName returns predictable names
mockedGenerateWorktreeName.mockReturnValue("test-worktree");
});
afterEach(() => {
vi.useRealTimers();
});
describe("sequential execution", () => {
it("forwards taskEnv into step session creation", async () => {
const prompt = makeStepPrompt("FN-001", 1);
const task = makeTaskDetail({ prompt, steps: [{ name: "Step 0", status: "pending" }] });
const settings = makeSettings({ maxParallelSteps: 1 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
pluginRunner: undefined,
taskEnv: { PATH: "/task/bin", TASK_ONLY: "1" },
} as any);
const result = await executor.executeAll();
expect(result).toHaveLength(1);
expect(result[0]?.success).toBe(true);
expect(mockedCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
taskEnv: { PATH: "/task/bin", TASK_ONLY: "1" },
}),
);
});
it("happy path: 3-step task, all steps succeed", async () => {
const prompt = makeStepPrompt("FN-001", 3);
const task = makeTaskDetail({ prompt, steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
{ name: "Step 2", status: "pending" },
]});
const settings = makeSettings({ maxParallelSteps: 1 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const onStepStart = vi.fn();
const onStepComplete = vi.fn();
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
onStepStart,
onStepComplete,
});
const results = await executor.executeAll();
expect(results).toHaveLength(3);
expect(results.every((r) => r.success)).toBe(true);
expect(results.every((r) => r.retries === 0)).toBe(true);
// Verify 3 sessions were created
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
// Verify callbacks
expect(onStepStart).toHaveBeenCalledTimes(3);
expect(onStepComplete).toHaveBeenCalledTimes(3);
expect(onStepStart).toHaveBeenNthCalledWith(1, 0);
expect(onStepStart).toHaveBeenNthCalledWith(2, 1);
expect(onStepStart).toHaveBeenNthCalledWith(3, 2);
});
it("includes token usage from session stats on successful step completion", async () => {
const prompt = makeStepPrompt("FN-001", 1);
const task = makeTaskDetail({
prompt,
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const session = {
...makeMockSession(),
getSessionStats: vi.fn().mockReturnValue({
tokens: {
input: 25,
output: 11,
cacheRead: 4,
cacheWrite: 2,
total: 42,
},
}),
};
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const results = await executor.executeAll();
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
stepIndex: 0,
success: true,
retries: 0,
tokenUsage: {
inputTokens: 25,
outputTokens: 11,
cachedTokens: 4,
cacheWriteTokens: 2,
totalTokens: 42,
},
});
expect(session.getSessionStats).toHaveBeenCalled();
});
it("includes token usage from session stats on failed step completion", async () => {
const prompt = makeStepPrompt("FN-001", 1);
const task = makeTaskDetail({
prompt,
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const session = {
...makeMockSession(() => Promise.reject(new Error("step failed"))),
getSessionStats: vi.fn().mockReturnValue({
tokens: {
input: 19,
output: 7,
cacheRead: 3,
cacheWrite: 0,
total: 29,
},
}),
};
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const executePromise = executor.executeAll();
await vi.runAllTimersAsync();
const results = await executePromise;
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
stepIndex: 0,
success: false,
error: "step failed",
tokenUsage: {
inputTokens: 19,
outputTokens: 7,
cachedTokens: 3,
totalTokens: 29,
},
});
expect(session.getSessionStats).toHaveBeenCalled();
});
it("keeps token usage undefined when session stats are unavailable", async () => {
const prompt = makeStepPrompt("FN-001", 1);
const task = makeTaskDetail({
prompt,
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const session = {
...makeMockSession(),
getSessionStats: vi.fn().mockReturnValue(undefined),
};
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const results = await executor.executeAll();
expect(results).toHaveLength(1);
expect(results[0].tokenUsage).toBeUndefined();
expect(session.getSessionStats).toHaveBeenCalled();
});
it("step failure with retry: fails first 2 attempts, succeeds on 3rd", async () => {
const prompt = makeStepPrompt("FN-001", 2);
const task = makeTaskDetail({ prompt, steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
]});
const settings = makeSettings({ maxParallelSteps: 1 });
let callCount = 0;
const session = makeMockSession(() => {
callCount++;
if (callCount <= 2) {
throw new Error("Session failed");
}
return Promise.resolve();
});
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const resultsPromise = executor.executeAll();
// Fast-forward through retry delays
await vi.advanceTimersByTimeAsync(30_000);
const results = await resultsPromise;
// Step 0 should succeed after retries
expect(results[0]).toMatchObject({
stepIndex: 0,
success: true,
retries: 2,
});
// 3 sessions created for step 0 (2 failures + 1 success) + 1 for step 1
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
});
it("step failure after max retries: returns failure result", async () => {
const prompt = makeStepPrompt("FN-001", 2);
const task = makeTaskDetail({ prompt, steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
]});
const settings = makeSettings({ maxParallelSteps: 1 });
const failingSession = makeMockSession(() => {
throw new Error("Persistent failure");
});
const successSession = makeMockSession();
let createCount = 0;
mockedCreateFnAgent.mockImplementation(() => {
createCount++;
if (createCount <= 4) {
// Step 0: 1 initial + 3 retries = 4 failures
return Promise.resolve({ session: failingSession } as any);
}
return Promise.resolve({ session: successSession } as any);
});
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const resultsPromise = executor.executeAll();
await vi.advanceTimersByTimeAsync(60_000);
const results = await resultsPromise;
// Step 0 should fail after max retries
expect(results[0]).toMatchObject({
stepIndex: 0,
success: false,
retries: 3,
});
expect(results[0].error).toContain("Persistent failure");
// Step 1 should still be attempted
expect(results[1]).toMatchObject({
stepIndex: 1,
success: true,
});
// 4 sessions for step 0 + 1 for step 1
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(5);
});
it("aborted flag: returns failed result immediately", async () => {
const prompt = makeStepPrompt("FN-001", 2);
const task = makeTaskDetail({ prompt, steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
]});
const settings = makeSettings({ maxParallelSteps: 1 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
// Abort before execution
await executor.terminateAllSessions();
const results = await executor.executeAll();
expect(results).toHaveLength(0); // No steps executed because aborted before executeAll
// All steps returned as failed because aborted was set
});
it("returns empty results for task with no steps", async () => {
const task = makeTaskDetail({
prompt: "# Task: FN-001\n\nNo steps defined.",
steps: [],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const results = await executor.executeAll();
expect(results).toEqual([]);
});
it("acquires and releases semaphore for each step", async () => {
const prompt = makeStepPrompt("FN-001", 2);
const task = makeTaskDetail({ prompt, steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
]});
const settings = makeSettings({ maxParallelSteps: 1 });
const semaphore = new AgentSemaphore(2);
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const acquireSpy = vi.spyOn(semaphore, "acquire");
const releaseSpy = vi.spyOn(semaphore, "release");
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
semaphore,
});
await executor.executeAll();
expect(acquireSpy).toHaveBeenCalledTimes(2);
expect(releaseSpy).toHaveBeenCalledTimes(2);
expect(semaphore.activeCount).toBe(0);
});
it("releases semaphore even when step fails", async () => {
const prompt = makeStepPrompt("FN-001", 1);
const task = makeTaskDetail({ prompt, steps: [
{ name: "Step 0", status: "pending" },
]});
const settings = makeSettings({ maxParallelSteps: 1 });
const semaphore = new AgentSemaphore(1);
mockedCreateFnAgent.mockRejectedValue(new Error("Agent creation failed"));
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
semaphore,
});
const resultsPromise = executor.executeAll();
await vi.advanceTimersByTimeAsync(60_000);
const results = await resultsPromise;
expect(semaphore.activeCount).toBe(0);
expect(results[0].success).toBe(false);
});
});
describe("parallel execution", () => {
it("creates separate worktrees for non-conflicting parallel steps", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 2 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
mockedExecSync.mockReturnValue("");
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const results = await executor.executeAll();
// Both steps should succeed
expect(results).toHaveLength(2);
expect(results.every((r) => r.success)).toBe(true);
// Worktree creation was called for parallel steps
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining("git worktree add"),
expect.anything(),
);
expect(mockedInstallTaskWorktreeIdentityGuard).toHaveBeenCalledWith(
expect.objectContaining({ taskId: "FN-001" }),
);
});
it("handles parallel step failure: successful step cherry-picked, failed cleaned up", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 2 });
let createCount = 0;
mockedCreateFnAgent.mockImplementation(() => {
createCount++;
if (createCount === 1) {
// Step 0 succeeds
return Promise.resolve({ session: makeMockSession() } as any);
}
// Step 1 fails
const failSession = makeMockSession(() => {
throw new Error("Step 1 failed");
});
return Promise.resolve({ session: failSession } as any);
});
mockedExecSync.mockReturnValue("");
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const resultsPromise = executor.executeAll();
await vi.advanceTimersByTimeAsync(60_000);
const results = await resultsPromise;
expect(results).toHaveLength(2);
// One should succeed, one should fail
const successes = results.filter((r) => r.success);
const failures = results.filter((r) => !r.success);
expect(successes.length + failures.length).toBe(2);
// Worktree cleanup should still happen
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining("git worktree remove"),
expect.anything(),
);
});
it("cherry-pick conflict is non-fatal", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 2 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
// Make git log return commits, but cherry-pick fails
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === "string" && cmd.includes("git log")) {
return "abc123def Some commit";
}
if (typeof cmd === "string" && cmd.includes("git cherry-pick") && !cmd.includes("--abort")) {
throw new Error("Merge conflict");
}
return "";
});
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const results = await executor.executeAll();
// Steps should still be reported as successful (cherry-pick failure is non-fatal)
expect(results).toHaveLength(2);
expect(results.every((r) => r.success)).toBe(true);
});
it("logs warning when cherry-pick --abort fails", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 2 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === "string" && cmd.includes("git log")) {
return "abc123def Some commit";
}
if (typeof cmd === "string" && cmd.includes("git cherry-pick") && cmd.includes("--abort")) {
throw new Error("abort failed");
}
if (typeof cmd === "string" && cmd.includes("git cherry-pick") && !cmd.includes("--abort")) {
throw new Error("Merge conflict");
}
return "";
});
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
await executor.executeAll();
expect(getStepSessionLogger().warn).toHaveBeenCalledWith(
expect.stringContaining("Cherry-pick --abort failed for step"),
);
});
it("logs warning when cherry-pick --abort fails after conflict and still throws conflict", async () => {
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 2),
});
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings: makeSettings({ maxParallelSteps: 2 }),
});
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("git log")) {
return "abc123";
}
if (cmd.includes("git cherry-pick") && cmd.includes("--abort")) {
throw new Error("abort failed");
}
if (cmd.includes("git cherry-pick")) {
throw new Error("Merge conflict");
}
return "";
});
await expect(
(executor as any).cherryPickCommits(1, "/project/.worktrees/step-1"),
).rejects.toThrow("Cherry-pick conflict for commit abc123 in step 1: Merge conflict");
expect(getStepSessionLogger().warn).toHaveBeenCalledWith(
"Cherry-pick --abort failed for step 1: abort failed",
);
});
it("semaphore integration: parallel steps acquire/release", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 2 });
const semaphore = new AgentSemaphore(4);
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
mockedExecSync.mockReturnValue("");
const acquireSpy = vi.spyOn(semaphore, "acquire");
const releaseSpy = vi.spyOn(semaphore, "release");
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
semaphore,
});
await executor.executeAll();
// Both parallel steps should acquire/release
expect(acquireSpy).toHaveBeenCalledTimes(2);
expect(releaseSpy).toHaveBeenCalledTimes(2);
expect(semaphore.activeCount).toBe(0);
});
describe("worktree creation failure handling", () => {
it("degrades to sequential when one worktree creation fails", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 2 });
mockedGenerateWorktreeName
.mockImplementationOnce(() => "wt-step-0")
.mockImplementationOnce(() => "wt-step-1");
let worktreeAddCount = 0;
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("git worktree add")) {
worktreeAddCount++;
if (worktreeAddCount === 2) {
throw new Error("step 1 worktree failed");
}
}
return "";
});
let releaseStep0: (() => void) | undefined;
const step0Gate = new Promise<void>((resolve) => {
releaseStep0 = resolve;
});
const executionEvents: string[] = [];
mockedCreateFnAgent.mockImplementation(({ cwd }: any) => {
if (cwd === "/project/.worktrees/wt-step-0") {
return Promise.resolve({
session: makeMockSession(async () => {
executionEvents.push("step-0-start");
await step0Gate;
executionEvents.push("step-0-end");
}),
} as any);
}
if (cwd === "/project/.worktrees/main") {
return Promise.resolve({
session: makeMockSession(async () => {
executionEvents.push("step-1-start");
}),
} as any);
}
throw new Error(`Unexpected cwd: ${cwd}`);
});
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const resultsPromise = executor.executeAll();
for (let i = 0; i < 20 && !executionEvents.includes("step-0-start"); i++) {
await Promise.resolve();
}
expect(executionEvents.includes("step-0-start")).toBe(true);
releaseStep0?.();
const results = await resultsPromise;
expect(results).toHaveLength(2);
expect(results.map((r) => r.stepIndex)).toEqual([0, 1]);
expect(results.every((r) => r.success)).toBe(true);
expect(mockedCreateFnAgent).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ cwd: "/project/.worktrees/main" }),
);
expect(executionEvents).toEqual(["step-0-start", "step-0-end", "step-1-start"]);
});
it("degrades to sequential when all worktree creations fail", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\`
### Step 2: Tests
- \`packages/engine/src/step-session-executor.test.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
{ name: "Step 2", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 3 });
let nameCounter = 0;
mockedGenerateWorktreeName.mockImplementation(() => `wt-fail-${nameCounter++}`);
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("git worktree add")) {
throw new Error("worktree creation failed");
}
return "";
});
let activePrimarySteps = 0;
let maxActivePrimarySteps = 0;
const cwdOrder: string[] = [];
mockedCreateFnAgent.mockImplementation(({ cwd }: any) => {
return Promise.resolve({
session: makeMockSession(async () => {
cwdOrder.push(cwd);
activePrimarySteps++;
maxActivePrimarySteps = Math.max(maxActivePrimarySteps, activePrimarySteps);
await Promise.resolve();
activePrimarySteps--;
}),
} as any);
});
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const results = await executor.executeAll();
expect(results).toHaveLength(3);
expect(results.map((r) => r.stepIndex)).toEqual([0, 1, 2]);
expect(results.every((r) => r.success)).toBe(true);
expect(cwdOrder).toEqual([
"/project/.worktrees/main",
"/project/.worktrees/main",
"/project/.worktrees/main",
]);
expect(maxActivePrimarySteps).toBe(1);
});
it("mixed success/failure: parallel steps cherry-pick, sequential step runs on primary", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\`
### Step 2: Tests
- \`packages/engine/src/step-session-executor.test.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
{ name: "Step 2", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 3 });
mockedGenerateWorktreeName
.mockImplementationOnce(() => "wt-mixed-0")
.mockImplementationOnce(() => "wt-mixed-1")
.mockImplementationOnce(() => "wt-mixed-2");
let worktreeAddCount = 0;
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("git worktree add")) {
worktreeAddCount++;
if (worktreeAddCount === 3) {
throw new Error("step 2 worktree failed");
}
}
if (cmd.includes("git log")) {
return "abc123";
}
return "";
});
let releaseParallel: (() => void) | undefined;
const parallelGate = new Promise<void>((resolve) => {
releaseParallel = resolve;
});
let activeParallelSteps = 0;
let maxActiveParallelSteps = 0;
const events: string[] = [];
mockedCreateFnAgent.mockImplementation(({ cwd }: any) => {
if (cwd === "/project/.worktrees/main") {
return Promise.resolve({
session: makeMockSession(async () => {
events.push("primary-start");
expect(activeParallelSteps).toBe(0);
events.push("primary-end");
}),
} as any);
}
const label = cwd.endsWith("wt-mixed-0") ? "parallel-0" : "parallel-1";
return Promise.resolve({
session: makeMockSession(async () => {
events.push(`${label}-start`);
activeParallelSteps++;
maxActiveParallelSteps = Math.max(maxActiveParallelSteps, activeParallelSteps);
await parallelGate;
activeParallelSteps--;
events.push(`${label}-end`);
}),
} as any);
});
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const resultsPromise = executor.executeAll();
for (let i = 0; i < 20 && events.filter((event) => event.endsWith("-start")).length < 2; i++) {
await Promise.resolve();
}
const parallelStartsBeforeRelease = events.filter((event) => event.endsWith("-start"));
expect(parallelStartsBeforeRelease).toEqual(expect.arrayContaining(["parallel-0-start", "parallel-1-start"]));
expect(events.includes("primary-start")).toBe(false);
releaseParallel?.();
const results = await resultsPromise;
expect(results).toHaveLength(3);
expect(results.map((r) => r.stepIndex)).toEqual([0, 1, 2]);
expect(results.every((r) => r.success)).toBe(true);
expect(maxActiveParallelSteps).toBe(2);
const primaryCwdCalls = mockedCreateFnAgent.mock.calls.filter(
([opts]) => (opts as { cwd?: string }).cwd === "/project/.worktrees/main",
);
expect(primaryCwdCalls).toHaveLength(1);
const primaryStartIdx = events.indexOf("primary-start");
expect(primaryStartIdx).toBeGreaterThan(events.indexOf("parallel-0-end"));
expect(primaryStartIdx).toBeGreaterThan(events.indexOf("parallel-1-end"));
const cherryPickCalls = mockedExecSync.mock.calls.filter(
([cmd]) => typeof cmd === "string" && cmd.includes("git cherry-pick \"abc123\""),
);
expect(cherryPickCalls).toHaveLength(2);
});
it("no degradation when all worktrees succeed", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 2 });
mockedGenerateWorktreeName
.mockImplementationOnce(() => "wt-success-0")
.mockImplementationOnce(() => "wt-success-1");
mockedExecSync.mockReturnValue("");
mockedCreateFnAgent.mockImplementation(({ cwd }: any) => {
return Promise.resolve({
session: makeMockSession(async () => {
expect(cwd).not.toBe("/project/.worktrees/main");
}),
} as any);
});
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const results = await executor.executeAll();
expect(results).toHaveLength(2);
expect(results.map((r) => r.stepIndex)).toEqual([0, 1]);
expect(results.every((r) => r.success)).toBe(true);
const primaryCwdCalls = mockedCreateFnAgent.mock.calls.filter(
([opts]) => (opts as { cwd?: string }).cwd === "/project/.worktrees/main",
);
expect(primaryCwdCalls).toHaveLength(0);
});
it("cleanup still removes successful parallel worktrees even when some failed", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\`
### Step 2: Tests
- \`packages/engine/src/step-session-executor.test.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
{ name: "Step 2", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 3 });
mockedGenerateWorktreeName
.mockImplementationOnce(() => "wt-clean-0")
.mockImplementationOnce(() => "wt-clean-1")
.mockImplementationOnce(() => "wt-clean-2");
let worktreeAddCount = 0;
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("git worktree add")) {
worktreeAddCount++;
if (worktreeAddCount === 3) {
throw new Error("step 2 worktree failed");
}
}
return "";
});
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
await executor.executeAll();
const removeCalls = mockedExecSync.mock.calls
.map(([cmd]) => cmd)
.filter((cmd): cmd is string => typeof cmd === "string" && cmd.includes("git worktree remove"));
expect(removeCalls).toHaveLength(2);
expect(removeCalls.some((cmd) => cmd.includes("wt-clean-0"))).toBe(true);
expect(removeCalls.some((cmd) => cmd.includes("wt-clean-1"))).toBe(true);
expect(removeCalls.some((cmd) => cmd.includes("wt-clean-2"))).toBe(false);
expect(removeCalls.some((cmd) => cmd.includes("/project/.worktrees/main"))).toBe(false);
});
});
});
describe("cleanup failure diagnostics", () => {
it("logs warning when session dispose fails during error cleanup", async () => {
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 1),
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const failingSession = makeMockSession(async () => {
throw new Error("step execution failed");
});
failingSession.dispose = vi.fn(() => {
throw new Error("dispose failed");
});
mockedCreateFnAgent.mockResolvedValue({ session: failingSession } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const resultsPromise = executor.executeAll();
await vi.advanceTimersByTimeAsync(60_000);
const results = await resultsPromise;
expect(results).toHaveLength(1);
expect(results[0]?.success).toBe(false);
expect(getStepSessionLogger().warn).toHaveBeenCalledWith(
expect.stringContaining("Failed to dispose session for step 0: dispose failed"),
);
});
});
describe("terminateAllSessions", () => {
it("disposes all active sessions and clears map", async () => {
const session0 = makeMockSession();
const session1 = makeMockSession();
let createCount = 0;
mockedCreateFnAgent.mockImplementation(() => {
createCount++;
const s = createCount === 1 ? session0 : session1;
return Promise.resolve({ session: s } as any);
});
const prompt = makeStepPrompt("FN-001", 2);
const task = makeTaskDetail({ prompt, steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
]});
const settings = makeSettings({ maxParallelSteps: 1 });
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
await executor.executeAll();
// After execution, sessions should already be cleaned up
// Calling terminateAllSessions is safe
await executor.terminateAllSessions();
});
it("sets aborted flag", async () => {
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 1),
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
await executor.terminateAllSessions();
// Subsequent executeAll should return empty (aborted before start)
const results = await executor.executeAll();
expect(results).toEqual([]);
});
});
describe("cleanup", () => {
it("removes parallel worktrees", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 2 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
mockedExecSync.mockReturnValue("");
const removeWorktreeSpy = vi.spyOn(worktreeBackendModule, "removeWorktree");
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
await executor.executeAll();
await executor.cleanup();
expect(removeWorktreeSpy).toHaveBeenCalledWith(
expect.objectContaining({
rootDir: "/project",
taskId: "FN-001",
settings,
worktreePath: expect.stringContaining("/project/.worktrees/"),
}),
);
});
it("handles missing worktrees gracefully", async () => {
const { existsSync } = await import("node:fs");
const mockedExistsSync = vi.mocked(existsSync);
// Make existsSync return false to simulate missing worktree
mockedExistsSync.mockReturnValue(false);
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 1),
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
// Should not throw even though worktree doesn't exist
await executor.cleanup();
});
it("deletes branches created for parallel worktrees", async () => {
const prompt = `# Task: FN-001
### Step 0: Core work
- \`packages/core/src/types.ts\`
### Step 1: Engine work
- \`packages/engine/src/pi.ts\``;
const task = makeTaskDetail({
prompt,
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
});
const settings = makeSettings({ maxParallelSteps: 2 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
mockedExecSync.mockReturnValue("");
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
await executor.executeAll();
await executor.cleanup();
// Verify branch deletion was called
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining("git branch -D"),
expect.anything(),
);
});
it("is idempotent after executeAll", async () => {
const prompt = makeStepPrompt("FN-001", 2);
const task = makeTaskDetail({ prompt, steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
]});
const settings = makeSettings({ maxParallelSteps: 1 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
await executor.executeAll();
// Calling cleanup twice should be safe
await executor.cleanup();
await executor.cleanup();
});
});
describe("logging", () => {
it("appends agent logs during step execution", async () => {
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 1),
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const store = { appendAgentLog } as unknown as TaskStore;
let onText: ((delta: string) => void) | undefined;
let onToolStart: ((name: string, args?: Record<string, unknown>) => void) | undefined;
let onToolEnd: ((name: string, isError: boolean, result?: unknown) => void) | undefined;
const session = makeMockSession(async () => {
onText?.("step output");
onToolStart?.("read", { path: "src/foo.ts" });
onToolEnd?.("read", false, "ok");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
onText = opts.onText;
onToolStart = opts.onToolStart;
onToolEnd = opts.onToolEnd;
return { session } as any;
});
const executor = new StepSessionExecutor({
store,
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
await executor.executeAll();
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "step output", "text", undefined, "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool", "src/foo.ts", "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool_result", "ok", "executor");
});
it("flushes AgentLogger in attempt finally block", async () => {
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 1),
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const store = { appendAgentLog: vi.fn().mockResolvedValue(undefined) } as unknown as TaskStore;
const flushSpy = vi.spyOn(AgentLogger.prototype, "flush").mockResolvedValue(undefined);
const session = makeMockSession(async () => {
throw new Error("step failed");
});
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
store,
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const resultPromise = executor.executeAll();
await vi.advanceTimersByTimeAsync(30_000);
await resultPromise;
expect(flushSpy).toHaveBeenCalled();
});
});
describe("context-limit recovery", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("succeeds when compact-and-resume recovers from context-limit error", async () => {
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 1),
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const store = { appendAgentLog } as unknown as TaskStore;
// Create session that throws context-limit error on first prompt
mockedCreateFnAgent.mockResolvedValue({
session: makeMockSession(),
} as any);
// Mock promptWithFallback: first call throws, subsequent calls succeed
const { promptWithFallback } = await import("../pi.js");
let callCount = 0;
vi.mocked(promptWithFallback).mockImplementation(async (session: any, prompt: string) => {
callCount++;
if (callCount === 1) {
throw new Error("context window exceeds limit (2013)");
}
// Subsequent calls (compact-and-resume) succeed
});
// Mock compactSessionContext to succeed
const { compactSessionContext } = await import("../pi.js");
vi.mocked(compactSessionContext).mockResolvedValue({
summary: "Compacted",
tokensBefore: 150000,
});
const executor = new StepSessionExecutor({
store,
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const results = await executor.executeAll();
expect(results).toHaveLength(1);
expect(results[0].success).toBe(true);
expect(results[0].retries).toBe(0);
});
it("succeeds with reduced-prompt retry when compact returns null", async () => {
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 1),
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const store = { appendAgentLog } as unknown as TaskStore;
mockedCreateFnAgent.mockResolvedValue({
session: makeMockSession(),
} as any);
// Mock promptWithFallback: first call throws context-limit, second succeeds (reduced prompt)
const { promptWithFallback } = await import("../pi.js");
let callCount = 0;
vi.mocked(promptWithFallback).mockImplementation(async (session: any, prompt: string) => {
callCount++;
if (callCount === 1) {
throw new Error("context window exceeds limit (2013)");
}
// Reduced-prompt succeeds
});
// Mock compactSessionContext to return null (no history)
const { compactSessionContext } = await import("../pi.js");
vi.mocked(compactSessionContext).mockResolvedValue(null);
const executor = new StepSessionExecutor({
store,
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const results = await executor.executeAll();
expect(results).toHaveLength(1);
expect(results[0].success).toBe(true);
});
it("fails when all recovery attempts fail", async () => {
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 1),
steps: [{ name: "Step 0", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const store = { appendAgentLog } as unknown as TaskStore;
mockedCreateFnAgent.mockResolvedValue({
session: makeMockSession(),
} as any);
// Mock promptWithFallback: always throws context-limit error
const { promptWithFallback } = await import("../pi.js");
vi.mocked(promptWithFallback).mockRejectedValue(
new Error("context window exceeds limit (2013)"),
);
// Mock compactSessionContext to return null (no history)
const { compactSessionContext } = await import("../pi.js");
vi.mocked(compactSessionContext).mockResolvedValue(null);
const executor = new StepSessionExecutor({
store,
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
});
const resultsPromise = executor.executeAll();
// Advance timers for retry delays
await vi.advanceTimersByTimeAsync(90_000);
const results = await resultsPromise;
// All recovery attempts exhausted, step should fail
expect(results).toHaveLength(1);
expect(results[0].success).toBe(false);
expect(results[0].error).toContain("context window exceeds limit");
});
});
});
// ── Skill Selection Regression Tests (FN-1514) ──────────────────────────
//
// Note: These tests verify that skillSelection is passed through the
// StepSessionExecutor to createFnAgent calls. The actual skill resolution
// logic is tested in session-skill-context.test.ts.
// The full integration with executeAll is tested indirectly through
// the executor tests which create StepSessionExecutor with skillSelection.
describe("StepSessionExecutor skillSelection regression (FN-1511)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
mockedGenerateWorktreeName.mockReturnValue("test-worktree");
});
afterEach(() => {
vi.useRealTimers();
});
describe("skillSelection option acceptance", () => {
it("StepSessionExecutor constructor accepts skillSelection option", async () => {
const skillSelection = {
projectRootDir: "/project",
requestedSkillNames: ["triage", "executor"],
sessionPurpose: "executor",
};
const taskDetail = makeTaskDetail({
prompt: makeStepPrompt("FN-SKILL", 1),
steps: [{ name: "Step 1", status: "pending" }],
});
const settings = makeSettings({ maxParallelSteps: 1 });
// Verify the constructor accepts skillSelection without throwing
const executor = new StepSessionExecutor({
store: { appendAgentLog: vi.fn() } as unknown as TaskStore,
taskDetail,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
skillSelection,
});
expect(executor).toBeDefined();
});
});
});
// ── Agent Tool Availability Tests ──────────────────────────────────────
describe("StepSessionExecutor tool availability", () => {
/**
* These tests verify tool configuration by capturing the customTools
* passed to createFnAgent during executeStep execution. Each test
* uses fake timers and advances time to resolve any pending sleep()s.
*/
async function captureCustomTools(options?: {
agentStore?: unknown;
messageStore?: unknown;
assignedAgentId?: string;
}): Promise<any[]> {
let captured: any[] = [];
vi.useFakeTimers({ shouldAdvanceTime: true });
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
captured = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const taskDetail = makeTaskDetail({
prompt: makeStepPrompt("FN-TOOLS", 0),
steps: [{ name: "Step 0", status: "pending" }],
assignedAgentId: options?.assignedAgentId,
});
const mockStore = {
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getTaskDocument: vi.fn().mockResolvedValue(null),
upsertTaskDocument: vi.fn().mockResolvedValue({ id: "doc-001", key: "test", content: "test", revision: 1, updatedAt: new Date().toISOString(), createdAt: new Date().toISOString(), author: "test" }),
logEntry: vi.fn().mockResolvedValue(undefined),
} as any;
const mockSettings = makeSettings({ maxParallelSteps: 1 });
const executor = new StepSessionExecutor({
store: mockStore,
taskDetail,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings: mockSettings,
agentStore: options?.agentStore as any,
messageStore: options?.messageStore as any,
});
try {
const executePromise = executor.executeAll();
// Advance fake timers to allow sleep() calls in retry loop to complete
await vi.advanceTimersByTimeAsync(30000);
await executePromise;
} catch {
// Ignore execution errors — we're only capturing the tools
} finally {
vi.useRealTimers();
}
return captured;
}
it("includes fn_list_agents and fn_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("fn_list_agents");
expect(toolNames).toContain("fn_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("fn_list_agents");
expect(toolNames).not.toContain("fn_delegate_task");
});
it("includes fn_send_message and fn_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("fn_send_message");
expect(toolNames).toContain("fn_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("fn_send_message");
expect(toolNames).not.toContain("fn_read_messages");
});
it("excludes messaging tools 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("fn_send_message");
expect(toolNames).not.toContain("fn_read_messages");
});
it("includes fn_task_log and fn_task_create when store is available", async () => {
const tools = await captureCustomTools({});
const toolNames = tools.map((t: any) => t.name);
expect(toolNames).toContain("fn_task_log");
expect(toolNames).toContain("fn_task_create");
});
});
describe("StepSessionExecutor executor model lane hierarchy", () => {
async function captureAgentModel(settingsOverrides: Partial<Settings>, taskOverrides: Partial<TaskDetail> = {}) {
let capturedProvider: string | undefined;
let capturedModelId: string | undefined;
vi.useFakeTimers({ shouldAdvanceTime: true });
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedProvider = opts.defaultProvider;
capturedModelId = opts.defaultModelId;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const executor = new StepSessionExecutor({
taskDetail: makeTaskDetail({
prompt: makeStepPrompt("FN-MODEL", 1),
steps: [{ name: "Step 0", status: "pending" }],
...taskOverrides,
}),
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings: makeSettings({ maxParallelSteps: 1, ...settingsOverrides }),
});
try {
const executePromise = executor.executeAll();
await vi.advanceTimersByTimeAsync(30_000);
await executePromise;
} finally {
vi.useRealTimers();
}
return { provider: capturedProvider, modelId: capturedModelId };
}
beforeEach(() => {
vi.clearAllMocks();
});
it("uses project default override pair when execution lanes are absent", async () => {
const resolved = await captureAgentModel({
executionProvider: undefined,
executionModelId: undefined,
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
expect(resolved).toEqual({
provider: "openai",
modelId: "gpt-4o",
});
});
it("falls through to global default when project default override is incomplete", async () => {
const resolved = await captureAgentModel({
executionProvider: undefined,
executionModelId: undefined,
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
defaultProviderOverride: "openai",
defaultModelIdOverride: undefined,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
expect(resolved).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
});
});
});