pi-crew 0.9.41 → 0.9.44

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.
Files changed (170) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +14 -0
  3. package/dist/build-meta.json +6450 -12419
  4. package/dist/index.mjs +57998 -68417
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/migration/atomic-write-v2-migration.md +2 -0
  7. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  8. package/package.json +4 -2
  9. package/scripts/README.md +6 -0
  10. package/scripts/build-bundle.mjs +2 -1
  11. package/scripts/check-all-skills.ts +3 -3
  12. package/scripts/dev-runner.mjs +106 -0
  13. package/scripts/postinstall.mjs +6 -2
  14. package/scripts/verify-skill.ts +2 -2
  15. package/src/agents/agent-config.ts +1 -1
  16. package/src/config/config.ts +20 -1
  17. package/src/config/drift-detector.ts +97 -15
  18. package/src/config/resilient-parser.ts +4 -4
  19. package/src/config/types.ts +0 -2
  20. package/src/extension/crew-cleanup.ts +17 -3
  21. package/src/extension/crew-vibes/config.ts +1 -1
  22. package/src/extension/crew-vibes/font-detect.ts +16 -3
  23. package/src/extension/crew-vibes/index.ts +1 -1
  24. package/src/extension/cross-extension-rpc.ts +26 -9
  25. package/src/extension/management.ts +6 -5
  26. package/src/extension/register.ts +58 -1573
  27. package/src/extension/registration/command-registration.ts +57 -0
  28. package/src/extension/registration/commands.ts +7 -2
  29. package/src/extension/registration/context-builder.ts +131 -0
  30. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  31. package/src/extension/registration/foreground-run-controller.ts +238 -0
  32. package/src/extension/registration/hook-registration.ts +119 -0
  33. package/src/extension/registration/lazy-configurers.ts +124 -0
  34. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  35. package/src/extension/registration/observability.ts +33 -4
  36. package/src/extension/registration/registration-types.ts +149 -0
  37. package/src/extension/registration/runtime-cleanup.ts +230 -0
  38. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  39. package/src/extension/registration/tool-registration.ts +50 -0
  40. package/src/extension/registration/wire-cross-extension.ts +37 -0
  41. package/src/extension/rpc-hmac.ts +21 -1
  42. package/src/extension/run-import.ts +3 -3
  43. package/src/extension/team-tool/auto-summarize.ts +4 -4
  44. package/src/extension/team-tool/cancel.ts +5 -5
  45. package/src/extension/team-tool/chain-executor.ts +5 -4
  46. package/src/extension/team-tool/context.ts +11 -1
  47. package/src/extension/team-tool/doctor.ts +2 -1
  48. package/src/extension/team-tool/goal-wrap.ts +2 -2
  49. package/src/extension/team-tool/goal.ts +1 -1
  50. package/src/extension/team-tool/handle-schedule.ts +25 -36
  51. package/src/extension/team-tool/orchestrate.ts +0 -1
  52. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  53. package/src/extension/team-tool/run.ts +220 -214
  54. package/src/extension/team-tool/workflow-manage.ts +1 -1
  55. package/src/extension/team-tool.ts +69 -16
  56. package/src/hooks/registry.ts +7 -2
  57. package/src/observability/correlation.ts +4 -3
  58. package/src/observability/event-bus.ts +10 -1
  59. package/src/observability/event-to-metric.ts +8 -7
  60. package/src/observability/exporters/adapter.ts +6 -9
  61. package/src/observability/exporters/otlp-exporter.ts +81 -0
  62. package/src/observability/metric-retention.ts +4 -0
  63. package/src/observability/metric-sink.ts +14 -4
  64. package/src/prompt/prompt-runtime.ts +15 -0
  65. package/src/runtime/adaptive-plan.ts +1 -1
  66. package/src/runtime/async-runner.ts +13 -4
  67. package/src/runtime/background-runner.ts +32 -23
  68. package/src/runtime/capability-inventory.ts +1 -1
  69. package/src/runtime/chain-runner.ts +5 -5
  70. package/src/runtime/checkpoint.ts +6 -16
  71. package/src/runtime/child-pi-constants.ts +42 -0
  72. package/src/runtime/child-pi-kill.ts +180 -0
  73. package/src/runtime/child-pi-spawn.ts +234 -0
  74. package/src/runtime/child-pi-steering.ts +128 -0
  75. package/src/runtime/child-pi-streams.ts +296 -0
  76. package/src/runtime/child-pi-transcript.ts +169 -0
  77. package/src/runtime/child-pi.ts +80 -822
  78. package/src/runtime/coalesce-tasks.ts +1 -1
  79. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  80. package/src/runtime/crash-recovery.ts +63 -47
  81. package/src/runtime/delivery-coordinator.ts +1 -1
  82. package/src/runtime/deterministic-ast.ts +2 -2
  83. package/src/runtime/diagnostic-export.ts +2 -1
  84. package/src/runtime/dwf-state-store.ts +5 -0
  85. package/src/runtime/dynamic-workflow-context.ts +78 -18
  86. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  87. package/src/runtime/foreground-control.ts +3 -2
  88. package/src/runtime/goal-evaluator.ts +59 -27
  89. package/src/runtime/goal-loop-runner.ts +46 -13
  90. package/src/runtime/goal-state-store.ts +1 -1
  91. package/src/runtime/handoff-manager.ts +0 -2
  92. package/src/runtime/intercom-bridge.ts +0 -2
  93. package/src/runtime/live-agent-manager.ts +30 -5
  94. package/src/runtime/live-extension-bridge.ts +0 -2
  95. package/src/runtime/live-session-runtime.ts +65 -57
  96. package/src/runtime/manifest-cache.ts +59 -0
  97. package/src/runtime/mcp-proxy.ts +1 -1
  98. package/src/runtime/orphan-worker-registry.ts +3 -3
  99. package/src/runtime/output-validator.ts +1 -1
  100. package/src/runtime/path-overlap.ts +1 -1
  101. package/src/runtime/pi-args.ts +3 -3
  102. package/src/runtime/pi-json-output.ts +4 -3
  103. package/src/runtime/pi-spawn.ts +7 -1
  104. package/src/runtime/pipeline-runner.ts +1 -4
  105. package/src/runtime/post-checks.ts +0 -1
  106. package/src/runtime/retry-runner.ts +1 -1
  107. package/src/runtime/run-coalesced-task-group.ts +18 -23
  108. package/src/runtime/scheduler.ts +2 -0
  109. package/src/runtime/session-resources.ts +1 -1
  110. package/src/runtime/settings-store.ts +31 -1
  111. package/src/runtime/skill-effectiveness.ts +2 -2
  112. package/src/runtime/stale-reconciler.ts +23 -53
  113. package/src/runtime/subagent-manager.ts +19 -3
  114. package/src/runtime/task-output-context.ts +2 -1
  115. package/src/runtime/task-runner/prompt-builder.ts +3 -3
  116. package/src/runtime/task-runner/run-projection.ts +3 -1
  117. package/src/runtime/task-runner/state-helpers.ts +13 -1
  118. package/src/runtime/task-runner.ts +31 -6
  119. package/src/runtime/team-runner.ts +8 -2
  120. package/src/runtime/verification-gates.ts +54 -5
  121. package/src/schema/config-schema.ts +19 -1
  122. package/src/schema/team-tool-schema.ts +2 -0
  123. package/src/skills/discover-skills.ts +25 -15
  124. package/src/state/active-run-registry.ts +0 -1
  125. package/src/state/atomic-write.ts +37 -11
  126. package/src/state/blob-store.ts +3 -9
  127. package/src/state/contracts.ts +5 -1
  128. package/src/state/crew-init.ts +3 -2
  129. package/src/state/decision-ledger.ts +2 -2
  130. package/src/state/event-log-rotation.ts +1 -1
  131. package/src/state/event-log.ts +4 -3
  132. package/src/state/gitignore-manager.ts +2 -1
  133. package/src/state/health-store.ts +5 -2
  134. package/src/state/instinct-store.ts +11 -2
  135. package/src/state/locks.ts +132 -49
  136. package/src/state/mailbox.ts +3 -4
  137. package/src/state/observation-store.ts +1 -13
  138. package/src/state/run-cache.ts +12 -12
  139. package/src/state/run-graph.ts +2 -1
  140. package/src/state/run-metrics.ts +1 -1
  141. package/src/state/schedule.ts +7 -1
  142. package/src/state/state-store.ts +9 -3
  143. package/src/state/tiered-eval.ts +1 -1
  144. package/src/state/worker-atomic-writer.ts +1 -0
  145. package/src/ui/dynamic-border.ts +13 -3
  146. package/src/ui/live-conversation-overlay.ts +7 -27
  147. package/src/ui/live-duration.ts +2 -2
  148. package/src/ui/live-run-sidebar.ts +56 -32
  149. package/src/ui/mascot.ts +19 -13
  150. package/src/ui/powerbar-publisher.ts +244 -178
  151. package/src/ui/render-coalescer.ts +44 -3
  152. package/src/ui/render-diff.ts +2 -2
  153. package/src/ui/run-dashboard.ts +122 -21
  154. package/src/ui/run-event-bus.ts +66 -0
  155. package/src/ui/run-snapshot-cache.ts +84 -81
  156. package/src/ui/settings-overlay.ts +3 -3
  157. package/src/ui/status-colors.ts +24 -0
  158. package/src/ui/tool-progress-formatter.ts +1 -1
  159. package/src/ui/tool-render.ts +3 -3
  160. package/src/ui/transcript-cache.ts +5 -10
  161. package/src/ui/transcript-entries.ts +68 -72
  162. package/src/ui/transcript-viewer.ts +54 -14
  163. package/src/ui/widget/index.ts +26 -15
  164. package/src/ui/widget/widget-model.ts +18 -9
  165. package/src/ui/widget/widget-renderer.ts +2 -5
  166. package/src/utils/fingerprint.ts +1 -1
  167. package/src/utils/paths.ts +1 -1
  168. package/src/utils/project-detector.ts +1 -1
  169. package/src/worktree/worktree-manager.ts +47 -19
  170. package/src/state/atomic-write-v2.ts +0 -108
