pi-agent-fleet 0.5.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/src/edits.ts CHANGED
@@ -8,14 +8,14 @@ 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;
15
15
  message: string;
16
16
  }
17
17
 
18
- const EDITABLE_NODE_STATUSES: ReadonlySet<NodeStatus> = new Set(["pending", "ready", "failed", "contract_failed", "killed"]);
18
+ const EDITABLE_NODE_STATUSES: ReadonlySet<NodeStatus> = new Set(["pending", "ready", "failed", "contract_failed", "killed", "blocked"]);
19
19
 
20
20
  export async function editNode(
21
21
  fleet: ActiveFleet,
@@ -28,8 +28,8 @@ export async function editNode(
28
28
  const node = fleet.state.nodes[nodeId];
29
29
  if (!worker || !node) return { ok: false, message: `unknown node "${nodeId}"` };
30
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
- }
31
+ return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending, blocked, failed, contract_failed, or killed nodes can be edited` };
32
+ }
33
33
  switch (key) {
34
34
  case "model": {
35
35
  const r = resolveModelReference(registry, value);
@@ -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` };
@@ -1,7 +1,7 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { ActiveFleet } from "./controller.js";
4
- import { readState } from "./state.js";
4
+ import { initFleetState, readState } from "./state.js";
5
5
  import type { FleetSpec, FleetState } from "./types.js";
6
6
 
7
7
  export interface FleetRootInfo {
@@ -13,7 +13,16 @@ export interface FleetRootInfo {
13
13
 
14
14
  export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
15
15
  const spec = JSON.parse(await readFile(join(fleetRoot, "fleet.json"), "utf-8")) as FleetSpec;
16
- const state = await readState(fleetRoot);
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
+ }
17
26
  return {
18
27
  spec,
19
28
  fleetRoot,
@@ -23,6 +32,7 @@ export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
23
32
  running: false,
24
33
  sessions: new Map(),
25
34
  killedNodes: new Set(),
35
+ relaunchRequests: new Set(),
26
36
  };
27
37
  }
28
38
 
