agentic-toolkit/src/modules/phase-scheduler.test.ts
Khalim Conn-Kowlessar 1d8a77b29b feat: scaffold agentic-toolkit (runner + skills + setup)
Initial implementation of Domna's agentic toolkit per PRD #1:

- Runner CLI (src/cli.ts) wrapping sandcastle.run() with Docker provider
- Pure modules: PhaseScheduler, PromptBuilder, FailureHandler with tests
- Project Status v2 GraphQL client + parsers with tests
- BranchManager (git/gh wrapper) and LoopOrchestrator (per-tick algorithm)
- Variant-aware: per-ticket (one PR per issue, phase-gated, exit between phases)
  vs single-pr (one PR for the whole DAG, halt on failure)
- /to-project skill that creates a repo-level project, configures the Status
  schema the runner expects, and sets initial issue statuses
- setup.sh that installs Matt Pocock skills + Domna skills via npx skills

Out of scope at v1: remote runners, Slack notifications, stacked PRs,
cross-repo projects, SHA-pinning of upstream skills (tracks HEAD until the
skills CLI supports repo#sha).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 12:40:26 +01:00

122 lines
3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ProjectIssue } from "../types.js";
import { currentPhase, schedule } from "./phase-scheduler.js";
const issue = (
n: number,
blockedBy: number[] = [],
overrides: Partial<ProjectIssue> = {},
): ProjectIssue => ({
number: n,
nodeId: `node-${n}`,
title: `Issue ${n}`,
body: "",
kind: "AFK",
status: "Ready",
blockedBy,
...overrides,
});
describe("schedule", () => {
it("puts unblocked issues in phase 0", () => {
const phases = schedule([issue(1), issue(2), issue(3)]);
expect(phases).toHaveLength(1);
expect(phases[0]?.issues.map((i) => i.number)).toEqual([1, 2, 3]);
});
it("orders a linear chain into one phase per node", () => {
const phases = schedule([
issue(1),
issue(2, [1]),
issue(3, [2]),
]);
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
[1],
[2],
[3],
]);
});
it("groups fan-out children into the same phase", () => {
const phases = schedule([
issue(1),
issue(2, [1]),
issue(3, [1]),
issue(4, [1]),
]);
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
[1],
[2, 3, 4],
]);
});
it("handles a diamond DAG", () => {
const phases = schedule([
issue(1),
issue(2, [1]),
issue(3, [1]),
issue(4, [2, 3]),
]);
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
[1],
[2, 3],
[4],
]);
});
it("treats blockers outside the input set as satisfied", () => {
const phases = schedule([issue(2, [99]), issue(3, [2])]);
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
[2],
[3],
]);
});
it("excludes Done issues from phases but keeps their blockers satisfied", () => {
const phases = schedule([
issue(1, [], { status: "Done" }),
issue(2, [1]),
issue(3, [2]),
]);
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
[2],
[3],
]);
});
it("partitions disconnected components in parallel", () => {
const phases = schedule([
issue(1),
issue(2, [1]),
issue(10),
issue(11, [10]),
]);
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
[1, 10],
[2, 11],
]);
});
it("throws on a cycle", () => {
expect(() => schedule([issue(1, [2]), issue(2, [1])])).toThrow(/Cycle/);
});
});
describe("currentPhase", () => {
it("returns the first phase containing any non-Done issue", () => {
const phases = schedule([
issue(1, [], { status: "Done" }),
issue(2, [1], { status: "In progress" }),
issue(3, [2]),
]);
expect(currentPhase(phases)?.issues.map((i) => i.number)).toEqual([2]);
});
it("returns undefined when all phases are Done", () => {
const phases = schedule([
issue(1, [], { status: "Done" }),
issue(2, [1], { status: "Done" }),
]);
expect(currentPhase(phases)).toBeUndefined();
});
});