pi-agent-fleet 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-fleet",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "DAG-of-agents fleets for pi — parallel workers with contracts, reviewer-gated iteration loops, and live progress",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/controller.ts CHANGED
@@ -70,7 +70,37 @@ export async function statusText(fleet: ActiveFleet): Promise<string> {
70
70
  : failed ? `next: fleet_relaunch ${failed[0]}`
71
71
  : state.status === "completed" ? `next: read report ${reportPath}`
72
72
  : `next: inspect ${join(fleet.fleetRoot, "state.json")}`;
73
- return `${renderDag(fleet.spec, state)}\n\nreport: ${reportPath}\n${next}`;
73
+ let crashWarning = "";
74
+ if (state.status === "running") {
75
+ const heartbeat = state.heartbeat_at;
76
+ const stale = !heartbeat || (Date.now() - new Date(heartbeat).getTime() > 60000);
77
+ if (stale && typeof state.pid === "number") {
78
+ let dead = false;
79
+ try {
80
+ process.kill(state.pid, 0);
81
+ } catch (e) {
82
+ if ((e as NodeJS.ErrnoException).code === "ESRCH") dead = true;
83
+ }
84
+ if (dead) {
85
+ crashWarning = `\n\nwarning: fleet appears crashed (stale heartbeat/dead pid ${state.pid}); run fleet_continue to recover`;
86
+ }
87
+ }
88
+ }
89
+ return `${renderDag(fleet.spec, state)}\n\nreport: ${reportPath}\n${next}${crashWarning}`;
90
+ }
91
+
92
+ export function checkCostLimits(fleet: ActiveFleet, ctx: ExtensionContext): void {
93
+ const warn = fleet.spec.config.warn_cost_usd;
94
+ const cap = fleet.spec.config.max_cost_usd;
95
+ const cost = fleet.state.cost_usd_estimate;
96
+ if (warn && !fleet.costWarned && cost >= warn) {
97
+ fleet.costWarned = true;
98
+ if (ctx.hasUI) ctx.ui.notify(`fleet cost warning: $${cost.toFixed(4)} >= $${warn}`, "warning");
99
+ }
100
+ if (cap && cost >= cap && !fleet.killSwitch.killed) {
101
+ fleet.killSwitch.killed = true;
102
+ if (ctx.hasUI) ctx.ui.notify(`fleet cost cap reached: $${cost.toFixed(4)} >= $${cap.toFixed(4)}`, "error");
103
+ }
74
104
  }
75
105
 
76
106
  export async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
@@ -180,17 +210,40 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
180
210
 
181
211
  fleet.running = true;
182
212
  fleet.costWarned = false;
183
- fleet.state = { ...fleet.state, status: "running" };
213
+ fleet.state = { ...fleet.state, status: "running", pid: process.pid, heartbeat_at: new Date().toISOString() };
214
+ await writeState(fleet.fleetRoot, fleet.state);
184
215
  const stopSpinner = startSpinner(ctx, fleet);
185
216
  updateWidget(ctx, fleet);
186
217
 
