@fradser/pi-agent-teams 0.5.2 → 0.5.3

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/spawner.ts CHANGED
@@ -8,13 +8,17 @@
8
8
  */
9
9
 
10
10
  import { spawn } from "node:child_process";
11
+ import { randomUUID } from "node:crypto";
11
12
  import * as fs from "node:fs";
12
13
  import * as os from "node:os";
13
14
  import * as path from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
- import { extractTextContent } from "@fradser/pi-kit";
16
+ import { extractTextContent, resolvePiCli } from "@fradser/pi-kit";
16
17
  import type { WorkerUsage } from "./types";
17
18
 
19
+ /** High default safety cap; explicit task budgets can still be lower. */
20
+ export const DEFAULT_TURN_BUDGET = 100;
21
+
18
22
  /**
19
23
  * Build the one-task execution prompt for a spawned worker.
20
24
  *
@@ -26,9 +30,12 @@ export function buildAutonomousPrompt(opts: {
26
30
  role: string;
27
31
  prompt: string;
28
32
  taskId?: string;
29
- timeoutSec: number;
33
+ turnBudget?: number;
30
34
  }): string {
31
35
  const taskLine = `\nAssigned task: [${opts.taskId}].\nWhen finished, you MUST call teammate_message with status="completed" to submit your FULL final deliverable.`;
36
+ const budgetLine = opts.turnBudget
37
+ ? `4. You have a maximum of ${opts.turnBudget} assistant turn(s) — manage your turn budget, avoid unnecessary exploration, and deliver your final message before the budget is exhausted.`
38
+ : `4. There is no turn budget — work until the task is complete. Do not rush or truncate your work.`;
32
39
 
33
40
  return `You are a FULLY AUTONOMOUS teammate named "${opts.name}" (agent: ${opts.role}) in a pi multi-agent team run.
34
41
 
@@ -40,7 +47,7 @@ YOUR ROLE IN THIS RUN:
40
47
  1. Work directly on your assigned scope and declared paths. DAG upstream results are ALREADY injected into this prompt below; do not poll for them.
41
48
  2. Report plans, progress, and blockers with teammate_message. When finished, call teammate_message with status="completed" and put your FULL deliverable in the body. If blocked or failed, use status="failed" and explain the error.
42
49
  3. There is no peer or leader-to-worker message channel. Make decisions within the assigned task and report blockers instead of waiting for a reply.
43
- 4. The hard wall-clock cap is ${opts.timeoutSec}s — manage your time budget, avoid unnecessary exploration, and deliver your final message before the deadline.
50
+ ${budgetLine}
44
51
 
45
52
  BOUND CAPABILITIES:
46
53
  - teammate_message reports progress or a final deliverable to the team leader.`;
@@ -59,10 +66,10 @@ export interface SpawnPiWorkerOptions {
59
66
  env?: Record<string, string | undefined>;
60
67
  /** Working directory for the child (defaults to the parent's cwd). */
61
68
  cwd?: string;
62
- /** Kill the worker after this many milliseconds (undefined = no timeout). */
63
- timeoutMs?: number;
64
- /** Output parsing mode. "json" (default) parses usage from JSONL output. */
65
- mode?: "json" | "text";
69
+ /** Maximum assistant turns for this worker (defaults to DEFAULT_TURN_BUDGET). */
70
+ turnBudget?: number;
71
+ /** Output parsing mode. RPC keeps stdin available for runtime steering. */
72
+ mode?: "json" | "rpc" | "text";
66
73
  /** Called whenever JSON-mode worker output reveals live model or tool activity. */
67
74
  onUpdate?: (update: WorkerProgressUpdate) => void;
68
75
  /** Called once with the captured output when the child exits. */
@@ -78,7 +85,7 @@ export interface WorkerProcessResult {
78
85
  stdout: string;
79
86
  stderr: string;
80
87
  usage?: WorkerUsage;
81
- timedOut: boolean;
88
+ turnBudgetExceeded?: boolean;
82
89
  }
