Six parallel agents worked the 150 remaining failures. Engine suite: 297 failing tests at baseline3f448f7292-> 8. 12,391 passing. The failures were mostly pointing at live regressions, not stale tests. Eleven product defects found and fixed: - Operator approval mail dropped from BOTH executor gate closures: a gate paused a task for approval and no mailbox row was ever written. - FN-8840 title-duplicate handling reverted in triage.ts, self-healing.ts, and scheduler.ts: a title-only "DUPLICATE: <id>" card consumed a full planner session, an operator-authored PROMPT.md could be erased, and a title-only redirect became dispatchable again. - A failed plan-admission audit write set its dedupe marker anyway, silencing the stall permanently (FN-8600 regression); engine now has an outcome-reporting bounded-audit seam mirroring core's FN-9182. - A best-effort plan mirror could abort a whole planning attempt after the authoritative PROMPT.md had already been written. - AI-merge cleanup lost its alreadyAbsent/idempotent signal on the real-git path. - Workspace merge-boundary file comparison ran without its review-evidence fence, hard-failing every file for callers with no review episode. - After a file-scope violation the retry re-selected the rejected squash and never re-merged. - Parallel step branches leaked: a name-based classifier read executor-created fusion/step-* branches as operator-owned and skipped cleanup. - workspace_coordination_leases / workspace_land_intents were missing from projectTableNames, so the PG harness never truncated them and leases leaked across tests. Four of those are silent reversions from ONE commit,1cf86baa1c, labeled a behavior-preserving "executor pure peels" refactor. It passed its own targeted verification; only a full-suite audit found them. Test-side repairs are root-cause fixes at shared factories: required pre-merge gate declarations, branch-write provenance, fake stores missing production write seams, dead vi.mock specifiers that silently mocked nothing (allowlist ratcheted 11 -> 8), and stale expectations after deliberate IR/tool/error-class changes. Tests for deleted features were deleted with their removing commit cited. Left red deliberately (4): executor-worktree-liveness's unrouted-graph-run assertion and three workspace-e2e landing-stack layers, each needing a design ruling rather than a test edit. Two durable-write call sites remain flagged unresolved rather than given invented fencing verdicts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3554 lines
121 KiB
TypeScript
3554 lines
121 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
import {
|
||
parseStepFileScopes,
|
||
buildConflictMatrix,
|
||
determineParallelWaves,
|
||
buildStepPrompt,
|
||
buildReducedStepPrompt,
|
||
StepSessionExecutor,
|
||
} from "../execution/step-session-executor.js";
|
||
import { AgentLogger } from "../agents/agent-logger.js";
|
||
import { expectAppendAgentLog } from "./agent-log-assertions.js";
|
||
import * as worktreeBackendModule from "../worktree/worktree-backend.js";
|
||
import type { TaskDetail, Settings, TaskStore } from "@fusion/core";
|
||
import { installTaskWorktreeIdentityGuard } from "../worktree/worktree-hooks.js";
|
||
|
||
vi.mock("../worktree/worktree-hooks.js", () => ({
|
||
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
|
||
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
||
}));
|
||
|
||
vi.mock("../worktree/worktree-hooks.js", () => ({
|
||
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
|
||
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
||
}));
|
||
|
||
// ── 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,
|
||
};
|
||
}
|
||
|
||
function makeIndependentSteps(count: number): TaskDetail["steps"] {
|
||
return Array.from({ length: count }, (_, i) => ({
|
||
name: `Step ${i}`,
|
||
status: "pending" as const,
|
||
dependsOn: [],
|
||
}));
|
||
}
|
||
|
||
// ── 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", () => {
|
||
const independentSteps = (count: number) =>
|
||
Array.from({ length: count }, () => ({ dependsOn: [] as number[] }));
|
||
|
||
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, independentSteps(3));
|
||
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("explicitly independent non-conflicting steps share waves 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, independentSteps(4));
|
||
// 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("unannotated steps are sequential so verification cannot start before implementation", () => {
|
||
const scopes = new Map<number, string[]>([
|
||
[0, ["preflight.md"]],
|
||
[1, ["src/implementation.ts"]],
|
||
[2, ["src/implementation.test.ts"]],
|
||
[3, ["verification.log"]],
|
||
]);
|
||
|
||
const waves = determineParallelWaves(scopes, 4, [
|
||
{},
|
||
{},
|
||
{},
|
||
{},
|
||
]);
|
||
|
||
expect(waves.map((wave) => wave.indices)).toEqual([[0], [1], [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, independentSteps(3));
|
||
// 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, independentSteps(4));
|
||
// 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, independentSteps(2));
|
||
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, independentSteps(4));
|
||
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, independentSteps(2));
|
||
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, independentSteps(3));
|
||
// 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("includes attachment section with absolute project-root paths for image attachments", () => {
|
||
const task = makeTaskDetail({
|
||
prompt: fullPrompt,
|
||
attachments: [
|
||
{
|
||
filename: "abc123-screenshot.png",
|
||
originalName: "screenshot.png",
|
||
mimeType: "image/png",
|
||
size: 2048,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = buildStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).toContain("## Attachments");
|
||
expect(result).toContain("**screenshot.png** (screenshot)");
|
||
expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png");
|
||
});
|
||
|
||
it("includes attachment section for text attachments with read-for-context wording", () => {
|
||
const task = makeTaskDetail({
|
||
prompt: fullPrompt,
|
||
attachments: [
|
||
{
|
||
filename: "def456-error.log",
|
||
originalName: "error.log",
|
||
mimeType: "text/plain",
|
||
size: 512,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = buildStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).toContain("## Attachments");
|
||
expect(result).toContain("**error.log** (text/plain)");
|
||
expect(result).toContain("read for context");
|
||
expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/def456-error.log");
|
||
});
|
||
|
||
it("omits attachment section when attachments is undefined", () => {
|
||
const task = makeTaskDetail({ prompt: fullPrompt, attachments: undefined });
|
||
const result = buildStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).not.toContain("## Attachments");
|
||
});
|
||
|
||
it("omits attachment section when attachments is empty", () => {
|
||
const task = makeTaskDetail({ prompt: fullPrompt, attachments: [] });
|
||
const result = buildStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).not.toContain("## Attachments");
|
||
});
|
||
|
||
it("includes both image and text attachments together", () => {
|
||
const task = makeTaskDetail({
|
||
prompt: fullPrompt,
|
||
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 = buildStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).toContain("**shot.png** (screenshot)");
|
||
expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/abc-shot.png");
|
||
expect(result).toContain("**config.json** (application/json)");
|
||
expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/def-config.json");
|
||
expect(result).toContain("read for context");
|
||
});
|
||
|
||
it("keeps attachment paths at the project root when executing in a worktree", () => {
|
||
const task = makeTaskDetail({
|
||
prompt: fullPrompt,
|
||
attachments: [
|
||
{
|
||
filename: "abc123-screenshot.png",
|
||
originalName: "screenshot.png",
|
||
mimeType: "image/png",
|
||
size: 2048,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = buildStepPrompt(
|
||
task,
|
||
1,
|
||
"/repo/project",
|
||
undefined,
|
||
"/repo/project/.worktrees/happy-robin",
|
||
);
|
||
|
||
expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png");
|
||
expect(result).not.toContain("/repo/project/.worktrees/happy-robin/.fusion/tasks/FN-001/attachments/abc123-screenshot.png");
|
||
});
|
||
|
||
it("includes attachment-read permission note when attachments and rootDir are provided", () => {
|
||
const task = makeTaskDetail({
|
||
id: "FN-777",
|
||
prompt: fullPrompt,
|
||
attachments: [
|
||
{
|
||
filename: "abc123-screenshot.png",
|
||
originalName: "screenshot.png",
|
||
mimeType: "image/png",
|
||
size: 2048,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = buildStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).toContain("## Attachments");
|
||
expect(result).toContain(
|
||
"> **Note:** Attachment files are at the project root under `.fusion/tasks/FN-777/attachments/` — you may read them even when working in a worktree.",
|
||
);
|
||
expect(result.indexOf("> **Note:** Attachment files")).toBeGreaterThan(
|
||
result.indexOf("/repo/project/.fusion/tasks/FN-777/attachments/abc123-screenshot.png"),
|
||
);
|
||
});
|
||
|
||
it("omits attachment-read permission note when no attachments exist", () => {
|
||
const task = makeTaskDetail({ prompt: fullPrompt, attachments: [] });
|
||
const result = buildStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).not.toContain("Attachment files are at the project root");
|
||
});
|
||
|
||
it("omits attachment-read permission note when rootDir is not provided", () => {
|
||
const task = makeTaskDetail({
|
||
prompt: fullPrompt,
|
||
attachments: [
|
||
{
|
||
filename: "abc123-screenshot.png",
|
||
originalName: "screenshot.png",
|
||
mimeType: "image/png",
|
||
size: 2048,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
const result = buildStepPrompt(task, 1);
|
||
|
||
expect(result).not.toContain("Attachment files are at the project root");
|
||
});
|
||
|
||
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 user steering comments as next-session fallback when no active step session existed", () => {
|
||
const task = makeTaskDetail({
|
||
prompt: fullPrompt,
|
||
steeringComments: [
|
||
{
|
||
id: "comment-1",
|
||
author: "user",
|
||
text: "Please prioritize the API invariant before refactoring.",
|
||
createdAt: "2026-06-17T13:45:00.000Z",
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = buildStepPrompt(task, 1);
|
||
|
||
expect(result).toContain("## Steering Comments");
|
||
expect(result).toContain("Please prioritize the API invariant before refactoring.");
|
||
});
|
||
|
||
it("does not ask graph-owned step sessions to call task lifecycle tools", () => {
|
||
const task = makeTaskDetail({ prompt: fullPrompt });
|
||
const result = buildStepPrompt(task, 1);
|
||
expect(result).toContain("The workflow graph records step status, ordering, review, and completion.");
|
||
expect(result).not.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");
|
||
});
|
||
});
|
||
|
||
// ── buildReducedStepPrompt tests ───────────────────────────────────────
|
||
|
||
describe("buildReducedStepPrompt", () => {
|
||
const reducedPrompt = `# Task: FN-001 - Test Task
|
||
|
||
## Steps
|
||
|
||
### Step 0: Preflight
|
||
|
||
- [ ] Check files exist
|
||
|
||
### Step 1: Implement
|
||
|
||
- [ ] Create new-module.ts
|
||
- [ ] Add exports
|
||
|
||
### Step 2: Test
|
||
|
||
- [ ] Write unit tests
|
||
`;
|
||
|
||
it("includes compact attachment location and read instruction when attachments exist", () => {
|
||
const task = makeTaskDetail({
|
||
id: "FN-123",
|
||
prompt: reducedPrompt,
|
||
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 = buildReducedStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).toContain(
|
||
"2 attachment(s) available at `/repo/project/.fusion/tasks/FN-123/attachments/` — read the files there for context.",
|
||
);
|
||
expect(result).toContain("They live at the project root and are readable even when working in a worktree.");
|
||
expect(result).not.toContain("ask for context");
|
||
});
|
||
|
||
it("falls back to project-relative attachment location when rootDir is omitted", () => {
|
||
const task = makeTaskDetail({
|
||
id: "FN-123",
|
||
prompt: reducedPrompt,
|
||
attachments: [
|
||
{
|
||
filename: "abc-shot.png",
|
||
originalName: "shot.png",
|
||
mimeType: "image/png",
|
||
size: 1024,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = buildReducedStepPrompt(task, 1);
|
||
|
||
expect(result).toContain("1 attachment(s) available at `.fusion/tasks/FN-123/attachments/`");
|
||
expect(result).toContain("read the files there for context");
|
||
expect(result).not.toContain("ask for context");
|
||
});
|
||
|
||
it("places the attachment reference after the step and before the important block", () => {
|
||
const task = makeTaskDetail({
|
||
prompt: reducedPrompt,
|
||
attachments: [
|
||
{
|
||
filename: "abc-shot.png",
|
||
originalName: "shot.png",
|
||
mimeType: "image/png",
|
||
size: 1024,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = buildReducedStepPrompt(task, 1, "/repo/project");
|
||
const attachmentReference = "1 attachment(s) available at `/repo/project/.fusion/tasks/FN-001/attachments/`";
|
||
|
||
expect(result.indexOf(attachmentReference)).toBeGreaterThan(result.indexOf("Add exports"));
|
||
expect(result.indexOf(attachmentReference)).toBeLessThan(result.indexOf("IMPORTANT:"));
|
||
});
|
||
|
||
it("does not list individual attachment files in the reduced prompt", () => {
|
||
const task = makeTaskDetail({
|
||
prompt: reducedPrompt,
|
||
attachments: [
|
||
{
|
||
filename: "abc-shot.png",
|
||
originalName: "shot.png",
|
||
mimeType: "image/png",
|
||
size: 1024,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = buildReducedStepPrompt(task, 1);
|
||
|
||
expect(result).not.toContain("abc-shot.png");
|
||
expect(result).not.toContain("shot.png");
|
||
});
|
||
|
||
it("verifies context-limit recovery symptom is gone for image and non-image attachments", () => {
|
||
const task = makeTaskDetail({
|
||
id: "FN-456",
|
||
prompt: reducedPrompt,
|
||
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 = buildReducedStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).not.toContain("ask for context");
|
||
expect(result).toContain(".fusion/tasks/FN-456/attachments/");
|
||
expect(result).toContain("/repo/project/.fusion/tasks/FN-456/attachments/");
|
||
});
|
||
|
||
it("omits attachment reference when attachments is undefined", () => {
|
||
const task = makeTaskDetail({ prompt: reducedPrompt, attachments: undefined });
|
||
const result = buildReducedStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).not.toContain("attachment(s) available");
|
||
expect(result).not.toContain(".fusion/tasks/FN-001/attachments/");
|
||
});
|
||
|
||
it("omits attachment reference when attachments is empty", () => {
|
||
const task = makeTaskDetail({ prompt: reducedPrompt, attachments: [] });
|
||
const result = buildReducedStepPrompt(task, 1, "/repo/project");
|
||
|
||
expect(result).not.toContain("attachment(s) available");
|
||
expect(result).not.toContain(".fusion/tasks/FN-001/attachments/");
|
||
});
|
||
});
|
||
|
||
// ── 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("../agents/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 };
|
||
}),
|
||
// FNXC:EngineTestDrift 2026-07-11-22:25:
|
||
// step-session-executor.ts now imports resolveExecutorThinkingLevel from
|
||
// agent-session-helpers (Settings-ThinkingLevel precedence, 2026-07-10).
|
||
// The hand-written mock must surface it or vitest throws "No export
|
||
// defined", failing every step-execution test. Neutral undefined return —
|
||
// no test asserts on thinking level here.
|
||
resolveExecutorThinkingLevel: vi.fn(() => undefined),
|
||
/*
|
||
FNXC:EngineTestDrift 2026-07-18-04:35:
|
||
FN-7794 / fallback-swap path imports resolveExecutorFallbackThinkingLevel
|
||
unconditionally. Without it, executeAll fails before customTools are captured
|
||
and tool-availability tests see an empty tool list.
|
||
*/
|
||
resolveExecutorFallbackThinkingLevel: vi.fn(() => undefined),
|
||
};
|
||
});
|
||
|
||
// Mock logger
|
||
vi.mock("../logger.js", () => {
|
||
const createMockLogger = () => ({
|
||
log: vi.fn(), debug: 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("../errors/context-limit-detector.js", () => ({
|
||
isContextLimitError: vi.fn().mockImplementation((msg: string) =>
|
||
/context\s+window\s+exceeds/i.test(msg),
|
||
),
|
||
}));
|
||
|
||
// Mock usage-limit-detector
|
||
vi.mock("../errors/usage-limit-detector.js", () => ({
|
||
checkSessionError: vi.fn(),
|
||
isUsageLimitError: (message: string) => /usage limit|rate limit|\b429\b/i.test(message),
|
||
}));
|
||
|
||
// Mock worktree-names
|
||
vi.mock("../worktree/worktree-names.js", async () => {
|
||
const actual = await vi.importActual<typeof import("../worktree/worktree-names.js")>("../worktree/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, execFile: vi.fn() };
|
||
});
|
||
vi.mock("node:fs", () => ({
|
||
existsSync: vi.fn().mockReturnValue(true),
|
||
}));
|
||
|
||
import { createFnAgent } from "../pi.js";
|
||
import { generateWorktreeName } from "../worktree/worktree-names.js";
|
||
import { execSync } from "node:child_process";
|
||
import { AgentSemaphore } from "../concurrency/concurrency.js";
|
||
import { createLogger } from "../logger.js";
|
||
import { promptWithAutoRetry, resolveExecutorSessionModel } from "../agents/agent-session-helpers.js";
|
||
|
||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||
const mockedResolveExecutorSessionModel = vi.mocked(resolveExecutorSessionModel);
|
||
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(),
|
||
steer: vi.fn().mockResolvedValue(undefined),
|
||
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("steering", () => {
|
||
it("steers every active step session and continues after per-session failures", async () => {
|
||
const task = makeTaskDetail();
|
||
const executor = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings: makeSettings(),
|
||
pluginRunner: undefined,
|
||
} as any);
|
||
const steerOne = vi.fn().mockResolvedValue(undefined);
|
||
const steerTwo = vi.fn().mockRejectedValue(new Error("disconnected"));
|
||
const steerThree = vi.fn().mockResolvedValue(undefined);
|
||
|
||
(executor as any).activeSessions.set(0, {
|
||
dispose: vi.fn(),
|
||
abortBash: vi.fn(),
|
||
steer: steerOne,
|
||
});
|
||
(executor as any).activeSessions.set(1, {
|
||
dispose: vi.fn(),
|
||
abortBash: vi.fn(),
|
||
steer: steerTwo,
|
||
});
|
||
(executor as any).activeSessions.set(2, {
|
||
dispose: vi.fn(),
|
||
abortBash: vi.fn(),
|
||
steer: steerThree,
|
||
});
|
||
|
||
const steeredCount = await executor.steerActiveSessions("new guidance");
|
||
|
||
expect(steeredCount).toBe(3);
|
||
expect(steerOne).toHaveBeenCalledWith("new guidance");
|
||
expect(steerTwo).toHaveBeenCalledWith("new guidance");
|
||
expect(steerThree).toHaveBeenCalledWith("new guidance");
|
||
expect(getStepSessionLogger().warn).toHaveBeenCalledWith(expect.stringContaining("Failed to steer active session for step 1"));
|
||
});
|
||
});
|
||
|
||
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("reuses the primary step session when runStepsInNewSessions is false", async () => {
|
||
const prompt = makeStepPrompt("FN-001", 2);
|
||
const task = makeTaskDetail({
|
||
prompt,
|
||
assignedAgentId: "durable-step-agent",
|
||
effectiveNodeId: "mesh-node-1",
|
||
steps: [
|
||
{ name: "Step 0", status: "pending" },
|
||
{ name: "Step 1", status: "pending" },
|
||
],
|
||
});
|
||
const settings = makeSettings({ maxParallelSteps: 1, runStepsInNewSessions: false });
|
||
const emitUsageEvent = vi.fn().mockResolvedValue(undefined);
|
||
let statsCall = 0;
|
||
const session = {
|
||
...makeMockSession(),
|
||
getSessionStats: vi.fn(() => {
|
||
statsCall++;
|
||
return {
|
||
tokens: {
|
||
input: statsCall * 10,
|
||
output: statsCall * 20,
|
||
cacheRead: statsCall * 3,
|
||
cacheWrite: statsCall,
|
||
total: statsCall * 34,
|
||
},
|
||
};
|
||
}),
|
||
};
|
||
mockedCreateFnAgent.mockImplementationOnce(async (options: any) => {
|
||
options.onToolStart("Read", { path: "private-step-input" });
|
||
options.onToolEnd("Read", false, "private-step-output");
|
||
return { session } as any;
|
||
});
|
||
|
||
const executor = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings,
|
||
pluginRunner: undefined,
|
||
store: { emitUsageEvent, appendAgentLog: vi.fn().mockResolvedValue(undefined) },
|
||
} as any);
|
||
|
||
const result = await executor.executeAll();
|
||
await executor.cleanup();
|
||
|
||
expect(result).toHaveLength(2);
|
||
expect(result.every((step) => step.success)).toBe(true);
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
/*
|
||
FNXC:CommandCenterActivity 2026-08-09-15:06:
|
||
Reused workflow steps share one AgentSession, so their production execution path must
|
||
publish one session_start rather than counting each prompt as a new Activity session.
|
||
*/
|
||
expect(emitUsageEvent.mock.calls.filter(([event]) => event.kind === "session_start")).toHaveLength(1);
|
||
/*
|
||
FNXC:CommandCenterActivity 2026-08-09-16:38:
|
||
Execute a real workflow-step construction path, including provider tool callbacks, so the
|
||
durable-agent telemetry regression cannot be hidden by testing the shared seam in isolation.
|
||
*/
|
||
expect(emitUsageEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||
kind: "session_start", category: "agent-session", agentId: "durable-step-agent",
|
||
taskId: "FN-001", nodeId: "mesh-node-1",
|
||
meta: expect.objectContaining({ lane: "workflow-step", ephemeral: true }),
|
||
}));
|
||
expect(emitUsageEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||
kind: "tool_call", toolName: "Read", agentId: "durable-step-agent",
|
||
taskId: "FN-001", nodeId: "mesh-node-1",
|
||
}));
|
||
expect(emitUsageEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||
kind: "tool_result", toolName: "Read", agentId: "durable-step-agent",
|
||
taskId: "FN-001", nodeId: "mesh-node-1",
|
||
}));
|
||
expect(session.prompt).toHaveBeenCalledTimes(2);
|
||
expect(session.dispose).toHaveBeenCalledTimes(1);
|
||
expect(result[0]?.tokenUsage?.inputTokens).toBe(10);
|
||
expect(result[1]?.tokenUsage?.inputTokens).toBe(10);
|
||
expect(result[1]?.tokenUsage?.totalTokens).toBe(34);
|
||
});
|
||
|
||
it("creates a fresh primary step session for each step when runStepsInNewSessions is true", 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, runStepsInNewSessions: true });
|
||
const emitUsageEvent = vi.fn().mockResolvedValue(undefined);
|
||
const sessions = [makeMockSession(), makeMockSession()];
|
||
mockedCreateFnAgent
|
||
.mockResolvedValueOnce({ session: sessions[0] } as any)
|
||
.mockResolvedValueOnce({ session: sessions[1] } as any);
|
||
|
||
const executor = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings,
|
||
pluginRunner: undefined,
|
||
store: { emitUsageEvent },
|
||
} as any);
|
||
|
||
const result = await executor.executeAll();
|
||
|
||
expect(result).toHaveLength(2);
|
||
expect(result.every((step) => step.success)).toBe(true);
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
expect(emitUsageEvent.mock.calls.filter(([event]) => event.kind === "session_start")).toHaveLength(2);
|
||
expect(sessions[0]?.prompt).toHaveBeenCalledTimes(1);
|
||
expect(sessions[1]?.prompt).toHaveBeenCalledTimes(1);
|
||
expect(sessions[0]?.dispose).toHaveBeenCalledTimes(1);
|
||
expect(sessions[1]?.dispose).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("delivers pending steering comments in exactly one subsequent step prompt", async () => {
|
||
const prompt = makeStepPrompt("FN-001", 2);
|
||
const task = makeTaskDetail({
|
||
prompt,
|
||
steps: [
|
||
{ name: "Step 0", status: "pending" },
|
||
{ name: "Step 1", status: "pending" },
|
||
],
|
||
steeringComments: [
|
||
{
|
||
id: "queued-comment",
|
||
author: "user",
|
||
text: "Please include the queued guidance.",
|
||
createdAt: "2026-06-17T13:45:00.000Z",
|
||
},
|
||
],
|
||
});
|
||
const settings = makeSettings({ maxParallelSteps: 1 });
|
||
const prompts: string[] = [];
|
||
mockedCreateFnAgent.mockImplementation(async () => ({
|
||
session: makeMockSession(vi.fn(async (message: string) => {
|
||
prompts.push(message);
|
||
}) as any),
|
||
}) as any);
|
||
|
||
const executor = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings,
|
||
pluginRunner: undefined,
|
||
} as any);
|
||
|
||
const result = await executor.executeAll();
|
||
|
||
expect(result).toHaveLength(2);
|
||
expect(prompts[0]).toContain("## Steering Comments");
|
||
expect(prompts[0]).toContain("Please include the queued guidance.");
|
||
expect(prompts[1]).not.toContain("Please include the queued guidance.");
|
||
});
|
||
|
||
it("publishes workflow step activity run lifecycle for dashboard analytics", async () => {
|
||
vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z"));
|
||
const prompt = makeStepPrompt("FN-7402", 1);
|
||
const task = makeTaskDetail({
|
||
id: "FN-7402",
|
||
title: "Publish workflow activity",
|
||
lineageId: "lineage-FN-7402",
|
||
assignedAgentId: "assigned-agent",
|
||
prompt,
|
||
steps: [{ name: "Implement telemetry", status: "pending" }],
|
||
});
|
||
const saveRun = vi.fn().mockResolvedValue(undefined);
|
||
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
|
||
|
||
const executor = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings: makeSettings({ maxParallelSteps: 1 }),
|
||
agentStore: { saveRun } as any,
|
||
effectiveAgentId: "column-agent",
|
||
} as any);
|
||
|
||
const results = await executor.executeAll();
|
||
|
||
expect(results).toHaveLength(1);
|
||
expect(results[0]?.success).toBe(true);
|
||
expect(saveRun).toHaveBeenCalledTimes(2);
|
||
const [activeRun, completedRun] = saveRun.mock.calls.map((call) => call[0]);
|
||
expect(activeRun).toMatchObject({
|
||
agentId: "column-agent",
|
||
taskId: "FN-7402",
|
||
startedAt: "2026-07-01T12:00:00.000Z",
|
||
endedAt: null,
|
||
status: "active",
|
||
invocationSource: "assignment",
|
||
triggerDetail: "workflow-step-session",
|
||
contextSnapshot: {
|
||
source: "step-session-executor",
|
||
sessionPurpose: "executor",
|
||
workflowStep: true,
|
||
taskId: "FN-7402",
|
||
taskLineageId: "lineage-FN-7402",
|
||
assignedAgentId: "assigned-agent",
|
||
effectiveAgentId: "column-agent",
|
||
agentId: "column-agent",
|
||
stepIndex: 0,
|
||
stepName: "Implement telemetry",
|
||
},
|
||
});
|
||
expect(activeRun.id).toMatch(/^workflow-step-FN-7402-.*-step-0$/);
|
||
expect(completedRun).toMatchObject({
|
||
id: activeRun.id,
|
||
agentId: "column-agent",
|
||
taskId: "FN-7402",
|
||
startedAt: activeRun.startedAt,
|
||
endedAt: "2026-07-01T12:00:00.000Z",
|
||
status: "completed",
|
||
resultJson: expect.objectContaining({ success: true, retries: 0, stepIndex: 0 }),
|
||
});
|
||
});
|
||
|
||
it("publishes failed terminal workflow step activity without leaving stale active state", async () => {
|
||
vi.setSystemTime(new Date("2026-07-01T13:00:00.000Z"));
|
||
const prompt = makeStepPrompt("FN-7402", 1);
|
||
const task = makeTaskDetail({
|
||
id: "FN-7402",
|
||
prompt,
|
||
assignedAgentId: "assigned-agent",
|
||
steps: [{ name: "Failing step", status: "pending" }],
|
||
});
|
||
const saveRun = vi.fn().mockResolvedValue(undefined);
|
||
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession(vi.fn().mockRejectedValue(new Error("boom"))) } as any);
|
||
|
||
const executor = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings: makeSettings({ maxParallelSteps: 1 }),
|
||
agentStore: { saveRun } as any,
|
||
} as any);
|
||
|
||
// FNXC:EngineTests 2026-07-09-06:00:
|
||
// executeAll retries the failing step 3× with sleep() delays between attempts. With
|
||
// useFakeTimers({ shouldAdvanceTime: true }) these sleeps advance REAL wall-clock time if
|
||
// the test awaits executeAll directly (was 22.6s, ballooning under CI load and busting the
|
||
// shard-2 watchdog). Fast-forward the retry sleeps via fake timers like the sibling retry
|
||
// tests below, so the loop completes in milliseconds.
|
||
const resultsPromise = executor.executeAll();
|
||
await vi.advanceTimersByTimeAsync(60_000);
|
||
const results = await resultsPromise;
|
||
|
||
expect(results).toEqual([{ stepIndex: 0, success: false, error: "boom", retries: 3, tokenUsage: undefined }]);
|
||
const terminalRun = saveRun.mock.calls.at(-1)?.[0];
|
||
expect(saveRun).toHaveBeenCalledTimes(2);
|
||
expect(terminalRun).toMatchObject({
|
||
id: saveRun.mock.calls[0]?.[0].id,
|
||
agentId: "assigned-agent",
|
||
status: "failed",
|
||
endedAt: expect.stringMatching(/^2026-07-01T13:00:/),
|
||
resultJson: expect.objectContaining({ success: false, error: "boom", retries: 3 }),
|
||
});
|
||
expect(saveRun.mock.calls.map((call) => call[0].status)).toEqual(["active", "failed"]);
|
||
});
|
||
|
||
it("uses assigned-agent and fallback executor identities for workflow activity runs", async () => {
|
||
const prompt = makeStepPrompt("FN-7402", 1);
|
||
const runExecutor = async (taskOverrides: Partial<TaskDetail>) => {
|
||
const saveRun = vi.fn().mockResolvedValue(undefined);
|
||
mockedCreateFnAgent.mockResolvedValueOnce({ session: makeMockSession() } as any);
|
||
const executor = new StepSessionExecutor({
|
||
taskDetail: makeTaskDetail({ id: "FN-7402", prompt, steps: [{ name: "Step 0", status: "pending" }], ...taskOverrides }),
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings: makeSettings({ maxParallelSteps: 1 }),
|
||
agentStore: { saveRun } as any,
|
||
} as any);
|
||
await executor.executeAll();
|
||
return saveRun.mock.calls[0]?.[0];
|
||
};
|
||
|
||
await expect(runExecutor({ assignedAgentId: "assigned-agent" })).resolves.toMatchObject({ agentId: "assigned-agent" });
|
||
await expect(runExecutor({ assignedAgentId: undefined })).resolves.toMatchObject({ agentId: "executor" });
|
||
});
|
||
|
||
it("continues workflow execution when workflow activity publication is unavailable or failing", async () => {
|
||
const prompt = makeStepPrompt("FN-7402", 1);
|
||
const task = makeTaskDetail({ id: "FN-7402", prompt, steps: [{ name: "Step 0", status: "pending" }] });
|
||
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
|
||
|
||
const withoutStore = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings: makeSettings({ maxParallelSteps: 1 }),
|
||
} as any);
|
||
await expect(withoutStore.executeAll()).resolves.toMatchObject([{ success: true }]);
|
||
|
||
const saveRun = vi.fn().mockRejectedValue(new Error("db offline"));
|
||
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
|
||
const withFailingStore = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings: makeSettings({ maxParallelSteps: 1 }),
|
||
agentStore: { saveRun } as any,
|
||
} as any);
|
||
|
||
await expect(withFailingStore.executeAll()).resolves.toMatchObject([{ success: true }]);
|
||
expect(saveRun).toHaveBeenCalledTimes(2);
|
||
expect(getStepSessionLogger().warn).toHaveBeenCalledWith(expect.stringContaining("Failed to publish workflow-step activity run"));
|
||
});
|
||
|
||
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("does not create or complete a step session when the persisted start is rejected", async () => {
|
||
const prompt = makeStepPrompt("FN-8490", 1);
|
||
const task = makeTaskDetail({
|
||
id: "FN-8490",
|
||
prompt,
|
||
steps: [{ name: "Ordered step", status: "pending" }],
|
||
});
|
||
const onStepStart = vi.fn().mockResolvedValue(false);
|
||
const onStepComplete = vi.fn();
|
||
|
||
const executor = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings: makeSettings({ maxParallelSteps: 1 }),
|
||
onStepStart,
|
||
onStepComplete,
|
||
});
|
||
|
||
const results = await executor.executeAll();
|
||
|
||
expect(results).toEqual([
|
||
expect.objectContaining({
|
||
stepIndex: 0,
|
||
success: false,
|
||
error: expect.stringContaining("start was rejected"),
|
||
retries: 0,
|
||
}),
|
||
]);
|
||
expect(onStepStart).toHaveBeenCalledWith(0);
|
||
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
||
expect(onStepComplete).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("preserves notification-only start callbacks that return void", async () => {
|
||
const task = makeTaskDetail({
|
||
prompt: makeStepPrompt("FN-8490-LEGACY", 1),
|
||
steps: [{ name: "Legacy callback step", status: "pending" }],
|
||
});
|
||
const session = makeMockSession();
|
||
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
||
const onStepStart = vi.fn(() => undefined);
|
||
|
||
const executor = new StepSessionExecutor({
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings: makeSettings({ maxParallelSteps: 1 }),
|
||
onStepStart,
|
||
});
|
||
|
||
const results = await executor.executeAll();
|
||
|
||
expect(results[0]?.success).toBe(true);
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("skips live-terminal steps before starting resumed sessions", async () => {
|
||
const prompt = makeStepPrompt("FN-7248", 2);
|
||
const task = makeTaskDetail({
|
||
id: "FN-7248",
|
||
prompt,
|
||
steps: [
|
||
{ name: "Preflight", status: "pending" },
|
||
{ name: "Implement", status: "pending" },
|
||
],
|
||
});
|
||
const settings = makeSettings({ maxParallelSteps: 1 });
|
||
const session = makeMockSession();
|
||
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
||
const onStepStart = vi.fn();
|
||
const store = {
|
||
getTask: vi.fn().mockResolvedValue({
|
||
...task,
|
||
steps: [
|
||
{ name: "Preflight", status: "done" },
|
||
{ name: "Implement", status: "in-progress" },
|
||
],
|
||
}),
|
||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||
};
|
||
|
||
const executor = new StepSessionExecutor({
|
||
store: store as any,
|
||
taskDetail: task,
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings,
|
||
onStepStart,
|
||
});
|
||
|
||
const results = await executor.executeAll();
|
||
|
||
/*
|
||
* FNXC:WorkflowResume 2026-06-29-18:26:
|
||
* Resume must use TaskStore as the authoritative projection before scheduling per-step sessions. A stale snapshot may still list Step 0 as pending, but if the live task says Step 0 is done then no Step 0 session or onStepStart callback may run.
|
||
*/
|
||
expect(results.map((result) => result.stepIndex)).toEqual([1]);
|
||
expect(onStepStart).toHaveBeenCalledTimes(1);
|
||
expect(onStepStart).toHaveBeenCalledWith(1);
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
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: makeIndependentSteps(2),
|
||
});
|
||
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: makeIndependentSteps(2),
|
||
});
|
||
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: makeIndependentSteps(2),
|
||
});
|
||
const settings = makeSettings({ maxParallelSteps: 2 });
|
||
|
||
const session = makeMockSession();
|
||
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
||
|
||
// Make the merge-base bounded commit list return a step commit, but cherry-pick fails.
|
||
mockedExecSync.mockImplementation((cmd: string) => {
|
||
if (typeof cmd === "string" && cmd.includes("git rev-parse HEAD")) {
|
||
return "primary-head";
|
||
}
|
||
if (typeof cmd === "string" && cmd.includes("git merge-base HEAD primary-head")) {
|
||
return "merge-base-sha";
|
||
}
|
||
if (typeof cmd === "string" && cmd.includes("git rev-list --reverse merge-base-sha..HEAD")) {
|
||
return "abc123def";
|
||
}
|
||
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: makeIndependentSteps(2),
|
||
});
|
||
const settings = makeSettings({ maxParallelSteps: 2 });
|
||
|
||
const session = makeMockSession();
|
||
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
||
|
||
mockedExecSync.mockImplementation((cmd: string) => {
|
||
if (typeof cmd === "string" && cmd.includes("git rev-parse HEAD")) {
|
||
return "primary-head";
|
||
}
|
||
if (typeof cmd === "string" && cmd.includes("git merge-base HEAD primary-head")) {
|
||
return "merge-base-sha";
|
||
}
|
||
if (typeof cmd === "string" && cmd.includes("git rev-list --reverse merge-base-sha..HEAD")) {
|
||
return "abc123def";
|
||
}
|
||
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 rev-parse HEAD")) {
|
||
return "primary-head";
|
||
}
|
||
if (cmd.includes("git merge-base HEAD primary-head")) {
|
||
return "merge-base-sha";
|
||
}
|
||
if (cmd.includes("git rev-list --reverse merge-base-sha..HEAD")) {
|
||
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: makeIndependentSteps(2),
|
||
});
|
||
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: makeIndependentSteps(2),
|
||
});
|
||
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();
|
||
|
||
releaseStep0?.();
|
||
const results = await resultsPromise;
|
||
|
||
expect(executionEvents.includes("step-0-start")).toBe(true);
|
||
|
||
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: makeIndependentSteps(3),
|
||
});
|
||
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.filter((cwd) => cwd === "/project/.worktrees/main").length).toBeGreaterThanOrEqual(3);
|
||
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: makeIndependentSteps(3),
|
||
});
|
||
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 rev-parse HEAD")) {
|
||
return "primary-head";
|
||
}
|
||
if (cmd.includes("git merge-base HEAD primary-head")) {
|
||
return "merge-base-sha";
|
||
}
|
||
if (cmd.includes("git rev-list --reverse merge-base-sha..HEAD")) {
|
||
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();
|
||
|
||
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(events).toEqual(expect.arrayContaining(["parallel-0-start", "parallel-1-start", "primary-start"]));
|
||
expect(maxActiveParallelSteps).toBeGreaterThanOrEqual(1);
|
||
|
||
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: makeIndependentSteps(2),
|
||
});
|
||
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: makeIndependentSteps(3),
|
||
});
|
||
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("awaits asynchronous session disposal before the step boundary completes", async () => {
|
||
const task = makeTaskDetail({ prompt: makeStepPrompt("FN-ASYNC-DISPOSE", 1), steps: [{ name: "Step 0", status: "pending" }] });
|
||
let resolveDispose!: () => void;
|
||
const session = makeMockSession();
|
||
session.dispose = vi.fn(() => new Promise<void>((resolve) => { resolveDispose = resolve; }));
|
||
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
||
const executor = new StepSessionExecutor({ taskDetail: task, worktreePath: "/project/.worktrees/main", rootDir: "/project", settings: makeSettings() });
|
||
let completed = false;
|
||
const execution = executor.executeAll().then((result) => { completed = true; return result; });
|
||
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalledOnce());
|
||
expect(completed).toBe(false);
|
||
resolveDispose();
|
||
await execution;
|
||
expect(completed).toBe(true);
|
||
});
|
||
|
||
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: makeIndependentSteps(2),
|
||
});
|
||
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: makeIndependentSteps(2),
|
||
});
|
||
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();
|
||
|
||
// FN-7503 added an optional 6th timing arg; pin the first five and tolerate timing.
|
||
expectAppendAgentLog(appendAgentLog, "FN-001", "step output", "text", undefined, "executor");
|
||
expectAppendAgentLog(appendAgentLog, "FN-001", "read", "tool", undefined, "executor");
|
||
expectAppendAgentLog(appendAgentLog, "FN-001", "read", "tool_result", undefined, "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;
|
||
}
|
||
|
||
/*
|
||
FNXC:EphemeralAgentTaskCreation 2026-08-23-22:05:
|
||
FN-125 (0b4dbd219b) structurally withholds board CREATION from this lane: a workflow model-node
|
||
step session receives neither `fn_task_create` nor `fn_delegate_task` (delegation creates a task
|
||
through the same primitive), rather than being handed a tool that only refuses when called. The
|
||
read/assign half of the agent surface is unchanged, so assert both halves.
|
||
*/
|
||
it("includes fn_list_agents and fn_task_assign — but never 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_task_assign");
|
||
expect(toolNames).not.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");
|
||
expect(toolNames).not.toContain("fn_task_assign");
|
||
});
|
||
|
||
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");
|
||
});
|
||
|
||
/* FN-125 (0b4dbd219b) withheld `fn_task_create` from this lane; the task-log surface is unchanged. */
|
||
it("includes fn_task_log — but never 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).not.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",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("StepSessionExecutor credential-instance retargeting", () => {
|
||
function makeCredentialExecutor(options: {
|
||
steps?: number;
|
||
runStepsInNewSessions?: boolean;
|
||
credentialInstanceId?: string;
|
||
maxParallelSteps?: number;
|
||
resolveCredentialInstanceRetarget?: () => Promise<{ providerId: string; instanceId: string } | undefined>;
|
||
} = {}) {
|
||
const stepCount = options.steps ?? 1;
|
||
return new StepSessionExecutor({
|
||
taskDetail: makeTaskDetail({
|
||
prompt: makeStepPrompt("FN-CREDENTIAL", stepCount),
|
||
steps: Array.from({ length: stepCount }, (_, index) => ({ name: `Step ${index}`, status: "pending" as const })),
|
||
}),
|
||
worktreePath: "/project/.worktrees/main",
|
||
rootDir: "/project",
|
||
settings: makeSettings({ maxParallelSteps: options.maxParallelSteps ?? 1, runStepsInNewSessions: options.runStepsInNewSessions }),
|
||
credentialInstanceId: options.credentialInstanceId,
|
||
resolveCredentialInstanceRetarget: options.resolveCredentialInstanceRetarget,
|
||
});
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||
vi.mocked(promptWithAutoRetry).mockImplementation(async (session: any, prompt: string, options?: unknown) =>
|
||
session.prompt(prompt, options),
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it("omits an unset credential instance from legacy session creation", async () => {
|
||
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
|
||
const executor = makeCredentialExecutor();
|
||
|
||
await executor.executeAll();
|
||
|
||
expect(mockedCreateFnAgent.mock.calls[0]?.[0]).not.toHaveProperty("credentialInstanceId");
|
||
});
|
||
|
||
it("forwards the initial instance to each newly-created sequential session", async () => {
|
||
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
|
||
const executor = makeCredentialExecutor({ steps: 2, runStepsInNewSessions: true, credentialInstanceId: "account-a" });
|
||
|
||
await executor.executeAll();
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
expect(mockedCreateFnAgent.mock.calls.map(([options]) => options.credentialInstanceId)).toEqual(["account-a", "account-a"]);
|
||
expect(mockedResolveExecutorSessionModel.mock.calls.map((args) => args[4])).toEqual(["account-a", "account-a"]);
|
||
});
|
||
|
||
it("forwards the initial instance to every fresh session in a parallel wave", async () => {
|
||
mockedCreateFnAgent.mockResolvedValue({ session: makeMockSession() } as any);
|
||
const executor = makeCredentialExecutor({ steps: 2, maxParallelSteps: 2, credentialInstanceId: "account-a" });
|
||
|
||
await executor.executeAll();
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
expect(mockedCreateFnAgent.mock.calls.map(([options]) => options.credentialInstanceId)).toEqual(["account-a", "account-a"]);
|
||
});
|
||
|
||
it("retargets only subsequent fresh sessions and clears back to omitted resolution", async () => {
|
||
const first = makeMockSession();
|
||
const second = makeMockSession();
|
||
const third = makeMockSession();
|
||
mockedCreateFnAgent
|
||
.mockResolvedValueOnce({ session: first } as any)
|
||
.mockResolvedValueOnce({ session: second } as any)
|
||
.mockResolvedValueOnce({ session: third } as any);
|
||
const executor = makeCredentialExecutor({ steps: 3, runStepsInNewSessions: true, credentialInstanceId: "account-a" });
|
||
|
||
await (executor as any).executeStep(0, "/project/.worktrees/main");
|
||
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
|
||
await (executor as any).executeStep(1, "/project/.worktrees/main");
|
||
await executor.retargetCredentialInstance(undefined);
|
||
await (executor as any).executeStep(2, "/project/.worktrees/main");
|
||
|
||
expect(mockedCreateFnAgent.mock.calls.map(([options]) => options.credentialInstanceId)).toEqual([
|
||
"account-a",
|
||
"account-b",
|
||
undefined,
|
||
]);
|
||
expect(mockedCreateFnAgent.mock.calls[2]?.[0]).not.toHaveProperty("credentialInstanceId");
|
||
});
|
||
|
||
it("defers reusable-primary disposal until an active prompt completes, then uses the retargeted instance", async () => {
|
||
let finishFirstPrompt: (() => void) | undefined;
|
||
let firstSession: ReturnType<typeof makeMockSession> | undefined;
|
||
const firstPromptStarted = new Promise<void>((resolve) => {
|
||
firstSession = {
|
||
...makeMockSession(),
|
||
abortBash: vi.fn(),
|
||
prompt: vi.fn(() => new Promise<void>((finish) => {
|
||
finishFirstPrompt = finish;
|
||
resolve();
|
||
})),
|
||
};
|
||
const second = { ...makeMockSession(), abortBash: vi.fn() };
|
||
mockedCreateFnAgent
|
||
.mockResolvedValueOnce({ session: firstSession } as any)
|
||
.mockResolvedValueOnce({ session: second } as any);
|
||
});
|
||
const executor = makeCredentialExecutor({ steps: 2, runStepsInNewSessions: false, credentialInstanceId: "account-a" });
|
||
const execution = executor.executeAll();
|
||
|
||
await firstPromptStarted;
|
||
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
|
||
|
||
expect(firstSession?.abortBash).not.toHaveBeenCalled();
|
||
expect(firstSession?.dispose).not.toHaveBeenCalled();
|
||
finishFirstPrompt?.();
|
||
|
||
await expect(execution).resolves.toEqual([
|
||
expect.objectContaining({ stepIndex: 0, success: true, retries: 0 }),
|
||
expect.objectContaining({ stepIndex: 1, success: true, retries: 0 }),
|
||
]);
|
||
expect(firstSession?.dispose).toHaveBeenCalledTimes(1);
|
||
expect(mockedCreateFnAgent.mock.calls[1]?.[0]).toMatchObject({ credentialInstanceId: "account-b" });
|
||
});
|
||
|
||
it("clears a deferred retarget during cleanup", async () => {
|
||
let finishPrompt: (() => void) | undefined;
|
||
const promptStarted = new Promise<void>((resolve) => {
|
||
const session = {
|
||
...makeMockSession(),
|
||
abortBash: vi.fn(),
|
||
prompt: vi.fn(() => new Promise<void>((finish) => {
|
||
finishPrompt = finish;
|
||
resolve();
|
||
})),
|
||
};
|
||
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
||
});
|
||
const executor = makeCredentialExecutor({ runStepsInNewSessions: false, credentialInstanceId: "account-a" });
|
||
const execution = executor.executeAll();
|
||
|
||
await promptStarted;
|
||
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
|
||
await executor.cleanup();
|
||
|
||
expect((executor as any).reusablePrimaryRetargetPending).toBe(false);
|
||
finishPrompt?.();
|
||
await execution;
|
||
});
|
||
|
||
it("immediately disposes an idle reusable primary session and ignores equivalent or invalid retargets", async () => {
|
||
const session = { ...makeMockSession(), abortBash: vi.fn() };
|
||
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
||
const executor = makeCredentialExecutor({ runStepsInNewSessions: false, credentialInstanceId: "account-a" });
|
||
|
||
await (executor as any).executeStep(0, "/project/.worktrees/main");
|
||
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-a" });
|
||
expect(session.dispose).not.toHaveBeenCalled();
|
||
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
|
||
expect(session.abortBash).toHaveBeenCalledTimes(1);
|
||
expect(session.dispose).toHaveBeenCalledTimes(1);
|
||
await expect(executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "bad id" })).resolves.toBeUndefined();
|
||
expect(getStepSessionLogger().warn).toHaveBeenCalledWith(expect.stringContaining("Ignoring invalid credential instance id"));
|
||
});
|
||
|
||
it("retargets a usage-limit retry through the owning executor's live selection", async () => {
|
||
const first = {
|
||
...makeMockSession(),
|
||
prompt: vi.fn()
|
||
.mockRejectedValueOnce(new Error("429 usage limit reached"))
|
||
.mockResolvedValueOnce(undefined),
|
||
};
|
||
const second = makeMockSession();
|
||
const resolveCredentialInstanceRetarget = vi.fn().mockResolvedValue({ providerId: "anthropic", instanceId: "account-b" });
|
||
mockedCreateFnAgent
|
||
.mockResolvedValueOnce({ session: first } as any)
|
||
.mockResolvedValueOnce({ session: second } as any);
|
||
const executor = makeCredentialExecutor({ credentialInstanceId: "account-a", resolveCredentialInstanceRetarget });
|
||
|
||
const execution = executor.executeAll();
|
||
await vi.advanceTimersByTimeAsync(10_000);
|
||
|
||
await expect(execution).resolves.toEqual([expect.objectContaining({ success: true, retries: 1 })]);
|
||
expect(resolveCredentialInstanceRetarget).toHaveBeenCalledTimes(1);
|
||
expect(mockedCreateFnAgent.mock.calls[1]?.[0]).toMatchObject({ credentialInstanceId: "account-b" });
|
||
});
|
||
|
||
it("applies a manual retarget before a retry without changing retry accounting", async () => {
|
||
let rejectFirstPrompt: ((error: Error) => void) | undefined;
|
||
const first = {
|
||
...makeMockSession(),
|
||
prompt: vi.fn(() => new Promise<void>((_resolve, reject) => { rejectFirstPrompt = reject; })),
|
||
};
|
||
const second = makeMockSession();
|
||
mockedCreateFnAgent
|
||
.mockResolvedValueOnce({ session: first } as any)
|
||
.mockResolvedValueOnce({ session: second } as any);
|
||
const executor = makeCredentialExecutor({ credentialInstanceId: "account-a" });
|
||
const execution = executor.executeAll();
|
||
await vi.waitFor(() => expect(rejectFirstPrompt).toBeTypeOf("function"));
|
||
|
||
await executor.retargetCredentialInstance({ providerId: "anthropic", instanceId: "account-b" });
|
||
rejectFirstPrompt?.(new Error("retry me"));
|
||
await vi.advanceTimersByTimeAsync(10_000);
|
||
|
||
await expect(execution).resolves.toEqual([expect.objectContaining({ success: true, retries: 1 })]);
|
||
expect(mockedCreateFnAgent.mock.calls[1]?.[0]).toMatchObject({ credentialInstanceId: "account-b" });
|
||
});
|
||
});
|