pi-agent-fleet 0.4.0 → 0.5.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/controller.ts CHANGED
@@ -3,6 +3,7 @@ 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
5
  import { 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";
@@ -25,6 +26,7 @@ export interface ActiveFleet {
25
26
  costWarned?: boolean;
26
27
  sessions: Map<string, AgentSessionLike>;
27
28
  killedNodes: Set<string>;
29
+ widgetVisible?: boolean;
28
30
  }
29
31
 
30
32
  export interface ActiveFleetCell {
@@ -34,7 +36,7 @@ export interface ActiveFleetCell {
34
36
  export const activeFleet: ActiveFleetCell = { current: undefined };
35
37
 
36
38
  export function updateWidget(ctx: ExtensionContext, fleet: ActiveFleet, spinnerFrame?: number): void {
37
- if (ctx.hasUI) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state, { spinnerFrame }));
39
+ if (ctx.hasUI && fleet.widgetVisible) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state, { spinnerFrame }));
38
40
  }
39
41
 
40
42
  export function startSpinner(ctx: ExtensionContext, fleet: ActiveFleet, intervalMs = 150): () => void {
@@ -60,7 +62,14 @@ export async function currentState(fleet: ActiveFleet): Promise<FleetState> {
60
62
 
61
63
  export async function statusText(fleet: ActiveFleet): Promise<string> {
62
64
  const state = await currentState(fleet);
63
- return renderDag(fleet.spec, state);
65
+ const reportPath = join(fleet.fleetRoot, "report.md");
66
+ const failed = Object.entries(state.nodes).find(([, n]) => n.status === "failed" || n.status === "contract_failed");
67
+ const next = state.status === "planned" ? "next: fleet_launch"
68
+ : state.status === "running" ? "next: fleet_status, fleet_canvas, or fleet_kill <id>|all"
69
+ : failed ? `next: fleet_relaunch ${failed[0]}`
70
+ : state.status === "completed" ? `next: read report ${reportPath}`
71
+ : `next: inspect ${join(fleet.fleetRoot, "state.json")}`;
72
+ return `${renderDag(fleet.spec, state)}\n\nreport: ${reportPath}\n${next}`;
64
73
  }
65
74
 
66
75
  export async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
@@ -263,9 +272,21 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
263
272
  }
264
273
  }
265
274
 
