pi-agent-fleet 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/examples/two-worker-fleet.json +21 -0
- package/package.json +52 -0
- package/src/contracts.ts +78 -0
- package/src/dag.ts +190 -0
- package/src/index.ts +600 -0
- package/src/placeholder.ts +1 -0
- package/src/prompts.ts +116 -0
- package/src/report.ts +81 -0
- package/src/runner.ts +95 -0
- package/src/scheduler.ts +226 -0
- package/src/state.ts +156 -0
- package/src/types.ts +109 -0
- package/src/ui.ts +30 -0
- package/src/viz.ts +36 -0
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { getDependents } from "./dag.js";
|
|
3
|
+
import type { FleetSpec, FleetState } from "./types.js";
|
|
4
|
+
import { renderDag } from "./viz.js";
|
|
5
|
+
|
|
6
|
+
export function buildWorkerPrompt(opts: {
|
|
7
|
+
spec: FleetSpec;
|
|
8
|
+
state: FleetState;
|
|
9
|
+
workerId: string;
|
|
10
|
+
fleetRoot: string;
|
|
11
|
+
}): string {
|
|
12
|
+
const { spec, state, workerId, fleetRoot } = opts;
|
|
13
|
+
const worker = spec.workers.find((w) => w.id === workerId);
|
|
14
|
+
if (!worker) throw new Error(`unknown worker "${workerId}"`);
|
|
15
|
+
const workerDir = `${fleetRoot}/workers/${workerId}`;
|
|
16
|
+
const deps = spec.workers.filter((w) => worker.depends_on.includes(w.id));
|
|
17
|
+
const dependents = getDependents(spec, workerId);
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
|
|
20
|
+
const fleetTs = basename(fleetRoot);
|
|
21
|
+
|
|
22
|
+
out.push(`# Fleet worker: ${workerId}`, "", `Type: ${worker.type}`, "", `## Task`, "", worker.task, "");
|
|
23
|
+
|
|
24
|
+
if (state.iteration > 1 && worker.iterate !== false) {
|
|
25
|
+
const last = state.iterations[state.iterations.length - 1];
|
|
26
|
+
if (last?.verdict_body) {
|
|
27
|
+
out.push(`## Reviewer feedback (iteration ${state.iteration - 1})`, "", last.verdict_body, "");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (worker.outputs.some((o) => o.kind === "verdict") && state.iterations.length > 0) {
|
|
32
|
+
const reviews = state.iterations.filter((s) => s.verdict !== null);
|
|
33
|
+
if (reviews.length > 0) {
|
|
34
|
+
out.push("## Previous reviews", "");
|
|
35
|
+
for (const s of reviews) {
|
|
36
|
+
out.push(`### Iteration ${s.n} — verdict: ${s.verdict}`);
|
|
37
|
+
if (s.verdict_body) out.push(s.verdict_body);
|
|
38
|
+
out.push("");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
out.push("## The fleet DAG", "", "```", renderDag(spec), "```", "");
|
|
44
|
+
for (const w of spec.workers) {
|
|
45
|
+
if (w.id !== workerId) out.push(`- ${w.id} (${w.type}): ${w.task}`);
|
|
46
|
+
}
|
|
47
|
+
out.push("");
|
|
48
|
+
|
|
49
|
+
out.push("## Your upstream inputs", "");
|
|
50
|
+
if (deps.length === 0) {
|
|
51
|
+
out.push("No upstream dependencies — you are a layer-0 node.", "");
|
|
52
|
+
} else {
|
|
53
|
+
for (const d of deps) {
|
|
54
|
+
if (d.worktree === true) {
|
|
55
|
+
out.push(`- ${d.id} worktree: ${fleetRoot}/worktrees/${d.id} (branch fleet/${fleetTs}/${d.id})`);
|
|
56
|
+
out.push(" — merge or cherry-pick from here if you need its repo changes");
|
|
57
|
+
}
|
|
58
|
+
if (d.outputs.length === 0) out.push(`- ${d.id}: (no declared outputs — read its session notes in ${fleetRoot}/workers/${d.id}/output/ if present)`);
|
|
59
|
+
for (const o of d.outputs) {
|
|
60
|
+
const abs = o.path.startsWith("output/")
|
|
61
|
+
? `${fleetRoot}/workers/${d.id}/${o.path}`
|
|
62
|
+
: o.path;
|
|
63
|
+
out.push(`- ${d.id}: ${abs} (${o.kind})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
out.push("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
out.push("## What downstream nodes need from you", "");
|
|
70
|
+
if (dependents.length === 0) {
|
|
71
|
+
out.push("No downstream nodes — your outputs terminate the DAG.", "");
|
|
72
|
+
} else {
|
|
73
|
+
for (const dep of dependents) out.push(`- ${dep} depends on your outputs`);
|
|
74
|
+
out.push("");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (worker.outputs.some((o) => o.kind === "verdict")) {
|
|
78
|
+
out.push(
|
|
79
|
+
"## Writing your verdict",
|
|
80
|
+
"",
|
|
81
|
+
"Your review file MUST start with a verdict line, exactly one of:",
|
|
82
|
+
"verdict: lgtm",
|
|
83
|
+
"verdict: iterate",
|
|
84
|
+
"verdict: escalate",
|
|
85
|
+
"Below the verdict line, write actionable fix instructions per worker — file path, function name, what to change. A verdict line with no body FAILS the contract. The builders see this body as feedback next iteration, so be specific.",
|
|
86
|
+
"",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
out.push("## Your output obligations", "");
|
|
91
|
+
if (worker.outputs.length === 0) {
|
|
92
|
+
out.push("No declared outputs — completion is enough.", "");
|
|
93
|
+
} else {
|
|
94
|
+
for (const o of worker.outputs) {
|
|
95
|
+
out.push(`- ${o.path} (${o.kind}${o.required ? ", REQUIRED" : ", optional"})`);
|
|
96
|
+
}
|
|
97
|
+
out.push("");
|
|
98
|
+
}
|
|
99
|
+
if (worker.outputs.some((o) => o.path.startsWith("output/"))) {
|
|
100
|
+
out.push(`Save ALL output files to ${workerDir}/output/ — use absolute paths.`, "");
|
|
101
|
+
}
|
|
102
|
+
if (worker.outputs.some((o) => !o.path.startsWith("output/"))) {
|
|
103
|
+
out.push("Write code changes directly at their repo paths.", "");
|
|
104
|
+
}
|
|
105
|
+
if (worker.worktree === true) {
|
|
106
|
+
out.push("## Your worktree", "");
|
|
107
|
+
out.push("Work inside your own git worktree, not the main checkout.");
|
|
108
|
+
out.push("If it does not exist yet, create it:");
|
|
109
|
+
out.push(` git worktree add ${fleetRoot}/worktrees/${workerId} -b fleet/${fleetTs}/${workerId}`);
|
|
110
|
+
out.push("If it already exists (iteration > 1), reuse it and your existing branch.");
|
|
111
|
+
out.push("Make ALL repo changes inside the worktree. Commit your work there.");
|
|
112
|
+
out.push("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return out.join("\n");
|
|
116
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import type { FleetSpec, FleetState } from "./types.js";
|
|
6
|
+
import { renderDag } from "./viz.js";
|
|
7
|
+
|
|
8
|
+
const execFileP = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
function formatDuration(ms: number): string {
|
|
11
|
+
if (ms <= 0) return "0.0s";
|
|
12
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function gitDiffStat(repoCwd: string, _sinceIso: string): Promise<string> {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execFileP("git", ["-C", repoCwd, "diff", "--stat", "HEAD"]);
|
|
18
|
+
const out = stdout.trim();
|
|
19
|
+
return out.length > 0 ? out : "(no changes)";
|
|
20
|
+
} catch {
|
|
21
|
+
return "(not a git repo)";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function writeReport(opts: {
|
|
26
|
+
spec: FleetSpec;
|
|
27
|
+
state: FleetState;
|
|
28
|
+
fleetRoot: string;
|
|
29
|
+
repoCwd: string;
|
|
30
|
+
}): Promise<string> {
|
|
31
|
+
const { spec, state, fleetRoot, repoCwd } = opts;
|
|
32
|
+
const experiment = (spec as unknown as { experiment?: string }).experiment;
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
lines.push(`# Fleet report: ${spec.fleet_name}`, "");
|
|
35
|
+
lines.push(`- status: **${state.status}**`);
|
|
36
|
+
lines.push(`- created: ${state.created_at}`);
|
|
37
|
+
lines.push(`- total cost estimate: $${state.cost_usd_estimate.toFixed(2)}`);
|
|
38
|
+
if (experiment) lines.push(`- experiment: ${experiment}`);
|
|
39
|
+
lines.push("", "## DAG", "", "```", renderDag(spec, state), "```", "");
|
|
40
|
+
lines.push("## Nodes", "", "| id | status | turns | tokens | cost | contract | outputs |",
|
|
41
|
+
"|---|---|---|---|---|---|---|");
|
|
42
|
+
for (const w of spec.workers) {
|
|
43
|
+
const n = state.nodes[w.id];
|
|
44
|
+
const contract = n.contract_result
|
|
45
|
+
? n.contract_result.ok ? "✓" : `✗ ${n.contract_result.checks.filter((c) => !c.ok).map((c) => c.path).join(", ")}`
|
|
46
|
+
: "—";
|
|
47
|
+
lines.push(`| ${w.id} | ${n.status} | ${n.turns} | ${n.tokens} | $${n.cost_usd_estimate.toFixed(2)} | ${contract} | ${n.produced_outputs.join(", ") || "—"} |`);
|
|
48
|
+
}
|
|
49
|
+
if (spec.config.loop) {
|
|
50
|
+
lines.push("", "## Iterations", "", "| n | verdict | tokens | cost | duration |",
|
|
51
|
+
"|---|---|---|---|---|");
|
|
52
|
+
for (const it of state.iterations) {
|
|
53
|
+
const tokens = Object.values(it.nodes).reduce((sum, n) => sum + n.tokens, 0);
|
|
54
|
+
const cost = Object.values(it.nodes).reduce((sum, n) => sum + n.cost_usd_estimate, 0);
|
|
55
|
+
const durationMs = new Date(it.ended_at).getTime() - new Date(it.started_at).getTime();
|
|
56
|
+
lines.push(`| ${it.n} | ${it.verdict ?? "—"} | ${tokens} | $${cost.toFixed(2)} | ${formatDuration(durationMs)} |`);
|
|
57
|
+
}
|
|
58
|
+
for (const it of state.iterations) {
|
|
59
|
+
lines.push("", `### Iteration ${it.n}`, "");
|
|
60
|
+
lines.push(`- verdict: ${it.verdict ?? "—"}`);
|
|
61
|
+
if (it.verdict_body) lines.push("", it.verdict_body);
|
|
62
|
+
for (const w of spec.workers) {
|
|
63
|
+
const n = it.nodes[w.id];
|
|
64
|
+
if (n) lines.push(`- ${w.id}: ${n.status} · ${n.turns} turns · ${n.tokens} tok`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (spec.workers.some((w) => w.worktree)) {
|
|
69
|
+
lines.push("", "## Worktree branches", "");
|
|
70
|
+
const base = basename(fleetRoot);
|
|
71
|
+
for (const w of spec.workers) {
|
|
72
|
+
if (w.worktree) lines.push(`- fleet/${base}/${w.id}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
lines.push("", "## Code changes", "", "```", await gitDiffStat(repoCwd, state.created_at), "```", "");
|
|
76
|
+
lines.push("## Artifacts", "", `- state: ${join(fleetRoot, "state.json")}`,
|
|
77
|
+
`- sessions: ${join(fleetRoot, "workers", "<id>", "session.jsonl")}`, "");
|
|
78
|
+
const md = lines.join("\n");
|
|
79
|
+
await writeFile(join(fleetRoot, "report.md"), md, "utf-8");
|
|
80
|
+
return md;
|
|
81
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { WorkerSpec } from "./types.js";
|
|
3
|
+
import { WORKER_TYPE_TOOLS } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type WorkerEvent =
|
|
6
|
+
| { type: "turn"; nodeId: string; turns: number }
|
|
7
|
+
| { type: "tokens"; nodeId: string; tokens: number }
|
|
8
|
+
| { type: "cost"; nodeId: string; cost: number }
|
|
9
|
+
| { type: "done"; nodeId: string }
|
|
10
|
+
| { type: "error"; nodeId: string; message: string };
|
|
11
|
+
|
|
12
|
+
export interface AgentSessionLike {
|
|
13
|
+
prompt(t: string): Promise<void>;
|
|
14
|
+
abort(): Promise<void>;
|
|
15
|
+
subscribe(l: (e: { type: string; message?: unknown }) => void): () => void;
|
|
16
|
+
dispose(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SessionOpts {
|
|
20
|
+
cwd: string;
|
|
21
|
+
sessionDir: string;
|
|
22
|
+
tools: string[];
|
|
23
|
+
model?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SessionFactory = (opts: SessionOpts) => Promise<AgentSessionLike>;
|
|
27
|
+
|
|
28
|
+
export const defaultSessionFactory: SessionFactory = async (opts) => {
|
|
29
|
+
const { session } = await createAgentSession({
|
|
30
|
+
cwd: opts.cwd,
|
|
31
|
+
tools: opts.tools,
|
|
32
|
+
sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
|
|
33
|
+
});
|
|
34
|
+
return session as unknown as AgentSessionLike;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface RunWorkerOpts {
|
|
38
|
+
nodeId: string;
|
|
39
|
+
worker: WorkerSpec;
|
|
40
|
+
prompt: string;
|
|
41
|
+
repoCwd: string;
|
|
42
|
+
sessionDir?: string;
|
|
43
|
+
onEvent: (e: WorkerEvent) => void;
|
|
44
|
+
sessionFactory?: SessionFactory;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RunWorkerResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
turns: number;
|
|
50
|
+
tokens: number;
|
|
51
|
+
cost: number;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
56
|
+
const factory = opts.sessionFactory ?? defaultSessionFactory;
|
|
57
|
+
const session = await factory({
|
|
58
|
+
cwd: opts.repoCwd,
|
|
59
|
+
sessionDir: opts.sessionDir ?? opts.repoCwd,
|
|
60
|
+
tools: WORKER_TYPE_TOOLS[opts.worker.type],
|
|
61
|
+
model: opts.worker.model,
|
|
62
|
+
});
|
|
63
|
+
let turns = 0;
|
|
64
|
+
let tokens = 0;
|
|
65
|
+
let cost = 0;
|
|
66
|
+
const unsub = session.subscribe((e) => {
|
|
67
|
+
if (e.type === "turn_end") {
|
|
68
|
+
turns++;
|
|
69
|
+
opts.onEvent({ type: "turn", nodeId: opts.nodeId, turns });
|
|
70
|
+
}
|
|
71
|
+
if (e.type === "message_end") {
|
|
72
|
+
const msg = e.message as { role?: string; usage?: { totalTokens?: number; cost?: { total?: number } } } | undefined;
|
|
73
|
+
if (msg?.role === "assistant" && msg.usage?.totalTokens) {
|
|
74
|
+
tokens += msg.usage.totalTokens;
|
|
75
|
+
opts.onEvent({ type: "tokens", nodeId: opts.nodeId, tokens });
|
|
76
|
+
}
|
|
77
|
+
if (msg?.role === "assistant" && msg.usage?.cost?.total) {
|
|
78
|
+
cost += msg.usage.cost.total;
|
|
79
|
+
opts.onEvent({ type: "cost", nodeId: opts.nodeId, cost });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
await session.prompt(opts.prompt);
|
|
85
|
+
opts.onEvent({ type: "done", nodeId: opts.nodeId });
|
|
86
|
+
return { ok: true, turns, tokens, cost };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
89
|
+
opts.onEvent({ type: "error", nodeId: opts.nodeId, message });
|
|
90
|
+
return { ok: false, turns, tokens, cost, error: message };
|
|
91
|
+
} finally {
|
|
92
|
+
unsub();
|
|
93
|
+
session.dispose();
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { verifyOutputs } from "./contracts.js";
|
|
4
|
+
import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
|
|
5
|
+
import { TERMINAL_NODE_STATUSES } from "./types.js";
|
|
6
|
+
import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export type SpawnFn = (nodeId: string) => Promise<{ ok: boolean; turns: number; tokens: number; cost?: number; error?: string }>;
|
|
9
|
+
|
|
10
|
+
export interface RunFleetOpts {
|
|
11
|
+
spec: FleetSpec;
|
|
12
|
+
fleetRoot: string;
|
|
13
|
+
repoCwd: string | ((nodeId: string) => string);
|
|
14
|
+
spawn: SpawnFn;
|
|
15
|
+
onNodeChange?: (nodeId: string, s: NodeState) => void;
|
|
16
|
+
killSwitch?: { killed: boolean };
|
|
17
|
+
pauseSwitch?: { paused: boolean };
|
|
18
|
+
resumeFrom?: FleetState;
|
|
19
|
+
continuePass?: boolean;
|
|
20
|
+
onIterationEnd?: (snap: IterationSnapshot) => void;
|
|
21
|
+
prepareIteration?: (n: number, state: FleetState) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const FAILED: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed", "blocked"]);
|
|
25
|
+
|
|
26
|
+
function allNodesTerminal(state: FleetState, spec: FleetSpec): boolean {
|
|
27
|
+
return spec.workers.every((w) => TERMINAL_NODE_STATUSES.has(state.nodes[w.id].status));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function cleanReplayOutputs(spec: FleetSpec, fleetRoot: string): Promise<void> {
|
|
31
|
+
for (const w of spec.workers) {
|
|
32
|
+
if (w.iterate === false) continue;
|
|
33
|
+
const outDir = join(fleetRoot, "workers", w.id, "output");
|
|
34
|
+
await rm(outDir, { recursive: true, force: true });
|
|
35
|
+
await mkdir(outDir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
40
|
+
const { spec, fleetRoot } = opts;
|
|
41
|
+
const loop = spec.config.loop;
|
|
42
|
+
|
|
43
|
+
let state: FleetState;
|
|
44
|
+
if (opts.resumeFrom) {
|
|
45
|
+
state = { ...opts.resumeFrom, paused: false, status: "running" };
|
|
46
|
+
await writeState(fleetRoot, state);
|
|
47
|
+
if (allNodesTerminal(state, spec) && !opts.continuePass) {
|
|
48
|
+
state = resetForIteration(state, spec);
|
|
49
|
+
await writeState(fleetRoot, state);
|
|
50
|
+
await cleanReplayOutputs(spec, fleetRoot);
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
state = initFleetState(spec);
|
|
54
|
+
state = { ...state, status: "running" };
|
|
55
|
+
await writeState(fleetRoot, state);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const patch = async (id: string, p: Partial<NodeState>) => {
|
|
59
|
+
state = patchNode(fleetRoot, state, id, p);
|
|
60
|
+
await writeState(fleetRoot, state);
|
|
61
|
+
opts.onNodeChange?.(id, state.nodes[id]);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const running = new Set<Promise<void>>();
|
|
65
|
+
|
|
66
|
+
const repoCwdFor = (nodeId: string): string =>
|
|
67
|
+
typeof opts.repoCwd === "function" ? opts.repoCwd(nodeId) : opts.repoCwd;
|
|
68
|
+
|
|
69
|
+
const runPass = async (): Promise<void> => {
|
|
70
|
+
while (true) {
|
|
71
|
+
// block nodes whose deps failed
|
|
72
|
+
for (const w of spec.workers) {
|
|
73
|
+
const n = state.nodes[w.id];
|
|
74
|
+
if (n.status !== "pending" && n.status !== "ready") continue;
|
|
75
|
+
if (w.depends_on.some((d) => FAILED.has(state.nodes[d].status))) {
|
|
76
|
+
await patch(w.id, { status: "blocked", ended_at: new Date().toISOString() });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (opts.killSwitch?.killed) {
|
|
80
|
+
for (const w of spec.workers) {
|
|
81
|
+
const n = state.nodes[w.id];
|
|
82
|
+
if (!TERMINAL_NODE_STATUSES.has(n.status)) {
|
|
83
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
// dispatch ready
|
|
89
|
+
const activeCount = running.size;
|
|
90
|
+
let slots = spec.config.max_concurrent - activeCount;
|
|
91
|
+
for (const w of spec.workers) {
|
|
92
|
+
if (slots <= 0) break;
|
|
93
|
+
const n = state.nodes[w.id];
|
|
94
|
+
if (n.status !== "pending" && n.status !== "ready") continue;
|
|
95
|
+
const depsDone = w.depends_on.every((d) => state.nodes[d].status === "completed");
|
|
96
|
+
if (!depsDone) continue;
|
|
97
|
+
slots--;
|
|
98
|
+
await patch(w.id, { status: "running", started_at: new Date().toISOString() });
|
|
99
|
+
const p = opts.spawn(w.id).then(async (res) => {
|
|
100
|
+
if (opts.killSwitch?.killed) return;
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const contract = await verifyOutputs({
|
|
106
|
+
workerDir: `${fleetRoot}/workers/${w.id}`,
|
|
107
|
+
repoCwd: repoCwdFor(w.id),
|
|
108
|
+
outputs: w.outputs,
|
|
109
|
+
});
|
|
110
|
+
await patch(w.id, {
|
|
111
|
+
status: contract.ok ? "completed" : "contract_failed",
|
|
112
|
+
ended_at: new Date().toISOString(),
|
|
113
|
+
turns: res.turns,
|
|
114
|
+
tokens: res.tokens,
|
|
115
|
+
cost_usd_estimate: res.cost ?? 0,
|
|
116
|
+
contract_result: contract,
|
|
117
|
+
produced_outputs: contract.checks.filter((c) => c.ok).map((c) => c.path),
|
|
118
|
+
});
|
|
119
|
+
}).finally(() => running.delete(p));
|
|
120
|
+
running.add(p);
|
|
121
|
+
}
|
|
122
|
+
if (running.size > 0) {
|
|
123
|
+
await Promise.race(running);
|
|
124
|
+
} else if (spec.workers.every((w) => TERMINAL_NODE_STATUSES.has(state.nodes[w.id].status))) {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await Promise.allSettled([...running]);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (!loop) {
|
|
132
|
+
await runPass();
|
|
133
|
+
const anyFailed = spec.workers.some((w) =>
|
|
134
|
+
["failed", "contract_failed"].includes(state.nodes[w.id].status));
|
|
135
|
+
const finalStatus = opts.killSwitch?.killed ? "killed" : anyFailed ? "failed" : "completed";
|
|
136
|
+
state = { ...state, status: finalStatus };
|
|
137
|
+
await writeState(fleetRoot, state);
|
|
138
|
+
return state;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const maxIterations = loop.max_iterations;
|
|
142
|
+
const reviewerId = loop.gate === "reviewer"
|
|
143
|
+
? spec.workers.find((w) => w.outputs.some((o) => o.kind === "verdict"))?.id
|
|
144
|
+
: undefined;
|
|
145
|
+
|
|
146
|
+
const initialIteration = state.iteration;
|
|
147
|
+
for (let n = state.iteration; n <= maxIterations; n++) {
|
|
148
|
+
if (opts.pauseSwitch?.paused || state.paused) {
|
|
149
|
+
state = { ...state, status: "paused", paused: true };
|
|
150
|
+
await writeState(fleetRoot, state);
|
|
151
|
+
return state;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (n > initialIteration) {
|
|
155
|
+
state = resetForIteration(state, spec);
|
|
156
|
+
await writeState(fleetRoot, state);
|
|
157
|
+
await cleanReplayOutputs(spec, fleetRoot);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await opts.prepareIteration?.(n, state);
|
|
161
|
+
|
|
162
|
+
await runPass();
|
|
163
|
+
|
|
164
|
+
let verdict: Verdict | null = null;
|
|
165
|
+
let verdictBody: string | null = null;
|
|
166
|
+
if (reviewerId) {
|
|
167
|
+
const cr = state.nodes[reviewerId].contract_result;
|
|
168
|
+
verdict = cr?.verdict ?? null;
|
|
169
|
+
verdictBody = cr?.verdict_body ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
state = snapshotIteration(state, verdict, verdictBody, spec);
|
|
173
|
+
const snap = state.iterations[state.iterations.length - 1];
|
|
174
|
+
opts.onIterationEnd?.(snap);
|
|
175
|
+
await archiveIteration(fleetRoot, state.iteration, spec.workers.map((w) => w.id));
|
|
176
|
+
|
|
177
|
+
if (opts.killSwitch?.killed) {
|
|
178
|
+
state = { ...state, status: "killed" };
|
|
179
|
+
await writeState(fleetRoot, state);
|
|
180
|
+
return state;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const anyFailed = spec.workers.some((w) =>
|
|
184
|
+
["failed", "contract_failed"].includes(state.nodes[w.id].status));
|
|
185
|
+
if (anyFailed) {
|
|
186
|
+
state = { ...state, status: "failed" };
|
|
187
|
+
await writeState(fleetRoot, state);
|
|
188
|
+
return state;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (loop.gate === "reviewer") {
|
|
192
|
+
const v = verdict;
|
|
193
|
+
if (v === "lgtm") {
|
|
194
|
+
const streak = state.lgtm_streak + 1;
|
|
195
|
+
if (streak >= loop.lgtm_count) {
|
|
196
|
+
state = { ...state, status: "completed", lgtm_streak: streak };
|
|
197
|
+
await writeState(fleetRoot, state);
|
|
198
|
+
return state;
|
|
199
|
+
}
|
|
200
|
+
state = { ...state, lgtm_streak: streak };
|
|
201
|
+
await writeState(fleetRoot, state);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (v === "iterate") {
|
|
205
|
+
state = { ...state, lgtm_streak: 0 };
|
|
206
|
+
await writeState(fleetRoot, state);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (v === "escalate") {
|
|
210
|
+
state = { ...state, status: "paused", paused: true };
|
|
211
|
+
await writeState(fleetRoot, state);
|
|
212
|
+
return state;
|
|
213
|
+
}
|
|
214
|
+
// Reviewer completed without a readable verdict; treat as fleet failure.
|
|
215
|
+
state = { ...state, status: "failed" };
|
|
216
|
+
await writeState(fleetRoot, state);
|
|
217
|
+
return state;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// gate: "none" — continue to next iteration until cap.
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
state = { ...state, status: "failed" };
|
|
224
|
+
await writeState(fleetRoot, state);
|
|
225
|
+
return state;
|
|
226
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { cp, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function initFleetState(spec: FleetSpec): FleetState {
|
|
6
|
+
const nodes: Record<string, NodeState> = {};
|
|
7
|
+
for (const w of spec.workers) {
|
|
8
|
+
nodes[w.id] = { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] };
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
fleet_name: spec.fleet_name,
|
|
12
|
+
status: "planned",
|
|
13
|
+
created_at: new Date().toISOString(),
|
|
14
|
+
cost_usd_estimate: 0,
|
|
15
|
+
nodes,
|
|
16
|
+
iteration: 1,
|
|
17
|
+
lgtm_streak: 0,
|
|
18
|
+
paused: false,
|
|
19
|
+
iterations: [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readState(fleetRoot: string): Promise<FleetState> {
|
|
24
|
+
const parsed = JSON.parse(await readFile(join(fleetRoot, "state.json"), "utf-8")) as Partial<FleetState>;
|
|
25
|
+
if (!parsed.fleet_name || !parsed.status || !parsed.created_at || !parsed.nodes) {
|
|
26
|
+
throw new Error("invalid state.json");
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...parsed as FleetState,
|
|
30
|
+
cost_usd_estimate: parsed.cost_usd_estimate ?? 0,
|
|
31
|
+
iteration: parsed.iteration ?? 1,
|
|
32
|
+
lgtm_streak: parsed.lgtm_streak ?? 0,
|
|
33
|
+
paused: parsed.paused ?? false,
|
|
34
|
+
iterations: parsed.iterations ?? [],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fleetCost(state: FleetState): number {
|
|
39
|
+
const liveCost = Object.values(state.nodes).reduce((sum, n) => sum + n.cost_usd_estimate, 0);
|
|
40
|
+
const archivedCost = state.iterations.reduce(
|
|
41
|
+
(sum, iter) => sum + Object.values(iter.nodes).reduce((s, n) => s + n.cost_usd_estimate, 0),
|
|
42
|
+
0,
|
|
43
|
+
);
|
|
44
|
+
return liveCost + archivedCost;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function writeState(fleetRoot: string, state: FleetState): Promise<void> {
|
|
48
|
+
const unique = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
49
|
+
const tmp = join(fleetRoot, `.state.json.${unique}.tmp`);
|
|
50
|
+
await writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
51
|
+
await rename(tmp, join(fleetRoot, "state.json"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function snapshotIteration(
|
|
55
|
+
state: FleetState,
|
|
56
|
+
verdict: Verdict | null,
|
|
57
|
+
verdictBody: string | null,
|
|
58
|
+
spec?: FleetSpec,
|
|
59
|
+
): FleetState {
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const startedAts = spec
|
|
62
|
+
? spec.workers
|
|
63
|
+
.filter((w) => w.iterate !== false)
|
|
64
|
+
.map((w) => state.nodes[w.id]?.started_at)
|
|
65
|
+
.filter((s): s is string => !!s)
|
|
66
|
+
.sort()
|
|
67
|
+
: Object.values(state.nodes)
|
|
68
|
+
.map((n) => n.started_at)
|
|
69
|
+
.filter((s): s is string => !!s)
|
|
70
|
+
.sort();
|
|
71
|
+
const snapshot: IterationSnapshot = {
|
|
72
|
+
n: state.iteration,
|
|
73
|
+
verdict,
|
|
74
|
+
verdict_body: verdictBody,
|
|
75
|
+
started_at: startedAts[0] ?? now,
|
|
76
|
+
ended_at: now,
|
|
77
|
+
nodes: structuredClone(state.nodes) as Record<string, NodeState>,
|
|
78
|
+
};
|
|
79
|
+
const zeroed: Record<string, NodeState> = {};
|
|
80
|
+
for (const [id, n] of Object.entries(state.nodes)) {
|
|
81
|
+
zeroed[id] = { ...n, cost_usd_estimate: 0 };
|
|
82
|
+
}
|
|
83
|
+
return { ...state, nodes: zeroed, iterations: [...state.iterations, snapshot] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resetForIteration(state: FleetState, spec: FleetSpec): FleetState {
|
|
87
|
+
const nodes = { ...state.nodes };
|
|
88
|
+
for (const w of spec.workers) {
|
|
89
|
+
if (w.iterate !== false) {
|
|
90
|
+
nodes[w.id] = { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const cost = fleetCost({ ...state, nodes });
|
|
94
|
+
return { ...state, iteration: state.iteration + 1, nodes, cost_usd_estimate: cost };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function archiveIteration(fleetRoot: string, n: number, nodeIds: string[]): Promise<void> {
|
|
98
|
+
for (const id of nodeIds) {
|
|
99
|
+
const workerDir = join(fleetRoot, "workers", id);
|
|
100
|
+
const iterDir = join(fleetRoot, "iterations", String(n), "workers", id);
|
|
101
|
+
await cp(join(workerDir, "output"), join(iterDir, "output"), { recursive: true });
|
|
102
|
+
try {
|
|
103
|
+
await cp(join(workerDir, "prompt.md"), join(iterDir, "..", `${id}-prompt.md`));
|
|
104
|
+
} catch (e: unknown) {
|
|
105
|
+
const err = e as { code?: string };
|
|
106
|
+
if (err.code !== "ENOENT") throw e;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function patchNode(
|
|
112
|
+
_fleetRoot: string,
|
|
113
|
+
state: FleetState,
|
|
114
|
+
nodeId: string,
|
|
115
|
+
patch: Partial<NodeState>,
|
|
116
|
+
): FleetState {
|
|
117
|
+
const node = state.nodes[nodeId];
|
|
118
|
+
if (!node) throw new Error(`unknown node "${nodeId}"`);
|
|
119
|
+
const nodes = { ...state.nodes, [nodeId]: { ...node, ...patch } };
|
|
120
|
+
const cost = fleetCost({ ...state, nodes });
|
|
121
|
+
return { ...state, nodes, cost_usd_estimate: cost };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resetForRelaunch(state: FleetState, spec: FleetSpec, nodeId: string): FleetState {
|
|
125
|
+
if (!state.nodes[nodeId]) throw new Error(`unknown node "${nodeId}"`);
|
|
126
|
+
|
|
127
|
+
const dependents: Record<string, string[]> = {};
|
|
128
|
+
for (const w of spec.workers) {
|
|
129
|
+
for (const dep of w.depends_on) {
|
|
130
|
+
(dependents[dep] ??= []).push(w.id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const downstream = new Set<string>();
|
|
135
|
+
const walk = (id: string) => {
|
|
136
|
+
for (const d of dependents[id] ?? []) {
|
|
137
|
+
if (!downstream.has(d)) {
|
|
138
|
+
downstream.add(d);
|
|
139
|
+
walk(d);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
walk(nodeId);
|
|
144
|
+
|
|
145
|
+
const fresh: NodeState = { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] };
|
|
146
|
+
const nodes = { ...state.nodes };
|
|
147
|
+
nodes[nodeId] = fresh;
|
|
148
|
+
for (const id of downstream) {
|
|
149
|
+
if (state.nodes[id].status === "blocked") {
|
|
150
|
+
nodes[id] = fresh;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const cost = fleetCost({ ...state, nodes });
|
|
155
|
+
return { ...state, nodes, paused: false, cost_usd_estimate: cost };
|
|
156
|
+
}
|