first commit
This commit is contained in:
460
tests/agent-manager.test.ts
Normal file
460
tests/agent-manager.test.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { AgentManager } from "../src/agents/manager.js";
|
||||
import {
|
||||
ResourceProvisioningOrchestrator,
|
||||
type DiscoverySnapshot,
|
||||
type ResourceProvider,
|
||||
} from "../src/agents/provisioning.js";
|
||||
|
||||
test("queues work when session concurrency is saturated", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 1,
|
||||
maxSessionAgents: 1,
|
||||
maxRecursiveDepth: 2,
|
||||
});
|
||||
const session = manager.createSession("session-a");
|
||||
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstStarted = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let secondStarted = false;
|
||||
|
||||
const firstRun = session.runAgent({
|
||||
depth: 0,
|
||||
run: async () => {
|
||||
await firstStarted;
|
||||
return "first";
|
||||
},
|
||||
});
|
||||
|
||||
const secondRun = session.runAgent({
|
||||
depth: 0,
|
||||
run: async () => {
|
||||
secondStarted = true;
|
||||
return "second";
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(secondStarted, false);
|
||||
|
||||
assert.ok(releaseFirst);
|
||||
releaseFirst();
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([firstRun, secondRun]);
|
||||
assert.equal(firstResult, "first");
|
||||
assert.equal(secondResult, "second");
|
||||
assert.equal(manager.getActiveAgentCount(), 0);
|
||||
});
|
||||
|
||||
test("rejects agent runs above recursive depth limit", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 2,
|
||||
maxSessionAgents: 2,
|
||||
maxRecursiveDepth: 1,
|
||||
});
|
||||
const session = manager.createSession("session-b");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
session.runAgent({
|
||||
depth: 2,
|
||||
run: async () => "unreachable",
|
||||
}),
|
||||
/exceeds maxRecursiveDepth/,
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a session rejects queued runs", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 1,
|
||||
maxSessionAgents: 1,
|
||||
maxRecursiveDepth: 1,
|
||||
});
|
||||
const session = manager.createSession("session-c");
|
||||
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstRun = session.runAgent({
|
||||
depth: 0,
|
||||
run: async () =>
|
||||
new Promise<string>((resolve) => {
|
||||
releaseFirst = () => resolve("first");
|
||||
}),
|
||||
});
|
||||
|
||||
const queuedRun = session.runAgent({
|
||||
depth: 0,
|
||||
run: async () => "second",
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
session.close();
|
||||
|
||||
await assert.rejects(() => queuedRun, /was closed/);
|
||||
assert.ok(releaseFirst);
|
||||
releaseFirst();
|
||||
await firstRun;
|
||||
});
|
||||
|
||||
test("recursive fanout/fan-in avoids deadlock at maxConcurrentAgents=1", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 1,
|
||||
maxSessionAgents: 1,
|
||||
maxRecursiveDepth: 3,
|
||||
});
|
||||
const session = manager.createSession("recursive-deadlock");
|
||||
|
||||
const executionOrder: string[] = [];
|
||||
const result = await manager.runRecursiveAgent({
|
||||
sessionId: session.id,
|
||||
depth: 0,
|
||||
run: async ({ sessionId, intent }) => {
|
||||
executionOrder.push(`${sessionId}:${intent?.task ?? "root"}`);
|
||||
if (!intent) {
|
||||
return {
|
||||
type: "fanout" as const,
|
||||
intents: [
|
||||
{
|
||||
persona: "coder",
|
||||
task: "build-child",
|
||||
},
|
||||
],
|
||||
aggregate: ({ childResults }) => childResults[0]?.output ?? "missing",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "complete" as const,
|
||||
output: `done:${sessionId}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, "done:recursive-deadlock_child_1");
|
||||
assert.deepEqual(executionOrder, [
|
||||
"recursive-deadlock:root",
|
||||
"recursive-deadlock_child_1:build-child",
|
||||
]);
|
||||
assert.equal(manager.getActiveAgentCount(), 0);
|
||||
session.close();
|
||||
});
|
||||
|
||||
test("rejects recursive child spawn above depth limit", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 2,
|
||||
maxSessionAgents: 2,
|
||||
maxRecursiveDepth: 2,
|
||||
});
|
||||
const session = manager.createSession("recursive-depth");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
manager.runRecursiveAgent({
|
||||
sessionId: session.id,
|
||||
depth: 0,
|
||||
run: async ({ depth }) => {
|
||||
if (depth < 3) {
|
||||
return {
|
||||
type: "fanout" as const,
|
||||
intents: [
|
||||
{
|
||||
persona: "coder",
|
||||
task: `spawn-${String(depth + 1)}`,
|
||||
},
|
||||
],
|
||||
aggregate: ({ childResults }) => childResults[0]?.output ?? "missing",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "complete" as const,
|
||||
output: `leaf-${String(depth)}`,
|
||||
};
|
||||
},
|
||||
}),
|
||||
/Cannot spawn child at depth 3/,
|
||||
);
|
||||
session.close();
|
||||
});
|
||||
|
||||
test("closing parent session aborts active recursive work and releases child resources", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 2,
|
||||
maxSessionAgents: 2,
|
||||
maxRecursiveDepth: 3,
|
||||
});
|
||||
const session = manager.createSession("recursive-abort");
|
||||
|
||||
let notifyChildStarted: (() => void) | undefined;
|
||||
const childStarted = new Promise<void>((resolve) => {
|
||||
notifyChildStarted = resolve;
|
||||
});
|
||||
|
||||
let abortCount = 0;
|
||||
let releaseCount = 0;
|
||||
|
||||
const runPromise = manager.runRecursiveAgent({
|
||||
sessionId: session.id,
|
||||
depth: 0,
|
||||
run: async ({ intent, signal }) => {
|
||||
if (!intent) {
|
||||
return {
|
||||
type: "fanout" as const,
|
||||
intents: [
|
||||
{
|
||||
persona: "coder",
|
||||
task: "long-running",
|
||||
},
|
||||
],
|
||||
aggregate: () => "unreachable",
|
||||
};
|
||||
}
|
||||
|
||||
notifyChildStarted?.();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
abortCount += 1;
|
||||
reject(signal.reason ?? new Error("Aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
|
||||
return {
|
||||
type: "complete" as const,
|
||||
output: "child-done",
|
||||
};
|
||||
},
|
||||
childMiddleware: {
|
||||
releaseForChild: async () => {
|
||||
releaseCount += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await childStarted;
|
||||
session.close();
|
||||
|
||||
await assert.rejects(() => runPromise, /(AbortError|aborted|closed)/i);
|
||||
assert.equal(abortCount, 1);
|
||||
assert.equal(releaseCount, 1);
|
||||
assert.equal(manager.getActiveAgentCount(), 0);
|
||||
});
|
||||
|
||||
test("recursive children can be isolated via middleware-backed suballocation", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 2,
|
||||
maxSessionAgents: 2,
|
||||
maxRecursiveDepth: 2,
|
||||
});
|
||||
const session = manager.createSession("recursive-isolation");
|
||||
const provisioner = new ResourceProvisioningOrchestrator([
|
||||
createTestGitWorktreeProvider(),
|
||||
createTestPortRangeProvider(),
|
||||
]);
|
||||
|
||||
const parentResources = await provisioner.provisionSession({
|
||||
sessionId: session.id,
|
||||
workspaceRoot: "/repo",
|
||||
resources: [
|
||||
{
|
||||
kind: "git-worktree",
|
||||
options: {
|
||||
rootDirectory: "/repo/.ai_ops/worktrees",
|
||||
baseRef: "HEAD",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "port-range",
|
||||
options: {
|
||||
basePort: 41000,
|
||||
blockSize: 20,
|
||||
blockCount: 1,
|
||||
primaryPortOffset: 0,
|
||||
lockDirectory: "/repo/.ai_ops/locks/ports",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const parentSnapshot = parentResources.toDiscoverySnapshot();
|
||||
const childSnapshots = new Map<string, DiscoverySnapshot>();
|
||||
const childLeases = new Map<string, Awaited<ReturnType<typeof provisioner.provisionChildSession>>>();
|
||||
|
||||
try {
|
||||
await manager.runRecursiveAgent({
|
||||
sessionId: session.id,
|
||||
depth: 0,
|
||||
run: async ({ intent, sessionId }) => {
|
||||
if (!intent) {
|
||||
return {
|
||||
type: "fanout" as const,
|
||||
intents: [
|
||||
{ persona: "coder", task: "child-a" },
|
||||
{ persona: "coder", task: "child-b" },
|
||||
],
|
||||
aggregate: ({ childResults }) => childResults.map((entry) => entry.output).join(","),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "complete" as const,
|
||||
output: sessionId,
|
||||
};
|
||||
},
|
||||
childMiddleware: {
|
||||
allocateForChild: async ({ childSessionId, childIndex, childCount }) => {
|
||||
const lease = await provisioner.provisionChildSession({
|
||||
parentSnapshot,
|
||||
childSessionId,
|
||||
childIndex,
|
||||
childCount,
|
||||
});
|
||||
childLeases.set(childSessionId, lease);
|
||||
childSnapshots.set(childSessionId, lease.toDiscoverySnapshot());
|
||||
},
|
||||
releaseForChild: async ({ childSessionId }) => {
|
||||
const lease = childLeases.get(childSessionId);
|
||||
if (!lease) {
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.release();
|
||||
childLeases.delete(childSessionId);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const childOneSnapshot = childSnapshots.get("recursive-isolation_child_1");
|
||||
const childTwoSnapshot = childSnapshots.get("recursive-isolation_child_2");
|
||||
assert.ok(childOneSnapshot);
|
||||
assert.ok(childTwoSnapshot);
|
||||
|
||||
const childOnePort = readPortRangeConstraint(childOneSnapshot);
|
||||
const childTwoPort = readPortRangeConstraint(childTwoSnapshot);
|
||||
assert.ok(childOnePort.endPort < childTwoPort.startPort);
|
||||
|
||||
const childOneWorktree = readGitConstraint(childOneSnapshot).worktreePath;
|
||||
const childTwoWorktree = readGitConstraint(childTwoSnapshot).worktreePath;
|
||||
assert.notEqual(childOneWorktree, childTwoWorktree);
|
||||
} finally {
|
||||
for (const lease of childLeases.values()) {
|
||||
await lease.release();
|
||||
}
|
||||
await parentResources.release();
|
||||
session.close();
|
||||
}
|
||||
});
|
||||
|
||||
function createTestGitWorktreeProvider(): ResourceProvider {
|
||||
return {
|
||||
kind: "git-worktree",
|
||||
provision: async ({ sessionId, workspaceRoot, options }) => {
|
||||
const rootDirectory =
|
||||
typeof options.rootDirectory === "string" ? options.rootDirectory : `${workspaceRoot}/.ai_ops/worktrees`;
|
||||
const baseRef = typeof options.baseRef === "string" ? options.baseRef : "HEAD";
|
||||
const worktreePath = `${rootDirectory}/${sessionId}`;
|
||||
|
||||
return {
|
||||
kind: "git-worktree",
|
||||
hard: {
|
||||
repoRoot: workspaceRoot,
|
||||
worktreeRoot: rootDirectory,
|
||||
worktreePath,
|
||||
baseRef,
|
||||
},
|
||||
soft: {
|
||||
preferredWorkingDirectory: worktreePath,
|
||||
},
|
||||
release: async () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTestPortRangeProvider(): ResourceProvider {
|
||||
return {
|
||||
kind: "port-range",
|
||||
provision: async ({ sessionId, options }) => {
|
||||
const basePort = readNumberOption(options, "basePort", 36000);
|
||||
const blockSize = readNumberOption(options, "blockSize", 32);
|
||||
const blockCount = readNumberOption(options, "blockCount", 1);
|
||||
const primaryOffset = readNumberOption(options, "primaryPortOffset", 0);
|
||||
const blockIndex = 0;
|
||||
const startPort = basePort + blockIndex * blockSize;
|
||||
const endPort = startPort + blockSize - 1;
|
||||
const primaryPort = startPort + primaryOffset;
|
||||
const lockDirectory = readStringOption(options, "lockDirectory", "/tmp");
|
||||
|
||||
return {
|
||||
kind: "port-range",
|
||||
hard: {
|
||||
basePort,
|
||||
blockSize,
|
||||
blockCount,
|
||||
blockIndex,
|
||||
startPort,
|
||||
endPort,
|
||||
primaryPort,
|
||||
lockPath: `${lockDirectory}/${startPort}-${endPort}-${sessionId}.lock`,
|
||||
},
|
||||
release: async () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readNumberOption(options: Record<string, unknown>, key: string, fallback: number): number {
|
||||
const value = options[key];
|
||||
if (typeof value === "number" && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readStringOption(options: Record<string, unknown>, key: string, fallback: string): string {
|
||||
const value = options[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function readHardConstraint(snapshot: DiscoverySnapshot, kind: string): Record<string, unknown> {
|
||||
const constraint = snapshot.hardConstraints.find((entry) => entry.kind === kind);
|
||||
assert.ok(constraint);
|
||||
return constraint.allocation as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readGitConstraint(snapshot: DiscoverySnapshot): {
|
||||
worktreePath: string;
|
||||
} {
|
||||
const allocation = readHardConstraint(snapshot, "git-worktree");
|
||||
const worktreePath = allocation.worktreePath;
|
||||
assert.equal(typeof worktreePath, "string");
|
||||
if (typeof worktreePath !== "string") {
|
||||
throw new Error("Expected git-worktree allocation to include worktreePath.");
|
||||
}
|
||||
return {
|
||||
worktreePath,
|
||||
};
|
||||
}
|
||||
|
||||
function readPortRangeConstraint(snapshot: DiscoverySnapshot): {
|
||||
startPort: number;
|
||||
endPort: number;
|
||||
} {
|
||||
const allocation = readHardConstraint(snapshot, "port-range");
|
||||
const startPort = allocation.startPort;
|
||||
const endPort = allocation.endPort;
|
||||
assert.equal(typeof startPort, "number");
|
||||
assert.equal(typeof endPort, "number");
|
||||
if (typeof startPort !== "number" || typeof endPort !== "number") {
|
||||
throw new Error("Expected port-range allocation to include numeric startPort/endPort.");
|
||||
}
|
||||
return {
|
||||
startPort,
|
||||
endPort,
|
||||
};
|
||||
}
|
||||
119
tests/manifest-schema.test.ts
Normal file
119
tests/manifest-schema.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseAgentManifest } from "../src/agents/manifest.js";
|
||||
|
||||
function validManifest(): unknown {
|
||||
return {
|
||||
schemaVersion: "1",
|
||||
topologies: ["hierarchical", "retry-unrolled", "sequential"],
|
||||
personas: [
|
||||
{
|
||||
id: "product",
|
||||
displayName: "Product",
|
||||
systemPromptTemplate: "Product for {{repo}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file"],
|
||||
banlist: ["delete_file"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "coder",
|
||||
displayName: "Coder",
|
||||
systemPromptTemplate: "Coder for {{repo}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file", "write_file"],
|
||||
banlist: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [
|
||||
{
|
||||
parentPersonaId: "product",
|
||||
childPersonaId: "coder",
|
||||
constraints: {
|
||||
maxDepth: 2,
|
||||
maxChildren: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
topologyConstraints: {
|
||||
maxDepth: 5,
|
||||
maxRetries: 2,
|
||||
},
|
||||
pipeline: {
|
||||
entryNodeId: "product-node",
|
||||
nodes: [
|
||||
{
|
||||
id: "product-node",
|
||||
actorId: "product_actor",
|
||||
personaId: "product",
|
||||
},
|
||||
{
|
||||
id: "coder-node",
|
||||
actorId: "coder_actor",
|
||||
personaId: "coder",
|
||||
constraints: {
|
||||
maxRetries: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
from: "product-node",
|
||||
to: "coder-node",
|
||||
on: "success",
|
||||
when: [{ type: "always" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("parses a valid AgentManifest", () => {
|
||||
const manifest = parseAgentManifest(validManifest());
|
||||
assert.equal(manifest.schemaVersion, "1");
|
||||
assert.equal(manifest.pipeline.nodes.length, 2);
|
||||
assert.equal(manifest.relationships.length, 1);
|
||||
});
|
||||
|
||||
test("rejects pipeline cycles", () => {
|
||||
const manifest = validManifest() as {
|
||||
pipeline: {
|
||||
edges: Array<{ from: string; to: string; on: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
manifest.pipeline.edges.push({
|
||||
from: "coder-node",
|
||||
to: "product-node",
|
||||
on: "success",
|
||||
});
|
||||
|
||||
assert.throws(() => parseAgentManifest(manifest), /strict DAG/);
|
||||
});
|
||||
|
||||
test("rejects relationship with unknown persona", () => {
|
||||
const manifest = validManifest() as {
|
||||
relationships: Array<{ parentPersonaId: string; childPersonaId: string }>;
|
||||
};
|
||||
|
||||
manifest.relationships.push({
|
||||
parentPersonaId: "product",
|
||||
childPersonaId: "unknown",
|
||||
});
|
||||
|
||||
assert.throws(() => parseAgentManifest(manifest), /unknown child persona/);
|
||||
});
|
||||
|
||||
test("rejects relationship cycles", () => {
|
||||
const manifest = validManifest() as {
|
||||
relationships: Array<{ parentPersonaId: string; childPersonaId: string }>;
|
||||
};
|
||||
|
||||
manifest.relationships.push({
|
||||
parentPersonaId: "coder",
|
||||
childPersonaId: "product",
|
||||
});
|
||||
|
||||
assert.throws(() => parseAgentManifest(manifest), /Relationship graph must be acyclic/);
|
||||
});
|
||||
49
tests/mcp-converters.test.ts
Normal file
49
tests/mcp-converters.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
inferTransport,
|
||||
toClaudeServerConfig,
|
||||
toCodexServerConfig,
|
||||
} from "../src/mcp/converters.js";
|
||||
|
||||
test("infers stdio transport when url is absent", () => {
|
||||
const transport = inferTransport({
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-memory"],
|
||||
});
|
||||
assert.equal(transport, "stdio");
|
||||
});
|
||||
|
||||
test("infers http transport when url is present", () => {
|
||||
const transport = inferTransport({
|
||||
url: "http://localhost:3000/mcp",
|
||||
});
|
||||
assert.equal(transport, "http");
|
||||
});
|
||||
|
||||
test("throws for stdio codex server without command", () => {
|
||||
assert.throws(
|
||||
() => toCodexServerConfig("bad-server", { type: "stdio" }),
|
||||
/requires "command" for stdio transport/,
|
||||
);
|
||||
});
|
||||
|
||||
test("maps shared headers to codex http_headers", () => {
|
||||
const codexConfig = toCodexServerConfig("headers-server", {
|
||||
url: "http://localhost:3000/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(codexConfig.http_headers, {
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws for claude http server without url", () => {
|
||||
assert.throws(
|
||||
() => toClaudeServerConfig("bad-http", { type: "http" }),
|
||||
/requires "url" for http transport/,
|
||||
);
|
||||
});
|
||||
248
tests/orchestration-engine.test.ts
Normal file
248
tests/orchestration-engine.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { SchemaDrivenExecutionEngine } from "../src/agents/orchestration.js";
|
||||
import type { ActorExecutionResult } from "../src/agents/pipeline.js";
|
||||
|
||||
function createManifest(): unknown {
|
||||
return {
|
||||
schemaVersion: "1",
|
||||
topologies: ["hierarchical", "retry-unrolled", "sequential"],
|
||||
personas: [
|
||||
{
|
||||
id: "product",
|
||||
displayName: "Product",
|
||||
systemPromptTemplate: "Product planning for {{repo}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file"],
|
||||
banlist: ["delete_file"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "task",
|
||||
displayName: "Task",
|
||||
systemPromptTemplate: "Task planning for {{repo}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file", "write_file"],
|
||||
banlist: ["git_reset"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "coder",
|
||||
displayName: "Coder",
|
||||
systemPromptTemplate: "Coder implements {{ticket}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file", "write_file"],
|
||||
banlist: ["rm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "qa",
|
||||
displayName: "QA",
|
||||
systemPromptTemplate: "QA validates {{ticket}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file"],
|
||||
banlist: ["write_file"],
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [
|
||||
{
|
||||
parentPersonaId: "product",
|
||||
childPersonaId: "task",
|
||||
constraints: {
|
||||
maxChildren: 2,
|
||||
maxDepth: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentPersonaId: "task",
|
||||
childPersonaId: "coder",
|
||||
constraints: {
|
||||
maxChildren: 3,
|
||||
maxDepth: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
topologyConstraints: {
|
||||
maxDepth: 6,
|
||||
maxRetries: 2,
|
||||
},
|
||||
pipeline: {
|
||||
entryNodeId: "project-gate",
|
||||
nodes: [
|
||||
{
|
||||
id: "project-gate",
|
||||
actorId: "project_gate",
|
||||
personaId: "product",
|
||||
},
|
||||
{
|
||||
id: "task-plan",
|
||||
actorId: "task_plan",
|
||||
personaId: "task",
|
||||
},
|
||||
{
|
||||
id: "coder-1",
|
||||
actorId: "coder",
|
||||
personaId: "coder",
|
||||
constraints: {
|
||||
maxRetries: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "qa-1",
|
||||
actorId: "qa",
|
||||
personaId: "qa",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
from: "project-gate",
|
||||
to: "task-plan",
|
||||
on: "success",
|
||||
when: [
|
||||
{
|
||||
type: "state_flag",
|
||||
key: "needs_bootstrap",
|
||||
equals: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
from: "project-gate",
|
||||
to: "coder-1",
|
||||
on: "success",
|
||||
when: [
|
||||
{
|
||||
type: "state_flag",
|
||||
key: "needs_bootstrap",
|
||||
equals: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
from: "task-plan",
|
||||
to: "coder-1",
|
||||
on: "success",
|
||||
},
|
||||
{
|
||||
from: "coder-1",
|
||||
to: "qa-1",
|
||||
on: "success",
|
||||
when: [
|
||||
{
|
||||
type: "history_has_event",
|
||||
event: "validation_fail",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("runs DAG pipeline with state-dependent routing and retry behavior", async () => {
|
||||
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
|
||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));
|
||||
|
||||
await writeFile(resolve(workspaceRoot, "PRD.md"), "# PRD\n", "utf8");
|
||||
|
||||
let coderAttempts = 0;
|
||||
|
||||
const engine = new SchemaDrivenExecutionEngine({
|
||||
manifest: createManifest(),
|
||||
settings: {
|
||||
workspaceRoot,
|
||||
stateRoot,
|
||||
runtimeContext: {
|
||||
repo: "ai_ops",
|
||||
ticket: "AIOPS-123",
|
||||
},
|
||||
maxChildren: 4,
|
||||
maxDepth: 8,
|
||||
maxRetries: 3,
|
||||
},
|
||||
actorExecutors: {
|
||||
project_gate: async () => ({
|
||||
status: "success",
|
||||
payload: {
|
||||
phase: "gate",
|
||||
},
|
||||
stateFlags: {
|
||||
needs_bootstrap: true,
|
||||
},
|
||||
}),
|
||||
task_plan: async (input) => {
|
||||
assert.match(input.prompt, /ai_ops/);
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
plan: "roadmap",
|
||||
},
|
||||
stateFlags: {
|
||||
roadmap_ready: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
coder: async (input): Promise<ActorExecutionResult> => {
|
||||
assert.match(input.prompt, /AIOPS-123/);
|
||||
assert.deepEqual(input.toolClearance.allowlist, ["read_file", "write_file"]);
|
||||
coderAttempts += 1;
|
||||
if (coderAttempts === 1) {
|
||||
return {
|
||||
status: "validation_fail",
|
||||
payload: {
|
||||
issue: "missing test",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
code: "done",
|
||||
},
|
||||
};
|
||||
},
|
||||
qa: async () => ({
|
||||
status: "success",
|
||||
payload: {
|
||||
qa: "ok",
|
||||
},
|
||||
}),
|
||||
},
|
||||
behaviorHandlers: {
|
||||
coder: {
|
||||
onValidationFail: () => ({
|
||||
lastValidationFailure: "coder-1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await engine.runSession({
|
||||
sessionId: "session-orchestration-1",
|
||||
initialPayload: {
|
||||
task: "Implement pipeline",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result.records.map((record) => `${record.nodeId}:${record.status}:${String(record.attempt)}`),
|
||||
[
|
||||
"project-gate:success:1",
|
||||
"task-plan:success:1",
|
||||
"coder-1:validation_fail:1",
|
||||
"coder-1:success:2",
|
||||
"qa-1:success:1",
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(result.finalState.flags.needs_bootstrap, true);
|
||||
assert.equal(result.finalState.flags.roadmap_ready, true);
|
||||
assert.equal(result.finalState.metadata.lastValidationFailure, "coder-1");
|
||||
|
||||
assert.deepEqual(engine.planChildPersonas({ parentPersonaId: "task", depth: 1 }), ["coder"]);
|
||||
});
|
||||
64
tests/resource-suballocation.test.ts
Normal file
64
tests/resource-suballocation.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildChildResourceRequests,
|
||||
type DiscoverySnapshot,
|
||||
} from "../src/agents/provisioning.js";
|
||||
|
||||
function parentSnapshot(): DiscoverySnapshot {
|
||||
return {
|
||||
sessionId: "parent-session",
|
||||
workspaceRoot: "/repo",
|
||||
workingDirectory: "/repo/.ai_ops/worktrees/parent",
|
||||
hardConstraints: [
|
||||
{
|
||||
kind: "git-worktree",
|
||||
allocation: {
|
||||
repoRoot: "/repo",
|
||||
worktreeRoot: "/repo/.ai_ops/worktrees",
|
||||
worktreePath: "/repo/.ai_ops/worktrees/parent",
|
||||
baseRef: "HEAD",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "port-range",
|
||||
allocation: {
|
||||
basePort: 36000,
|
||||
blockSize: 32,
|
||||
blockCount: 512,
|
||||
blockIndex: 2,
|
||||
startPort: 36064,
|
||||
endPort: 36095,
|
||||
primaryPort: 36064,
|
||||
lockPath: "/repo/.ai_ops/locks/ports/36064-36095.lock",
|
||||
},
|
||||
},
|
||||
],
|
||||
softConstraints: {
|
||||
env: {},
|
||||
promptSections: [],
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("builds deterministic child suballocation requests", () => {
|
||||
const requests = buildChildResourceRequests({
|
||||
parentSnapshot: parentSnapshot(),
|
||||
childSessionId: "child-1",
|
||||
childIndex: 1,
|
||||
childCount: 4,
|
||||
});
|
||||
|
||||
assert.equal(requests.length, 2);
|
||||
|
||||
const gitRequest = requests.find((entry) => entry.kind === "git-worktree");
|
||||
assert.ok(gitRequest);
|
||||
assert.equal(typeof gitRequest.options?.rootDirectory, "string");
|
||||
|
||||
const portRequest = requests.find((entry) => entry.kind === "port-range");
|
||||
assert.ok(portRequest);
|
||||
assert.equal(portRequest.options?.basePort, 36072);
|
||||
assert.equal(portRequest.options?.blockSize, 8);
|
||||
assert.equal(portRequest.options?.blockCount, 1);
|
||||
});
|
||||
28
tests/state-context.test.ts
Normal file
28
tests/state-context.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { FileSystemStateContextManager } from "../src/agents/state-context.js";
|
||||
|
||||
test("state/context manager builds fresh contexts from handoff storage", async () => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-state-"));
|
||||
const manager = new FileSystemStateContextManager({
|
||||
rootDirectory: root,
|
||||
});
|
||||
|
||||
await manager.initializeSession("session-1");
|
||||
await manager.writeHandoff("session-1", {
|
||||
nodeId: "coder",
|
||||
payload: {
|
||||
task: "build feature",
|
||||
attempt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const first = await manager.buildFreshNodeContext("session-1", "coder");
|
||||
first.handoff.payload.task = "mutated";
|
||||
|
||||
const second = await manager.buildFreshNodeContext("session-1", "coder");
|
||||
assert.equal(second.handoff.payload.task, "build feature");
|
||||
});
|
||||
Reference in New Issue
Block a user