package/src/prompts.ts CHANGED
@@ -17,6 +17,17 @@ export function buildWorkerPrompt(opts: {
17
17
  const dependents = getDependents(spec, workerId);
18
18
  const out: string[] = [];
19
19
 
20
+ out.push(
21
+ "## Autonomy contract (read first)",
22
+ "",
23
+ "You are an unattended fleet worker — there is no human watching this session and nobody will answer questions.",
24
+ "- Everything in this prompt is PRE-APPROVED. Do not ask for approval; do the work.",
25
+ "- Do NOT invoke any skill or workflow with a human approval gate (e.g. brainstorming hard-gates). Skip gated steps and execute the task directly.",
26
+ "- Do not end your turn with a question, a plan awaiting approval, or an 'Approve?' prompt. End your turn only when every REQUIRED output below exists on disk.",
27
+ "- Ambiguity is yours to resolve: decide, record the decision in your output, and continue.",
28
+ "",
29
+ );
30
+
20
31
  const fleetTs = basename(fleetRoot);
21
32
 
22
33
  out.push(`# Fleet worker: ${workerId}`, "", `Type: ${worker.type}`, "", `## Task`, "", worker.task, "");
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
@@ -1,7 +1,7 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { contractFailureNote, verifyOutputs } from "./contracts.js";
4
- import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
4
+ import { archiveIteration, initFleetState, patchNode, relaunchResetIds, 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";
7
7
  import { commitWorktree, createWorktree, prepareIntegratorWorktree } from "./worktree.js";
@@ -20,10 +20,20 @@ export interface RunFleetOpts {
20
20
  killSwitch?: { killed: boolean };
21
21
  pauseSwitch?: { paused: boolean };
22
22
  nodeKills?: ReadonlySet<string>;
23
+ relaunchRequests?: Set<string>;
23
24
  resumeFrom?: FleetState;
24
25
  continuePass?: boolean;
25
26
  onIterationEnd?: (snap: IterationSnapshot) => void;
26
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);
27
37
  }
28
38
 
29
39
  const FAILED: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed", "blocked"]);
@@ -51,6 +61,19 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
51
61
  let state: FleetState;
52
62
  if (opts.resumeFrom) {
53
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
+ }
54
77
  await writeState(fleetRoot, state);
55
78
  if (allNodesTerminal(state, spec) && !opts.continuePass) {
56
79
  state = resetForIteration(state, spec);
@@ -70,6 +93,15 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
70
93
  };
71
94
 
72
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
+ }
73
105
 
74
106
  const repoCwdFor = (nodeId: string): string =>
75
107
  typeof opts.repoCwd === "function" ? opts.repoCwd(nodeId) : opts.repoCwd;
@@ -111,6 +143,31 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
111
143
 
112
144
  const runPass = async (): Promise<void> => {
113
145
  while (true) {
146
+ // apply queued relaunch requests (lost-wakeup fix, issue #1 bug 2)
147
+ if (opts.relaunchRequests && opts.relaunchRequests.size > 0) {
148
+ for (const id of [...opts.relaunchRequests]) {
149
+ opts.relaunchRequests.delete(id);
150
+ const n = state.nodes[id];
151
+ if (!n || !FAILED.has(n.status)) continue;
152
+ for (const rid of relaunchResetIds(spec, state, id)) {
153
+ const rn = state.nodes[rid];
154
+ if (!rn) continue;
155
+ if (rid === id && !FAILED.has(rn.status)) continue;
156
+ if (rid !== id && rn.status !== "blocked") continue;
157
+ await patch(rid, {
158
+ status: "pending",
159
+ started_at: undefined,
160
+ ended_at: undefined,
161
+ turns: 0,
162
+ tokens: 0,
163
+ cost_usd_estimate: 0,
164
+ produced_outputs: [],
165
+ contract_result: undefined,
166
+ status_note: undefined,
167
+ });
168
+ }
169
+ }
170
+ }
114
171
  // auto-initialize workers inserted into the spec after the run started
115
172
  for (const w of spec.workers) {
116
173
  if (!state.nodes[w.id]) {
@@ -144,6 +201,32 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
144
201
  }
145
202
  break;
146
203
  }
204
+ // honor kill requests for not-yet-running nodes (incl. blocked, issue #1 bug 3)
205
+ for (const w of spec.workers) {
206
+ const n = state.nodes[w.id];
207
+ if (!n) continue;
208
+ if (!opts.nodeKills?.has(w.id)) continue;
209
+ if (n.status === "pending" || n.status === "ready" || n.status === "blocked") {
210
+ await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
211
+ }
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
+
147
230
  // dispatch ready
148
231
  const activeCount = running.size;
149
232
  let slots = spec.config.max_concurrent - activeCount;
@@ -214,17 +297,57 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
214
297
  }
215
298
  }
216
299
 
300
+ const dispatchMs = Date.now();
217
301
  await patch(w.id, { status: "running", started_at: new Date().toISOString() });
218
- 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
+
219
339
  if (opts.killSwitch?.killed) return;
220
340
  if (opts.nodeKills?.has(w.id)) {
221
- 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 });
222
342
  return;
223
343
  }
344
+
224
345
  if (!res.ok) {
225
- 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);
226
348
  return;
227
349
  }
350
+
228
351
  if (w.worktree) {
229
352
  try {
230
353
  await commitWorktree({
@@ -238,9 +361,9 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
238
361
  await patch(w.id, {
239
362
  status: "failed",
240
363
  ended_at: new Date().toISOString(),
241
- turns: res.turns,
242
- tokens: res.tokens,
243
- cost_usd_estimate: res.cost ?? 0,
364
+ turns: totals.turns,
365
+ tokens: totals.tokens,
366
+ cost_usd_estimate: totals.cost,
244
367
  status_note: `commit failed: ${msg}`,
245
368
  });
246
369
  return;
@@ -250,22 +373,25 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
250
373
  workerDir: `${fleetRoot}/workers/${w.id}`,
251
374
  repoCwd: repoCwdFor(w.id),
252
375
  outputs: w.outputs,
376
+ notBeforeMs: dispatchMs,
253
377
  });
378
+ const costUnknown = res.tokens > 0 && res.cost === 0;
379
+ const costNote = costUnknown ? `cost unavailable: no pricing for model (${res.tokens} tokens used)` : undefined;
254
380
  await patch(w.id, {
255
381
  status: contract.ok ? "completed" : "contract_failed",
256
382
  ended_at: new Date().toISOString(),
257
- turns: res.turns,
258
- tokens: res.tokens,
259
- cost_usd_estimate: res.cost ?? 0,
383
+ turns: totals.turns,
384
+ tokens: totals.tokens,
385
+ cost_usd_estimate: totals.cost,
260
386
  contract_result: contract,
261
387
  produced_outputs: contract.checks.filter((c) => c.ok || c.actualPath).map((c) => c.path),
262
- status_note: contract.ok ? undefined : contractFailureNote(contract.checks),
388
+ status_note: contract.ok ? costNote : [contractFailureNote(contract.checks), costNote].filter(Boolean).join(" · "),
263
389
  });
264
390
  if (contract.ok) {
265
391
  const note = await opts.onNodeCompleted?.(w.id);
266
392
  if (note) await patch(w.id, { status_note: note });
267
393
  }
268
- }).finally(() => running.delete(p));
394
+ })().finally(() => running.delete(p));
269
395
  running.add(p);
