pi-agent-fleet 0.4.0 → 0.6.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/README.md +23 -8
- package/examples/json-number-pipeline.json +35 -0
- package/package.json +2 -2
- package/src/canvas-client.tsx +184 -66
- package/src/canvas-layout.ts +83 -0
- package/src/canvas.ts +178 -86
- package/src/command.ts +56 -42
- package/src/contracts.ts +76 -20
- package/src/controller.ts +72 -7
- package/src/dag.ts +32 -7
- package/src/edits.ts +7 -8
- package/src/fleet-recovery.ts +83 -0
- package/src/fleet-store.ts +11 -2
- package/src/insert.ts +2 -8
- package/src/model-resolution.ts +27 -2
- package/src/preferences.ts +10 -1
- package/src/prompts.ts +21 -0
- package/src/report.ts +28 -1
- package/src/scheduler.ts +51 -5
- package/src/state.ts +17 -17
- package/src/tools.ts +70 -35
- package/src/types.ts +8 -0
- package/src/worktree.ts +6 -0
package/src/contracts.ts
CHANGED
|
@@ -4,53 +4,94 @@ import type { ContractCheck, ContractOutput, ContractResult, Verdict } from "./t
|
|
|
4
4
|
|
|
5
5
|
export const VERDICT_RE = /^verdict:\s*(lgtm|iterate|escalate)\s*$/mi;
|
|
6
6
|
|
|
7
|
+
// Leading metadata lines that a markdown file may place before its first heading.
|
|
8
|
+
const MARKDOWN_METADATA_RE = /^(status|verdict):\s*(approved|needs-revision|escalate|lgtm|iterate)\s*$/i;
|
|
9
|
+
|
|
7
10
|
function resolvePath(workerDir: string, repoCwd: string, p: string): string {
|
|
8
11
|
if (isAbsolute(p)) return p;
|
|
9
12
|
return p.startsWith("output/") ? join(workerDir, p) : join(repoCwd, p);
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
function firstLines(content: string, n = 5): string {
|
|
16
|
+
return content
|
|
17
|
+
.split("\n")
|
|
18
|
+
.slice(0, n)
|
|
19
|
+
.map((l) => l.trimEnd())
|
|
20
|
+
.join(" / ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput, notBeforeMs?: number): Promise<ContractCheck> {
|
|
13
24
|
const full = resolvePath(workerDir, repoCwd, o.path);
|
|
14
|
-
const
|
|
25
|
+
const base: Omit<ContractCheck, "ok" | "error"> = { path: o.path, kind: o.kind, required: o.required, actualPath: full };
|
|
26
|
+
const fail = (error: string): ContractCheck => ({ ...base, ok: false, error });
|
|
15
27
|
let content: string;
|
|
16
28
|
try {
|
|
17
29
|
const s = await stat(full);
|
|
18
30
|
if (o.kind === "file-exists") {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
if (s.size === 0) return fail("empty file");
|
|
32
|
+
const repoRelative = !isAbsolute(o.path) && !o.path.startsWith("output/");
|
|
33
|
+
if (repoRelative && notBeforeMs !== undefined && s.mtimeMs < notBeforeMs) {
|
|
34
|
+
return fail("pre-existing repo file not modified since worker start");
|
|
35
|
+
}
|
|
36
|
+
return { ...base, ok: true };
|
|
22
37
|
}
|
|
23
38
|
content = await readFile(full, "utf-8");
|
|
24
39
|
} catch {
|
|
25
|
-
return
|
|
40
|
+
return { path: o.path, kind: o.kind, required: o.required, ok: false, error: "file not found" };
|
|
26
41
|
}
|
|
42
|
+
|
|
27
43
|
switch (o.kind) {
|
|
28
44
|
case "markdown": {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
const meaningful = content.split("\n").filter((l) => l.trim().length > 0);
|
|
46
|
+
const first10 = meaningful.slice(0, 10);
|
|
47
|
+
const heading = first10.find((l) => l.trim().startsWith("#"));
|
|
48
|
+
if (heading) {
|
|
49
|
+
return { ...base, ok: true };
|
|
50
|
+
}
|
|
51
|
+
// Check whether the only blocker was a leading metadata line like Status: ...
|
|
52
|
+
const metadataFirst = first10[0]?.match(MARKDOWN_METADATA_RE);
|
|
53
|
+
return { ...fail(metadataFirst ? "no markdown heading after leading metadata line" : "no markdown heading"), firstLines: firstLines(content) };
|
|
33
54
|
}
|
|
34
55
|
case "verdict": {
|
|
35
56
|
const m = content.match(VERDICT_RE);
|
|
36
|
-
if (!m) return
|
|
57
|
+
if (!m) return { ...base, ok: false, error: "no verdict line", firstLines: firstLines(content) };
|
|
37
58
|
const body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
|
|
38
59
|
return body.length > 0
|
|
39
|
-
? {
|
|
40
|
-
:
|
|
60
|
+
? { ...base, ok: true }
|
|
61
|
+
: { ...base, ok: false, error: "verdict line without body", firstLines: firstLines(content) };
|
|
41
62
|
}
|
|
42
|
-
case "json":
|
|
63
|
+
case "json": {
|
|
64
|
+
let parsed: unknown;
|
|
43
65
|
try {
|
|
44
|
-
JSON.parse(content);
|
|
45
|
-
return { path: o.path, kind: o.kind, required: o.required, ok: true };
|
|
66
|
+
parsed = JSON.parse(content);
|
|
46
67
|
} catch (e) {
|
|
47
|
-
return
|
|
68
|
+
return { ...base, ok: false, error: `json parse error: ${(e as Error).message}`, firstLines: firstLines(content) };
|
|
48
69
|
}
|
|
70
|
+
if (o.schema) {
|
|
71
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
72
|
+
return { ...base, ok: false, error: "json schema requires an object", firstLines: firstLines(content) };
|
|
73
|
+
}
|
|
74
|
+
const obj = parsed as Record<string, unknown>;
|
|
75
|
+
for (const key of o.schema.required_keys ?? []) {
|
|
76
|
+
if (!Object.hasOwn(obj, key)) {
|
|
77
|
+
return { ...base, ok: false, error: `missing required key "${key}"`, firstLines: firstLines(content) };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const key of o.schema.number_keys ?? []) {
|
|
81
|
+
const v = obj[key];
|
|
82
|
+
const ok = typeof v === "number" || (Array.isArray(v) && v.every((x) => typeof x === "number"));
|
|
83
|
+
if (!ok) {
|
|
84
|
+
return { ...base, ok: false, error: `key "${key}" must be a number or number[]`, firstLines: firstLines(content) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { ...base, ok: true };
|
|
89
|
+
}
|
|
49
90
|
case "yaml": {
|
|
50
91
|
const bad = content.split("\n").some((l) => l.includes("\t"));
|
|
51
92
|
return content.trim().length > 0 && !bad
|
|
52
|
-
? {
|
|
53
|
-
:
|
|
93
|
+
? { ...base, ok: true }
|
|
94
|
+
: { ...base, ok: false, error: "empty or invalid yaml" };
|
|
54
95
|
}
|
|
55
96
|
default:
|
|
56
97
|
return fail(`unknown kind`);
|
|
@@ -61,8 +102,9 @@ export async function verifyOutputs(opts: {
|
|
|
61
102
|
workerDir: string;
|
|
62
103
|
repoCwd: string;
|
|
63
104
|
outputs: ContractOutput[];
|
|
105
|
+
notBeforeMs?: number;
|
|
64
106
|
}): Promise<ContractResult> {
|
|
65
|
-
const checks = await Promise.all(opts.outputs.map((o) => checkOne(opts.workerDir, opts.repoCwd, o)));
|
|
107
|
+
const checks = await Promise.all(opts.outputs.map((o) => checkOne(opts.workerDir, opts.repoCwd, o, opts.notBeforeMs)));
|
|
66
108
|
const ok = checks.every((c) => !c.required || c.ok);
|
|
67
109
|
|
|
68
110
|
const verdictCheck = checks.find((c) => c.kind === "verdict" && c.ok);
|
|
@@ -76,3 +118,17 @@ export async function verifyOutputs(opts: {
|
|
|
76
118
|
const verdict_body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
|
|
77
119
|
return verdict_body.length > 0 ? { ok, checks, verdict, verdict_body } : { ok, checks };
|
|
78
120
|
}
|
|
121
|
+
|
|
122
|
+
/** Format a concise, actionable note for the first failed required contract. */
|
|
123
|
+
export function contractFailureNote(checks: ContractCheck[]): string {
|
|
124
|
+
const failed = checks.find((c) => c.required && !c.ok);
|
|
125
|
+
if (!failed) return "contract failed";
|
|
126
|
+
const parts = [
|
|
127
|
+
`${failed.path}: ${failed.error}`,
|
|
128
|
+
`actual: ${failed.actualPath ?? "(not found)"}`,
|
|
129
|
+
];
|
|
130
|
+
if (failed.firstLines) {
|
|
131
|
+
parts.push(`first lines: ${failed.firstLines}`);
|
|
132
|
+
}
|
|
133
|
+
return parts.join("; ");
|
|
134
|
+
}
|
package/src/controller.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
|
-
import { writeWorkerPrompts } from "./fleet-store.js";
|
|
5
|
+
import { persistFleetJson, writeWorkerPrompts } from "./fleet-store.js";
|
|
6
|
+
import { recoverLatestFleet } from "./fleet-recovery.js";
|
|
6
7
|
import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
|
|
7
8
|
import { insertWorkers } from "./insert.js";
|
|
8
9
|
import { writeReport } from "./report.js";
|
|
9
10
|
import { runWorker, sessionFactoryForModel, workerWithResolvedModel, type AgentSessionLike } from "./runner.js";
|
|
10
11
|
import { runFleet } from "./scheduler.js";
|
|
11
|
-
import { patchNode, readState, writeState } from "./state.js";
|
|
12
|
+
import { patchNode, readState, resetForRelaunch, writeState } from "./state.js";
|
|
12
13
|
import type { FleetSpec, FleetState } from "./types.js";
|
|
13
14
|
import { TERMINAL_NODE_STATUSES } from "./types.js";
|
|
14
15
|
import { buildWidgetLines } from "./ui.js";
|
|
@@ -25,6 +26,8 @@ export interface ActiveFleet {
|
|
|
25
26
|
costWarned?: boolean;
|
|
26
27
|
sessions: Map<string, AgentSessionLike>;
|
|
27
28
|
killedNodes: Set<string>;
|
|
29
|
+
relaunchRequests: Set<string>;
|
|
30
|
+
widgetVisible?: boolean;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export interface ActiveFleetCell {
|
|
@@ -34,7 +37,7 @@ export interface ActiveFleetCell {
|
|
|
34
37
|
export const activeFleet: ActiveFleetCell = { current: undefined };
|
|
35
38
|
|
|
36
39
|
export function updateWidget(ctx: ExtensionContext, fleet: ActiveFleet, spinnerFrame?: number): void {
|
|
37
|
-
if (ctx.hasUI) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state, { spinnerFrame }));
|
|
40
|
+
if (ctx.hasUI && fleet.widgetVisible) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state, { spinnerFrame }));
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export function startSpinner(ctx: ExtensionContext, fleet: ActiveFleet, intervalMs = 150): () => void {
|
|
@@ -60,7 +63,14 @@ export async function currentState(fleet: ActiveFleet): Promise<FleetState> {
|
|
|
60
63
|
|
|
61
64
|
export async function statusText(fleet: ActiveFleet): Promise<string> {
|
|
62
65
|
const state = await currentState(fleet);
|
|
63
|
-
|
|
66
|
+
const reportPath = join(fleet.fleetRoot, "report.md");
|
|
67
|
+
const failed = Object.entries(state.nodes).find(([, n]) => n.status === "failed" || n.status === "contract_failed");
|
|
68
|
+
const next = state.status === "planned" ? "next: fleet_launch"
|
|
69
|
+
: state.status === "running" ? "next: fleet_status, fleet_canvas, or fleet_kill <id>|all"
|
|
70
|
+
: failed ? `next: fleet_relaunch ${failed[0]}`
|
|
71
|
+
: state.status === "completed" ? `next: read report ${reportPath}`
|
|
72
|
+
: `next: inspect ${join(fleet.fleetRoot, "state.json")}`;
|
|
73
|
+
return `${renderDag(fleet.spec, state)}\n\nreport: ${reportPath}\n${next}`;
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
export async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
|
|
@@ -80,6 +90,48 @@ export function prepareRelaunch(fleet: ActiveFleet, nodeId: string): void {
|
|
|
80
90
|
fleet.pauseSwitch.paused = false;
|
|
81
91
|
}
|
|
82
92
|
|
|
93
|
+
export interface RelaunchRequestResult {
|
|
94
|
+
message: string;
|
|
95
|
+
startNow: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function requestRelaunch(
|
|
99
|
+
fleet: ActiveFleet,
|
|
100
|
+
nodeId: string,
|
|
101
|
+
model: string | undefined,
|
|
102
|
+
registry: ModelRegistryLike,
|
|
103
|
+
): Promise<RelaunchRequestResult> {
|
|
104
|
+
const worker = fleet.spec.workers.find((w) => w.id === nodeId);
|
|
105
|
+
if (!worker) return { message: `unknown node "${nodeId}"`, startNow: false };
|
|
106
|
+
const node = fleet.state.nodes[nodeId];
|
|
107
|
+
const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
|
|
108
|
+
if (!node || !relaunchable.has(node.status)) {
|
|
109
|
+
return {
|
|
110
|
+
message: `node "${nodeId}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`,
|
|
111
|
+
startNow: false,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (model) {
|
|
115
|
+
const resolved = resolveModelReference(registry, model);
|
|
116
|
+
if (!resolved.ok) return { message: resolved.error, startNow: false };
|
|
117
|
+
const canonical = `${resolved.model.provider}/${resolved.model.id}`;
|
|
118
|
+
fleet.spec.workers = fleet.spec.workers.map((w) => (w.id === nodeId ? { ...w, model: canonical } : w));
|
|
119
|
+
await persistFleetJson(fleet);
|
|
120
|
+
}
|
|
121
|
+
prepareRelaunch(fleet, nodeId);
|
|
122
|
+
if (fleet.running) {
|
|
123
|
+
fleet.relaunchRequests.add(nodeId);
|
|
124
|
+
return {
|
|
125
|
+
message: `relaunch queued for ${nodeId} (fleet running; dispatches on next scheduler pass)`,
|
|
126
|
+
startNow: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
fleet.state = resetForRelaunch(fleet.state, fleet.spec, nodeId);
|
|
130
|
+
await writeState(fleet.fleetRoot, fleet.state);
|
|
131
|
+
await writeWorkerPrompts(fleet);
|
|
132
|
+
return { message: `fleet relaunch requested for ${nodeId}`, startNow: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
83
135
|
export function registerNodeSession(fleet: ActiveFleet, nodeId: string, session: AgentSessionLike): void {
|
|
84
136
|
fleet.sessions.set(nodeId, session);
|
|
85
137
|
if (fleet.killedNodes.has(nodeId)) void session.abort().catch(() => {});
|
|
@@ -214,6 +266,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
214
266
|
killSwitch: fleet.killSwitch,
|
|
215
267
|
pauseSwitch: fleet.pauseSwitch,
|
|
216
268
|
nodeKills: fleet.killedNodes,
|
|
269
|
+
relaunchRequests: fleet.relaunchRequests,
|
|
217
270
|
onNodeChange: (nodeId, nodeState) => {
|
|
218
271
|
fleet.state = fleet.state.nodes[nodeId]
|
|
219
272
|
? patchNode(fleet.fleetRoot, fleet.state, nodeId, nodeState)
|
|
@@ -263,9 +316,21 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
263
316
|
}
|
|
264
317
|
}
|
|
265
318
|
|
|
266
|
-
export async function killFleet(target: string): Promise<string> {
|
|
267
|
-
const active = activeFleet.current;
|
|
319
|
+
export async function killFleet(target: string, cwd?: string): Promise<string> {
|
|
320
|
+
const active = activeFleet.current ?? (cwd ? await recoverLatestFleet(cwd) : undefined);
|
|
321
|
+
if (active) activeFleet.current ??= active;
|
|
268
322
|
if (!active) return "no fleet planned yet";
|
|
323
|
+
if (!active.running) {
|
|
324
|
+
try {
|
|
325
|
+
active.state = await readState(active.fleetRoot);
|
|
326
|
+
} catch {
|
|
327
|
+
// keep in-memory state
|
|
328
|
+
}
|
|
329
|
+
if (active.state.status === "running") {
|
|
330
|
+
const where = target === "all" ? "fleet" : `node "${target}"`;
|
|
331
|
+
return `${where} not killed: fleet state on disk is "running" — it appears to be running in another live session; kill it there (this session holds no live scheduler, so a kill here would silently no-op)`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
269
334
|
if (target === "all") {
|
|
270
335
|
active.killSwitch.killed = true;
|
|
271
336
|
return "fleet kill requested";
|
|
@@ -273,7 +338,7 @@ export async function killFleet(target: string): Promise<string> {
|
|
|
273
338
|
const worker = active.spec.workers.find((w) => w.id === target);
|
|
274
339
|
const node = active.state.nodes[target];
|
|
275
340
|
if (!worker || !node) return `unknown node "${target}"`;
|
|
276
|
-
if (TERMINAL_NODE_STATUSES.has(node.status)) return `node "${target}" already ${node.status}`;
|
|
341
|
+
if (TERMINAL_NODE_STATUSES.has(node.status) && node.status !== "blocked") return `node "${target}" already ${node.status}`;
|
|
277
342
|
active.killedNodes.add(target);
|
|
278
343
|
const session = active.sessions.get(target);
|
|
279
344
|
if (session) {
|
package/src/dag.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FleetSpec, GateKind, LoopConfig, OutputKind, ThinkingLevelName, WorkerSpec, WorkerType } from "./types.js";
|
|
1
|
+
import type { ContractOutput, FleetSpec, GateKind, JsonOutputSchema, LoopConfig, OutputKind, ThinkingLevelName, WorkerSpec, WorkerType } from "./types.js";
|
|
2
2
|
import { THINKING_LEVELS } from "./types.js";
|
|
3
3
|
|
|
4
4
|
export class CycleError extends Error {
|
|
@@ -49,10 +49,9 @@ export function getDependents(spec: FleetSpec, nodeId: string): string[] {
|
|
|
49
49
|
return spec.workers.filter((w) => w.depends_on.includes(nodeId)).map((w) => w.id);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function
|
|
53
|
-
const worktrees = workers.filter((w) => w.worktree);
|
|
52
|
+
export function findRepoOutputOwnershipConflicts(workers: WorkerSpec[]): string[] {
|
|
54
53
|
const claims = new Map<string, string[]>();
|
|
55
|
-
for (const w of
|
|
54
|
+
for (const w of workers) {
|
|
56
55
|
for (const o of w.outputs) {
|
|
57
56
|
if (!o.path.startsWith("output/")) {
|
|
58
57
|
const list = claims.get(o.path) ?? [];
|
|
@@ -71,7 +70,7 @@ function findWorktreeOwnershipConflicts(workers: WorkerSpec[]): string[] {
|
|
|
71
70
|
const aBeforeB = byId.get(a)!.depends_on.includes(b);
|
|
72
71
|
const bBeforeA = byId.get(b)!.depends_on.includes(a);
|
|
73
72
|
if (!aBeforeB && !bBeforeA) {
|
|
74
|
-
errors.push(`
|
|
73
|
+
errors.push(`repo output ownership conflict: "${path}" claimed by "${a}" and "${b}" without ordered handoff`);
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
}
|
|
@@ -132,6 +131,21 @@ export function validateFleetSpec(
|
|
|
132
131
|
errors.push(`worker "${id}": output path must be relative and stay within the repo`);
|
|
133
132
|
}
|
|
134
133
|
}
|
|
134
|
+
if (o.schema !== undefined) {
|
|
135
|
+
if (o.kind !== "json") {
|
|
136
|
+
errors.push(`worker "${id}": output schema only allowed with kind "json"`);
|
|
137
|
+
} else if (typeof o.schema !== "object" || o.schema === null || Array.isArray(o.schema)) {
|
|
138
|
+
errors.push(`worker "${id}": output schema must be an object`);
|
|
139
|
+
} else {
|
|
140
|
+
const s = o.schema as Record<string, unknown>;
|
|
141
|
+
for (const name of ["required_keys", "number_keys"] as const) {
|
|
142
|
+
const keys = s[name];
|
|
143
|
+
if (keys !== undefined && (!Array.isArray(keys) || keys.some((k) => typeof k !== "string" || k.length === 0))) {
|
|
144
|
+
errors.push(`worker "${id}": schema.${name} must be an array of non-empty strings`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
135
149
|
}
|
|
136
150
|
const wEffort = typeof w.effort === "string" ? w.effort as ThinkingLevelName : undefined;
|
|
137
151
|
if (wEffort !== undefined && !THINKING_LEVELS.includes(wEffort)) {
|
|
@@ -144,7 +158,18 @@ export function validateFleetSpec(
|
|
|
144
158
|
model: typeof w.model === "string" ? w.model : undefined,
|
|
145
159
|
effort: wEffort,
|
|
146
160
|
depends_on: Array.isArray(w.depends_on) ? (w.depends_on as string[]) : [],
|
|
147
|
-
outputs: outputs.map((o) =>
|
|
161
|
+
outputs: outputs.map((o) => {
|
|
162
|
+
const out: ContractOutput = { path: String(o.path), kind: o.kind as OutputKind, required: o.required !== false };
|
|
163
|
+
const s = o.schema;
|
|
164
|
+
if (o.kind === "json" && typeof s === "object" && s !== null && !Array.isArray(s)) {
|
|
165
|
+
const rec = s as Record<string, unknown>;
|
|
166
|
+
const schema: JsonOutputSchema = {};
|
|
167
|
+
if (Array.isArray(rec.required_keys)) schema.required_keys = rec.required_keys as string[];
|
|
168
|
+
if (Array.isArray(rec.number_keys)) schema.number_keys = rec.number_keys as string[];
|
|
169
|
+
out.schema = schema;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}),
|
|
148
173
|
iterate: w.iterate !== false,
|
|
149
174
|
worktree: w.worktree === true,
|
|
150
175
|
});
|
|
@@ -170,8 +195,8 @@ export function validateFleetSpec(
|
|
|
170
195
|
if (!hasIntegratorPath(workers)) {
|
|
171
196
|
errors.push("multiple worktree workers require an integrator that depends on all worktree workers");
|
|
172
197
|
}
|
|
173
|
-
errors.push(...findWorktreeOwnershipConflicts(workers));
|
|
174
198
|
}
|
|
199
|
+
errors.push(...findRepoOutputOwnershipConflicts(workers));
|
|
175
200
|
|
|
176
201
|
let loopConfig: LoopConfig | undefined;
|
|
177
202
|
if (cfg.loop !== undefined && cfg.loop !== null) {
|
package/src/edits.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { rename, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ActiveFleet } from "./controller.js";
|
|
4
|
+
import { persistFleetJson } from "./fleet-store.js";
|
|
4
5
|
import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
|
|
5
6
|
import { buildWorkerPrompt } from "./prompts.js";
|
|
7
|
+
import type { NodeStatus, ThinkingLevelName } from "./types.js";
|
|
6
8
|
import { THINKING_LEVELS } from "./types.js";
|
|
7
|
-
import type { ThinkingLevelName } from "./types.js";
|
|
8
9
|
|
|
9
10
|
export type NodeEditKey = "model" | "effort" | "task";
|
|
10
11
|
export type ConfigEditKey = "max_concurrent" | "warn_cost_usd" | "model" | "effort";
|
|
@@ -14,9 +15,7 @@ export interface EditResult {
|
|
|
14
15
|
message: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
await writeFile(join(fleet.fleetRoot, "fleet.json"), `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
|
|
19
|
-
}
|
|
18
|
+
const EDITABLE_NODE_STATUSES: ReadonlySet<NodeStatus> = new Set(["pending", "ready", "failed", "contract_failed", "killed", "blocked"]);
|
|
20
19
|
|
|
21
20
|
export async function editNode(
|
|
22
21
|
fleet: ActiveFleet,
|
|
@@ -28,8 +27,8 @@ export async function editNode(
|
|
|
28
27
|
const worker = fleet.spec.workers.find((w) => w.id === nodeId);
|
|
29
28
|
const node = fleet.state.nodes[nodeId];
|
|
30
29
|
if (!worker || !node) return { ok: false, message: `unknown node "${nodeId}"` };
|
|
31
|
-
if (
|
|
32
|
-
return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending nodes can be edited` };
|
|
30
|
+
if (!EDITABLE_NODE_STATUSES.has(node.status)) {
|
|
31
|
+
return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending, blocked, failed, contract_failed, or killed nodes can be edited` };
|
|
33
32
|
}
|
|
34
33
|
switch (key) {
|
|
35
34
|
case "model": {
|
|
@@ -63,7 +62,7 @@ export async function editNode(
|
|
|
63
62
|
default:
|
|
64
63
|
return { ok: false, message: `unknown node edit key "${String(key)}" (keys: model, effort, task)` };
|
|
65
64
|
}
|
|
66
|
-
await
|
|
65
|
+
await persistFleetJson(fleet);
|
|
67
66
|
return { ok: true, message: `node "${nodeId}" ${key} updated` };
|
|
68
67
|
}
|
|
69
68
|
|
|
@@ -102,6 +101,6 @@ export async function editConfig(
|
|
|
102
101
|
default:
|
|
103
102
|
return { ok: false, message: `unknown config key "${String(key)}" (keys: max_concurrent, warn_cost_usd, model, effort)` };
|
|
104
103
|
}
|
|
105
|
-
await
|
|
104
|
+
await persistFleetJson(fleet);
|
|
106
105
|
return { ok: true, message: `config.${key} updated` };
|
|
107
106
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ActiveFleet } from "./controller.js";
|
|
4
|
+
import { initFleetState, readState } from "./state.js";
|
|
5
|
+
import type { FleetSpec, FleetState } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface FleetRootInfo {
|
|
8
|
+
name: string;
|
|
9
|
+
root: string;
|
|
10
|
+
status: string;
|
|
11
|
+
created_at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
|
|
15
|
+
const spec = JSON.parse(await readFile(join(fleetRoot, "fleet.json"), "utf-8")) as FleetSpec;
|
|
16
|
+
// Bare fleet.json-only root (e.g. hand-copied spec, never planned/launched):
|
|
17
|
+
// synthesize a fresh pending state so the canvas can still render the DAG.
|
|
18
|
+
let state: FleetState;
|
|
19
|
+
try {
|
|
20
|
+
state = await readState(fleetRoot);
|
|
21
|
+
} catch (e: unknown) {
|
|
22
|
+
const err = e as { code?: string };
|
|
23
|
+
if (err.code !== "ENOENT") throw e;
|
|
24
|
+
state = initFleetState(spec);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
spec,
|
|
28
|
+
fleetRoot,
|
|
29
|
+
state,
|
|
30
|
+
killSwitch: { killed: false },
|
|
31
|
+
pauseSwitch: { paused: false },
|
|
32
|
+
running: false,
|
|
33
|
+
sessions: new Map(),
|
|
34
|
+
killedNodes: new Set(),
|
|
35
|
+
relaunchRequests: new Set(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function listFleetRoots(cwd: string): Promise<FleetRootInfo[]> {
|
|
40
|
+
const base = join(cwd, ".fleet");
|
|
41
|
+
let entries: string[];
|
|
42
|
+
try {
|
|
43
|
+
entries = await readdir(base);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const out: FleetRootInfo[] = [];
|
|
48
|
+
for (const name of entries) {
|
|
49
|
+
const root = join(base, name);
|
|
50
|
+
try {
|
|
51
|
+
const s = await stat(join(root, "fleet.json"));
|
|
52
|
+
if (!s.isFile()) continue;
|
|
53
|
+
const state = JSON.parse(await readFile(join(root, "state.json"), "utf-8")) as Partial<FleetState>;
|
|
54
|
+
out.push({
|
|
55
|
+
name,
|
|
56
|
+
root,
|
|
57
|
+
status: typeof state.status === "string" ? state.status : "unknown",
|
|
58
|
+
created_at: typeof state.created_at === "string" ? state.created_at : new Date(s.mtimeMs).toISOString(),
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
// not a fleet root (no fleet.json) or unreadable state — skip or mark unknown
|
|
62
|
+
try {
|
|
63
|
+
await stat(join(root, "fleet.json"));
|
|
64
|
+
out.push({ name, root, status: "unknown", created_at: "" });
|
|
65
|
+
} catch {
|
|
66
|
+
// not a fleet root
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function recoverLatestFleet(cwd: string): Promise<ActiveFleet | undefined> {
|
|
75
|
+
const roots = await listFleetRoots(cwd);
|
|
76
|
+
const latest = roots.find((r) => r.status !== "unknown") ?? roots[0];
|
|
77
|
+
if (!latest) return undefined;
|
|
78
|
+
try {
|
|
79
|
+
return await readDiskFleet(latest.root);
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/fleet-store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { buildWorkerPrompt } from "./prompts.js";
|
|
4
4
|
import { writeState } from "./state.js";
|
|
@@ -47,9 +47,18 @@ export async function writePlanFiles(fleetRoot: string, spec: FleetSpec, state:
|
|
|
47
47
|
await writeState(fleetRoot, state);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export async function persistFleetJson(fleet: { fleetRoot: string; spec: FleetSpec }): Promise<void> {
|
|
51
|
+
const path = join(fleet.fleetRoot, "fleet.json");
|
|
52
|
+
const tmp = join(fleet.fleetRoot, `.fleet.json.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
53
|
+
await writeFile(tmp, `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
|
|
54
|
+
await rename(tmp, path);
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
export async function writeWorkerPrompts(fleet: { spec: FleetSpec; state: FleetState; fleetRoot: string }): Promise<void> {
|
|
51
58
|
await Promise.all(fleet.spec.workers.map(async (w) => {
|
|
59
|
+
const dir = join(fleet.fleetRoot, "workers", w.id);
|
|
60
|
+
await mkdir(dir, { recursive: true });
|
|
52
61
|
const prompt = buildWorkerPrompt({ spec: fleet.spec, state: fleet.state, workerId: w.id, fleetRoot: fleet.fleetRoot });
|
|
53
|
-
await writeFile(join(
|
|
62
|
+
await writeFile(join(dir, "prompt.md"), prompt, "utf-8");
|
|
54
63
|
}));
|
|
55
64
|
}
|
package/src/insert.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ActiveFleet } from "./controller.js";
|
|
4
4
|
import { validateFleetSpec } from "./dag.js";
|
|
5
|
+
import { persistFleetJson } from "./fleet-store.js";
|
|
5
6
|
import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
|
|
6
7
|
import { buildWorkerPrompt } from "./prompts.js";
|
|
7
8
|
import { writeState } from "./state.js";
|
|
@@ -12,13 +13,6 @@ export interface InsertResult {
|
|
|
12
13
|
inserted?: string[];
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
async function persistFleetJson(fleet: ActiveFleet): Promise<void> {
|
|
16
|
-
const path = join(fleet.fleetRoot, "fleet.json");
|
|
17
|
-
const tmp = join(fleet.fleetRoot, `.fleet.json.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
18
|
-
await writeFile(tmp, `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
|
|
19
|
-
await rename(tmp, path);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
16
|
async function insertWorkersSerialized(
|
|
23
17
|
fleet: ActiveFleet,
|
|
24
18
|
raw: unknown,
|
package/src/model-resolution.ts
CHANGED
|
@@ -16,6 +16,32 @@ export function aliasesFor(model: Model<Api>): string[] {
|
|
|
16
16
|
];
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function canonicalModelRef(model: Model<Api>): string {
|
|
20
|
+
return `${model.provider}/${model.id}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function listModelRefs(registry: ModelRegistryLike, limit = 40): string[] {
|
|
24
|
+
const models = registry.getAvailable().length > 0 ? registry.getAvailable() : registry.getAll();
|
|
25
|
+
return [...new Map(models.map((m) => [canonicalModelRef(m), m])).keys()].slice(0, limit);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function suggestModelRefs(registry: ModelRegistryLike, ref: string, limit = 8): string[] {
|
|
29
|
+
const needle = ref.toLowerCase();
|
|
30
|
+
return listModelRefs(registry, 200)
|
|
31
|
+
.filter((r) => r.toLowerCase().includes(needle) || needle.split(/[/-]/).some((p) => p.length > 2 && r.toLowerCase().includes(p)))
|
|
32
|
+
.slice(0, limit);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatModelError(registry: ModelRegistryLike, label: string, ref: string, error: string): string {
|
|
36
|
+
const suggestions = suggestModelRefs(registry, ref);
|
|
37
|
+
const available = listModelRefs(registry);
|
|
38
|
+
return [
|
|
39
|
+
`${label}: ${error}`,
|
|
40
|
+
suggestions.length > 0 ? `suggestions: ${suggestions.join(", ")}` : undefined,
|
|
41
|
+
`available models: ${available.join(", ")}`,
|
|
42
|
+
].filter(Boolean).join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
export function resolveModelReference(
|
|
20
46
|
registry: ModelRegistryLike,
|
|
21
47
|
ref: string,
|
|
@@ -36,7 +62,6 @@ export function resolveModelReference(
|
|
|
36
62
|
tiers.push(pool.filter((m) => m.id.toLowerCase() === needle));
|
|
37
63
|
tiers.push(pool.filter((m) => byAlias(m, (a) => a === needle)));
|
|
38
64
|
tiers.push(pool.filter((m) => byAlias(m, (a) => a.startsWith(needle))));
|
|
39
|
-
tiers.push(pool.filter((m) => byAlias(m, (a) => a.includes(needle))));
|
|
40
65
|
|
|
41
66
|
for (const tier of tiers) {
|
|
42
67
|
const unique = [...new Map(tier.map((m) => [`${m.provider}/${m.id}`, m])).values()];
|
|
@@ -67,7 +92,7 @@ export function validateFleetModels(
|
|
|
67
92
|
if (seen.has(key)) continue;
|
|
68
93
|
seen.add(key);
|
|
69
94
|
const r = resolveModelReference(registry, ref);
|
|
70
|
-
if (!r.ok) errors.push(
|
|
95
|
+
if (!r.ok) errors.push(formatModelError(registry, label, ref, r.error));
|
|
71
96
|
}
|
|
72
97
|
return errors.length > 0 ? { ok: false, errors } : { ok: true };
|
|
73
98
|
}
|
package/src/preferences.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { canonicalModelRef, formatModelError, resolveModelReference } from "./model-resolution.js";
|
|
5
|
+
import type { ModelRegistryLike } from "./model-resolution.js";
|
|
4
6
|
import { THINKING_LEVELS } from "./types.js";
|
|
5
7
|
import type { ThinkingLevelName } from "./types.js";
|
|
6
8
|
|
|
@@ -59,6 +61,7 @@ export function mergeFleetConfig(raw: unknown, prefs: FleetPreferences): unknown
|
|
|
59
61
|
export function validatePreferenceValue(
|
|
60
62
|
key: string,
|
|
61
63
|
value: string,
|
|
64
|
+
registry?: ModelRegistryLike,
|
|
62
65
|
): { ok: true; parsed: number | string } | { ok: false; error: string } {
|
|
63
66
|
switch (key) {
|
|
64
67
|
case "max_concurrent": {
|
|
@@ -79,6 +82,11 @@ export function validatePreferenceValue(
|
|
|
79
82
|
}
|
|
80
83
|
case "model": {
|
|
81
84
|
if (value.trim().length === 0) return { ok: false, error: "model must be non-empty" };
|
|
85
|
+
if (registry) {
|
|
86
|
+
const r = resolveModelReference(registry, value.trim());
|
|
87
|
+
if (!r.ok) return { ok: false, error: formatModelError(registry, "preference model", value.trim(), r.error) };
|
|
88
|
+
return { ok: true, parsed: canonicalModelRef(r.model) };
|
|
89
|
+
}
|
|
82
90
|
return { ok: true, parsed: value.trim() };
|
|
83
91
|
}
|
|
84
92
|
default:
|
|
@@ -90,8 +98,9 @@ export function setPreference(
|
|
|
90
98
|
prefs: FleetPreferences,
|
|
91
99
|
key: string,
|
|
92
100
|
value: string,
|
|
101
|
+
registry?: ModelRegistryLike,
|
|
93
102
|
): { ok: true; prefs: FleetPreferences } | { ok: false; error: string } {
|
|
94
|
-
const v = validatePreferenceValue(key, value);
|
|
103
|
+
const v = validatePreferenceValue(key, value, registry);
|
|
95
104
|
if (!v.ok) return v;
|
|
96
105
|
return { ok: true, prefs: { ...prefs, [key]: v.parsed } };
|
|
97
106
|
}
|