266
- export async function killFleet(target: string): Promise<string> {
267
- const active = activeFleet.current;
275
+ export async function killFleet(target: string, cwd?: string): Promise<string> {
276
+ const active = activeFleet.current ?? (cwd ? await recoverLatestFleet(cwd) : undefined);
277
+ if (active) activeFleet.current ??= active;
268
278
  if (!active) return "no fleet planned yet";
279
+ if (!active.running) {
280
+ try {
281
+ active.state = await readState(active.fleetRoot);
282
+ } catch {
283
+ // keep in-memory state
284
+ }
285
+ if (active.state.status === "running") {
286
+ const where = target === "all" ? "fleet" : `node "${target}"`;
287
+ 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)`;
288
+ }
289
+ }
269
290
  if (target === "all") {
270
291
  active.killSwitch.killed = true;
271
292
  return "fleet kill requested";
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"]);
20
19
 
21
20
  export async function editNode(
22
21
  fleet: ActiveFleet,
@@ -28,9 +27,9 @@ 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` };
33
- }
30
+ if (!EDITABLE_NODE_STATUSES.has(node.status)) {
31
+ return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending, failed, contract_failed, or killed nodes can be edited` };
32
+ }
34
33
  switch (key) {
35
34
  case "model": {
36
35
  const r = resolveModelReference(registry, value);
@@ -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,73 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ActiveFleet } from "./controller.js";
4
+ import { 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
+ const state = await readState(fleetRoot);
17
+ return {
18
+ spec,
19
+ fleetRoot,
20
+ state,
21
+ killSwitch: { killed: false },
22
+ pauseSwitch: { paused: false },
23
+ running: false,
24
+ sessions: new Map(),
25
+ killedNodes: new Set(),
26
+ };
27
+ }
28
+
29
+ export async function listFleetRoots(cwd: string): Promise<FleetRootInfo[]> {
30
+ const base = join(cwd, ".fleet");
31
+ let entries: string[];
32
+ try {
33
+ entries = await readdir(base);
34
+ } catch {
35
+ return [];
36
+ }
37
+ const out: FleetRootInfo[] = [];
38
+ for (const name of entries) {
39
+ const root = join(base, name);
40
+ try {
41
+ const s = await stat(join(root, "fleet.json"));
42
+ if (!s.isFile()) continue;
43
+ const state = JSON.parse(await readFile(join(root, "state.json"), "utf-8")) as Partial<FleetState>;
44
+ out.push({
45
+ name,
46
+ root,
47
+ status: typeof state.status === "string" ? state.status : "unknown",
48
+ created_at: typeof state.created_at === "string" ? state.created_at : new Date(s.mtimeMs).toISOString(),
49
+ });
50
+ } catch {
51
+ // not a fleet root (no fleet.json) or unreadable state — skip or mark unknown
52
+ try {
53
+ await stat(join(root, "fleet.json"));
54
+ out.push({ name, root, status: "unknown", created_at: "" });
55
+ } catch {
56
+ // not a fleet root
57
+ }
58
+ }
59
+ }
60
+ out.sort((a, b) => b.created_at.localeCompare(a.created_at));
61
+ return out;
62
+ }
63
+
64
+ export async function recoverLatestFleet(cwd: string): Promise<ActiveFleet | undefined> {
65
+ const roots = await listFleetRoots(cwd);
66
+ const latest = roots.find((r) => r.status !== "unknown") ?? roots[0];
67
+ if (!latest) return undefined;
68
+ try {
69
+ return await readDiskFleet(latest.root);
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
@@ -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
  }
package/src/prompts.ts CHANGED
@@ -100,6 +100,16 @@ export function buildWorkerPrompt(opts: {
100
100
  } else {
101
101
  for (const o of worker.outputs) {
102
102
  out.push(`- ${o.path} (${o.kind}${o.required ? ", REQUIRED" : ", optional"})`);
103
+ if (o.kind === "json" && o.schema) {
104
+ const bits: string[] = [];
105
+ if (o.schema.required_keys && o.schema.required_keys.length > 0) {
106
+ bits.push(`must be a JSON object containing keys: ${o.schema.required_keys.join(", ")}`);
107
+ }
108
+ if (o.schema.number_keys && o.schema.number_keys.length > 0) {
109
+ bits.push(`these keys must be numbers or arrays of numbers: ${o.schema.number_keys.join(", ")}`);
110
+ }
111
+ if (bits.length > 0) out.push(` - ${bits.join("; ")}`);
112
+ }
103
113
  }
104
114
  out.push("");
105
115
  }
package/src/report.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { writeFile } from "node:fs/promises";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
3
  import { basename, join } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import type { FleetSpec, FleetState } from "./types.js";
@@ -72,6 +72,33 @@ export async function writeReport(opts: {
72
72
  if (w.worktree) lines.push(`- fleet/${base}/${w.id}`);
73
73
  }
74
74
  }
75
+ lines.push("", "## Next steps", "");
76
+ const reportPath = join(fleetRoot, "report.md");
77
+ const failed = Object.entries(state.nodes).find(([, n]) => n.status === "failed" || n.status === "contract_failed");
78
+ const next = state.status === "planned" ? "fleet_launch"
79
+ : state.status === "running" ? "fleet_status, fleet_canvas, or fleet_kill <id>|all"
80
+ : failed ? `fleet_relaunch ${failed[0]}`
81
+ : state.status === "completed" ? `read report ${reportPath}`
82
+ : `inspect ${join(fleetRoot, "state.json")}`;
83
+ lines.push(`- next: ${next}`);
84
+ lines.push("", "## JSON outputs", "");
85
+ let anyJson = false;
86
+ for (const w of spec.workers) {
87
+ const n = state.nodes[w.id];
88
+ for (const out of n?.produced_outputs ?? []) {
89
+ if (!out.startsWith("output/") || !out.endsWith(".json") || out.includes("..")) continue;
90
+ anyJson = true;
91
+ let content: string;
92
+ try {
93
+ const raw = await readFile(join(fleetRoot, "workers", w.id, out), "utf-8");
94
+ content = raw.length > 4096 ? `${raw.slice(0, 4096)}\n... (truncated)` : raw;
95
+ } catch {
96
+ content = "(unreadable)";
97
+ }
98
+ lines.push(`### ${w.id}: ${out}`, "", "```json", content.trimEnd(), "```", "");
99
+ }
100
+ }
101
+ if (!anyJson) lines.push("(none)", "");
75
102
  lines.push("", "## Code changes", "", "```", await gitDiffStat(repoCwd, state.created_at), "```", "");
76
103
  lines.push("## Artifacts", "", `- state: ${join(fleetRoot, "state.json")}`,
77
104
  `- sessions: ${join(fleetRoot, "workers", "<id>", "session.jsonl")}`, "");
package/src/scheduler.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { verifyOutputs } from "./contracts.js";
3
+ import { contractFailureNote, verifyOutputs } from "./contracts.js";
4
4
  import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
5
5
  import { TERMINAL_NODE_STATUSES } from "./types.js";
6
6
  import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict, WorkerSpec } from "./types.js";
@@ -258,7 +258,8 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
258
258
  tokens: res.tokens,
259
259
  cost_usd_estimate: res.cost ?? 0,
260
260
  contract_result: contract,
261
- produced_outputs: contract.checks.filter((c) => c.ok).map((c) => c.path),
261
+ produced_outputs: contract.checks.filter((c) => c.ok || c.actualPath).map((c) => c.path),
262
+ status_note: contract.ok ? undefined : contractFailureNote(contract.checks),
262
263
  });
263
264
  if (contract.ok) {
264
265
  const note = await opts.onNodeCompleted?.(w.id);