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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sagar Sarkale
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# pi-agent-fleet
|
|
2
|
+
|
|
3
|
+
DAG-of-agents fleets for [pi](https://github.com/earendil-works/pi-coding-agent). Define a fleet of worker agents as data, preview it, launch it, watch it run — with per-worker output contracts, reviewer-gated iteration loops, live cost tracking, and a machine-written report.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
● fleet: cost-relaunch-build · iteration 1/3 · last verdict: iterate · streak 0/1 (2/3 done · $1.42)
|
|
7
|
+
├─ ✓ builder-pricing (kimi-for-coding) · 32 turns · 945k tok · $0.83
|
|
8
|
+
├─ ⠹ builder-relaunch (kimi-for-coding) · 12 turns · 401k tok · editing src/scheduler.ts…
|
|
9
|
+
└─ ○ reviewer (k3) · waiting on builder-relaunch
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
One agent is a tool. A **fleet** is a workflow: researchers feed builders, builders feed reviewers, reviewers gate quality — all visible, all recorded, all contract-checked. Fleets run in-process via pi's SDK: any provider pi is logged into (Claude, Codex, Kimi, …) works per-node.
|
|
15
|
+
|
|
16
|
+
Philosophy: no auto-restart, no auto-kill, no babysitting. Failures surface; the operator decides. Contracts are checked at worker exit. The reviewer is the quality gate; you are the kill switch.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:pi-agent-fleet # from npm
|
|
22
|
+
pi install git:github.com/sagarsrc/pi-agent-fleet # from git
|
|
23
|
+
pi install /path/to/pi-agent-fleet # local
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
Ask your pi session (the LLM drives the tools):
|
|
29
|
+
|
|
30
|
+
> Plan and launch a fleet: two parallel writers and a combiner, all gpt-5.4-mini.
|
|
31
|
+
> writer-a writes output/a.md containing "# 123", writer-b writes output/b.md containing "# 456",
|
|
32
|
+
> combiner (depends on both) writes output/sum.md with the total.
|
|
33
|
+
> Each declares its output as a markdown contract.
|
|
34
|
+
|
|
35
|
+
The agent calls `fleet_plan` (validates + ASCII preview), you confirm, `fleet_launch` runs it. Watch the live widget; read the report at `.fleet/<name>-<ts>/report.md`.
|
|
36
|
+
|
|
37
|
+
## Fleet modes
|
|
38
|
+
|
|
39
|
+
**One-shot DAG** — static dependency graph, parallel layers, contracts at exit:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"fleet_name": "auth-research",
|
|
44
|
+
"type": "dag",
|
|
45
|
+
"config": { "max_concurrent": 4, "model": "gpt-5.4-mini" },
|
|
46
|
+
"workers": [
|
|
47
|
+
{ "id": "research", "type": "research", "task": "…",
|
|
48
|
+
"outputs": [{ "path": "output/findings.md", "kind": "markdown", "required": true }] },
|
|
49
|
+
{ "id": "build", "type": "code-run", "task": "…", "depends_on": ["research"],
|
|
50
|
+
"outputs": [{ "path": "src/auth/login.ts", "kind": "file-exists", "required": true }] }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Iterative fleet** — reviewer-gated replay until quality passes:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"config": {
|
|
60
|
+
"loop": { "gate": "reviewer", "max_iterations": 5, "lgtm_count": 2 }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A reviewer node writes a verdict contract (`verdict: lgtm | iterate | escalate` + actionable body). `iterate` → builders get the review injected as feedback next iteration. `lgtm` × `lgtm_count` consecutive → completed. `escalate` → fleet pauses and notifies you. `gate: "none"` → free-running loop (autoresearch pattern: eval instructions live in the worker's task text).
|
|
66
|
+
|
|
67
|
+
**Worktree mode** — `worktree: true` on a node: the worker is directed to create its own git worktree at a designated path/branch. Downstream nodes see upstream worktree paths/branches in their prompts. The extension runs zero git commands; conflicts are resolved by resolver nodes you add to the DAG.
|
|
68
|
+
|
|
69
|
+
**Run-once vs replay nodes** — `iterate: false`: node runs at iteration 1 only, outputs carry over (e.g. research that doesn't change).
|
|
70
|
+
|
|
71
|
+
## Contracts
|
|
72
|
+
|
|
73
|
+
Every worker declares `outputs[]` with kinds, verified in code at worker exit before dependents are released:
|
|
74
|
+
|
|
75
|
+
| kind | check |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `markdown` | exists, non-empty, starts with `#` |
|
|
78
|
+
| `file-exists` | exists, non-empty (repo-relative paths = code edits) |
|
|
79
|
+
| `verdict` | `verdict: lgtm\|iterate\|escalate` line + non-empty body |
|
|
80
|
+
| `json` / `yaml` | parses |
|
|
81
|
+
|
|
82
|
+
Failed required contract → `contract_failed`, dependents blocked, orchestrator notified. No silent passes.
|
|
83
|
+
|
|
84
|
+
## Tools & commands
|
|
85
|
+
|
|
86
|
+
| tool | purpose |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `fleet_plan` | validate + preview a fleet definition (no launch) |
|
|
89
|
+
| `fleet_launch` | launch (confirm gate, `skip_confirm` for unattended) |
|
|
90
|
+
| `fleet_status` | live DAG status |
|
|
91
|
+
| `fleet_pause` / `fleet_resume` | pause/resume loop fleets at iteration boundary |
|
|
92
|
+
| `fleet_relaunch` | re-run a failed node (+ blocked downstream), optional model override |
|
|
93
|
+
| `fleet_kill` | fleet-wide kill |
|
|
94
|
+
| `fleet_report` | regenerate report.md |
|
|
95
|
+
|
|
96
|
+
`/fleet viz | status | clear | pause | resume | relaunch <id> [model] | kill all`
|
|
97
|
+
|
|
98
|
+
## Records
|
|
99
|
+
|
|
100
|
+
Everything lands in `.fleet/<name>-<ts>/` (git-ignored): `state.json` (single source of truth, atomic writes), per-worker `prompt.md` + `session.jsonl` + outputs, per-iteration archives, and a machine-written `report.md` with per-worker turns/tokens/cost, contract results, verdict history, and git diff stats.
|
|
101
|
+
|
|
102
|
+
## Model selection
|
|
103
|
+
|
|
104
|
+
Fleet-wide default via `config.model`; per-node override via `worker.model`. Any model pi can resolve — including any provider pi is logged into. Cheap models for trivial writers, strong models for reviewers:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{ "id": "reviewer", "type": "reviewer", "model": "kimi-coding/k3", … }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm install
|
|
114
|
+
npm test # 111 tests, zero-API (fake session factory)
|
|
115
|
+
npm run typecheck
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Design docs live in `docs/superpowers/specs/`; experiment history in `docs/experiments/`.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"fleet_name": "smoke",
|
|
3
|
+
"type": "dag",
|
|
4
|
+
"config": { "max_concurrent": 2, "model": "gpt-5.4-mini" },
|
|
5
|
+
"workers": [
|
|
6
|
+
{
|
|
7
|
+
"id": "research",
|
|
8
|
+
"type": "write",
|
|
9
|
+
"task": "Write a markdown file with a heading and exactly 3 bullet points about good CLI UX. Under 60 words total.",
|
|
10
|
+
"depends_on": [],
|
|
11
|
+
"outputs": [{ "path": "output/findings.md", "kind": "markdown", "required": true }]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "summarize",
|
|
15
|
+
"type": "write",
|
|
16
|
+
"task": "Read the upstream findings file and write a markdown file with a heading and one sentence summary.",
|
|
17
|
+
"depends_on": ["research"],
|
|
18
|
+
"outputs": [{ "path": "output/summary.md", "kind": "markdown", "required": true }]
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-agent-fleet",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DAG-of-agents fleets for pi \u2014 parallel workers with contracts, reviewer-gated iteration loops, and live progress",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "vitest run",
|
|
8
|
+
"test:watch": "vitest",
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
13
|
+
"@earendil-works/pi-ai": "*",
|
|
14
|
+
"typebox": "*"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
18
|
+
"@earendil-works/pi-ai": "^0.80.0",
|
|
19
|
+
"typescript": "^5.5.0",
|
|
20
|
+
"vitest": "^2.1.0",
|
|
21
|
+
"@types/node": "^22.0.0"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./src/index.ts"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Sagar Sarkale",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/sagarsrc/pi-agent-fleet.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": "https://github.com/sagarsrc/pi-agent-fleet/issues",
|
|
35
|
+
"homepage": "https://github.com/sagarsrc/pi-agent-fleet#readme",
|
|
36
|
+
"keywords": [
|
|
37
|
+
"pi-package",
|
|
38
|
+
"pi-extension",
|
|
39
|
+
"pi-coding-agent",
|
|
40
|
+
"agents",
|
|
41
|
+
"multi-agent",
|
|
42
|
+
"dag",
|
|
43
|
+
"fleet",
|
|
44
|
+
"orchestration"
|
|
45
|
+
],
|
|
46
|
+
"files": [
|
|
47
|
+
"src/",
|
|
48
|
+
"examples/",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
]
|
|
52
|
+
}
|
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import type { ContractCheck, ContractOutput, ContractResult, Verdict } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export const VERDICT_RE = /^verdict:\s*(lgtm|iterate|escalate)\s*$/mi;
|
|
6
|
+
|
|
7
|
+
function resolvePath(workerDir: string, repoCwd: string, p: string): string {
|
|
8
|
+
if (isAbsolute(p)) return p;
|
|
9
|
+
return p.startsWith("output/") ? join(workerDir, p) : join(repoCwd, p);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput): Promise<ContractCheck> {
|
|
13
|
+
const full = resolvePath(workerDir, repoCwd, o.path);
|
|
14
|
+
const fail = (error: string): ContractCheck => ({ path: o.path, kind: o.kind, required: o.required, ok: false, error });
|
|
15
|
+
let content: string;
|
|
16
|
+
try {
|
|
17
|
+
const s = await stat(full);
|
|
18
|
+
if (o.kind === "file-exists") {
|
|
19
|
+
return s.size > 0
|
|
20
|
+
? { path: o.path, kind: o.kind, required: o.required, ok: true }
|
|
21
|
+
: fail("empty file");
|
|
22
|
+
}
|
|
23
|
+
content = await readFile(full, "utf-8");
|
|
24
|
+
} catch {
|
|
25
|
+
return fail("file not found");
|
|
26
|
+
}
|
|
27
|
+
switch (o.kind) {
|
|
28
|
+
case "markdown": {
|
|
29
|
+
const first = content.split("\n").find((l) => l.trim().length > 0) ?? "";
|
|
30
|
+
return first.startsWith("#")
|
|
31
|
+
? { path: o.path, kind: o.kind, required: o.required, ok: true }
|
|
32
|
+
: fail("no markdown heading");
|
|
33
|
+
}
|
|
34
|
+
case "verdict": {
|
|
35
|
+
const m = content.match(VERDICT_RE);
|
|
36
|
+
if (!m) return fail("no verdict line");
|
|
37
|
+
const body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
|
|
38
|
+
return body.length > 0
|
|
39
|
+
? { path: o.path, kind: o.kind, required: o.required, ok: true }
|
|
40
|
+
: fail("verdict line without body");
|
|
41
|
+
}
|
|
42
|
+
case "json":
|
|
43
|
+
try {
|
|
44
|
+
JSON.parse(content);
|
|
45
|
+
return { path: o.path, kind: o.kind, required: o.required, ok: true };
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return fail(`json parse error: ${(e as Error).message}`);
|
|
48
|
+
}
|
|
49
|
+
case "yaml": {
|
|
50
|
+
const bad = content.split("\n").some((l) => l.includes("\t"));
|
|
51
|
+
return content.trim().length > 0 && !bad
|
|
52
|
+
? { path: o.path, kind: o.kind, required: o.required, ok: true }
|
|
53
|
+
: fail("empty or invalid yaml");
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
return fail(`unknown kind`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function verifyOutputs(opts: {
|
|
61
|
+
workerDir: string;
|
|
62
|
+
repoCwd: string;
|
|
63
|
+
outputs: ContractOutput[];
|
|
64
|
+
}): Promise<ContractResult> {
|
|
65
|
+
const checks = await Promise.all(opts.outputs.map((o) => checkOne(opts.workerDir, opts.repoCwd, o)));
|
|
66
|
+
const ok = checks.every((c) => !c.required || c.ok);
|
|
67
|
+
|
|
68
|
+
const verdictCheck = checks.find((c) => c.kind === "verdict" && c.ok);
|
|
69
|
+
if (!verdictCheck) return { ok, checks };
|
|
70
|
+
|
|
71
|
+
const content = await readFile(resolvePath(opts.workerDir, opts.repoCwd, verdictCheck.path), "utf-8");
|
|
72
|
+
const m = content.match(VERDICT_RE);
|
|
73
|
+
if (!m) return { ok, checks };
|
|
74
|
+
|
|
75
|
+
const verdict = m[1].toLowerCase() as Verdict;
|
|
76
|
+
const verdict_body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
|
|
77
|
+
return verdict_body.length > 0 ? { ok, checks, verdict, verdict_body } : { ok, checks };
|
|
78
|
+
}
|
package/src/dag.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { FleetSpec, GateKind, LoopConfig, OutputKind, WorkerSpec, WorkerType } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class CycleError extends Error {
|
|
4
|
+
constructor(public remaining: string[]) {
|
|
5
|
+
super(`CYCLE:${remaining.join(",")}`);
|
|
6
|
+
this.name = "CycleError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const WORKER_TYPES: WorkerType[] = ["research", "code-run", "reviewer", "write", "read-only"];
|
|
11
|
+
const KINDS: OutputKind[] = ["markdown", "file-exists", "verdict", "json", "yaml"];
|
|
12
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
13
|
+
|
|
14
|
+
export function topoLayers(spec: FleetSpec): string[][] {
|
|
15
|
+
const ids = spec.workers.map((w) => w.id);
|
|
16
|
+
const indeg = new Map<string, number>(ids.map((id) => [id, 0]));
|
|
17
|
+
const rev = new Map<string, string[]>(ids.map((id) => [id, []]));
|
|
18
|
+
for (const w of spec.workers) {
|
|
19
|
+
for (const d of w.depends_on) {
|
|
20
|
+
indeg.set(w.id, (indeg.get(w.id) ?? 0) + 1);
|
|
21
|
+
rev.get(d)?.push(w.id);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const layers: string[][] = [];
|
|
25
|
+
let current = ids.filter((id) => indeg.get(id) === 0);
|
|
26
|
+
let placed = 0;
|
|
27
|
+
while (current.length > 0) {
|
|
28
|
+
layers.push(current);
|
|
29
|
+
placed += current.length;
|
|
30
|
+
const next: string[] = [];
|
|
31
|
+
for (const n of current) {
|
|
32
|
+
for (const m of rev.get(n) ?? []) {
|
|
33
|
+
const v = (indeg.get(m) ?? 0) - 1;
|
|
34
|
+
indeg.set(m, v);
|
|
35
|
+
if (v === 0) next.push(m);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
current = next;
|
|
39
|
+
}
|
|
40
|
+
if (placed !== ids.length) {
|
|
41
|
+
throw new CycleError(ids.filter((id) => !layers.flat().includes(id)));
|
|
42
|
+
}
|
|
43
|
+
return layers;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getDependents(spec: FleetSpec, nodeId: string): string[] {
|
|
47
|
+
return spec.workers.filter((w) => w.depends_on.includes(nodeId)).map((w) => w.id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validateFleetSpec(
|
|
51
|
+
raw: unknown,
|
|
52
|
+
): { ok: true; spec: FleetSpec; layers: string[][] } | { ok: false; errors: string[] } {
|
|
53
|
+
const errors: string[] = [];
|
|
54
|
+
const r = raw as Record<string, unknown>;
|
|
55
|
+
if (typeof r?.fleet_name !== "string" || !ID_RE.test(r.fleet_name)) {
|
|
56
|
+
errors.push("fleet_name must be non-empty kebab-case");
|
|
57
|
+
}
|
|
58
|
+
if (r?.type !== "dag") errors.push('type must be "dag"');
|
|
59
|
+
const cfg = (r?.config ?? {}) as Record<string, unknown>;
|
|
60
|
+
const maxConcurrent = typeof cfg.max_concurrent === "number" ? cfg.max_concurrent : 4;
|
|
61
|
+
if (maxConcurrent < 1) errors.push("config.max_concurrent must be >= 1");
|
|
62
|
+
const model = typeof cfg.model === "string" ? cfg.model : "gpt-5.4";
|
|
63
|
+
const warnCost = typeof cfg.warn_cost_usd === "number" ? cfg.warn_cost_usd : undefined;
|
|
64
|
+
|
|
65
|
+
const rawWorkers = Array.isArray(r?.workers) ? (r.workers as Record<string, unknown>[]) : [];
|
|
66
|
+
if (rawWorkers.length === 0) errors.push("at least one worker required");
|
|
67
|
+
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const workers: WorkerSpec[] = [];
|
|
70
|
+
for (const w of rawWorkers) {
|
|
71
|
+
const id = typeof w.id === "string" ? w.id : "";
|
|
72
|
+
if (!ID_RE.test(id)) errors.push(`worker id "${id}" must match ${ID_RE}`);
|
|
73
|
+
if (seen.has(id)) errors.push(`duplicate worker id "${id}"`);
|
|
74
|
+
seen.add(id);
|
|
75
|
+
const type = w.type as WorkerType;
|
|
76
|
+
if (!WORKER_TYPES.includes(type)) errors.push(`worker "${id}": bad type "${String(w.type)}"`);
|
|
77
|
+
if (typeof w.task !== "string" || w.task.length === 0) errors.push(`worker "${id}": task required`);
|
|
78
|
+
const outputs = (Array.isArray(w.outputs) ? w.outputs : []) as Record<string, unknown>[];
|
|
79
|
+
for (const o of outputs) {
|
|
80
|
+
if (!KINDS.includes(o.kind as OutputKind)) errors.push(`worker "${id}": bad output kind "${String(o.kind)}"`);
|
|
81
|
+
if (typeof o.path !== "string" || o.path.length === 0) errors.push(`worker "${id}": output path required`);
|
|
82
|
+
if (typeof o.path === "string" && o.path.length > 0) {
|
|
83
|
+
const segments = o.path.split(/[/\\]/);
|
|
84
|
+
if (o.path.startsWith("/") || /^[A-Za-z]:/.test(o.path) || segments.includes("..")) {
|
|
85
|
+
errors.push(`worker "${id}": output path must be relative and stay within the repo`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
workers.push({
|
|
90
|
+
id,
|
|
91
|
+
type,
|
|
92
|
+
task: String(w.task ?? ""),
|
|
93
|
+
model: typeof w.model === "string" ? w.model : undefined,
|
|
94
|
+
depends_on: Array.isArray(w.depends_on) ? (w.depends_on as string[]) : [],
|
|
95
|
+
outputs: outputs.map((o) => ({ path: String(o.path), kind: o.kind as OutputKind, required: o.required !== false })),
|
|
96
|
+
iterate: w.iterate !== false,
|
|
97
|
+
worktree: w.worktree === true,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const idSet = new Set(workers.map((w) => w.id));
|
|
101
|
+
for (const w of workers) {
|
|
102
|
+
for (const d of w.depends_on) {
|
|
103
|
+
if (!idSet.has(d)) errors.push(`worker "${w.id}": unknown dependency "${d}"`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let loopConfig: LoopConfig | undefined;
|
|
108
|
+
if (cfg.loop !== undefined && cfg.loop !== null) {
|
|
109
|
+
if (typeof cfg.loop !== "object" || Array.isArray(cfg.loop)) {
|
|
110
|
+
errors.push("config.loop must be an object");
|
|
111
|
+
} else {
|
|
112
|
+
const loop = cfg.loop as Record<string, unknown>;
|
|
113
|
+
const maxIterations = loop.max_iterations;
|
|
114
|
+
if (typeof maxIterations !== "number" || !Number.isInteger(maxIterations) || maxIterations < 1) {
|
|
115
|
+
errors.push("loop.max_iterations must be an integer >= 1");
|
|
116
|
+
}
|
|
117
|
+
const gate = loop.gate;
|
|
118
|
+
if (gate !== "reviewer" && gate !== "none") {
|
|
119
|
+
errors.push('loop.gate must be "reviewer" or "none"');
|
|
120
|
+
}
|
|
121
|
+
const lgtmCount = loop.lgtm_count;
|
|
122
|
+
const lgtmCountProvided = lgtmCount !== undefined;
|
|
123
|
+
if (lgtmCountProvided) {
|
|
124
|
+
if (typeof lgtmCount !== "number" || !Number.isInteger(lgtmCount) || lgtmCount < 1) {
|
|
125
|
+
errors.push("loop.lgtm_count must be an integer >= 1");
|
|
126
|
+
} else if (gate === "none") {
|
|
127
|
+
errors.push("loop.lgtm_count is not allowed with gate none");
|
|
128
|
+
} else if (typeof maxIterations === "number" && Number.isInteger(maxIterations) && maxIterations >= 1 && lgtmCount > maxIterations) {
|
|
129
|
+
errors.push("loop.lgtm_count must be <= max_iterations");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (gate === "reviewer" && typeof maxIterations === "number" && Number.isInteger(maxIterations) && maxIterations >= 1) {
|
|
133
|
+
const verdictNodes = workers.filter((w) => w.outputs.some((o) => o.kind === "verdict"));
|
|
134
|
+
if (verdictNodes.length !== 1) {
|
|
135
|
+
errors.push(`gate reviewer requires exactly one verdict-output node, found ${verdictNodes.length}`);
|
|
136
|
+
} else {
|
|
137
|
+
const verdictNode = verdictNodes[0];
|
|
138
|
+
if (verdictNode.iterate === false) {
|
|
139
|
+
errors.push(`verdict node "${verdictNode.id}" must have iterate enabled`);
|
|
140
|
+
} else {
|
|
141
|
+
const dependents = workers.filter((w) => w.depends_on.includes(verdictNode.id));
|
|
142
|
+
if (dependents.length > 0) {
|
|
143
|
+
errors.push(`verdict node "${verdictNode.id}" must be a sink, but is depended on by ${dependents.map((w) => w.id).join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (gate === "none" && workers.every((w) => w.iterate === false)) {
|
|
149
|
+
errors.push("gate none requires at least one replay node (iterate: false on all nodes makes the loop a no-op)");
|
|
150
|
+
}
|
|
151
|
+
if (errors.length === 0) {
|
|
152
|
+
loopConfig = {
|
|
153
|
+
gate: gate as GateKind,
|
|
154
|
+
max_iterations: maxIterations as number,
|
|
155
|
+
lgtm_count: lgtmCountProvided ? (lgtmCount as number) : 1,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const byId = new Map(workers.map((w) => [w.id, w]));
|
|
160
|
+
for (const w of workers) {
|
|
161
|
+
if (w.iterate !== false) continue;
|
|
162
|
+
for (const d of w.depends_on) {
|
|
163
|
+
const dep = byId.get(d);
|
|
164
|
+
if (dep && dep.iterate !== false) {
|
|
165
|
+
errors.push(`run-once node "${w.id}" depends on replay node "${d}"`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const spec: FleetSpec = {
|
|
173
|
+
fleet_name: String(r?.fleet_name ?? ""),
|
|
174
|
+
type: "dag",
|
|
175
|
+
config: { max_concurrent: maxConcurrent, model, warn_cost_usd: warnCost, loop: loopConfig },
|
|
176
|
+
workers,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
let layers: string[][] = [];
|
|
180
|
+
if (errors.length === 0) {
|
|
181
|
+
try {
|
|
182
|
+
layers = topoLayers(spec);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (e instanceof CycleError) errors.push(e.message);
|
|
185
|
+
else throw e;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
189
|
+
return { ok: true, spec, layers };
|
|
190
|
+
}
|