@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.
@@ -6,13 +6,13 @@ import { resolveAgent } from "./agents";
6
6
  import { isWorkerEvent } from "./worker";
7
7
  import {
8
8
  cancelBlockedDependents, cancelNode, cancelRun, clearWorkerRunEvents, clearStateDirty, isStateDirty,
9
- deliverToLeader, failRunTimeout, findSharedWorkspaceWriteConflict, getNode,
9
+ deliverToLeader, findSharedWorkspaceWriteConflict, getNode,
10
10
  getRun, getState, listNodes, listRuns, markNodeRunning, markStateDirty,
11
11
  readyPendingNodes, receiveWorkerMessage, runningNodeCount, settleRun, setNodeSpawnInfo,
12
- SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus,
12
+ SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus, validateStructuredOutput,
13
13
  } from "./state";
14
14
  import {
15
- CancellationIntents, buildAutonomousPrompt, finishReportedWorker, isCompletedWorkerExit,
15
+ CancellationIntents, buildAutonomousPrompt, DEFAULT_TURN_BUDGET, finishReportedWorker, isCompletedWorkerExit,
16
16
  POST_REPORT_GRACE_MS, spawnPiWorker, terminateWorker,
17
17
  type WorkerProcessResult,
18
18
  } from "./spawner";
@@ -27,25 +27,21 @@ export interface DispatchCtx {
27
27
  }
28
28
 
29
29
  export const MAX_SESSION_WORKERS = 8;
30
- const DEFAULT_NODE_TIMEOUT_MS = 30 * 60 * 1000;
31
30
  const LIVE_POLL_MS = 500;
32
31
  const cancellationIntents = new CancellationIntents();
33
32
  const reportedWorkerShutdowns = new Set<string>();
34
33
  let liveStateFile: string | undefined;
35
34
  let livePollTimer: ReturnType<typeof setInterval> | undefined;
36
- let machineCtx: DispatchCtx | undefined;
37
35
  let sendUpdate: (subject: string, body: string, runId?: string) => void = () => {};
38
36
  let notifyChange: () => void = () => {};
39
37
 