187
- const checkCostWarning = () => {
188
- const warn = fleet.spec.config.warn_cost_usd;
189
- if (!warn || fleet.costWarned) return;
190
- const cost = fleet.state.cost_usd_estimate;
191
- if (cost >= warn) {
192
- fleet.costWarned = true;
193
- if (ctx.hasUI) ctx.ui.notify(`fleet cost warning: $${cost.toFixed(4)} >= $${warn}`, "warning");
218
+ let lastHeartbeatWrite = Date.now();
219
+ const heartbeatInterval = setInterval(async () => {
220
+ const now = Date.now();
221
+ if (now - lastHeartbeatWrite < 5000) return;
222
+ fleet.state = { ...fleet.state, heartbeat_at: new Date().toISOString() };
223
+ lastHeartbeatWrite = now;
224
+ try {
225
+ await writeState(fleet.fleetRoot, fleet.state);
226
+ } catch {
227
+ // ignore heartbeat write failures
228
+ }
229
+ }, 5000);
230
+ if (typeof heartbeatInterval.unref === "function") heartbeatInterval.unref();
231
+
232
+ let cleanedUp = false;
233
+ let finalStateWritten = false;
234
+ const cleanup = () => {
235
+ if (cleanedUp) return;
236
+ cleanedUp = true;
237
+ stopSpinner();
238
+ clearInterval(heartbeatInterval);
239
+ };
240
+ const writeFinalState = async () => {
241
+ if (finalStateWritten) return;
242
+ finalStateWritten = true;
243
+ try {
244
+ await writeState(fleet.fleetRoot, fleet.state);
245
+ } catch {
246
+ // best-effort final persistence
194
247
  }
195
248
  };
196
249
 
@@ -229,6 +282,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
229
282
  repoCwd: worktreeCwd,
230
283
  sessionDir,
231
284
  thinkingLevel: effort,
285
+ extensionAllowlist: fleet.spec.config.worker_extensions,
232
286
  sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
233
287
  onSession: (s) => registerNodeSession(fleet, nodeId, s),
234
288
  onEvent: (e) => {
@@ -236,7 +290,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
236
290
  if (e.type === "tokens") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { tokens: e.tokens });
237
291
  if (e.type === "cost") {
238
292
  fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { cost_usd_estimate: e.cost });
239
- checkCostWarning();
293
+ checkCostLimits(fleet, ctx);
240
294
  }
241
295
  if (e.type === "error") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { status_note: e.message });
242
296
  updateWidget(ctx, fleet);
@@ -271,7 +325,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
271
325
  fleet.state = fleet.state.nodes[nodeId]
272
326
  ? patchNode(fleet.fleetRoot, fleet.state, nodeId, nodeState)
273
327
  : { ...fleet.state, nodes: { ...fleet.state.nodes, [nodeId]: nodeState } };
274
- checkCostWarning();
328
+ checkCostLimits(fleet, ctx);
275
329
  updateWidget(ctx, fleet);
276
330
  },
277
331
  onNodeAdded: (w) => {
@@ -298,9 +352,10 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
298
352
  });
299
353
  fleet.state = state;
300
354
  fleet.running = false;
301
- stopSpinner();
355
+ cleanup();
302
356
  updateWidget(ctx, fleet); // keep final per-node stats visible (todo #12)
303
- const report = await writeReport({ spec: fleet.spec, state, fleetRoot: fleet.fleetRoot, repoCwd: ctx.cwd });
357
+ await writeReport({ spec: fleet.spec, state, fleetRoot: fleet.fleetRoot, repoCwd: ctx.cwd });
358
+ await writeFinalState();
304
359
  const last = state.iterations[state.iterations.length - 1];
305
360
  if (state.status === "paused" && last?.verdict === "escalate") {
306
361
  if (ctx.hasUI) ctx.ui.notify(`fleet paused: reviewer escalated; report: ${join(fleet.fleetRoot, "report.md")}`, "warning");
@@ -309,8 +364,9 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
309
364
  }
310
365
  } catch (err: unknown) {
311
366
  fleet.running = false;
312
- stopSpinner();
367
+ cleanup();
313
368
  updateWidget(ctx, fleet);
369
+ await writeFinalState();
314
370
  const error = err instanceof Error ? err.message : String(err);
315
371
  if (ctx.hasUI) ctx.ui.notify(`fleet failed: ${error}`, "error");
316
372
  }
package/src/dag.ts CHANGED
@@ -107,6 +107,10 @@ export function validateFleetSpec(
107
107
  errors.push(`config.effort must be one of ${THINKING_LEVELS.join(", ")}`);
108
108
  }
109
109
  const warnCost = typeof cfg.warn_cost_usd === "number" ? cfg.warn_cost_usd : undefined;