83
90
 
84
91
  /** Live state extracted from a spawned worker's JSON-mode output. */
@@ -91,13 +98,13 @@ export interface WorkerProgressUpdate {
91
98
  }
92
99
 
93
100
  /** A worker only succeeds after a normal zero exit; signals are failures. */
94
- export function isSuccessfulWorkerExit(result: Pick<WorkerProcessResult, "exitCode" | "signal" | "timedOut">): boolean {
95
- return result.exitCode === 0 && result.signal === null && !result.timedOut;
101
+ export function isSuccessfulWorkerExit(result: Pick<WorkerProcessResult, "exitCode" | "signal">): boolean {
102
+ return result.exitCode === 0 && result.signal === null;
96
103
  }
97
104
 
98
- /** A reported completion remains successful when the harness closes it or teardown observes a signal/timeout. */
105
+ /** A reported completion remains successful when the harness closes it or teardown observes a signal. */
99
106
  export function isCompletedWorkerExit(
100
- result: Pick<WorkerProcessResult, "exitCode" | "signal" | "timedOut">,
107
+ result: Pick<WorkerProcessResult, "exitCode" | "signal">,
101
108
  reportedCompleted = false,
102
109
  ): boolean {
103
110
  return isSuccessfulWorkerExit(result) || reportedCompleted;
@@ -152,68 +159,18 @@ export class CancellationIntents {
152
159
  /** Live workers by teammate name — lets the panel interrupt/stop a running worker. */
153
160
  const workers = new Map<string, ReturnType<typeof spawn>>();
154
161
 
155
- export interface WorkerTimeoutHandle {
156
- cancel(): void;
157
- dispose(): void;
158
- wasCancelled(): boolean;
159
- didTimeout(): boolean;
162
+ /** Send a steering message to an RPC worker without adding a peer mailbox. */
163
+ export function sendWorkerSteer(name: string, message: string): boolean {
164
+ const child = workers.get(name);
165
+ if (!child?.stdin || child.stdin.destroyed || !child.stdin.writable) return false;
166
+ child.stdin.write(`${JSON.stringify({ type: "steer", message })}\n`);
167
+ return true;
160
168
  }
161
169
 
162
- const workerTimeouts = new WeakMap<ReturnType<typeof spawn>, WorkerTimeoutHandle>();
163
-
164
170
  function isChildRunning(child: ReturnType<typeof spawn>): boolean {
165
171
  return child.exitCode === null && child.signalCode === null;
166
172
  }
167
173
 
168
- export function registerWorkerTimeout(
169
- child: ReturnType<typeof spawn>,
170
- timeoutMs: number,
171
- ): WorkerTimeoutHandle {
172
- let cancelled = false;
173
- let disposed = false;
174
- let timedOut = false;
175
- let timer: ReturnType<typeof setTimeout> | undefined;
176
-
177
- const handle: WorkerTimeoutHandle = {
178
- cancel() {
179
- if (disposed) return;
180
- cancelled = true;
181
- if (timer) clearTimeout(timer);
182
- disposed = true;
183
- workerTimeouts.delete(child);
184
- },
185
- dispose() {
186
- if (disposed) return;
187
- if (timer) clearTimeout(timer);
188
- disposed = true;
189
- workerTimeouts.delete(child);
190
- },
191
- wasCancelled: () => cancelled,
192
- didTimeout: () => timedOut,
193
- };
194
-
195
- timer = setTimeout(() => {
196
- if (disposed || cancelled) return;
197
- if (!isChildRunning(child)) {
198
- handle.dispose();
199
- return;
200
- }
201
- try {
202
- if (child.kill("SIGKILL")) timedOut = true;
203
- } catch {
204
- // A failed kill is not a timeout outcome; close/error will report the
205
- // actual child result.
206
- }
207
- }, timeoutMs);
208
- timer.unref?.();
209
- workerTimeouts.set(child, handle);
210
- return handle;
211
- }
212
-
213
- export function cancelWorkerTimeout(child: ReturnType<typeof spawn>): void {
214
- workerTimeouts.get(child)?.cancel();
215
- }
216
-
217
174
  function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promise<boolean> {
218
175
  return new Promise((resolve) => {
219
176
  let settled = false;
@@ -238,7 +195,6 @@ function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promi
238
195
  * resolve only after a bounded wait observes the child `close` event.
239
196
  */
240
197
  export async function terminateChildProcess(child: ReturnType<typeof spawn>, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
241
- cancelWorkerTimeout(child);
242
198
  if (!isChildRunning(child)) return false;
243
199
 
244
200
  const closedAfterTerm = waitForClose(child, graceMs);
@@ -289,9 +245,20 @@ export async function terminateAllWorkers(graceMs = CANCEL_GRACE_MS): Promise<vo
289
245
  const TASK_ARG_LIMIT = 8000;
290
246
  const OUTPUT_CAP = 16_000;
291
247
 
248
+ function appendCapped(chunks: string[], chunk: string, cap: number): void {
249
+ chunks.push(chunk);
250
+ let total = chunks.reduce((sum, value) => sum + value.length, 0);
251
+ while (total > cap && chunks.length > 1) {
252
+ total -= chunks.shift()?.length ?? 0;
253
+ }
254
+ if (total > cap && chunks.length === 1) chunks[0] = chunks[0].slice(-cap);
255
+ }
256
+
292
257
  type JsonEvent = {
293
258
  type?: string;
259
+ toolCallId?: string;
294
260
  toolName?: string;
261
+ args?: unknown;
295
262
  assistantMessageEvent?: {
296
263
  type?: string;
297
264
  delta?: string;
@@ -321,18 +288,53 @@ export function parseWorkerOutput(stdout: string): { text: string; usage?: Worke
321
288
  return { text: state.text.trim(), usage: state.usage };
322
289
  }
323
290
 
291
+ /** Parse the latest live progress state from a worker JSON stream. */
292
+ export function parseWorkerProgress(stdout: string): WorkerProgressUpdate {
293
+ const state = createWorkerStreamState();
294
+ for (const line of stdout.split("\n")) applyWorkerJsonLine(state, line);
295
+ return {
296
+ text: truncate(state.text, OUTPUT_CAP),
297
+ activeTool: state.activeTool,
298
+ liveThinking: truncate(state.thinking, OUTPUT_CAP),
299
+ turns: state.turns,
300
+ finalResponse: state.finalResponse,
301
+ };
302
+ }
303
+
324
304
  interface WorkerStreamState {
325
305
  text: string;
326
306
  thinking: string;
327
307
  toolcallArgs: string;
328
308
  activeTool?: string;
309
+ activeToolCallId?: string;
310
+ activeTools: Map<string, string>;
329
311
  turns: number;
330
312
  finalResponse?: boolean;
331
313
  usage?: WorkerUsage;
332
314
  }
333
315
 
334
316
  function createWorkerStreamState(): WorkerStreamState {
335
- return { text: "", thinking: "", toolcallArgs: "", turns: 0 };
317
+ return { text: "", thinking: "", toolcallArgs: "", activeTools: new Map(), turns: 0 };
318
+ }
319
+
320
+ function setActiveTool(state: WorkerStreamState, toolCallId: string | undefined, label: string): void {
321
+ const key = toolCallId ?? `tool-${state.activeTools.size}`;
322
+ state.activeTools.set(key, label);
323
+ state.activeToolCallId = key;
324
+ state.activeTool = label;
325
+ }
326
+
327
+ function clearActiveTool(state: WorkerStreamState, toolCallId: string | undefined): void {
328
+ if (toolCallId) state.activeTools.delete(toolCallId);
329
+ else state.activeTools.clear();
330
+ const next = [...state.activeTools.entries()].at(-1);
331
+ state.activeToolCallId = next?.[0];
332
+ state.activeTool = next?.[1];
333
+ }
334
+
335
+ function toolExecutionLabel(toolName: string | undefined, args: unknown): string {
336
+ const serialized = typeof args === "string" ? args : JSON.stringify(args ?? {});
337
+ return toolcallLabel(serialized) ?? toolName ?? "tool";
336
338
  }
337
339
 
338
340
  /** Human-readable label from a partially streamed tool-call argument JSON. */
@@ -363,9 +365,31 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
363
365
  } catch {
364
366
  return false;
365
367
  }
368
+ if (event.type === "message_start" && event.message?.role === "assistant") {
369
+ state.thinking = "";
370
+ state.activeTool = undefined;
371
+ state.activeToolCallId = undefined;
372
+ state.activeTools.clear();
373
+ return true;
374
+ }
375
+ if (event.type === "tool_execution_start") {
376
+ setActiveTool(state, event.toolCallId, toolExecutionLabel(event.toolName, event.args));
377
+ return true;
378
+ }
379
+ if (event.type === "tool_execution_end") {
380
+ if (!event.toolCallId || state.activeTools.has(event.toolCallId)) {
381
+ clearActiveTool(state, event.toolCallId);
382
+ return true;
383
+ }
384
+ return false;
385
+ }
366
386
  if (event.type !== "message_update") {
367
387
  if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
368
388
  state.turns++;
389
+ state.activeTool = undefined;
390
+ state.activeToolCallId = undefined;
391
+ state.activeTools.clear();
392
+ state.thinking = "";
369
393
  if (event.message.stopReason === "stop") state.finalResponse = true;
370
394
  const parts = extractTextContent(event.message.content, "");
371
395
  if (parts.trim()) state.text = parts;
@@ -386,14 +410,18 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
386
410
  if (!sub) return false;
387
411
  switch (sub.type) {
388
412
  case "text_delta":
413
+ state.activeTool = undefined;
389
414
  state.text += sub.delta ?? "";
390
415
  return true;
391
416
  case "thinking_delta":
417
+ state.activeTool = undefined;
392
418
  state.thinking += sub.delta ?? "";
393
419
  return true;
394
420
  case "toolcall_start":
395
421
  state.toolcallArgs = "";
396
422
  state.activeTool = undefined;
423
+ state.activeToolCallId = undefined;
424
+ state.activeTools.clear();
397
425
  return true;
398
426
  case "toolcall_delta": {
399
427
  state.toolcallArgs += sub.delta ?? "";
@@ -401,14 +429,14 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
401
429
  if (label) state.activeTool = label;
402
430
  return true;
403
431
  }
404
- case "toolcall_end": {
405
- const tc = (sub as { toolCall?: { name?: string } }).toolCall;
406
- // Prefer the richer delta-derived label (e.g. "bash: echo hello");
407
- // fall back to the bare tool name only when no label could be parsed.
408
- if (tc?.name && !state.activeTool) state.activeTool = tc.name;
432
+ case "toolcall_end":
433
+ // The tool call has been emitted; execution events now own the current
434
+ // activity label until the tool result arrives.
409
435
  state.toolcallArgs = "";
436
+ state.activeTool = undefined;
437
+ state.activeToolCallId = undefined;
438
+ state.activeTools.clear();
410
439
  return true;
411
- }
412
440
  default:
413
441
  return false;
414
442
  }
@@ -425,61 +453,11 @@ function truncate(text: string, cap: number): string {
425
453
  return `${text.slice(0, cap)}\n... [truncated ${text.length - cap} chars]`;
426
454
  }
427
455
 
428
- export interface PiCliResolution {
429
- /** Command to execute — either a runtime (node/bun) or a `pi` binary. */
430
- command: string;
431
- /** Leading args: the CLI script path when running via a runtime, else empty. */
432
- args: string[];
433
- }
434
-
435
- /** Whether a file is the pi coding-agent CLI script (verified against its package manifest). */
436
- function isPiPackageScript(filePath: string): boolean {
437
- try {
438
- const resolved = fs.realpathSync(filePath);
439
- if (!/\.(mjs|cjs|js)$/.test(resolved)) return false;
440
- let dir = path.dirname(resolved);
441
- while (dir !== path.dirname(dir)) {
442
- const pkgPath = path.join(dir, "package.json");
443
- if (fs.existsSync(pkgPath)) {
444
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
445
- return pkg.name === "@earendil-works/pi-coding-agent";
446
- }
447
- dir = path.dirname(dir);
448
- }
449
- } catch {
450
- // Unreadable paths are simply not candidates.
451
- }
452
- return false;
453
- }
454
-
455
456
  /**
456
- * Resolve how to launch a worker Pi process.
457
- *
458
- * Resolution order:
459
- * 1. `process.argv[1]` — the current Pi process entry, verified against the
460
- * package manifest (avoids mistaking unrelated scripts for the CLI).
461
- * 2. The installed `@earendil-works/pi-coding-agent` package's `dist/cli.js`.
462
- * 3. A `pi` binary on PATH (best effort).
457
+ * Resolve how to launch a Pi CLI process.
458
+ * Re-exported from pi-kit for backward compatibility.
463
459
  */
464
- export function resolvePiCli(): PiCliResolution {
465
- const argv1 = process.argv[1];
466
- if (argv1 && isPiPackageScript(argv1)) {
467
- return { command: process.execPath, args: [path.resolve(argv1)] };
468
- }
469
-
470
- try {
471
- const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
472
- const packageRoot = path.dirname(path.dirname(entry));
473
- const cliPath = path.join(packageRoot, "dist", "cli.js");
474
- if (fs.existsSync(cliPath)) {
475
- return { command: process.execPath, args: [cliPath] };
476
- }
477
- } catch {
478
- // Package resolution is best-effort; fall through to PATH.
479
- }
480
-
481
- return { command: "pi", args: [] };
482
- }
460
+ export { resolvePiCli } from "@fradser/pi-kit";
483
461
 
484
462
  /**
485
463
  * Spawn a child Pi process that executes the task description in
@@ -491,11 +469,10 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
491
469
  // binary surfaces later as a child "error" event through onError.
492
470
  const cli = resolvePiCli();
493
471
  const workerExtension = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
472
+ const mode = options.mode ?? "json";
494
473
  const args: string[] = [
495
474
  ...cli.args,
496
- "--print",
497
- "--mode",
498
- "json",
475
+ ...(mode === "rpc" ? ["--mode", "rpc"] : ["--print", "--mode", mode]),
499
476
  "--no-session",
500
477
  "--no-extensions",
501
478
  "--extension",
@@ -524,14 +501,14 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
524
501
  error: error instanceof Error ? error.message : String(error),
525
502
  });
526
503
 
504
+ let taskText = `Task: ${options.description}`;
527
505
  try {
528
- const taskText = `Task: ${options.description}`;
529
- if (taskText.length > TASK_ARG_LIMIT) {
506
+ if (mode !== "rpc" && taskText.length > TASK_ARG_LIMIT) {
530
507
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "teammate-"));
531
508
  const taskFile = path.join(tempDir, "task.md");
532
509
  fs.writeFileSync(taskFile, taskText, { mode: 0o600 });
533
510
  args.push(`@${taskFile}`);
534
- } else {
511
+ } else if (mode !== "rpc") {
535
512
  args.push(taskText);
536
513
  }
537
514
  } catch (error) {
@@ -544,7 +521,7 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
544
521
  child = spawn(cli.command, args, {
545
522
  cwd: options.cwd,
546
523
  env: { ...process.env, ...options.env },
547
- stdio: ["ignore", "pipe", "pipe"],
524
+ stdio: [mode === "rpc" ? "pipe" : "ignore", "pipe", "pipe"],
548
525
  });
549
526
  } catch (error) {
550
527
  cleanupTempDir();
@@ -552,12 +529,16 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
552
529
  }
553
530
  if (options.workerName) workers.set(options.workerName, child);
554
531
 
532
+ if (mode === "rpc") {
533
+ child.stdin?.write(`${JSON.stringify({ type: "prompt", id: randomUUID(), message: taskText })}\n`);
534
+ }
535
+
555
536
  const stdoutChunks: string[] = [];
556
537
  const stderrChunks: string[] = [];
557
538
  const streamState = createWorkerStreamState();
558
539
  let stdoutBuffer = "";
559
- let timeoutHandle: WorkerTimeoutHandle | undefined;
560
540
  let settled = false;
541
+ let turnBudgetExceeded = false;
561
542
  const emitProgress = () => options.onUpdate?.({
562
543
  text: truncate(streamState.text, OUTPUT_CAP),
563
544
  activeTool: streamState.activeTool,
@@ -565,24 +546,25 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
565
546
  turns: streamState.turns,
566
547
  finalResponse: streamState.finalResponse,
567
548
  });
568
- child.stdout.on("data", (chunk: Buffer) => {
549
+ child.stdout?.on("data", (chunk: Buffer) => {
569
550
  const text = chunk.toString();
570
- stdoutChunks.push(text);
551
+ appendCapped(stdoutChunks, text, OUTPUT_CAP * 2);
571
552
  stdoutBuffer += text;
572
553
  const lines = stdoutBuffer.split("\n");
573
554
  stdoutBuffer = lines.pop() ?? "";
574
555
  let changed = false;
575
- for (const line of lines) changed = applyWorkerJsonLine(streamState, line) || changed;
556
+ for (const line of lines) {
557
+ changed = applyWorkerJsonLine(streamState, line) || changed;
558
+ if (options.turnBudget && streamState.turns >= options.turnBudget && !streamState.finalResponse && !turnBudgetExceeded) {
559
+ turnBudgetExceeded = true;
560
+ void terminateChildProcess(child);
561
+ }
562
+ }
576
563
  if (changed) emitProgress();
577
564
  });
578
- child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk.toString()));
579
-
580
- if (options.timeoutMs) {
581
- timeoutHandle = registerWorkerTimeout(child, options.timeoutMs);
582
- }
565
+ child.stderr?.on("data", (chunk: Buffer) => appendCapped(stderrChunks, chunk.toString(), OUTPUT_CAP * 2));
583
566
 
584
567
  child.on("error", (error) => {
585
- timeoutHandle?.dispose();
586
568
  cleanupTempDir();
587
569
  settled = true;
588
570
  if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
@@ -590,8 +572,6 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
590
572
  });
591
573
 
592
574
  child.on("close", (code, signal) => {
593
- const timedOut = timeoutHandle?.didTimeout() ?? false;
594
- timeoutHandle?.dispose();
595
575
  cleanupTempDir();
596
576
  // A spawn failure already reported via onError (Node fires error then
597
577
  // close) — do not double-report through onExit, which would let a failed
@@ -599,12 +579,12 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
599
579
  if (settled) return;
600
580
  if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
601
581
  const rawStdout = stdoutChunks.join("");
602
- const parsed = (options.mode ?? "json") === "json"
603
- ? parseWorkerOutput(rawStdout)
604
- : { text: rawStdout.trim(), usage: undefined };
582
+ const parsed = mode === "text"
583
+ ? { text: rawStdout.trim(), usage: undefined }
584
+ : parseWorkerOutput(rawStdout);
605
585
  const stdout = truncate(parsed.text, OUTPUT_CAP);
606
586
  const stderr = truncate(stderrChunks.join("").trim(), OUTPUT_CAP);
607
- options.onExit({ pid: child.pid ?? 0, exitCode: code, signal, stdout, stderr, usage: parsed.usage, timedOut });
587
+ options.onExit({ pid: child.pid ?? 0, exitCode: code, signal, stdout, stderr, usage: parsed.usage, turnBudgetExceeded });
608
588
  });
609
589
 
610
590
  return { pid: child.pid ?? 0 };
package/src/state.ts CHANGED
@@ -84,6 +84,28 @@ export function normalizeNodePaths(paths: string[]): { ok: true; paths: string[]
84
84
 
85
85
  // ── Run creation ──────────────────────────────────────────────────
86
86
 
87
+ export const MAX_STRUCTURED_OUTPUT_BYTES = 64 * 1024;
88
+ export const MAX_STRUCTURED_OUTPUT_DEPTH = 8;
89
+ export const MAX_FANOUT_ITEMS = 32;
90
+
91
+ export function structuredOutputSize(value: unknown): number {
92
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
93
+ }
94
+
95
+ export function structuredOutputDepth(value: unknown): number {
96
+ if (!value || typeof value !== "object") return 0;
97
+ if (Array.isArray(value)) return 1 + Math.max(0, ...value.map(structuredOutputDepth));
98
+ return 1 + Math.max(0, ...Object.values(value as Record<string, unknown>).map(structuredOutputDepth));
99
+ }
100
+
101
+ export function validateStructuredOutput(value: unknown): string | undefined {
102
+ let serialized: string;
103
+ try { serialized = JSON.stringify(value); } catch { return "Structured output must be JSON-serializable."; }
104
+ if (Buffer.byteLength(serialized, "utf8") > MAX_STRUCTURED_OUTPUT_BYTES) return `Structured output exceeds ${MAX_STRUCTURED_OUTPUT_BYTES} bytes.`;
105
+ if (structuredOutputDepth(value) > MAX_STRUCTURED_OUTPUT_DEPTH) return `Structured output exceeds depth ${MAX_STRUCTURED_OUTPUT_DEPTH}.`;
106
+ return undefined;
107
+ }
108
+
87
109
  export interface RunNodeInput {
88
110
  id: string;
89
111
  agent: string;
@@ -91,7 +113,10 @@ export interface RunNodeInput {
91
113
  paths: string[];
92
114
  access: Node["access"];
93
115
  model?: string;
94
- timeoutMs?: number;
116
+ mode?: Node["mode"];
117
+ turnBudget?: number;
118
+ forkContext?: string[];
119
+ inputBindings?: Record<string, string>;
95
120
  dependsOn: string[];
96
121
  }
97
122
 
@@ -122,7 +147,6 @@ export function createRun(
122
147
  concurrency: number;
123
148
  worktree: boolean;
124
149
  background?: boolean;
125
- timeoutMs?: number;
126
150
  summarize?: boolean;
127
151
  summaryAgent?: string;
128
152
  nodes: RunNodeInput[];
@@ -146,6 +170,15 @@ export function createRun(
146
170
  return { ok: false, error: `Node "${node.id}" depends on unknown node "${dep}".` };
147
171
  }
148
172
  }
173
+ for (const source of Object.values(node.inputBindings ?? {})) {
174
+ const dependencyId = source.split("#", 1)[0];
175
+ if (!node.dependsOn.includes(dependencyId)) {
176
+ return { ok: false, error: `Node "${node.id}" input binding must reference a dependency: "${source}".` };
177
+ }
178
+ }
179
+ if (node.forkContext?.some((dependencyId) => !node.dependsOn.includes(dependencyId))) {
180
+ return { ok: false, error: `Node "${node.id}" forkContext must reference dependencies only.` };
181
+ }
149
182
  normalized.push({
150
183
  id: node.id,
151
184
  workerKey: "", // filled after the run id is assigned
@@ -154,7 +187,10 @@ export function createRun(
154
187
  paths: normalizedPaths.paths,
155
188
  access: node.access,
156
189
  model: node.model,
157
- timeoutMs: node.timeoutMs,
190
+ mode: node.mode,
191
+ turnBudget: node.turnBudget,
192
+ forkContext: node.forkContext ? [...new Set(node.forkContext)] : undefined,
193
+ inputBindings: node.inputBindings ? { ...node.inputBindings } : undefined,
158
194
  dependsOn: [...new Set(node.dependsOn)],
159
195
  status: "pending",
160
196
  createdAt: Date.now(),
@@ -208,8 +244,6 @@ export function createRun(
208
244
  concurrency: input.concurrency,
209
245
  worktree: input.worktree,
210
246
  background: input.background ?? true,
211
- timeoutMs: input.timeoutMs,
212
- deadlineAt: input.timeoutMs ? Date.now() + input.timeoutMs : undefined,
213
247
  nodes: {},
214
248
  createdAt: Date.now(),
215
249
  updatedAt: Date.now(),
@@ -526,40 +560,6 @@ export function cancelNode(runId: string, nodeId: string): { ok: boolean; runnin
526
560
  return { ok: true, runningNodeIds };
527
561
  }
528
562
 
529
- /**
530
- * Fail a run for exceeding its hard wall-clock cap: pending nodes are
531
- * cancelled and the run is marked failed; live workers must be terminated by
532
- * the caller.
533
- */
534
- export function failRunTimeout(runId: string, errorMessage: string): { ok: boolean; runningNodeIds: string[]; error?: string } {
535
- const run = state.runs[runId];
536
- if (!run) return { ok: false, runningNodeIds: [], error: `Run "${runId}" not found.` };
537
- if (run.status !== "running") return { ok: false, runningNodeIds: [], error: `Run "${runId}" is already ${run.status}.` };
538
- const runningNodeIds: string[] = [];
539
- for (const node of Object.values(run.nodes)) {
540
- if (node.status === "running") {
541
- // The node's worker is terminated by the caller; clearing the spawn means
542
- // its late close event cannot pollute a retried (reset) node.
543
- node.status = "failed";
544
- node.errorMessage = errorMessage;
545
- node.completedAt = Date.now();
546
- node.spawn = undefined;
547
- node.updatedAt = Date.now();
548
- runningNodeIds.push(node.id);
549
- } else if (node.status === "pending") {
550
- node.status = "cancelled";
551
- node.completedAt = Date.now();
552
- node.updatedAt = Date.now();
553
- }
554
- }
555
- run.status = "failed";
556
- run.errorMessage = errorMessage;
557
- run.finishedAt = Date.now();
558
- run.updatedAt = Date.now();
559
- markStateDirty();
560
- return { ok: true, runningNodeIds };
561
- }
562
-
563
563
  /**
564
564
  * Reset failed and cancelled nodes of a settled run back to pending so the
565
565
  * run can be re-dispatched. Completed nodes are retained. The run returns to
@@ -612,6 +612,7 @@ export function retryRun(runId: string, nodeIds?: string[]): { ok: boolean; rese
612
612
  node.errorMessage = undefined;
613
613
  node.completedAt = undefined;
614
614
  node.spawn = undefined;
615
+ node.nodeFollowUpSent = undefined;
615
616
  node.updatedAt = Date.now();
616
617
  }
617
618
  const reset = [...resetSet];
@@ -620,9 +621,6 @@ export function retryRun(runId: string, nodeIds?: string[]): { ok: boolean; rese
620
621
  run.finishedAt = undefined;
621
622
  run.completionNotified = false;
622
623
  run.settledMessageSent = false;
623
- // Re-arm the run-level cap: a stale deadline would re-fail the retried run
624
- // on the very next poll.
625
- run.deadlineAt = run.timeoutMs ? Date.now() + run.timeoutMs : undefined;
626
624
  run.updatedAt = Date.now();
627
625
  markStateDirty();
628
626
  return { ok: true, reset };