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/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
- async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput): Promise<ContractCheck> {
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 fail = (error: string): ContractCheck => ({ path: o.path, kind: o.kind, required: o.required, ok: false, error });
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
- return s.size > 0
20
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
21
- : fail("empty file");
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 fail("file not found");
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 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");
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 fail("no verdict line");
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
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
40
- : fail("verdict line without body");
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 fail(`json parse error: ${(e as Error).message}`);
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
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
53
- : fail("empty or invalid yaml");
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
- return renderDag(fleet.spec, state);
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 findWorktreeOwnershipConflicts(workers: WorkerSpec[]): string[] {
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 worktrees) {
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(`worktree ownership conflict: "${path}" claimed by "${a}" and "${b}" without ordered handoff`);
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) => ({ path: String(o.path), kind: o.kind as OutputKind, required: o.required !== false })),
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
- async function persistSpec(fleet: ActiveFleet): Promise<void> {
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 (node.status !== "pending" && node.status !== "ready") {
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 persistSpec(fleet);
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 persistSpec(fleet);
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
+ }
@@ -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(fleet.fleetRoot, "workers", w.id, "prompt.md"), prompt, "utf-8");
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, rename, writeFile } from "node:fs/promises";
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,
@@ -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(`${label}: ${r.error}`);
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
  }
@@ -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
  }