@fradser/pi-agent-teams 0.5.1 → 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.
@@ -2,25 +2,23 @@
2
2
 
3
3
  import { randomUUID } from "node:crypto";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
6
5
  import { resolveAgent } from "./agents";
7
6
  import { isWorkerEvent } from "./worker";
8
7
  import {
9
8
  cancelBlockedDependents, cancelNode, cancelRun, clearWorkerRunEvents, clearStateDirty, isStateDirty,
10
- deliverToLeader, failRunTimeout, findSharedWorkspaceWriteConflict, getNode, getNodeByWorkerKey,
9
+ deliverToLeader, findSharedWorkspaceWriteConflict, getNode,
11
10
  getRun, getState, listNodes, listRuns, markNodeRunning, markStateDirty,
12
- readyPendingNodes, receiveWorkerMessage, retryRun, runningNodeCount, settleRun, setNodeSpawnInfo,
13
- SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus,
11
+ readyPendingNodes, receiveWorkerMessage, runningNodeCount, settleRun, setNodeSpawnInfo,
12
+ SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus, validateStructuredOutput,
14
13
  } from "./state";
15
14
  import {
16
- CancellationIntents, buildAutonomousPrompt, finishReportedWorker, isCompletedWorkerExit,
17
- POST_REPORT_GRACE_MS, spawnPiWorker, terminateAllWorkers, terminateWorker,
15
+ CancellationIntents, buildAutonomousPrompt, DEFAULT_TURN_BUDGET, finishReportedWorker, isCompletedWorkerExit,
16
+ POST_REPORT_GRACE_MS, spawnPiWorker, terminateWorker,
18
17
  type WorkerProcessResult,
19
18
  } from "./spawner";
20
19
  import { buildNodeTerminalResult } from "./terminal";
21
20
  import { captureWorktreeDiff, cleanupWorktree, createWorktree, discardWorktree } from "./worktree";
22
21
  import { readWorkerEvents, removeWorkerOutbox, stateFilePath, workerOutboxPath, writeStateFile } from "./statefile";
23
- import type { Run } from "./types";
24
22
 