@@ -78,7 +78,7 @@ export function planCoalescedGroups(
78
78
  const buckets = new Map<string, TeamTaskState[]>();
79
79
  for (const taskId of readyTaskIds) {
80
80
  const task = taskById.get(taskId);
81
- if (!task || !task.stepId) continue;
81
+ if (!task?.stepId) continue;
82
82
  const step = stepById.get(task.stepId);
83
83
  if (!step) continue;
84
84
 
@@ -59,7 +59,7 @@ export class TailCaptureStage implements ICompactStage {
59
59
  // never contains a partial multi-byte sequence.
60
60
  if (Buffer.byteLength(text, "utf-8") <= this.maxBytes) return text;
61
61
  let tail = text.slice(Math.max(0, text.length - this.maxBytes));
62
- while (Buffer.byteLength(tail, "utf-8") > this.maxBytes) tail = tail.slice(1024);
62
+ while (Buffer.byteLength(tail, "utf-8") > this.maxBytes) tail = tail.slice(0, -1);
63
63
  return this.marker ? `${this.marker}\n${tail}` : tail;
64
64
  }
65
65
  // Char cap mode.
@@ -397,31 +397,43 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
397
397
  if (manifest?.status === "running" && manifest.async?.pid !== undefined) {
398
398
  const pidAlive = checkProcessLiveness(manifest.async.pid).alive;
399
399
  if (!pidAlive && !hasRecentLifeEvidence(entry, manifest.updatedAt, now, staleThresholdMs)) {
400
- // Dead PID + no recent life evidence → cancel the manifest and unregister
400
+ // Dead PID + no recent life evidence → cancel the manifest and unregister.
401
+ // RT-F5: wrap load+modify+save in withRunLockSync so concurrent writers
402
+ // (e.g. team-runner final saveRunTasks) cannot interleave between the
403
+ // repaired-tasks write and the manifest-status flip. terminateLiveAgentsForRun
404
+ // is fire-and-forget so it stays OUTSIDE the lock to avoid blocking the
405
+ // critical section on async IPC.
401
406
  try {
402
- const fullLoaded = loadRunManifestById(entry.cwd, entry.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
407
+ const fullLoaded = loadRunManifestById(entry.cwd, entry.runId);
403
408
  if (fullLoaded) {
404
- const now_iso = new Date(now).toISOString();
405
- const repairedTasks = fullLoaded.tasks.map((task) => {
406
- if (task.status === "running" || task.status === "queued" || task.status === "waiting") {
407
- return {
408
- ...task,
409
- status: "cancelled" as const,
410
- finishedAt: now_iso,
411
- error: "Orphaned run: worker process dead and no recent activity",
412
- };
409
+ withRunLockSync(fullLoaded.manifest, () => {
410
+ // Re-read under lock so the stale-best-effort load above
411
+ // can't cause us to clobber a fresh status flip.
412
+ const fresh = loadRunManifestById(entry.cwd, entry.runId);
413
+ if (!fresh) return;
414
+ if (fresh.manifest.status !== "running") return;
415
+ const now_iso = new Date(now).toISOString();
416
+ const repairedTasks = fresh.tasks.map((task) => {
417
+ if (task.status === "running" || task.status === "queued" || task.status === "waiting") {
418
+ return {
419
+ ...task,
420
+ status: "cancelled" as const,
421
+ finishedAt: now_iso,
422
+ error: "Orphaned run: worker process dead and no recent activity",
423
+ };
424
+ }
425
+ return task;
426
+ });
427
+ saveRunTasks(fresh.manifest, repairedTasks);
428
+ for (const task of repairedTasks) {
429
+ try {
430
+ upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
431
+ } catch {
432
+ /* non-critical */
433
+ }
413
434
  }
414
- return task;
435
+ updateRunStatus(fresh.manifest, "cancelled", "Orphaned run: worker process dead and no recent activity");
415
436
  });
416
- saveRunTasks(fullLoaded.manifest, repairedTasks);
417
- for (const task of repairedTasks) {
418
- try {
419
- upsertCrewAgent(fullLoaded.manifest, recordFromTask(fullLoaded.manifest, task, "scaffold"));
420
- } catch {
421
- /* non-critical */
422
- }
423
- }
424
- updateRunStatus(fullLoaded.manifest, "cancelled", "Orphaned run: worker process dead and no recent activity");
425
437
  void terminateLiveAgentsForRun(
426
438
  fullLoaded.manifest.runId,
427
439
  "cancelled",
@@ -447,35 +459,39 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
447
459
  if (manifest?.status === "running" && manifest.async === undefined) {
448
460
  if (!hasRecentLifeEvidence(entry, manifest.updatedAt, now, staleThresholdMs)) {
449
461
  try {
450
- const fullLoaded = loadRunManifestById(entry.cwd, entry.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
462
+ const fullLoaded = loadRunManifestById(entry.cwd, entry.runId);
451
463
  if (fullLoaded && fullLoaded.manifest.status === "running") {
452
- const now_iso = new Date(now).toISOString();
453
- const repairedTasks = fullLoaded.tasks.map((task) => {
454
- if (task.status === "running" || task.status === "queued" || task.status === "waiting") {
455
- return {
456
- ...task,
457
- status: "cancelled" as const,
458
- finishedAt: now_iso,
459
- error: "Orphaned run: workflow completed but manifest never updated to terminal status",
460
- };
464
+ withRunLockSync(fullLoaded.manifest, () => {
465
+ const fresh = loadRunManifestById(entry.cwd, entry.runId);
466
+ if (fresh?.manifest.status !== "running") return;
467
+ const now_iso = new Date(now).toISOString();
468
+ const repairedTasks = fresh.tasks.map((task) => {
469
+ if (task.status === "running" || task.status === "queued" || task.status === "waiting") {
470
+ return {
471
+ ...task,
472
+ status: "cancelled" as const,
473
+ finishedAt: now_iso,
474
+ error: "Orphaned run: workflow completed but manifest never updated to terminal status",
475
+ };
476
+ }
477
+ return task;
478
+ });
479
+ saveRunTasks(fresh.manifest, repairedTasks);
480
+ for (const task of repairedTasks) {
481
+ try {
482
+ upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
483
+ } catch {
484
+ /* non-critical */
485
+ }
461
486
  }
462
- return task;
487
+ updateRunStatus(
488
+ fresh.manifest,
489
+ "cancelled",
490
+ "Orphaned run: no async worker and no manifest update in over " +
491
+ Math.round(staleThresholdMs / 60000) +
492
+ " minutes",
493
+ );
463
494
  });
464
- saveRunTasks(fullLoaded.manifest, repairedTasks);
465
- for (const task of repairedTasks) {
466
- try {
467
- upsertCrewAgent(fullLoaded.manifest, recordFromTask(fullLoaded.manifest, task, "scaffold"));
468
- } catch {
469
- /* non-critical */
470
- }
471
- }
472
- updateRunStatus(
473
- fullLoaded.manifest,
474
- "cancelled",
475
- "Orphaned run: no async worker and no manifest update in over " +
476
- Math.round(staleThresholdMs / 60000) +
477
- " minutes",
478
- );
479
495
  void terminateLiveAgentsForRun(
480
496
  fullLoaded.manifest.runId,
481
497
  "cancelled",
@@ -21,7 +21,6 @@ export interface DeliveryCoordinatorDeps {
21
21
  const PENDING_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
22
22
 
23
23
  export class DeliveryCoordinator {
24
- private ownerSessionId: string | undefined;
25
24
  private active = false;
26
25
  private generation = 0;
27
26
  private pending: PendingDelivery[] = [];
@@ -29,6 +28,7 @@ export class DeliveryCoordinator {
29
28
  private readonly deps: DeliveryCoordinatorDeps;
30
29
  private ttlTimer: ReturnType<typeof setInterval> | undefined;
31
30
  private timerStarted = false;
31
+ private ownerSessionId: string | undefined = undefined;
32
32
 
33
33
  constructor(deps: DeliveryCoordinatorDeps) {
34
34
  this.deps = deps;
@@ -120,9 +120,9 @@ function isNewDateExpression(node: AstNode): boolean {
120
120
  */
121
121
  function isMemberExpression(node: AstNode, childKey: string, objectName: string, propertyName: string): boolean {
122
122
  const child = asAstNode(node[childKey]);
123
- if (!child || child.type !== "MemberExpression") return false;
123
+ if (child?.type !== "MemberExpression") return false;
124
124
  const object = asAstNode(child.object);
125
- if (!object || object.type !== "Identifier" || object.name !== objectName) return false;
125
+ if (object?.type !== "Identifier" || object.name !== objectName) return false;
126
126
  return propertyNameOf(child) === propertyName;
127
127
  }
128
128
 
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { MetricRegistry } from "../observability/metric-registry.ts";
5
5
  import type { MetricSnapshot } from "../observability/metrics-primitives.ts";
6
+ import { atomicWriteFile } from "../state/atomic-write.ts";
6
7
  import { readEvents, type TeamEvent } from "../state/event-log.ts";
7
8
  import { loadRunManifestById } from "../state/state-store.ts";
8
9
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
@@ -148,7 +149,7 @@ export async function exportDiagnostic(
148
149
  const dir = path.join(loaded.manifest.artifactsRoot, "diagnostic");
149
150
  fs.mkdirSync(dir, { recursive: true });
150
151
  const filePath = path.join(dir, `diagnostic-${safeTimestamp}.json`);
151
- fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf-8");
152
+ atomicWriteFile(filePath, `${JSON.stringify(report, null, 2)}\n`);
152
153
  return { path: filePath, report };
153
154
  }
154
155
 
@@ -28,6 +28,11 @@ export interface DwfCheckpointState {
28
28
  logs: string[]; // capped copy (≤1000); the events log (dwf.log) is the durable source of truth
29
29
  spent: number; // budget accumulator (round-14 P1-2)
30
30
  agentCount: number;
31
+ /** PERS-1: per-agent-call idempotency cache. Maps a deterministic call ID to the
32
+ * cached result so a DWF resume skips already-completed agent calls (avoids
33
+ * duplicating artifacts/mailbox/tokens). Uses Record (not Map) for JSON
34
+ * serialization safety. */
35
+ completedAgentCalls?: Record<string, { text: string; usage?: { input: number; output: number } }>;
31
36
  updatedAt: string;
32
37
  }
33
38
 
@@ -35,6 +35,7 @@ import { logInternalError } from "../utils/internal-error.ts";
35
35
  import { cleanupAgentWorktreeAsync, prepareAgentWorktreeAsync } from "../worktree/worktree-manager.ts";
36
36
  import { runChildPi } from "./child-pi.ts";
37
37
  import type { DwfCheckpointState } from "./dwf-state-store.ts";
38
+ import { withWorkerSlot } from "./global-worker-cap.ts";
38
39
  import { mapConcurrent } from "./parallel-utils.ts";
39
40
  import { parsePiJsonOutput } from "./pi-json-output.ts";
40
41
  import { renderPlanTemplate } from "./plan-templates.ts";
@@ -269,6 +270,11 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
269
270
  logs: opts.resumedState ? [...opts.resumedState.logs].slice(0, 1000) : [],
270
271
  args: opts.args ?? {},
271
272
  };
273
+ // PERS-1: per-agent-call idempotency cache. Hydrated from resumedState so a DWF
274
+ // resume skips already-completed agent calls (avoids duplicating artifacts/mailbox/tokens).
275
+ // Uses Record (not Map) for JSON serialization safety.
276
+ const completedAgentCalls: Record<string, { text: string; usage?: { input: number; output: number } }> =
277
+ opts.resumedState?.completedAgentCalls ?? {};
272
278
  // round-14 P1-2: frozen budget surface. The closures read wfState.spent so the
273
279
  // object stays live after Object.freeze(ctx). total is a snapshot primitive.
274
280
  const budget = Object.freeze({
@@ -290,10 +296,35 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
290
296
  // regardless of which return/throw path is taken.
291
297
  let worktreePath: string | undefined;
292
298
  let worktreeBranch: string | undefined;
299
+ // BDG-2: declared before the try so the catch block can un-reserve on failure.
300
+ const ESTIMATE = 4096;
301
+ let reserved = false;
293
302
  try {
294
- // round-14 P1-2: budget check BEFORE spawning. When the per-workflow token
295
- // budget is exhausted, reject the call without consuming a child worker.
296
- if (budget.total !== null && budget.remaining() <= 0) {
303
+ // PERS-1: per-agent-call idempotency. Compute a deterministic call ID
304
+ // from the call arguments and check if this call was already completed
305
+ // (e.g. during a previous run before a crash). If so, return the cached
306
+ // result without re-spawning the agent.
307
+ const callId = JSON.stringify({
308
+ role: call.role,
309
+ agent: call.agent,
310
+ prompt: call.prompt,
311
+ model: call.model,
312
+ schema: call.schema ? "yes" : "no",
313
+ });
314
+ const cached = completedAgentCalls[callId];
315
+ if (cached) {
316
+ return {
317
+ ok: true,
318
+ text: cached.text,
319
+ usage: cached.usage,
320
+ durationMs: 0,
321
+ };
322
+ }
323
+
324
+ // BDG-2: reserve-then-adjust budget. Before spawning, estimate the cost and
325
+ // reserve it by adding to wfState.spent. This prevents N concurrent calls from
326
+ // all seeing the same remaining budget and overspending.
327
+ if (budget.total !== null && budget.remaining() < ESTIMATE) {
297
328
  return {
298
329
  ok: false,
299
330
  text: "",
@@ -301,6 +332,10 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
301
332
  durationMs: 0,
302
333
  };
303
334
  }
335
+ // Reserve the estimate before spawning.
336
+ wfState.spent += ESTIMATE;
337
+ reserved = true;
338
+
304
339
  const agentConfig = resolveAgentForRole(call.role, {
305
340
  explicitAgent: call.agent,
306
341
  team: opts.team,
@@ -353,20 +388,25 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
353
388
  }
354
389
  }
355
390
 
356
- const childResult = await runChildPi({
357
- cwd: agentCwd,
358
- task,
359
- agent: effectiveAgent,
360
- model: call.model ?? opts.modelOverride ?? agentConfig.model,
361
- skillPaths: undefined, // skills resolved via agent config + team-role plumbing
362
- maxTurns: call.maxTurns,
363
- graceTurns: call.graceTurns,
364
- signal: opts.signal,
365
- artifactsRoot: manifest.artifactsRoot,
366
- runId: manifest.runId,
367
- role: call.role ?? call.agent,
368
- });
391
+ const childResult = await withWorkerSlot(() =>
392
+ runChildPi({
393
+ cwd: agentCwd,
394
+ task,
395
+ agent: effectiveAgent,
396
+ model: call.model ?? opts.modelOverride ?? agentConfig.model,
397
+ skillPaths: undefined, // skills resolved via agent config + team-role plumbing
398
+ maxTurns: call.maxTurns,
399
+ graceTurns: call.graceTurns,
400
+ signal: opts.signal,
401
+ artifactsRoot: manifest.artifactsRoot,
402
+ runId: manifest.runId,
403
+ role: call.role ?? call.agent,
404
+ }),
405
+ );
369
406
  if (childResult.exitCode !== 0 || childResult.error) {
407
+ // BDG-2: un-reserve the estimate on spawn failure.
408
+ wfState.spent -= ESTIMATE;
409
+ reserved = false;
370
410
  return {
371
411
  ok: false,
372
412
  text: "",
@@ -376,8 +416,10 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
376
416
  }
377
417
  const parsed = parsePiJsonOutput(childResult.stdout);
378
418
  // round-14 P1-2: accumulate this run's token usage into the workflow budget.
379
- // Covers both the success and schema-mismatch paths (both report parsed.usage).
380
- wfState.spent += (parsed.usage?.input ?? 0) + (parsed.usage?.output ?? 0);
419
+ // BDG-2: adjust the reserve — subtract the estimate, add the actual usage.
420
+ // This correctly reduces spent when actualUsage < ESTIMATE.
421
+ wfState.spent += (parsed.usage?.input ?? 0) + (parsed.usage?.output ?? 0) - ESTIMATE;
422
+ reserved = false;
381
423
  let text = parsed.finalText ?? "";
382
424
  // Round-11 test fix: parsePiJsonOutput only extracts text from pi event stream
383
425
  // ({type:"message_end", message:{role:"assistant", content:[...]}}). When the
@@ -400,6 +442,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
400
442
  producer: "dynamic-workflow",
401
443
  });
402
444
  if (call.schema !== undefined && !extracted.structured) {
445
+ // BDG-2: un-reserve was already done above (reserved = false after adjust).
403
446
  return {
404
447
  ok: false,
405
448
  text,
@@ -409,6 +452,11 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
409
452
  durationMs: Date.now() - started,
410
453
  };
411
454
  }
455
+ // PERS-1: cache the successful result for idempotency on resume.
456
+ completedAgentCalls[callId] = {
457
+ text,
458
+ usage: { input: parsed.usage?.input ?? 0, output: parsed.usage?.output ?? 0 },
459
+ };
412
460
  return {
413
461
  ok: true,
414
462
  text,
@@ -418,6 +466,8 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
418
466
  durationMs: Date.now() - started,
419
467
  };
420
468
  } catch (error) {
469
+ // BDG-2: un-reserve the estimate on failure (only if still reserved).
470
+ if (reserved) wfState.spent -= ESTIMATE;
421
471
  logInternalError("dynamic-workflow-context.agent", error, `runId=${manifest.runId}`);
422
472
  return {
423
473
  ok: false,
@@ -450,6 +500,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
450
500
  logs: wfState.logs.slice(0, 1000),
451
501
  spent: wfState.spent,
452
502
  agentCount,
503
+ completedAgentCalls,
453
504
  updatedAt: new Date().toISOString(),
454
505
  });
455
506
  } catch (checkpointError) {
@@ -709,6 +760,12 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
709
760
  get: () => agentCount,
710
761
  enumerable: false,
711
762
  });
763
+ // PERS-1: completedAgentCalls is read-only from the runner; the agent() method
764
+ // is the only writer. Exposed so getWorkflowCheckpoint() can include it.
765
+ Object.defineProperty(ctx, "__completedAgentCalls", {
766
+ get: () => completedAgentCalls,
767
+ enumerable: false,
768
+ });
712
769
  return ctx;
713
770
  }
714
771
 
@@ -757,6 +814,9 @@ export function getWorkflowCheckpoint(ctx: WorkflowCtx): DwfCheckpointState {
757
814
  logs: logs ?? [],
758
815
  spent: ctx.budget.spent(),
759
816
  agentCount: (ctx as unknown as { __agentCount?: number }).__agentCount ?? 0,
817
+ completedAgentCalls:
818
+ (ctx as unknown as { __completedAgentCalls?: Record<string, { text: string; usage?: { input: number; output: number } }> })
819
+ .__completedAgentCalls ?? {},
760
820
  updatedAt: new Date().toISOString(),
761
821
  };
762
822
  }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { readFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
+ import { transformSync } from "esbuild";
22
23
  import { writeArtifact } from "../state/artifact-store.ts";
23
24
  import { appendEvent } from "../state/event-log.ts";
24
25
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
@@ -113,7 +114,11 @@ async function loadWorkflowModule(scriptPath: string): Promise<DynamicWorkflowSc
113
114
  // raw .dwf.ts file. This is the same source jiti will execute.
114
115
  const scriptSource = readFileSync(scriptPath, "utf-8");
115
116
  if (isDeterminismCheckEnabled()) {
116
- assertDeterministicScript(scriptSource);
117
+ // DISC-1: acorn can only parse JavaScript, not TypeScript. Real .dwf.ts files
118
+ // contain type annotations/imports that cause a silent parse error, so the
119
+ // determinism check never runs. Transpile to JS via esbuild first.
120
+ const js = transformSync(scriptSource, { loader: "ts", format: "esm" }).code;
121
+ assertDeterministicScript(js);
117
122
  }
118
123
  // jiti is the same loader async-runner.ts uses (resolveTypeScriptLoader). We require it
119
124
  // lazily so this module stays importable in environments without jiti (type-only consumers).
@@ -166,9 +171,16 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
166
171
  });
167
172
  }
168
173
 
174
+ // ERR-1: Create a timeout AbortController so we can abort spawned children on script timeout.
175
+ // Combine the input signal with the timeout controller's signal so EITHER source
176
+ // (external abort OR our timeout) propagates to runChildPi via the ctx.
177
+ // AbortSignal.any is available since Node 20.3 — the project requires Node 20+.
178
+ const timeoutController = new AbortController();
179
+ const combinedSignal = AbortSignal.any([signal, timeoutController.signal]);
180
+
169
181
  const ctx = makeWorkflowCtx(manifest, {
170
182
  concurrency: input.concurrency ?? workflow.maxConcurrency ?? 4,
171
- signal,
183
+ signal: combinedSignal,
172
184
  team: input.team,
173
185
  modelOverride: input.modelOverride,
174
186
  tokenBudget: input.tokenBudget ?? workflow.maxTokenBudget,
@@ -200,6 +212,10 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
200
212
  let timeoutHandle: NodeJS.Timeout | undefined;
201
213
  const timeoutPromise = new Promise<never>((_, reject) => {
202
214
  timeoutHandle = setTimeout(() => {
215
+ // ERR-1: abort the timeout controller so the abort propagates to
216
+ // runChildPi (via the combined signal passed to makeWorkflowCtx),
217
+ // which kills spawned children instead of letting them run.
218
+ timeoutController.abort();
203
219
  reject(
204
220
  new Error(
205
221
  `Dynamic workflow script timed out after ${SCRIPT_TIMEOUT_MS}ms. The script may have spawned a child process that did not exit. Check for spawn/exec calls without proper stdio handling.`,
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { atomicWriteFile } from "../state/atomic-write.ts";
3
4
  import { appendEvent } from "../state/event-log.ts";
4
5
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
6
  import { logInternalError } from "../utils/internal-error.ts";
@@ -82,7 +83,7 @@ export function writeForegroundInterruptRequest(
82
83
  try {
83
84
  fs.mkdirSync(lockDir, { recursive: true });
84
85
  try {
85
- fs.writeFileSync(pidFile, String(process.pid), "utf-8");
86
+ atomicWriteFile(pidFile, String(process.pid));
86
87
  } catch {
87
88
  /* best-effort */
88
89
  }
@@ -178,7 +179,7 @@ export function writeForegroundInterruptRequest(
178
179
  acknowledged: false,
179
180
  };
180
181
  fs.mkdirSync(path.dirname(controlPath), { recursive: true });
181
- fs.writeFileSync(controlPath, `${JSON.stringify({ requests: [...requests, request] }, null, 2)}\n`, "utf-8");
182
+ atomicWriteFile(controlPath, `${JSON.stringify({ requests: [...requests, request] }, null, 2)}\n`);
182
183
  appendEvent(manifest.eventsPath, {
183
184
  type: "foreground.interrupt_requested",
184
185
  runId: manifest.runId,
@@ -188,7 +188,9 @@ function tryParseDirectVerdict(stdout: string): { achieved: boolean; reason: str
188
188
  * Returns a GoalVerdict. On any failure (non-zero exit, non-JSON, invalid shape),
189
189
  * returns a `BLOCKED:`-prefixed verdict so the loop stops (§0c C6 fallback).
190
190
  */
191
- export async function evaluateGoal(input: EvaluateGoalInput): Promise<GoalVerdict> {
191
+ export async function evaluateGoal(
192
+ input: EvaluateGoalInput,
193
+ ): Promise<{ verdict: GoalVerdict; judgeUsage?: { totalTokens: number; inputTokens?: number; outputTokens?: number } }> {
192
194
  const agent = synthesizeJudgeAgentConfig();
193
195
  const task = buildJudgeTask(input);
194
196
  const evaluatedAt = new Date().toISOString();
@@ -212,12 +214,15 @@ export async function evaluateGoal(input: EvaluateGoalInput): Promise<GoalVerdic
212
214
  });
213
215
 
214
216
  if (result.exitCode !== 0 || result.error) {
215
- return blockedVerdict(
216
- input.turn,
217
- input.model,
218
- evaluatedAt,
219
- `judge spawn failed (exit=${result.exitCode}): ${result.error ?? result.stderr.slice(0, 200)}`,
220
- );
217
+ return {
218
+ verdict: blockedVerdict(
219
+ input.turn,
220
+ input.model,
221
+ evaluatedAt,
222
+ `judge spawn failed (exit=${result.exitCode}): ${result.error ?? result.stderr.slice(0, 200)}`,
223
+ ),
224
+ judgeUsage: undefined,
225
+ };
221
226
  }
222
227
 
223
228
  const parsed = parsePiJsonOutput(result.stdout);
@@ -231,16 +236,22 @@ export async function evaluateGoal(input: EvaluateGoalInput): Promise<GoalVerdic
231
236
  const direct = !finalText.trim() ? tryParseDirectVerdict(result.stdout) : undefined;
232
237
  if (direct) {
233
238
  return {
234
- turn: input.turn,
235
- achieved: direct.achieved,
236
- reason: direct.reason,
237
- evidenceRefs: direct.evidenceRefs,
238
- evaluatorModel: input.model,
239
- evaluatedAt,
239
+ verdict: {
240
+ turn: input.turn,
241
+ achieved: direct.achieved,
242
+ reason: direct.reason,
243
+ evidenceRefs: direct.evidenceRefs,
244
+ evaluatorModel: input.model,
245
+ evaluatedAt,
246
+ },
247
+ judgeUsage: undefined,
240
248
  };
241
249
  }
242
250
  if (!finalText.trim()) {
243
- return blockedVerdict(input.turn, input.model, evaluatedAt, "judge produced no output");
251
+ return {
252
+ verdict: blockedVerdict(input.turn, input.model, evaluatedAt, "judge produced no output"),
253
+ judgeUsage: undefined,
254
+ };
244
255
  }
245
256
 
246
257
  const extracted = extractStructuredResult(finalText);
@@ -252,27 +263,48 @@ export async function evaluateGoal(input: EvaluateGoalInput): Promise<GoalVerdic
252
263
  })
253
264
  : undefined;
254
265
  if (!data || typeof data.achieved !== "boolean" || typeof data.reason !== "string") {
255
- return blockedVerdict(input.turn, input.model, evaluatedAt, `judge output not valid verdict JSON: ${truncate(finalText, 200)}`);
266
+ return {
267
+ verdict: blockedVerdict(
268
+ input.turn,
269
+ input.model,
270
+ evaluatedAt,
271
+ `judge output not valid verdict JSON: ${truncate(finalText, 200)}`,
272
+ ),
273
+ judgeUsage: undefined,
274
+ };
256
275
  }
257
276
  const evidenceRefs = Array.isArray(data.evidenceRefs)
258
277
  ? data.evidenceRefs.filter((r): r is string => typeof r === "string")
259
278
  : undefined;
279
+ const judgeUsage = parsed.usage
280
+ ? {
281
+ totalTokens: (parsed.usage.input ?? 0) + (parsed.usage.output ?? 0),
282
+ inputTokens: parsed.usage.input,
283
+ outputTokens: parsed.usage.output,
284
+ }
285
+ : undefined;
260
286
  return {
261
- turn: input.turn,
262
- achieved: data.achieved,
263
- reason: data.reason,
264
- evidenceRefs,
265
- evaluatorModel: input.model,
266
- evaluatedAt,
287
+ verdict: {
288
+ turn: input.turn,
289
+ achieved: data.achieved,
290
+ reason: data.reason,
291
+ evidenceRefs,
292
+ evaluatorModel: input.model,
293
+ evaluatedAt,
294
+ },
295
+ judgeUsage,
267
296
  };
268
297
  } catch (error) {
269
298
  logInternalError("goal-evaluator.evaluateGoal", error, `turn=${input.turn}`);
270
- return blockedVerdict(
271
- input.turn,
272
- input.model,
273
- evaluatedAt,
274
- `judge threw: ${error instanceof Error ? error.message : String(error)}`,
275
- );
299
+ return {
300
+ verdict: blockedVerdict(
301
+ input.turn,
302
+ input.model,
303
+ evaluatedAt,
304
+ `judge threw: ${error instanceof Error ? error.message : String(error)}`,
305
+ ),
306
+ judgeUsage: undefined,
307
+ };
276
308
  }
277
309
  }
278
310