270
396
  }
271
397
  if (running.size > 0) {
@@ -275,13 +401,46 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
275
401
  return !!n && TERMINAL_NODE_STATUSES.has(n.status);
276
402
  })) {
277
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
+ }
278
431
  }
279
432
  }
280
433
  await Promise.allSettled([...running]);
281
434
  };
282
435
 
436
+ const runPassUntilDrained = async () => {
437
+ do {
438
+ await runPass();
439
+ } while (opts.relaunchRequests && opts.relaunchRequests.size > 0);
440
+ };
441
+
283
442
  if (!loop) {
284
- await runPass();
443
+ await runPassUntilDrained();
285
444
  const anyFailed = spec.workers.some((w) =>
286
445
  ["failed", "contract_failed"].includes(state.nodes[w.id]?.status ?? ""));
287
446
  const finalStatus = opts.killSwitch?.killed ? "killed" : anyFailed ? "failed" : "completed";
@@ -311,7 +470,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
311
470
 
312
471
  await opts.prepareIteration?.(n, state);
313
472
 
314
- await runPass();
473
+ await runPassUntilDrained();
315
474
 
316
475
  let verdict: Verdict | null = null;
317
476
  let verdictBody: string | null = null;
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
 
@@ -121,34 +123,34 @@ export function patchNode(
121
123
  return { ...state, nodes, cost_usd_estimate: cost };
122
124
  }
123
125
 
124
- export function resetForRelaunch(state: FleetState, spec: FleetSpec, nodeId: string): FleetState {
126
+ export function relaunchResetIds(spec: FleetSpec, state: FleetState, nodeId: string): string[] {
125
127
  if (!state.nodes[nodeId]) throw new Error(`unknown node "${nodeId}"`);
126
-
127
128
  const dependents: Record<string, string[]> = {};
128
129
  for (const w of spec.workers) {
129
130
  for (const dep of w.depends_on) {
130
131
  (dependents[dep] ??= []).push(w.id);
131
132
  }
132
133
  }
133
-
134
- const downstream = new Set<string>();
135
- const walk = (id: string) => {
136
- for (const d of dependents[id] ?? []) {
137
- if (!downstream.has(d)) {
138
- downstream.add(d);
139
- walk(d);
140
- }
134
+ const out = [nodeId];
135
+ const seen = new Set(out);
136
+ const queue = [nodeId];
137
+ while (queue.length) {
138
+ for (const d of dependents[queue.shift()!] ?? []) {
139
+ if (seen.has(d)) continue;
140
+ seen.add(d);
141
+ if (state.nodes[d]?.status === "blocked") out.push(d);
142
+ queue.push(d);
141
143
  }
142
- };
143
- walk(nodeId);
144
+ }
145
+ return out;
146
+ }
144
147
 
148
+ export function resetForRelaunch(state: FleetState, spec: FleetSpec, nodeId: string): FleetState {
145
149
  const fresh: NodeState = { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] };
146
150
  const nodes = { ...state.nodes };
147
- nodes[nodeId] = fresh;
148
- for (const id of downstream) {
149
- if (state.nodes[id].status === "blocked") {
150
- nodes[id] = fresh;
151
- }
151
+ for (const id of relaunchResetIds(spec, state, nodeId)) {
152
+ if (id !== nodeId && state.nodes[id]?.status !== "blocked") continue;
153
+ nodes[id] = fresh;
152
154
  }
153
155
 
154
156
  const cost = fleetCost({ ...state, nodes });