40
- export function initRunMachine(ctx: DispatchCtx, stateFile: string, hooks: { sendUpdate: typeof sendUpdate; notifyChange: () => void }): void {
41
- machineCtx = ctx;
38
+ export function initRunMachine(_ctx: DispatchCtx, stateFile: string, hooks: { sendUpdate: typeof sendUpdate; notifyChange: () => void }): void {
42
39
  liveStateFile = stateFile;
43
40
  sendUpdate = hooks.sendUpdate;
44
41
  notifyChange = hooks.notifyChange;
45
42
  }
46
43
 
47
44
  export function ensureRunContext(ctx: DispatchCtx): void {
48
- machineCtx = ctx;
49
45
  if (!liveStateFile && ctx.sessionManager) {
50
46
  liveStateFile = stateFilePath(ctx.sessionManager.getSessionFile(), ctx.cwd ?? process.cwd());
51
47
  }
@@ -55,16 +51,47 @@ export function shutdownRunMachine(): void {
55
51
  if (livePollTimer) clearInterval(livePollTimer);
56
52
  livePollTimer = undefined;
57
53
  liveStateFile = undefined;
58
- machineCtx = undefined;
59
54
  sendUpdate = () => {};
60
55
  notifyChange = () => {};
61
56
  }
62
57
 
58
+ function resolveInputBinding(run: import("./types").Run, node: import("./types").Node, source: string): string {
59
+ const separator = source.indexOf("#");
60
+ const dependencyId = separator >= 0 ? source.slice(0, separator) : source;
61
+ if (!node.dependsOn.includes(dependencyId)) return "(rejected: source is not a dependency)";
62
+ const dependency = run.nodes[dependencyId];
63
+ if (!dependency) return "(rejected: dependency not found)";
64
+ const value = dependency.structuredOutput ?? dependency.namedOutputs ?? dependency.result;
65
+ if (separator < 0) return JSON.stringify(value);
66
+ const pointer = source.slice(separator + 1).replace(/^\/json/, "");
67
+ if (!pointer || pointer === "") return JSON.stringify(value);
68
+ let current: unknown = value;
69
+ for (const part of pointer.split("/").filter(Boolean).map((item) => item.replaceAll("~1", "/").replaceAll("~0", "~"))) {
70
+ if (!current || typeof current !== "object") return "(missing)";
71
+ current = (current as Record<string, unknown>)[part];
72
+ }
73
+ return JSON.stringify(current);
74
+ }
75
+
63
76
  function currentStateFile(): string {
64
77
  if (!liveStateFile) throw new Error("Agent Teams session state is unavailable.");
65
78
  return liveStateFile;
66
79
  }
67
80
 
81
+ function sendNodeFollowUp(run: import("./types").Run, node: import("./types").Node, subject: string, body: string): void {
82
+ if (!run.background || node.id === SUMMARY_NODE_ID || node.nodeFollowUpSent) return;
83
+ node.nodeFollowUpSent = true;
84
+ markStateDirty();
85
+ sendUpdate(`Teammate ${node.id} ${terminalVerb(subject)}`, body, run.id);
86
+ }
87
+
88
+ function terminalVerb(subject: string): string {
89
+ if (subject === "Node completed") return "completed";
90
+ if (subject === "Node failed") return "failed";
91
+ if (subject === "Node cancelled") return "was cancelled";
92
+ return subject.toLowerCase();
93
+ }
94
+
68
95
  function flushStateSnapshot(): void {
69
96
  if (!liveStateFile || !isStateDirty()) return;
70
97
  try {
@@ -106,13 +133,25 @@ export function applyWorkerEvents(): void {
106
133
  type: "message",
107
134
  subject: event.subject,
108
135
  body: event.body,
136
+ status: event.status,
137
+ data: event.data,
109
138
  });
139
+ if (event.data?.kind === "named_output" && event.data.name && typeof event.data.value === "string") {
140
+ node.namedOutputs = { ...node.namedOutputs, [event.data.name]: event.data.value };
141
+ markStateDirty();
142
+ }
143
+ if (event.data?.kind === "output" && event.data.output !== undefined && !validateStructuredOutput(event.data.output)) {
144
+ node.structuredOutput = event.data.output;
145
+ markStateDirty();
146
+ }
110
147
  if (event.status && !["completed", "failed", "cancelled"].includes(node.status)) {
111
148
  if (event.status === "completed") {
112
149
  updateNodeStatus(run.id, node.id, "completed", event.body, undefined);
150
+ sendNodeFollowUp(run, node, "Node completed", event.body);
113
151
  requestReportedWorkerShutdown(node.workerKey, spawnId);
114
152
  } else if (event.status === "failed") {
115
153
  updateNodeStatus(run.id, node.id, "failed", undefined, event.body);
154
+ sendNodeFollowUp(run, node, "Node failed", event.body);
116
155
  requestReportedWorkerShutdown(node.workerKey, spawnId);
117
156
  }
118
157
  }
@@ -156,7 +195,6 @@ export function ensureLivePoll(): void {
156
195
  livePollTimer = setInterval(() => {
157
196
  try {
158
197
  applyWorkerEvents();
159
- enforceRunTimeouts();
160
198
  flushStateSnapshot();
161
199
  notifyChange();
162
200
  ensureLivePoll();
@@ -170,26 +208,6 @@ export function ensureLivePoll(): void {
170
208
  }
171
209
  }
172
210
 
173
- /** Fail runs whose run-level hard wall-clock cap was exceeded. */
174
- function enforceRunTimeouts(): void {
175
- for (const run of listRuns()) {
176
- if (run.status !== "running" || !run.deadlineAt || Date.now() < run.deadlineAt) continue;
177
- const failed = failRunTimeout(run.id, `Run timed out after ${Math.round((run.timeoutMs ?? 0) / 1000)}s.`);
178
- if (!failed.ok) continue;
179
- for (const nodeId of failed.runningNodeIds) {
180
- // Spawn was cleared by failRunTimeout, so terminate by the stable workerKey
181
- // (a no-op when the child already closed).
182
- const node = getNode(run.id, nodeId);
183
- if (node) void terminateWorker(node.workerKey).catch(() => false);
184
- }
185
- onRunSettled(run.id);
186
- // failRunTimeout clears spawns, so those nodes' close events skip their
187
- // finalize path — release write-deferred nodes in OTHER runs here.
188
- if (machineCtx) scheduleAllRuns(machineCtx);
189
- }
190
- }
191
-
192
-
193
211
  // ── Run dispatch machinery ────────────────────────────────────────
194
212
 
195
213
  /** Compact run summary for tool returns and follow-ups. When the run has a
@@ -203,11 +221,8 @@ export function buildRunSummary(runId: string): string {
203
221
  acc[node.status] = (acc[node.status] ?? 0) + 1;
204
222
  return acc;
205
223
  }, {});
206
- const lines = [
207
- `## Run [${run.id}] ${run.status}`,
208
- `${Object.keys(run.nodes).length} node(s): ${Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(", ")}`,
209
- "",
210
- ];
224
+ const countsStr = Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(", ");
225
+ const lines = [`Run [${run.id}] ${countsStr}`, ""];
211
226
  if (run.summary) {
212
227
  lines.push(run.summary, "");
213
228
  } else if (nodes.length === 1) {
@@ -222,9 +237,6 @@ export function buildRunSummary(runId: string): string {
222
237
  }
223
238
  }
224
239
  }
225
- for (const node of nodes) {
226
- lines.push(`- [${node.id}] ${node.status} (${node.agent})`);
227
- }
228
240
  return lines.join("\n");
229
241
  }
230
242
 
@@ -326,9 +338,6 @@ export async function cancelRunAndTerminate(
326
338
  if (run.status !== "running") return { ok: false, error: `Run "${runId}" is already ${run.status}.` };
327
339
  const cancellation = cancelRun(runId);
328
340
  if (!cancellation.ok) return { ok: false, error: cancellation.error ?? "Failed to cancel run." };
329
- run.status = "cancelled";
330
- run.finishedAt = Date.now();
331
- run.updatedAt = Date.now();
332
341
  run.completionNotified = true;
333
342
  markStateDirty();
334
343
  for (const nodeId of cancellation.runningNodeIds) {
@@ -339,7 +348,13 @@ export async function cancelRunAndTerminate(
339
348
  cancellationIntents.resolve(spawnId, terminated);
340
349
  }
341
350
  publishStateSnapshot();
342
- onRunSettled(runId);
351
+ if (!cancellation.runningNodeIds.some((nodeId) => getNode(runId, nodeId)?.status === "running")) {
352
+ run.status = "cancelled";
353
+ run.finishedAt = Date.now();
354
+ run.updatedAt = Date.now();
355
+ markStateDirty();
356
+ onRunSettled(runId);
357
+ }
343
358
  scheduleAllRuns(ctx);
344
359
  notifyChange();
345
360
  return { ok: true };
@@ -353,10 +368,18 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
353
368
  if (!run || !node || node.status !== "pending") return;
354
369
  const agent = resolveAgent(node.agent, run.cwd);
355
370
  const stateFile = liveStateFile ?? (ctx.sessionManager ? stateFilePath(ctx.sessionManager.getSessionFile(), run.cwd) : "");
356
- if (!agent || !stateFile) {
357
- updateNodeStatus(runId, nodeId, "failed", undefined, agent ? "Shared state file unavailable." : `Agent "${node.agent}" not found.`);
371
+ const failBeforeSpawn = (error: string): void => {
372
+ updateNodeStatus(runId, nodeId, "failed", undefined, error);
373
+ const failedNode = getNode(runId, nodeId);
374
+ const subject = "Node failed";
375
+ const body = `Node [${runId}/${nodeId}] could not start.\nError: ${error}`;
376
+ deliverToLeader({ from: node.workerKey, subject, body, runId });
377
+ if (failedNode) sendNodeFollowUp(run, failedNode, subject, body);
358
378
  cancelBlockedDependents(runId, nodeId);
359
379
  scheduleRun(runId, ctx);
380
+ };
381
+ if (!agent || !stateFile) {
382
+ failBeforeSpawn(agent ? "Shared state file unavailable." : `Agent "${node.agent}" not found.`);
360
383
  return;
361
384
  }
362
385
 
@@ -368,9 +391,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
368
391
  if (run.worktree) {
369
392
  worktree = createWorktree(run.cwd, `${runId}-${nodeId}`);
370
393
  if ("error" in worktree) {
371
- updateNodeStatus(runId, nodeId, "failed", undefined, `Cannot isolate node: ${worktree.error}`);
372
- cancelBlockedDependents(runId, nodeId);
373
- scheduleRun(runId, ctx);
394
+ failBeforeSpawn(`Cannot isolate node: ${worktree.error}`);
374
395
  return;
375
396
  }
376
397
  }
@@ -389,15 +410,24 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
389
410
  status: "running",
390
411
  startedAt: Date.now(),
391
412
  isolation: run.worktree ? "worktree" : "none",
413
+ mode: node.mode,
392
414
  });
393
415
 
394
- const timeoutMs = node.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
416
+ const turnBudget = node.turnBudget ?? DEFAULT_TURN_BUDGET;
417
+ const bindingLines = node.inputBindings
418
+ ? Object.entries(node.inputBindings).map(([name, source]) => `Input ${name}: ${resolveInputBinding(run, node, source)}`)
419
+ : [];
395
420
  const upstream = node.dependsOn
396
421
  .map((depId) => run.nodes[depId])
397
422
  .filter((dep): dep is NonNullable<typeof dep> => Boolean(dep))
423
+ .filter((dep) => !node.forkContext || node.forkContext.includes(dep.id))
398
424
  .map((dep) => {
399
425
  const body = dep.result?.trim() || dep.errorMessage?.trim() || `${dep.status} with no written result.`;
400
- return `--- ${dep.id} (${dep.agent}, ${dep.status}) ---\n${body}`;
426
+ const named = dep.namedOutputs && Object.keys(dep.namedOutputs).length > 0
427
+ ? `\nNamed outputs: ${JSON.stringify(dep.namedOutputs)}`
428
+ : "";
429
+ const structured = dep.structuredOutput === undefined ? "" : `\nStructured output: ${JSON.stringify(dep.structuredOutput)}`;
430
+ return `--- ${dep.id} (${dep.agent}, ${dep.status}) ---\n${body}${named}${structured}`;
401
431
  });
402
432
  const description = [
403
433
  buildAutonomousPrompt({
@@ -405,7 +435,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
405
435
  role: node.agent,
406
436
  prompt: agent.prompt,
407
437
  taskId: nodeId,
408
- timeoutSec: Math.round(timeoutMs / 1000),
438
+ turnBudget,
409
439
  }),
410
440
  "",
411
441
  "=== TASK ===",
@@ -413,6 +443,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
413
443
  `Paths: ${node.paths.join(", ")}`,
414
444
  node.prompt,
415
445
  ...(upstream.length > 0 ? ["", "=== UPSTREAM HANDOFF ===", ...upstream] : []),
446
+ ...(bindingLines.length > 0 ? ["", "=== NAMED INPUTS ===", ...bindingLines] : []),
416
447
  ].join("\n");
417
448
 
418
449
  const finalizeNode = (result: WorkerProcessResult, cancelled = false) => {
@@ -438,7 +469,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
438
469
  const completedAfterFinalResponse = nodeNow?.spawn?.finalResponse === true;
439
470
  const workerReportedFailure = reportedTerminalStatus === "failed";
440
471
  const completedAfterShutdown = (reportedTerminalStatus === "completed" || completedAfterFinalResponse)
441
- && (result.signal === "SIGTERM" || result.exitCode === 128 + 15 || result.timedOut);
472
+ && result.signal === "SIGTERM";
442
473
  const ok = isCompletedWorkerExit(
443
474
  result,
444
475
  reportedTerminalStatus === "completed" || completedAfterFinalResponse,
@@ -453,12 +484,11 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
453
484
  stdout: (result.stdout + patchText).trim() ? result.stdout + patchText : undefined,
454
485
  stderr: ok ? undefined : result.stderr,
455
486
  usage: result.usage,
456
- timedOut: result.timedOut,
457
487
  isolation: run.worktree ? "worktree" : "none",
458
488
  error: ok
459
489
  ? undefined
460
- : result.timedOut
461
- ? `Worker timed out after ${Math.round(timeoutMs / 1000)}s.`
490
+ : result.turnBudgetExceeded
491
+ ? `Worker exceeded its turn budget of ${turnBudget} turn(s).`
462
492
  : result.signal
463
493
  ? `Worker was terminated by ${result.signal}.`
464
494
  : workerReportedFailure
@@ -479,7 +509,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
479
509
  runId,
480
510
  nodeId,
481
511
  agent: node.agent,
482
- result,
512
+ result: { ...result, timedOut: result.turnBudgetExceeded ?? false },
483
513
  nodeResult: nodeNow?.result,
484
514
  nodeError: nodeNow?.errorMessage,
485
515
  cancelled,
@@ -487,6 +517,8 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
487
517
  patchText,
488
518
  });
489
519
  deliverToLeader({ from: workerKey, subject: terminalSubject, body: terminalBody, runId: runId });
520
+ const settledNode = getNode(runId, nodeId);
521
+ if (!cancelled && settledNode) sendNodeFollowUp(run, settledNode, terminalSubject, terminalBody);
490
522
  if (cancelled) {
491
523
  // A cancelled node keeps its process outcome but not a misleading error.
492
524
  const cleared = getNode(runId, nodeId);
@@ -518,12 +550,11 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
518
550
  error: typeof error === "string" ? error : error.message,
519
551
  });
520
552
  reportedWorkerShutdowns.delete(spawnId);
521
- deliverToLeader({
522
- from: workerKey,
523
- subject: "Node failed",
524
- body: `Node [${runId}/${nodeId}] could not start.\nError: ${typeof error === "string" ? error : error.message}`,
525
- runId: runId,
526
- });
553
+ const subject = "Node failed";
554
+ const body = `Node [${runId}/${nodeId}] could not start.\nError: ${typeof error === "string" ? error : error.message}`;
555
+ deliverToLeader({ from: workerKey, subject, body, runId: runId });
556
+ const failedNode = getNode(runId, nodeId);
557
+ if (failedNode) sendNodeFollowUp(run, failedNode, subject, body);
527
558
  cancelBlockedDependents(runId, nodeId);
528
559
  compactFinishedNodeRun(stateFile, workerKey, spawnId);
529
560
  if (worktree && !("error" in worktree)) discardWorktree(worktree);
@@ -533,12 +564,13 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
533
564
 
534
565
  const started = spawnPiWorker({
535
566
  workerName: workerKey,
567
+ mode: node.mode,
536
568
  description,
537
569
  model: node.model ?? agent.model,
538
570
  tools: agent.tools,
539
571
  cwd: workerCwd,
540
572
  env: workerEnv,
541
- timeoutMs,
573
+ turnBudget,
542
574
  onUpdate: (progress) => {
543
575
  updateNodeSpawnProgress(runId, nodeId, spawnId, {
544
576
  liveText: progress.text,
@@ -565,6 +597,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
565
597
  status: "running",
566
598
  startedAt: node.spawn?.startedAt ?? Date.now(),
567
599
  isolation: run.worktree ? "worktree" : "none",
600
+ mode: node.mode,
568
601
  });
569
602
  ensureLivePoll();
570
603
  publishStateSnapshot();