110
+ const maxCost = typeof cfg.max_cost_usd === "number" ? cfg.max_cost_usd : undefined;
111
+ const workerExtensions = Array.isArray(cfg.worker_extensions)
112
+ ? (cfg.worker_extensions as unknown[]).filter((e): e is string => typeof e === "string")
113
+ : undefined;
110
114
 
111
115
  const rawWorkers = Array.isArray(r?.workers) ? (r.workers as Record<string, unknown>[]) : [];
112
116
  if (rawWorkers.length === 0) errors.push("at least one worker required");
@@ -266,7 +270,7 @@ export function validateFleetSpec(
266
270
  const spec: FleetSpec = {
267
271
  fleet_name: String(r?.fleet_name ?? ""),
268
272
  type: "dag",
269
- config: { max_concurrent: maxConcurrent, model, effort, warn_cost_usd: warnCost, loop: loopConfig },
273
+ config: { max_concurrent: maxConcurrent, model, effort, warn_cost_usd: warnCost, max_cost_usd: maxCost, worker_extensions: workerExtensions, loop: loopConfig },
270
274
  workers,
271
275
  };
272
276
 
package/src/edits.ts CHANGED
@@ -8,7 +8,7 @@ import type { NodeStatus, ThinkingLevelName } from "./types.js";
8
8
  import { THINKING_LEVELS } from "./types.js";
9
9
 
10
10
  export type NodeEditKey = "model" | "effort" | "task";
11
- export type ConfigEditKey = "max_concurrent" | "warn_cost_usd" | "model" | "effort";
11
+ export type ConfigEditKey = "max_concurrent" | "warn_cost_usd" | "max_cost_usd" | "worker_extensions" | "model" | "effort";
12
12
 
13
13
  export interface EditResult {
14
14
  ok: boolean;
@@ -85,6 +85,25 @@ export async function editConfig(
85
85
  fleet.spec.config.warn_cost_usd = n;
86
86
  break;
87
87
  }
88
+ case "max_cost_usd": {
89
+ const n = Number(value);
90
+ if (!Number.isFinite(n) || n < 0) return { ok: false, message: "max_cost_usd must be a number >= 0" };
91
+ fleet.spec.config.max_cost_usd = n;
92
+ break;
93
+ }
94
+ case "worker_extensions": {
95
+ // accept JSON array or comma-separated string
96
+ let list: string[];
97
+ try {
98
+ const parsed: unknown = JSON.parse(value);
99
+ list = Array.isArray(parsed) ? parsed.map(String) : [value];
100
+ } catch {
101
+ list = value.split(",").map((s) => s.trim()).filter(Boolean);
102
+ }
103
+ if (list.some((s) => s.length === 0)) return { ok: false, message: "worker_extensions must be non-empty strings" };
104
+ fleet.spec.config.worker_extensions = list;
105
+ break;
106
+ }
88
107
  case "model": {
89
108
  const r = resolveModelReference(registry, value);
90
109
  if (!r.ok) return { ok: false, message: r.error };
@@ -99,7 +118,7 @@ export async function editConfig(
99
118
  break;
100
119
  }
101
120
  default:
102
- return { ok: false, message: `unknown config key "${String(key)}" (keys: max_concurrent, warn_cost_usd, model, effort)` };
121
+ return { ok: false, message: `unknown config key "${String(key)}" (keys: max_concurrent, warn_cost_usd, max_cost_usd, worker_extensions, model, effort)` };
103
122
  }
104
123
  await persistFleetJson(fleet);
105
124
  return { ok: true, message: `config.${key} updated` };
package/src/runner.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createAgentSession, SessionManager, type CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
1
+ import { createAgentSession, SessionManager, DefaultResourceLoader, type CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
2
2
  import type { Api, Model } from "@earendil-works/pi-ai";
3
3
  import type { ThinkingLevelName, WorkerSpec } from "./types.js";
4
4
  import { WORKER_TYPE_TOOLS } from "./types.js";
@@ -25,16 +25,52 @@ export interface SessionOpts {
25
25
  tools: string[];
26
26
  model?: string;
27
27
  thinkingLevel?: ThinkingLevelName;
28
+ extensionAllowlist?: string[];
28
29
  }
29
30
 
30
31
  export type SessionFactory = (opts: SessionOpts) => Promise<AgentSessionLike>;
31
32
 
33
+ // Fleet workers must not load user extensions/skills: reading global skill files
34
+ // burns tokens mid-task, and extensions like pi-web-access run background async work
35
+ // that can crash the pi process after the session is disposed.
36
+ // Exception: some model providers are registered BY extensions (e.g. opencode-pi
37
+ // registers the opencode-cli provider). worker_extensions allowlists exactly those.
38
+ export function filterExtensionsByAllowlist<T extends { path: string }>(
39
+ extensions: T[],
40
+ allowlist: string[] | undefined,
41
+ ): T[] {
42
+ if (!allowlist || allowlist.length === 0) return extensions;
43
+ return extensions.filter((e) => allowlist.some((a) => e.path.includes(a)));
44
+ }
45
+
46
+ async function createLeanResourceLoader(cwd: string, extensionAllowlist?: string[]): Promise<DefaultResourceLoader> {
47
+ const allow = extensionAllowlist && extensionAllowlist.length > 0;
48
+ const loader = new DefaultResourceLoader({
49
+ cwd,
50
+ agentDir: cwd,
51
+ noExtensions: !allow,
52
+ extensionsOverride: allow
53
+ ? (base) => ({ ...base, extensions: filterExtensionsByAllowlist(base.extensions, extensionAllowlist) })
54
+ : undefined,
55
+ noSkills: true,
56
+ noPromptTemplates: true,
57
+ noThemes: true,
58
+ noContextFiles: true,
59
+ systemPromptOverride: () => undefined,
60
+ appendSystemPromptOverride: () => [],
61
+ });
62
+ await loader.reload();
63
+ return loader;
64
+ }
65
+
32
66
  export const defaultSessionFactory: SessionFactory = async (opts) => {
67
+ const resourceLoader = await createLeanResourceLoader(opts.cwd, opts.extensionAllowlist);
33
68
  const { session } = await createAgentSession({
34
69
  cwd: opts.cwd,
35
70
  tools: opts.tools,
36
71
  sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
37
72
  thinkingLevel: opts.thinkingLevel as ThinkingLevelOption,
73
+ resourceLoader,
38
74
  });
39
75
  return session as unknown as AgentSessionLike;
40
76
  };
@@ -49,6 +85,7 @@ export interface RunWorkerOpts {
49
85
  onSession?: (session: AgentSessionLike) => void;
50
86
  sessionFactory?: SessionFactory;
51
87
  thinkingLevel?: ThinkingLevelName;
88
+ extensionAllowlist?: string[];
52
89
  }
53
90
 
54
91
  export interface RunWorkerResult {
@@ -69,6 +106,7 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
69
106
  tools: WORKER_TYPE_TOOLS[opts.worker.type],
70
107
  model: opts.worker.model,
71
108
  thinkingLevel: opts.thinkingLevel,
109
+ extensionAllowlist: opts.extensionAllowlist,
72
110
  });
73
111
  } catch (err) {
74
112
  const message = err instanceof Error ? err.message : String(err);
@@ -79,13 +117,29 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
79
117
  let turns = 0;
80
118
  let tokens = 0;
81
119
  let cost = 0;
120
+ // pi resolves session.prompt() normally even when the model run ends in an error
121
+ // (e.g. provider usage limit) — the failure is only visible on the final assistant
122
+ // message's stopReason/errorMessage. Track the last assistant message so a resolved
123
+ // prompt that actually errored is reported as ok:false instead of running the output
124
+ // contract against files that were never written.
125
+ let lastStopReason: string | undefined;
126
+ let lastErrorMessage: string | undefined;
82
127
  const unsub = session.subscribe((e) => {
83
128
  if (e.type === "turn_end") {
84
129
  turns++;
85
130
  opts.onEvent({ type: "turn", nodeId: opts.nodeId, turns });
86
131
  }
87
132
  if (e.type === "message_end") {
88
- const msg = e.message as { role?: string; usage?: { totalTokens?: number; cost?: { total?: number } } } | undefined;
133
+ const msg = e.message as {
134
+ role?: string;
135
+ stopReason?: string;
136
+ errorMessage?: string;
137
+ usage?: { totalTokens?: number; cost?: { total?: number } };
138
+ } | undefined;
139
+ if (msg?.role === "assistant") {
140
+ lastStopReason = msg.stopReason;
141
+ lastErrorMessage = msg.errorMessage;
142
+ }
89
143
  if (msg?.role === "assistant" && msg.usage?.totalTokens) {
90
144
  tokens += msg.usage.totalTokens;
91
145
  opts.onEvent({ type: "tokens", nodeId: opts.nodeId, tokens });
@@ -98,6 +152,11 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
98
152
  });
99
153
  try {
100
154
  await session.prompt(opts.prompt);
155
+ if (lastStopReason === "error") {
156
+ const message = lastErrorMessage ?? "worker model run ended with an error";
157
+ opts.onEvent({ type: "error", nodeId: opts.nodeId, message });
158
+ return { ok: false, turns, tokens, cost, error: message };
159
+ }
101
160
  opts.onEvent({ type: "done", nodeId: opts.nodeId });
102
161
  return { ok: true, turns, tokens, cost };
103
162
  } catch (err) {
@@ -112,12 +171,14 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
112
171
 
113
172
  export function sessionFactoryForModel(model: Model<Api>): SessionFactory {
114
173
  return async (opts) => {
174
+ const resourceLoader = await createLeanResourceLoader(opts.cwd, opts.extensionAllowlist);
115
175
  const { session } = await createAgentSession({
116
176
  cwd: opts.cwd,
117
177
  tools: opts.tools,
118
178
  sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
119
179
  model,
120
180
  thinkingLevel: opts.thinkingLevel as ThinkingLevelOption,
181
+ resourceLoader,
121
182
  });
122
183
  return session as unknown as AgentSessionLike;
123
184
  };
package/src/scheduler.ts CHANGED
@@ -25,6 +25,15 @@ export interface RunFleetOpts {
25
25
  continuePass?: boolean;
26
26
  onIterationEnd?: (snap: IterationSnapshot) => void;
27
27
  prepareIteration?: (n: number, state: FleetState) => Promise<void>;
28
+ retryDelayMs?: (attempt: number) => number;
29
+ }
30
+
31
+ const MAX_RETRY_ATTEMPTS = 3;
32
+ const DEFAULT_RETRY_DELAY_MS = (attempt: number): number => 1000 * 2 ** attempt;
33
+ const RETRYABLE_ERROR_RE = /usage limit|rate.?limit|429|overloaded|502|503|timed? ?out|timeout|econnreset|etimedout|econnrefused|fetch failed/i;
34
+
35
+ export function isRetryableError(message: string): boolean {
36
+ return RETRYABLE_ERROR_RE.test(message);
28
37
  }
29
38
 
30
39
  const FAILED: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed", "blocked"]);
@@ -52,6 +61,19 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
52
61
  let state: FleetState;
53
62
  if (opts.resumeFrom) {
54
63
  state = { ...opts.resumeFrom, paused: false, status: "running" };
64
+ // Crash recovery: nodes left "running" on disk by a dead process have no live
65
+ // session in this runFleet call — reset them to pending so they get dispatched.
66
+ // Without this, the scheduler can neither dispatch nor terminate them and spins
67
+ // forever (nothing runnable, not all nodes terminal).
68
+ for (const w of spec.workers) {
69
+ if (state.nodes[w.id]?.status === "running") {
70
+ state = patchNode(fleetRoot, state, w.id, {
71
+ status: "pending",
72
+ started_at: undefined,
73
+ status_note: "recovered stale running state from a previous crashed/killed process",
74
+ });
75
+ }
76
+ }
55
77
  await writeState(fleetRoot, state);
56
78
  if (allNodesTerminal(state, spec) && !opts.continuePass) {
57
79
  state = resetForIteration(state, spec);
@@ -71,6 +93,15 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
71
93
  };
72
94
 
73
95
  const running = new Set<Promise<void>>();
96
+ const circuitErrors = new Map<string, Set<string>>();
97
+ let circuitOpen = false;
98
+ function recordCircuitError(nodeId: string, error: string | undefined) {
99
+ if (!error) return;
100
+ const set = circuitErrors.get(error) ?? new Set<string>();
101
+ set.add(nodeId);
102
+ circuitErrors.set(error, set);
103
+ if (set.size >= 2) circuitOpen = true;
104
+ }
74
105
 
75
106
  const repoCwdFor = (nodeId: string): string =>
76
107
  typeof opts.repoCwd === "function" ? opts.repoCwd(nodeId) : opts.repoCwd;
@@ -179,6 +210,23 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
179
210
  await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
180
211
  }
181
212
  }
213
+ // circuit breaker: two distinct nodes failed with the identical error
214
+ if (circuitOpen) {
215
+ const [tripError, tripSet] = [...circuitErrors.entries()].find(([_, ids]) => ids.size >= 2) ?? ["", new Set<string>()];
216
+ const tripCount = tripSet.size;
217
+ const note = `circuit breaker: ${tripCount} nodes failed with identical error: ${tripError.slice(0, 80)}`;
218
+ for (const w of spec.workers) {
219
+ const n = state.nodes[w.id];
220
+ if (!n || (n.status !== "pending" && n.status !== "ready")) continue;
221
+ await patch(w.id, { status: "blocked", ended_at: new Date().toISOString(), status_note: note });
222
+ }
223
+ if (running.size > 0) {
224
+ await Promise.race(running);
225
+ continue;
226
+ }
227
+ break;
228
+ }
229
+
182
230
  // dispatch ready
183
231
  const activeCount = running.size;
184
232
  let slots = spec.config.max_concurrent - activeCount;
@@ -251,16 +299,55 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
251
299
 
252
300
  const dispatchMs = Date.now();
253
301
  await patch(w.id, { status: "running", started_at: new Date().toISOString() });
254
- const p = opts.spawn(w.id).then(async (res) => {
302
+ const p = (async () => {
303
+ let totals = { turns: 0, tokens: 0, cost: 0 };
304
+ let lastRes: Awaited<ReturnType<typeof opts.spawn>> | undefined;
305
+ let finalError: string | undefined;
306
+
307
+ for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
308
+ if (opts.killSwitch?.killed) return;
309
+ if (opts.nodeKills?.has(w.id)) {
310
+ await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
311
+ return;
312
+ }
313
+ const res = await opts.spawn(w.id);
314
+ lastRes = res;
315
+ totals.turns += res.turns;
316
+ totals.tokens += res.tokens;
317
+ totals.cost += res.cost ?? 0;
318
+ if (opts.killSwitch?.killed) return;
319
+ if (opts.nodeKills?.has(w.id)) {
320
+ await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
321
+ return;
322
+ }
323
+ if (res.ok) {
324
+ finalError = undefined;
325
+ break;
326
+ }
327
+ finalError = res.error;
328
+ if (!res.error || !isRetryableError(res.error) || attempt === MAX_RETRY_ATTEMPTS - 1) {
329
+ break;
330
+ }
331
+ await patch(w.id, { status_note: `retry ${attempt + 1}/${MAX_RETRY_ATTEMPTS - 1} after: ${res.error}` });
332
+ const delayMs = opts.retryDelayMs ? opts.retryDelayMs(attempt) : DEFAULT_RETRY_DELAY_MS(attempt);
333
+ await new Promise((r) => setTimeout(r, delayMs));
334
+ }
335
+
336
+ const res = lastRes;
337
+ if (!res) return;
338
+
255
339
  if (opts.killSwitch?.killed) return;
256
340
  if (opts.nodeKills?.has(w.id)) {
257
- await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
341
+ await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
258
342
  return;
259
343
  }
344
+
260
345
  if (!res.ok) {
261
- await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
346
+ await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
347
+ recordCircuitError(w.id, finalError ?? res.error);
262
348
  return;
263
349
  }
350
+
264
351
  if (w.worktree) {
265
352
  try {
266
353
  await commitWorktree({
@@ -274,9 +361,9 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
274
361
  await patch(w.id, {
275
362
  status: "failed",
276
363
  ended_at: new Date().toISOString(),
277
- turns: res.turns,
278
- tokens: res.tokens,
279
- cost_usd_estimate: res.cost ?? 0,
364
+ turns: totals.turns,
365
+ tokens: totals.tokens,
366
+ cost_usd_estimate: totals.cost,
280
367
  status_note: `commit failed: ${msg}`,
281
368
  });
282
369
  return;
@@ -293,9 +380,9 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
293
380
  await patch(w.id, {
294
381
  status: contract.ok ? "completed" : "contract_failed",
295
382
  ended_at: new Date().toISOString(),
296
- turns: res.turns,
297
- tokens: res.tokens,
298
- cost_usd_estimate: res.cost ?? 0,
383
+ turns: totals.turns,
384
+ tokens: totals.tokens,
385
+ cost_usd_estimate: totals.cost,
299
386
  contract_result: contract,
300
387
  produced_outputs: contract.checks.filter((c) => c.ok || c.actualPath).map((c) => c.path),
301
388
  status_note: contract.ok ? costNote : [contractFailureNote(contract.checks), costNote].filter(Boolean).join(" · "),
@@ -304,7 +391,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
304
391
  const note = await opts.onNodeCompleted?.(w.id);
305
392
  if (note) await patch(w.id, { status_note: note });
306
393
  }
307
- }).finally(() => running.delete(p));
394
+ })().finally(() => running.delete(p));
308
395
  running.add(p);
309
396
  }
310
397
  if (running.size > 0) {
@@ -314,6 +401,33 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
314
401
  return !!n && TERMINAL_NODE_STATUSES.has(n.status);
315
402
  })) {
316
403
  break;
404
+ } else {
405
+ // Defensive: nothing in flight, not all terminal, and nothing dispatchable
406
+ // would mean a tight infinite loop (busy-spin, event-loop starvation).
407
+ // Fail the stuck nodes instead of hanging.
408
+ const dispatchable = spec.workers.some((w) => {
409
+ const n = state.nodes[w.id];
410
+ if (!n || (n.status !== "pending" && n.status !== "ready")) return false;
411
+ if (opts.nodeKills?.has(w.id)) return true; // will be killed next pass
412
+ return w.depends_on.every((d) => state.nodes[d]?.status === "completed");
413
+ });
414
+ const blockingDepsFailed = spec.workers.some((w) => {
415
+ const n = state.nodes[w.id];
416
+ if (!n || (n.status !== "pending" && n.status !== "ready")) return false;
417
+ return w.depends_on.some((d) => FAILED.has(state.nodes[d]?.status ?? ""));
418
+ });
419
+ if (!dispatchable && !blockingDepsFailed) {
420
+ for (const w of spec.workers) {
421
+ const n = state.nodes[w.id];
422
+ if (!n || TERMINAL_NODE_STATUSES.has(n.status)) continue;
423
+ await patch(w.id, {
424
+ status: "failed",
425
+ ended_at: new Date().toISOString(),
426
+ status_note: `stuck in non-terminal status "${n.status}" with no dispatchable path; failed to avoid scheduler hang`,
427
+ });
428
+ }
429
+ break;
430
+ }
317
431
  }
318
432
  }
319
433
  await Promise.allSettled([...running]);
package/src/state.ts CHANGED
@@ -32,6 +32,8 @@ export async function readState(fleetRoot: string): Promise<FleetState> {
32
32
  lgtm_streak: parsed.lgtm_streak ?? 0,
33
33
  paused: parsed.paused ?? false,
34
34
  iterations: parsed.iterations ?? [],
35
+ pid: parsed.pid,
36
+ heartbeat_at: parsed.heartbeat_at,
35
37
  };
36
38
  }
37
39
 
package/src/tools.ts CHANGED
@@ -58,6 +58,8 @@ export function registerFleetTools(pi: ExtensionAPI): void {
58
58
  model: Type.Optional(Type.String({ description: "Fleet-wide default model" })),
59
59
  effort: Type.Optional(EffortSchema),
60
60
  warn_cost_usd: Type.Optional(Type.Number()),
61
+ max_cost_usd: Type.Optional(Type.Number()),
62
+ worker_extensions: Type.Optional(Type.Array(Type.String())),
61
63
  loop: Type.Optional(Type.Object({
62
64
  gate: Type.Union([Type.Literal("reviewer"), Type.Literal("none")]),
63
65
  max_iterations: Type.Number(),
@@ -335,11 +337,11 @@ export function registerFleetTools(pi: ExtensionAPI): void {
335
337
  pi.registerTool({
336
338
  name: "fleet_edit",
337
339
  label: "Fleet Edit",
338
- description: "Edit the active fleet: a pending or relaunchable node's model, effort, or task — or fleet config (max_concurrent, warn_cost_usd, model, effort) when node_id is omitted. Changes persist to fleet.json and apply immediately. Edits to running or completed nodes are refused; pending, blocked, failed, contract_failed, and killed nodes can be edited (blocked nodes have not started — nothing to invalidate).",
340
+ description: "Edit the active fleet: a pending or relaunchable node's model, effort, or task — or fleet config (max_concurrent, warn_cost_usd, max_cost_usd, model, effort) when node_id is omitted. Changes persist to fleet.json and apply immediately. Edits to running or completed nodes are refused; pending, blocked, failed, contract_failed, and killed nodes can be edited (blocked nodes have not started — nothing to invalidate).",
339
341
  promptSnippet: "Edit a pending fleet node or fleet config.",
340
342
  parameters: Type.Object({
341
343
  node_id: Type.Optional(Type.String({ description: "Worker id to edit; omit for fleet config edits" })),
342
- key: Type.String({ description: "Node keys: model, effort, task. Config keys: max_concurrent, warn_cost_usd, model, effort" }),
344
+ key: Type.String({ description: "Node keys: model, effort, task. Config keys: max_concurrent, warn_cost_usd, max_cost_usd, model, effort" }),
343
345
  value: Type.String({ description: "New value" }),
344
346
  }),
345
347
  async execute(_id, params, _signal, _onUpdate, ctx) {
package/src/types.ts CHANGED
@@ -44,6 +44,11 @@ export interface FleetConfig {
44
44
  model?: string;
45
45
  effort?: ThinkingLevelName;
46
46
  warn_cost_usd?: number;
47
+ /** hard cap — fleet is killed when total estimated cost reaches it */
48
+ max_cost_usd?: number;
49
+ /** extension path fragments to load inside worker sessions (default: none).
50
+ * Needed for extension-provided model providers, e.g. ["opencode-pi"]. */
51
+ worker_extensions?: string[];
47
52
  loop?: LoopConfig;
48
53
  }
49
54
 
@@ -112,6 +117,10 @@ export interface FleetState {
112
117
  lgtm_streak: number;
113
118
  paused: boolean;
114
119
  iterations: IterationSnapshot[];
120
+ /** liveness signal written by the running controller */
121
+ pid?: number;
122
+ /** ISO timestamp liveness signal written by the running controller */
123
+ heartbeat_at?: string;
115
124
  }
116
125
 
117
126
  export const WORKER_TYPE_TOOLS: Record<WorkerType, string[]> = {