25
23
  export interface DispatchCtx {
26
24
  ui: ExtensionContext["ui"];
@@ -29,25 +27,21 @@ export interface DispatchCtx {
29
27
  }
30
28
 
31
29
  export const MAX_SESSION_WORKERS = 8;
32
- const DEFAULT_NODE_TIMEOUT_MS = 30 * 60 * 1000;
33
30
  const LIVE_POLL_MS = 500;
34
31
  const cancellationIntents = new CancellationIntents();
35
32
  const reportedWorkerShutdowns = new Set<string>();
36
33
  let liveStateFile: string | undefined;
37
34
  let livePollTimer: ReturnType<typeof setInterval> | undefined;
38
- let machineCtx: DispatchCtx | undefined;
39
35
  let sendUpdate: (subject: string, body: string, runId?: string) => void = () => {};
40
36
  let notifyChange: () => void = () => {};
41
37
 
42
- export function initRunMachine(ctx: DispatchCtx, stateFile: string, hooks: { sendUpdate: typeof sendUpdate; notifyChange: () => void }): void {
43
- machineCtx = ctx;
38
+ export function initRunMachine(_ctx: DispatchCtx, stateFile: string, hooks: { sendUpdate: typeof sendUpdate; notifyChange: () => void }): void {
44
39
  liveStateFile = stateFile;
45
40
  sendUpdate = hooks.sendUpdate;
46
41
  notifyChange = hooks.notifyChange;
47
42
  }
48
43
 
49
44
  export function ensureRunContext(ctx: DispatchCtx): void {
50
- machineCtx = ctx;
51
45
  if (!liveStateFile && ctx.sessionManager) {
52
46
  liveStateFile = stateFilePath(ctx.sessionManager.getSessionFile(), ctx.cwd ?? process.cwd());
53
47
  }
@@ -57,16 +51,47 @@ export function shutdownRunMachine(): void {
57
51
  if (livePollTimer) clearInterval(livePollTimer);
58
52
  livePollTimer = undefined;
59
53
  liveStateFile = undefined;
60
- machineCtx = undefined;
61
54
  sendUpdate = () => {};
62
55
  notifyChange = () => {};
63
56
  }
64
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
+
65
76
  function currentStateFile(): string {
66
77
  if (!liveStateFile) throw new Error("Agent Teams session state is unavailable.");
67
78
  return liveStateFile;
68
79
  }
69
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
+
70
95
  function flushStateSnapshot(): void {
71
96
  if (!liveStateFile || !isStateDirty()) return;
72
97
  try {
@@ -108,13 +133,25 @@ export function applyWorkerEvents(): void {
108
133
  type: "message",
109
134
  subject: event.subject,
110
135
  body: event.body,
136
+ status: event.status,
137
+ data: event.data,
111
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
+ }
112
147
  if (event.status && !["completed", "failed", "cancelled"].includes(node.status)) {
113
148
  if (event.status === "completed") {
114
149
  updateNodeStatus(run.id, node.id, "completed", event.body, undefined);
150
+ sendNodeFollowUp(run, node, "Node completed", event.body);
115
151
  requestReportedWorkerShutdown(node.workerKey, spawnId);
116
152
  } else if (event.status === "failed") {
117
153
  updateNodeStatus(run.id, node.id, "failed", undefined, event.body);
154
+ sendNodeFollowUp(run, node, "Node failed", event.body);
118
155
  requestReportedWorkerShutdown(node.workerKey, spawnId);
119
156
  }
120
157
  }
@@ -158,7 +195,6 @@ export function ensureLivePoll(): void {
158
195
  livePollTimer = setInterval(() => {
159
196
  try {
160
197
  applyWorkerEvents();
161
- enforceRunTimeouts();
162
198
  flushStateSnapshot();
163
199
  notifyChange();
164
200
  ensureLivePoll();
@@ -172,32 +208,8 @@ export function ensureLivePoll(): void {
172
208
  }
173
209
  }
174
210
 
175
- /** Fail runs whose run-level hard wall-clock cap was exceeded. */
176
- function enforceRunTimeouts(): void {
177
- for (const run of listRuns()) {
178
- if (run.status !== "running" || !run.deadlineAt || Date.now() < run.deadlineAt) continue;
179
- const failed = failRunTimeout(run.id, `Run timed out after ${Math.round((run.timeoutMs ?? 0) / 1000)}s.`);
180
- if (!failed.ok) continue;
181
- for (const nodeId of failed.runningNodeIds) {
182
- // Spawn was cleared by failRunTimeout, so terminate by the stable workerKey
183
- // (a no-op when the child already closed).
184
- const node = getNode(run.id, nodeId);
185
- if (node) void terminateWorker(node.workerKey).catch(() => false);
186
- }
187
- onRunSettled(run.id, machineCtx);
188
- // failRunTimeout clears spawns, so those nodes' close events skip their
189
- // finalize path — release write-deferred nodes in OTHER runs here.
190
- if (machineCtx) scheduleAllRuns(machineCtx);
191
- }
192
- }
193
-
194
-
195
211
  // ── Run dispatch machinery ────────────────────────────────────────
196
212
 
197
- function sleep(ms: number): Promise<void> {
198
- return new Promise((resolve) => setTimeout(resolve, ms));
199
- }
200
-
201
213
  /** Compact run summary for tool returns and follow-ups. When the run has a
202
214
  * synthesized __summary node result, that is shown instead of per-node
203
215
  * headlines (no truncation heuristics). */
@@ -209,11 +221,8 @@ export function buildRunSummary(runId: string): string {
209
221
  acc[node.status] = (acc[node.status] ?? 0) + 1;
210
222
  return acc;
211
223
  }, {});
212
- const lines = [
213
- `## Run [${run.id}] ${run.status}`,
214
- `${Object.keys(run.nodes).length} node(s): ${Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(", ")}`,
215
- "",
216
- ];
224
+ const countsStr = Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(", ");
225
+ const lines = [`Run [${run.id}] ${countsStr}`, ""];
217
226
  if (run.summary) {
218
227
  lines.push(run.summary, "");
219
228
  } else if (nodes.length === 1) {
@@ -228,17 +237,13 @@ export function buildRunSummary(runId: string): string {
228
237
  }
229
238
  }
230
239
  }
231
- for (const node of nodes) {
232
- lines.push(`- [${node.id}] ${node.status} (${node.agent})`);
233
- }
234
- lines.push("", "Console: /teammate");
235
240
  return lines.join("\n");
236
241
  }
237
242
 
238
243
  /** Called once a run reaches a terminal status. Idempotent: the leader summary
239
244
  * and follow-up fire only on the first settled observation (a run can transition
240
245
  * through settleRun once per node close). */
241
- export function onRunSettled(runId: string, ctx?: DispatchCtx): void {
246
+ export function onRunSettled(runId: string): void {
242
247
  const run = getRun(runId);
243
248
  if (!run) return;
244
249
  if (run.settledMessageSent) return;
@@ -266,7 +271,7 @@ export function scheduleRun(runId: string, ctx: DispatchCtx): void {
266
271
  if (!run || run.status !== "running") return;
267
272
  const settled = settleRun(runId);
268
273
  if (settled !== "running") {
269
- onRunSettled(runId, ctx);
274
+ onRunSettled(runId);
270
275
  return;
271
276
  }
272
277
  const sessionBudget = MAX_SESSION_WORKERS - listNodes().filter((node) => node.status === "running").length;
@@ -333,9 +338,6 @@ export async function cancelRunAndTerminate(
333
338
  if (run.status !== "running") return { ok: false, error: `Run "${runId}" is already ${run.status}.` };
334
339
  const cancellation = cancelRun(runId);
335
340
  if (!cancellation.ok) return { ok: false, error: cancellation.error ?? "Failed to cancel run." };
336
- run.status = "cancelled";
337
- run.finishedAt = Date.now();
338
- run.updatedAt = Date.now();
339
341
  run.completionNotified = true;
340
342
  markStateDirty();
341
343
  for (const nodeId of cancellation.runningNodeIds) {
@@ -346,7 +348,13 @@ export async function cancelRunAndTerminate(
346
348
  cancellationIntents.resolve(spawnId, terminated);
347
349
  }
348
350
  publishStateSnapshot();
349
- onRunSettled(runId, ctx);
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
+ }
350
358
  scheduleAllRuns(ctx);
351
359
  notifyChange();
352
360
  return { ok: true };
@@ -360,10 +368,18 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
360
368
  if (!run || !node || node.status !== "pending") return;
361
369
  const agent = resolveAgent(node.agent, run.cwd);
362
370
  const stateFile = liveStateFile ?? (ctx.sessionManager ? stateFilePath(ctx.sessionManager.getSessionFile(), run.cwd) : "");
363
- if (!agent || !stateFile) {
364
- 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);
365
378
  cancelBlockedDependents(runId, nodeId);
366
379
  scheduleRun(runId, ctx);
380
+ };
381
+ if (!agent || !stateFile) {
382
+ failBeforeSpawn(agent ? "Shared state file unavailable." : `Agent "${node.agent}" not found.`);
367
383
  return;
368
384
  }
369
385
 
@@ -375,9 +391,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
375
391
  if (run.worktree) {
376
392
  worktree = createWorktree(run.cwd, `${runId}-${nodeId}`);
377
393
  if ("error" in worktree) {
378
- updateNodeStatus(runId, nodeId, "failed", undefined, `Cannot isolate node: ${worktree.error}`);
379
- cancelBlockedDependents(runId, nodeId);
380
- scheduleRun(runId, ctx);
394
+ failBeforeSpawn(`Cannot isolate node: ${worktree.error}`);
381
395
  return;
382
396
  }
383
397
  }
@@ -396,15 +410,24 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
396
410
  status: "running",
397
411
  startedAt: Date.now(),
398
412
  isolation: run.worktree ? "worktree" : "none",
413
+ mode: node.mode,
399
414
  });
400
415
 
401
- 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
+ : [];
402
420
  const upstream = node.dependsOn
403
421
  .map((depId) => run.nodes[depId])
404
422
  .filter((dep): dep is NonNullable<typeof dep> => Boolean(dep))
423
+ .filter((dep) => !node.forkContext || node.forkContext.includes(dep.id))
405
424
  .map((dep) => {
406
425
  const body = dep.result?.trim() || dep.errorMessage?.trim() || `${dep.status} with no written result.`;
407
- 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}`;
408
431
  });
409
432
  const description = [
410
433
  buildAutonomousPrompt({
@@ -412,7 +435,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
412
435
  role: node.agent,
413
436
  prompt: agent.prompt,
414
437
  taskId: nodeId,
415
- timeoutSec: Math.round(timeoutMs / 1000),
438
+ turnBudget,
416
439
  }),
417
440
  "",
418
441
  "=== TASK ===",
@@ -420,6 +443,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
420
443
  `Paths: ${node.paths.join(", ")}`,
421
444
  node.prompt,
422
445
  ...(upstream.length > 0 ? ["", "=== UPSTREAM HANDOFF ===", ...upstream] : []),
446
+ ...(bindingLines.length > 0 ? ["", "=== NAMED INPUTS ===", ...bindingLines] : []),
423
447
  ].join("\n");
424
448
 
425
449
  const finalizeNode = (result: WorkerProcessResult, cancelled = false) => {
@@ -445,7 +469,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
445
469
  const completedAfterFinalResponse = nodeNow?.spawn?.finalResponse === true;
446
470
  const workerReportedFailure = reportedTerminalStatus === "failed";
447
471
  const completedAfterShutdown = (reportedTerminalStatus === "completed" || completedAfterFinalResponse)
448
- && (result.signal === "SIGTERM" || result.exitCode === 128 + 15 || result.timedOut);
472
+ && result.signal === "SIGTERM";
449
473
  const ok = isCompletedWorkerExit(
450
474
  result,
451
475
  reportedTerminalStatus === "completed" || completedAfterFinalResponse,
@@ -460,12 +484,11 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
460
484
  stdout: (result.stdout + patchText).trim() ? result.stdout + patchText : undefined,
461
485
  stderr: ok ? undefined : result.stderr,
462
486
  usage: result.usage,
463
- timedOut: result.timedOut,
464
487
  isolation: run.worktree ? "worktree" : "none",
465
488
  error: ok
466
489
  ? undefined
467
- : result.timedOut
468
- ? `Worker timed out after ${Math.round(timeoutMs / 1000)}s.`
490
+ : result.turnBudgetExceeded
491
+ ? `Worker exceeded its turn budget of ${turnBudget} turn(s).`
469
492
  : result.signal
470
493
  ? `Worker was terminated by ${result.signal}.`
471
494
  : workerReportedFailure
@@ -486,7 +509,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
486
509
  runId,
487
510
  nodeId,
488
511
  agent: node.agent,
489
- result,
512
+ result: { ...result, timedOut: result.turnBudgetExceeded ?? false },
490
513
  nodeResult: nodeNow?.result,
491
514
  nodeError: nodeNow?.errorMessage,
492
515
  cancelled,
@@ -494,6 +517,8 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
494
517
  patchText,
495
518
  });
496
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);
497
522
  if (cancelled) {
498
523
  // A cancelled node keeps its process outcome but not a misleading error.
499
524
  const cleared = getNode(runId, nodeId);
@@ -525,12 +550,11 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
525
550
  error: typeof error === "string" ? error : error.message,
526
551
  });
527
552
  reportedWorkerShutdowns.delete(spawnId);
528
- deliverToLeader({
529
- from: workerKey,
530
- subject: "Node failed",
531
- body: `Node [${runId}/${nodeId}] could not start.\nError: ${typeof error === "string" ? error : error.message}`,
532
- runId: runId,
533
- });
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);
534
558
  cancelBlockedDependents(runId, nodeId);
535
559
  compactFinishedNodeRun(stateFile, workerKey, spawnId);
536
560
  if (worktree && !("error" in worktree)) discardWorktree(worktree);
@@ -540,12 +564,13 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
540
564
 
541
565
  const started = spawnPiWorker({
542
566
  workerName: workerKey,
567
+ mode: node.mode,
543
568
  description,
544
569
  model: node.model ?? agent.model,
545
570
  tools: agent.tools,
546
571
  cwd: workerCwd,
547
572
  env: workerEnv,
548
- timeoutMs,
573
+ turnBudget,
549
574
  onUpdate: (progress) => {
550
575
  updateNodeSpawnProgress(runId, nodeId, spawnId, {
551
576
  liveText: progress.text,
@@ -572,6 +597,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
572
597
  status: "running",
573
598
  startedAt: node.spawn?.startedAt ?? Date.now(),
574
599
  isolation: run.worktree ? "worktree" : "none",
600
+ mode: node.mode,
575
601
  });
576
602
  ensureLivePoll();
577
603
  publishStateSnapshot();