pi-crew 0.11.1 → 0.11.4

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 (154) hide show
  1. package/CHANGELOG.md +82 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +742 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +5 -4
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +206 -41
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +55 -8
  20. package/src/extension/crew-input-router.ts +9 -2
  21. package/src/extension/help.ts +21 -10
  22. package/src/extension/knowledge-injection.ts +2 -1
  23. package/src/extension/management.ts +8 -3
  24. package/src/extension/notification-sink.ts +17 -0
  25. package/src/extension/plan-orchestrate.ts +19 -3
  26. package/src/extension/registration/command-utils.ts +28 -2
  27. package/src/extension/registration/commands/dashboard.ts +11 -1
  28. package/src/extension/registration/commands/manage.ts +31 -15
  29. package/src/extension/registration/commands/run.ts +24 -2
  30. package/src/extension/registration/commands/shared.ts +34 -10
  31. package/src/extension/registration/commands/status.ts +25 -2
  32. package/src/extension/registration/context-builder.ts +8 -2
  33. package/src/extension/registration/foreground-run-controller.ts +2 -2
  34. package/src/extension/registration/health-notify-policy.ts +100 -0
  35. package/src/extension/registration/lazy-configurers.ts +38 -0
  36. package/src/extension/registration/lifecycle-handlers.ts +107 -40
  37. package/src/extension/registration/lifecycle.ts +77 -10
  38. package/src/extension/registration/observability.ts +125 -53
  39. package/src/extension/registration/registration-types.ts +7 -5
  40. package/src/extension/registration/runtime-cleanup.ts +9 -3
  41. package/src/extension/registration/subagent-helpers.ts +38 -0
  42. package/src/extension/registration/team-tool.ts +9 -2
  43. package/src/extension/registration/wire-cross-extension.ts +30 -0
  44. package/src/extension/run-compare.ts +220 -0
  45. package/src/extension/run-export.ts +37 -5
  46. package/src/extension/run-maintenance.ts +155 -5
  47. package/src/extension/team-manager-command.ts +3 -2
  48. package/src/extension/team-tool/dispatch/index.ts +3 -2
  49. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  50. package/src/extension/team-tool/goal-wrap.ts +7 -0
  51. package/src/extension/team-tool/goal.ts +16 -1
  52. package/src/extension/team-tool/handle-settings.ts +19 -2
  53. package/src/extension/team-tool/health-monitor.ts +21 -7
  54. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  55. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  56. package/src/extension/team-tool/plan.ts +10 -0
  57. package/src/extension/team-tool/routing-hint.ts +63 -0
  58. package/src/extension/team-tool/run-intent.ts +1 -1
  59. package/src/extension/team-tool/run.ts +1 -1
  60. package/src/extension/team-tool/status.ts +4 -0
  61. package/src/extension/team-tool.ts +52 -6
  62. package/src/extension/webhook-notify.ts +382 -0
  63. package/src/observability/metric-sink.ts +12 -2
  64. package/src/prompt/prompt-runtime.ts +82 -31
  65. package/src/prompt/worker-events-channel.ts +12 -0
  66. package/src/runtime/README.md +1 -1
  67. package/src/runtime/async-runner.ts +123 -1
  68. package/src/runtime/background-runner.ts +313 -234
  69. package/src/runtime/broker/crew-broker.ts +76 -71
  70. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  73. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  74. package/src/runtime/broker/wait-status-cache.ts +1 -1
  75. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  76. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  77. package/src/runtime/crew-agent-records.ts +337 -45
  78. package/src/runtime/deadletter.ts +43 -1
  79. package/src/runtime/delegate-spawn.ts +21 -3
  80. package/src/runtime/deterministic-ast.ts +14 -2
  81. package/src/runtime/dispatch-batch.ts +72 -5
  82. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  83. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  84. package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
  85. package/src/runtime/group-join.ts +11 -3
  86. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  87. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  88. package/src/runtime/model/model-fallback.ts +21 -1
  89. package/src/runtime/model/pi-args.ts +8 -10
  90. package/src/runtime/model/runtime-warmup.ts +2 -0
  91. package/src/runtime/output/output-validator.ts +10 -5
  92. package/src/runtime/plan-approval.ts +14 -1
  93. package/src/runtime/recovery/crash-recovery.ts +96 -49
  94. package/src/runtime/run-worker.ts +12 -1
  95. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  96. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  97. package/src/runtime/scheduling/scheduler.ts +49 -13
  98. package/src/runtime/scheduling/semaphore.ts +148 -20
  99. package/src/runtime/scratchpad/README.md +1 -1
  100. package/src/runtime/scratchpad/protocol.ts +1 -1
  101. package/src/runtime/settings-store.ts +1 -1
  102. package/src/runtime/skill-instructions.ts +22 -0
  103. package/src/runtime/stale-reconciler.ts +85 -13
  104. package/src/runtime/subagent-manager.ts +43 -13
  105. package/src/runtime/task-runner/child-executor.ts +17 -6
  106. package/src/runtime/task-runner/post-execution.ts +44 -14
  107. package/src/runtime/task-runner/pre-execution.ts +26 -2
  108. package/src/runtime/task-runner/prompt-builder.ts +150 -45
  109. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  110. package/src/runtime/task-runner.ts +28 -1
  111. package/src/runtime/team-runner.ts +42 -2
  112. package/src/runtime/verification/verification-worktree.ts +3 -0
  113. package/src/runtime/workspace-lock.ts +4 -1
  114. package/src/schema/config-schema.ts +14 -0
  115. package/src/schema/team-tool-schema.ts +17 -0
  116. package/src/state/atomic-write.ts +53 -0
  117. package/src/state/contracts.ts +109 -0
  118. package/src/state/coordination/locks.ts +191 -33
  119. package/src/state/coordination/mailbox.ts +147 -17
  120. package/src/state/crew-init.ts +87 -12
  121. package/src/state/event-log/cursor.ts +37 -1
  122. package/src/state/event-log/event-log-rotation.ts +72 -7
  123. package/src/state/stores/active-run-registry.ts +13 -1
  124. package/src/state/stores/state-store.ts +120 -23
  125. package/src/state/types.ts +4 -0
  126. package/src/ui/dashboard-panes/agents-pane.ts +22 -10
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/index.ts +1 -1
  129. package/src/ui/keybinding-map.ts +22 -4
  130. package/src/ui/live-conversation-overlay.ts +6 -3
  131. package/src/ui/render-diff.ts +19 -6
  132. package/src/ui/run-dashboard.ts +109 -6
  133. package/src/ui/run-snapshot-cache.ts +27 -4
  134. package/src/ui/spinner.ts +26 -2
  135. package/src/ui/tool-progress-formatter.ts +2 -1
  136. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  137. package/src/ui/tool-renderers/index.ts +3 -3
  138. package/src/ui/widget/task-list.ts +8 -5
  139. package/src/ui/widget/widget-model.ts +3 -1
  140. package/src/utils/incremental-reader.ts +11 -3
  141. package/src/utils/paths.ts +94 -12
  142. package/src/utils/project-markers.ts +40 -0
  143. package/src/worktree/worktree-manager.ts +206 -26
  144. package/tsconfig.json +2 -0
  145. package/workflows/distill.workflow.md +3 -3
  146. package/workflows/fast-fix.workflow.md +1 -1
  147. package/workflows/plan-execute.workflow.md +1 -1
  148. package/workflows/review.workflow.md +1 -1
  149. package/workflows/strict-fast-fix.workflow.md +1 -1
  150. package/docs/migration-v0.4-v0.5.md +0 -208
  151. package/docs/runtime-flow.md +0 -148
  152. package/src/ui/card-colors.ts +0 -168
  153. package/src/utils/completion-dedupe.ts +0 -63
  154. package/src/utils/resolve-shell.ts +0 -36
@@ -1,9 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { atomicWriteJson, atomicWriteJsonCoalesced, flushPendingAtomicWrites, readJsonFile } from "../state/atomic-write.ts";
4
+ import {
5
+ atomicWriteJson,
6
+ atomicWriteJsonCoalesced,
7
+ cancelPendingCoalescedWriteForPath,
8
+ flushPendingAtomicWrites,
9
+ peekPendingCoalescedWrite,
10
+ readJsonFile,
11
+ } from "../state/atomic-write.ts";
5
12
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
6
13
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
14
+ import { readLinesSince } from "../utils/incremental-reader.ts";
7
15
  import { logInternalError } from "../utils/internal-error.ts";
8
16
  import { redactSecretString, redactSecrets } from "../utils/redaction.ts";
9
17
  import { assertSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts";
@@ -237,7 +245,9 @@ function withAgentsLock<T>(manifest: TeamRunManifest, fn: () => T): T {
237
245
  break;
238
246
  } catch (error) {
239
247
  const code = (error as NodeJS.ErrnoException).code;
240
- if (code !== "EEXIST" && code !== "EISDIR") throw error;
248
+ // EPERM/EACCES/EBUSY: Windows cross-process contention (another handle
249
+ // holds the lock open) — retry like EEXIST, bounded by the deadline.
250
+ if (code !== "EEXIST" && code !== "EISDIR" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") throw error;
241
251
  if (code === "EISDIR") {
242
252
  try {
243
253
  fs.rmSync(filePath, { recursive: true, force: true });
@@ -282,19 +292,22 @@ function setAsyncAgentReaderCache(
282
292
  }
283
293
 
284
294
  export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
285
- // 2.5: ensure intra-process coalesced writes are visible to subsequent
286
- // readers in the same process. Cross-process readers still see the file
287
- // after at most one coalesce window (250 ms).
288
- // R10-2: scoped flush — readCrewAgents only reads agents.json, so it must
289
- // not drain unrelated coalesced writes (tasks.json of live runs, other
290
- // runs' agents.json) process-wide on every read.
291
- flushPendingAtomicWrites(agentsPath(manifest));
295
+ // F06 / RR-017: read-after-write WITHOUT forcing the pending coalesced write
296
+ // to disk. `upsertCrewAgent` always reads first, so the previous scoped
297
+ // `flushPendingAtomicWrites(agentsPath)` (a scoped flush IS a write when an
298
+ // entry exists — atomic-write.ts:1147) turned every non-terminal upsert into
299
+ // a durable agents.json rewrite, destroying the very coalescing window the
300
+ // caller had just created (measured: 20 progress upserts → 19 agents.json
301
+ // renames). The buffered value is byte-for-byte what a flush would have
302
+ // written, so overlaying it preserves the "intra-process readers see the
303
+ // latest data" contract (and cross-process readers still see the file within
304
+ // one coalesce window) with zero I/O.
305
+ const agentsFilePath = agentsPath(manifest);
306
+ const pending = peekPendingCoalescedWrite<CrewAgentRecord[]>(agentsFilePath);
292
307
  try {
293
- const records = readJsonFileCoalesced(
294
- agentsPath(manifest),
295
- AGENT_READER_TTL_MS,
296
- () => readJsonFile<CrewAgentRecord[]>(agentsPath(manifest)) ?? [],
297
- );
308
+ const records =
309
+ pending ??
310
+ readJsonFileCoalesced(agentsFilePath, AGENT_READER_TTL_MS, () => readJsonFile<CrewAgentRecord[]>(agentsFilePath) ?? []);
298
311
  // Validate schema and deduplicate by id to handle concurrent write conflicts
299
312
  const seen = new Set<string>();
300
313
  const deduped = records.filter((r) => {
@@ -368,6 +381,122 @@ export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise<Cr
368
381
  return inFlight;
369
382
  }
370
383
 
384
+ // ---------------------------------------------------------------------------
385
+ // F06 / RR-017: dirty tracking for per-task status.json writes.
386
+ //
387
+ // `saveCrewAgents`/`saveCrewAgentsCoalesced` used to loop over EVERY record and
388
+ // write its status.json, with no notion of which records actually changed. The
389
+ // measured cost of updating ONE of four `completed` records was 6 renames / 12
390
+ // fsyncs, and the three unchanged records each paid a full durable rename they
391
+ // had no reason to pay.
392
+ //
393
+ // SCOPE OF THE SKIP — durable (terminal) writes only, deliberately:
394
+ // - a coalesced write is buffered in RAM and collapses per path, so skipping
395
+ // it saves no I/O; and a skip there could not be trusted anyway, since the
396
+ // buffered value may never reach disk (the memo would then claim a write
397
+ // that a crash erased). Coalesced writes therefore stay unconditional.
398
+ // - a DURABLE write is only skipped when the exact same bytes are already
399
+ // durably on disk: the memo is written ONLY by durable writes, and the skip
400
+ // is re-validated against the file actually existing. So a skip can never
401
+ // lose durability — it can only avoid re-writing identical bytes.
402
+ // Durability is otherwise untouched: agents.json keeps full durability on every
403
+ // save, terminal status keeps its deliberate DOUBLE write (H2/F4 — the second
404
+ // write is unconditional and is NOT removed here), and non-terminal status keeps
405
+ // the coalesced best-effort path.
406
+ // ---------------------------------------------------------------------------
407
+
408
+ /**
409
+ * Last record content (redacted, JSON-serialized) written DURABLY per status
410
+ * path.
411
+ *
412
+ * INVARIANT (single-writer, RM-03 / review MINOR 4): this memo is a
413
+ * PROCESS-GLOBAL Map with no locking, and it is only correct because exactly
414
+ * ONE writer owns a given `statusPath` at a time. Two concurrent writers for
415
+ * the same path would each see a stale memo and could skip a durable write
416
+ * that the other expects to have happened. The single-writer property is
417
+ * provided by the run-lock / task-claim protocol (see
418
+ * src/state/coordination/locks.ts): a task is claimed by one agent, and only
419
+ * that agent calls saveCrewAgents for its statusPath.
420
+ *
421
+ * Do NOT add a second writer for the same statusPath without first replacing
422
+ * this memo with a lock-protected (or content-addressed) structure — and do
423
+ * NOT rely on the memo across processes (it is per-process; cross-process
424
+ * readers see the file, which is why needsDurableStatusWrite re-checks
425
+ * fs.existsSync rather than trusting the memo).
426
+ *
427
+ * Bounded by LAST_WRITTEN_STATUS_MAX_ENTRIES with FIFO eviction, so an evicted
428
+ * entry merely causes one redundant durable write (safe), never a skipped one.
429
+ */
430
+ const lastWrittenStatus = new Map<string, string>();
431
+ const LAST_WRITTEN_STATUS_MAX_ENTRIES = 2048;
432
+
433
+ /** @internal Test-only: how many records a save actually persisted status for. */
434
+ let lastSaveStatusWriteCount = 0;
435
+ /** @internal Test-only: read the counter from the most recent save call. */
436
+ export function __test__lastAgentStatusWriteCount(): number {
437
+ return lastSaveStatusWriteCount;
438
+ }
439
+
440
+ /** @internal Test-only: drop the durable-write memo. */
441
+ export function __test__clearWrittenStatusMemo(): void {
442
+ lastWrittenStatus.clear();
443
+ }
444
+
445
+ function statusSnapshotKey(record: CrewAgentRecord): string {
446
+ return JSON.stringify(redactSecrets(record));
447
+ }
448
+
449
+ /**
450
+ * True when `record`'s status.json must be written durably: either its content
451
+ * differs from the last durable snapshot for that path, or the file is gone
452
+ * (someone else deleted it — never trust the memo over the filesystem).
453
+ */
454
+ function needsDurableStatusWrite(manifest: TeamRunManifest, record: CrewAgentRecord): boolean {
455
+ const statusPath = agentStatusPath(manifest, record.taskId);
456
+ const snapshot = statusSnapshotKey(record);
457
+ if (lastWrittenStatus.get(statusPath) !== snapshot) return true;
458
+ return !fs.existsSync(statusPath);
459
+ }
460
+
461
+ function rememberWrittenStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
462
+ const statusPath = agentStatusPath(manifest, record.taskId);
463
+ if (lastWrittenStatus.has(statusPath)) lastWrittenStatus.delete(statusPath);
464
+ lastWrittenStatus.set(statusPath, statusSnapshotKey(record));
465
+ while (lastWrittenStatus.size > LAST_WRITTEN_STATUS_MAX_ENTRIES) {
466
+ const oldest = lastWrittenStatus.keys().next().value;
467
+ if (oldest === undefined) break;
468
+ lastWrittenStatus.delete(oldest);
469
+ }
470
+ }
471
+
472
+ /** Drop the memo for a status path whose file was deleted (cancel/removal). */
473
+ function forgetWrittenStatus(statusPath: string): void {
474
+ lastWrittenStatus.delete(statusPath);
475
+ }
476
+
477
+ function writeStatusForRecords(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
478
+ lastSaveStatusWriteCount = 0;
479
+ for (const record of records) {
480
+ // H2 (2026-08-10): per-task status.json is a DENORMALIZED read
481
+ // optimization for the dashboard/notifier, not an authoritative
482
+ // record — agents.json + events.jsonl cover crash recovery.
483
+ // Previously EVERY record (including running/queued progress
484
+ // snapshots) got a full fsync: N+1 fsyncs per saveCrewAgents call
485
+ // (50-task team ≈ 750ms blocking). Only TERMINAL records keep
486
+ // full durability (F4: notifier/dashboard must see the final
487
+ // state immediately); non-terminal per-task status is best-effort
488
+ // coalesced like the upsertCrewAgent non-terminal path.
489
+ if (TERMINAL_AGENT_STATUSES.has(record.status ?? "")) {
490
+ if (!needsDurableStatusWrite(manifest, record)) continue;
491
+ lastSaveStatusWriteCount++;
492
+ writeCrewAgentStatus(manifest, record);
493
+ } else {
494
+ lastSaveStatusWriteCount++;
495
+ writeCrewAgentStatusCoalesced(manifest, record);
496
+ }
497
+ }
498
+ }
499
+
371
500
  export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
372
501
  // P0-3: flush any pending coalesced (best-effort) write first so a stale
373
502
  // debounced progress snapshot can't clobber this durable write.
@@ -379,22 +508,7 @@ export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentReco
379
508
  // durability.
380
509
  atomicWriteJson(filePath, redactSecrets(records));
381
510
  asyncAgentReaderCache.delete(filePath);
382
- for (const record of records) {
383
- // H2 (2026-08-10): per-task status.json is a DENORMALIZED read
384
- // optimization for the dashboard/notifier, not an authoritative
385
- // record — agents.json + events.jsonl cover crash recovery.
386
- // Previously EVERY record (including running/queued progress
387
- // snapshots) got a full fsync: N+1 fsyncs per saveCrewAgents call
388
- // (50-task team ≈ 750ms blocking). Only TERMINAL records keep
389
- // full durability (F4: notifier/dashboard must see the final
390
- // state immediately); non-terminal per-task status is best-effort
391
- // coalesced like the upsertCrewAgent non-terminal path.
392
- if (TERMINAL_AGENT_STATUSES.has(record.status ?? "")) {
393
- writeCrewAgentStatus(manifest, record);
394
- } else {
395
- writeCrewAgentStatusCoalesced(manifest, record);
396
- }
397
- }
511
+ writeStatusForRecords(manifest, records);
398
512
  });
399
513
  }
400
514
 
@@ -431,6 +545,15 @@ export function removeCrewAgent(manifest: TeamRunManifest, taskId: string): { re
431
545
  // 2. Remove per-task status.json
432
546
  try {
433
547
  const statusPath = agentStatusPath(manifest, taskId);
548
+ // F10 / RR-016: cancel the pending coalesced status write for THIS exact
549
+ // path BEFORE unlinking. The record was just dropped from the index, so
550
+ // `flushPendingAgentWrites` (which only iterates records still in the
551
+ // index) will never drain — or cancel — its buffered write. Without this
552
+ // the 250ms timer fired AFTER the unlink and RE-CREATED the file with the
553
+ // stale pre-cancel snapshot (measured: status.json reappearing as
554
+ // `status:"running"` after the exit drain while agents.json stayed empty).
555
+ cancelPendingCoalescedWriteForPath(statusPath);
556
+ forgetWrittenStatus(statusPath);
434
557
  if (fs.existsSync(statusPath)) {
435
558
  fs.unlinkSync(statusPath);
436
559
  removedStatus = true;
@@ -480,6 +603,10 @@ export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgen
480
603
  // F4: terminal agent status (completed/failed/cancelled/blocked) — keep full
481
604
  // durability so the notifier/dashboard health sees the final state immediately.
482
605
  atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record), { durability: "full" });
606
+ // F06/RR-017: record the DURABLE snapshot so a later save can skip re-writing
607
+ // byte-identical content for this record (see writeStatusForRecords). Only
608
+ // durable writes memoize — a coalesced write may never reach disk.
609
+ rememberWrittenStatus(manifest, record);
483
610
  }
484
611
 
485
612
  // 2.5 — coalesced variants. Buffer per-agent record + aggregate writes for
@@ -496,7 +623,7 @@ export function saveCrewAgentsCoalesced(manifest: TeamRunManifest, records: Crew
496
623
  // events.jsonl independently of these JSON files.
497
624
  atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS, { durability: "best-effort" });
498
625
  asyncAgentReaderCache.delete(filePath);
499
- for (const record of records) writeCrewAgentStatusCoalesced(manifest, record);
626
+ writeStatusForRecords(manifest, records);
500
627
  }
501
628
 
502
629
  export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record: CrewAgentRecord): void {
@@ -640,6 +767,183 @@ function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown
640
767
  return readCrewAgentEventsCursor(manifest, taskId).events;
641
768
  }
642
769
 
770
+ // ---------------------------------------------------------------------------
771
+ // F07 / RR-017: incremental agent-event cursor.
772
+ //
773
+ // The legacy reader did `readFileSync → split → JSON.parse each line → filter →
774
+ // slice`, applying `sinceSeq`/`limit` only AFTER reading and parsing the WHOLE
775
+ // file. Measured on a 10000-event file: 697788 bytes read per IDLE poll, linear
776
+ // in file size, and the inline panel polls it every 700 ms
777
+ // (src/ui/inline-panel/index.ts:46, consumer agent-transcript.ts:291).
778
+ //
779
+ // The fix REUSES the existing incremental primitive
780
+ // (`readLinesSince` in src/utils/incremental-reader.ts — the same module
781
+ // src/state/event-log/cursor.ts uses) instead of adding a new reader. A per-path
782
+ // watermark holds `{byteOffset, lineCount, prefixMaxSeq, size, mtimeMs, ino,
783
+ // dev}` — O(1) memory per path, bounded to 512 paths:
784
+ // - idle poll (unchanged size/mtimeMs/inode): ZERO event bytes read;
785
+ // - append: reads only the delta [byteOffset, size);
786
+ // - shrink (truncation) or inode replacement (rotation/rewrite): the watermark
787
+ // is discarded and the file is re-read from 0 — the same "rewrite-then-
788
+ // regrow" hole cursor.ts closes with inode stamping.
789
+ //
790
+ // WHY A PREFIX IS NEVER RE-READ (soundness): the cached prefix is the set of
791
+ // events already consumed before `byteOffset`. `prefixMaxSeq` is the maximum seq
792
+ // seen in that prefix, so EVERY prefix event satisfies `seq <= prefixMaxSeq`.
793
+ // When `sinceSeq >= prefixMaxSeq`, no prefix event can pass the
794
+ // `seq > sinceSeq` filter, so the answer is exactly "the delta's matches" —
795
+ // provable without assuming file order equals seq order. When
796
+ // `sinceSeq < prefixMaxSeq` the reader falls back to a full read from 0, so
797
+ // correctness never depends on the bound being tight.
798
+ //
799
+ // LEGACY SEQ SEMANTICS ARE PRESERVED EXACTLY: a line without a numeric `seq`
800
+ // gets `seq = lineIndex + 1`, where lineIndex is the index of that line among
801
+ // the file's NON-EMPTY lines. `readLinesSince` only increments `lineCount` for
802
+ // non-empty lines, so a delta's absolute base index is the cached `lineCount`
803
+ // and the parity holds without re-reading from offset 0.
804
+ //
805
+ // A trailing fragment without "\n" (a mid-append partial line) is held back by
806
+ // `readLinesSince` (it does not advance the committed offset past it), so it is
807
+ // neither lost nor parsed as a torn event — the next poll sees the completed
808
+ // line. Every writer of this file appends a trailing "\n", so this only ever
809
+ // defers a mid-write fragment.
810
+ // ---------------------------------------------------------------------------
811
+
812
+ interface AgentEventsCursorState {
813
+ byteOffset: number;
814
+ lineCount: number;
815
+ /** Max seq among events already consumed before `byteOffset`. */
816
+ prefixMaxSeq: number;
817
+ size: number;
818
+ mtimeMs: number;
819
+ ino: number;
820
+ dev: number;
821
+ }
822
+
823
+ const agentEventsCursorCache = new Map<string, AgentEventsCursorState>();
824
+ const AGENT_EVENTS_CURSOR_CACHE_MAX_ENTRIES = 512;
825
+
826
+ // F07 test hook (AC-5/AC-6/AC-11): bytes of event payload the cursor actually
827
+ // read. The tests assert STRUCTURALLY (bytes read, not wall-clock), so the
828
+ // reader exposes this counter instead of the suite monkey-patching `node:fs`
829
+ // (whose ESM named exports are frozen). Mirrors the `__test__agentRecordBufferCount`
830
+ // precedent in this file. Only the incremental read path increments it.
831
+ let agentEventBytesRead = 0;
832
+
833
+ /** @internal Test-only: reset the F07 byte counter. */
834
+ export function __test__resetAgentEventBytesRead(): void {
835
+ agentEventBytesRead = 0;
836
+ }
837
+
838
+ /** @internal Test-only: event-file bytes read since the last reset. */
839
+ export function __test__agentEventBytesRead(): number {
840
+ return agentEventBytesRead;
841
+ }
842
+
843
+ /** @internal Test-only: drop the per-path incremental cursor watermarks. */
844
+ export function __test__clearAgentEventsCursorCache(): void {
845
+ agentEventsCursorCache.clear();
846
+ }
847
+
848
+ /** @internal Test-only: inspect a watermark (undefined when uncached). */
849
+ export function __test__agentEventsCursorState(filePath: string): AgentEventsCursorState | undefined {
850
+ return agentEventsCursorCache.get(filePath);
851
+ }
852
+
853
+ function setAgentEventsCursorState(filePath: string, state: AgentEventsCursorState): void {
854
+ if (agentEventsCursorCache.has(filePath)) agentEventsCursorCache.delete(filePath);
855
+ agentEventsCursorCache.set(filePath, state);
856
+ while (agentEventsCursorCache.size > AGENT_EVENTS_CURSOR_CACHE_MAX_ENTRIES) {
857
+ const oldest = agentEventsCursorCache.keys().next().value;
858
+ if (oldest === undefined) break;
859
+ agentEventsCursorCache.delete(oldest);
860
+ }
861
+ }
862
+
863
+ /**
864
+ * Parse raw JSONL lines into cursor events, assigning the legacy `seq = index+1`
865
+ * fallback for lines that carry no numeric seq. `lineIndexBase` is the index of
866
+ * the FIRST line in this batch among the file's non-empty lines (0 when reading
867
+ * from offset 0).
868
+ *
869
+ * A single trailing "\r" is stripped so CRLF files behave like the legacy
870
+ * `split(/\r?\n/)` reader (JSON.parse tolerates it either way; the difference
871
+ * only shows up in the `raw` payload of an already-corrupt line).
872
+ */
873
+ function parseAgentEventLines(lines: string[], lineIndexBase: number): { events: Array<Record<string, unknown>>; maxSeq: number } {
874
+ const events: Array<Record<string, unknown>> = [];
875
+ let maxSeq = 0;
876
+ for (let index = 0; index < lines.length; index += 1) {
877
+ const raw = lines[index]!;
878
+ const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
879
+ const lineIndex = lineIndexBase + index;
880
+ let event: Record<string, unknown>;
881
+ try {
882
+ const parsed = JSON.parse(text) as unknown;
883
+ event = parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : { seq: lineIndex + 1, raw: text };
884
+ if (typeof event.seq !== "number") event.seq = lineIndex + 1;
885
+ } catch {
886
+ event = { seq: lineIndex + 1, raw: text };
887
+ }
888
+ events.push(event);
889
+ if (typeof event.seq === "number" && event.seq > maxSeq) maxSeq = event.seq;
890
+ }
891
+ return { events, maxSeq };
892
+ }
893
+
894
+ /**
895
+ * Read the agent events file, consuming only the bytes that are new since the
896
+ * last read of this path, and return every event with `seq > sinceSeq` (the
897
+ * legacy `total` is this array's length before `limit` is applied).
898
+ */
899
+ function readAgentEventMatches(filePath: string, sinceSeq: number): Array<Record<string, unknown>> {
900
+ let stat: fs.Stats;
901
+ try {
902
+ stat = fs.statSync(filePath);
903
+ } catch {
904
+ return [];
905
+ }
906
+ const cached = agentEventsCursorCache.get(filePath);
907
+ const sameFile = cached !== undefined && cached.ino === stat.ino && cached.dev === stat.dev;
908
+ // A shrink (truncate) or an inode change (rotation / rewrite) invalidates the
909
+ // byte offset: the new content must be read from 0 or events are lost.
910
+ const usable = sameFile && stat.size > cached.byteOffset;
911
+ const canServeDelta = usable && sinceSeq >= cached.prefixMaxSeq;
912
+ const canServeIdle = sameFile && stat.size === cached.byteOffset && stat.mtimeMs === cached.mtimeMs && sinceSeq >= cached.prefixMaxSeq;
913
+
914
+ if (canServeIdle) {
915
+ // Nothing changed AND no prefix event can pass the filter — no bytes read.
916
+ return [];
917
+ }
918
+
919
+ const baseOffset = canServeDelta ? cached.byteOffset : 0;
920
+ const baseLineCount = canServeDelta ? cached.lineCount : 0;
921
+ const baseMaxSeq = canServeDelta ? cached.prefixMaxSeq : 0;
922
+ const result = readLinesSince(filePath, { byteOffset: baseOffset, lineCount: baseLineCount });
923
+ // `readLinesSince` reads [state.byteOffset, fileSize); account for the bytes
924
+ // it requested (a held-back trailing fragment is still READ, just not parsed).
925
+ agentEventBytesRead += Math.max(0, stat.size - baseOffset);
926
+ const { events, maxSeq } = parseAgentEventLines(result.lines, baseLineCount);
927
+ const nextSeqInFile = Math.max(baseMaxSeq, maxSeq);
928
+ setAgentEventsCursorState(filePath, {
929
+ byteOffset: result.state.byteOffset,
930
+ lineCount: result.state.lineCount,
931
+ prefixMaxSeq: nextSeqInFile,
932
+ // `Math.max` covers a concurrent append that landed between the stat above
933
+ // and the read: storing the SMALLER (stale) size would make the next poll
934
+ // see `size === byteOffset` and needlessly re-read the whole file.
935
+ size: Math.max(stat.size, result.state.byteOffset),
936
+ mtimeMs: stat.mtimeMs,
937
+ ino: stat.ino,
938
+ dev: stat.dev,
939
+ });
940
+
941
+ // `events` is the whole-file parse when we could not serve a delta, and the
942
+ // delta-only parse otherwise — either way it holds EVERY event with
943
+ // seq > sinceSeq, because in the delta case the prefix is provably all <= sinceSeq.
944
+ return events.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
945
+ }
946
+
643
947
  export function readCrewAgentEventsCursor(
644
948
  manifest: TeamRunManifest,
645
949
  taskId: string,
@@ -676,20 +980,8 @@ export function readCrewAgentEventsCursor(
676
980
  const sinceSeq =
677
981
  typeof options.sinceSeq === "number" && Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : 0;
678
982
  const limit = typeof options.limit === "number" && Number.isInteger(options.limit) && options.limit >= 0 ? options.limit : undefined;
679
- const parsed = fs
680
- .readFileSync(filePath, "utf-8")
681
- .split(/\r?\n/)
682
- .filter(Boolean)
683
- .map((line, index) => {
684
- try {
685
- const event = JSON.parse(line) as Record<string, unknown>;
686
- if (typeof event.seq !== "number") event.seq = index + 1;
687
- return event;
688
- } catch {
689
- return { seq: index + 1, raw: line };
690
- }
691
- });
692
- const filtered = parsed.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
983
+
984
+ const filtered = readAgentEventMatches(filePath, sinceSeq);
693
985
  const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
694
986
  const returnedMaxSeq = events.reduce((max, event) => (typeof event.seq === "number" ? Math.max(max, event.seq) : max), sinceSeq);
695
987
  return {
@@ -1,8 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { TeamRunManifest } from "../state/types.ts";
4
-
5
4
  import { logInternalError } from "../utils/internal-error.ts";
5
+ import { projectCrewRoot } from "../utils/paths.ts";
6
6
 
7
7
  export type DeadletterReason = "max-retries" | "heartbeat-dead" | "manual";
8
8
 
@@ -14,12 +14,30 @@ export interface DeadletterEntry {
14
14
  lastError?: string;
15
15
  attemptId?: string;
16
16
  timestamp: string;
17
+ /** US-003 (2026-09-22): richer schema so the entry is self-sufficient for
18
+ * post-mortem AFTER the run dir is pruned (the project-level index below is
19
+ * the record that survives; the run-local file dies with the run dir). */
20
+ agent?: string;
21
+ role?: string;
22
+ /** Model-fallback attempt count at give-up time. */
23
+ modelAttempts?: number;
24
+ /** Run manifest status at the time of the write. */
25
+ runStatus?: string;
17
26
  }
18
27
 
19
28
  export function deadletterPath(manifest: TeamRunManifest): string {
20
29
  return path.join(manifest.stateRoot, "deadletter.jsonl");
21
30
  }
22
31
 
32
+ /**
33
+ * US-003: project-level dead-letter index, `<projectRoot>/.crew/state/deadletter/<runId>.jsonl`.
34
+ * Lives OUTSIDE any run dir, so per-run auto-prune (DP-01 keep=10) cannot delete it —
35
+ * this is the durable out-of-band record for post-mortem analysis.
36
+ */
37
+ export function deadletterIndexPath(manifest: TeamRunManifest): string {
38
+ return path.join(projectCrewRoot(manifest.cwd), "state", "deadletter", `${manifest.runId}.jsonl`);
39
+ }
40
+
23
41
  export function appendDeadletter(manifest: TeamRunManifest, entry: DeadletterEntry): void {
24
42
  try {
25
43
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
@@ -27,6 +45,19 @@ export function appendDeadletter(manifest: TeamRunManifest, entry: DeadletterEnt
27
45
  } catch (error) {
28
46
  logInternalError("deadletter.append", error, `taskId=${entry.taskId}`);
29
47
  }
48
+ // US-003: project-level index (survives run pruning). Best-effort AFTER the
49
+ // run-local record — appendDeadletter must never throw into the retry path.
50
+ // Honest deviation from the spec's "atomically (both or neither)" wording:
51
+ // cross-file atomicity without a journal is not physically possible; the
52
+ // contract is "both on success, never a caller-visible failure", with each
53
+ // failure logged via logInternalError.
54
+ try {
55
+ const indexPath = deadletterIndexPath(manifest);
56
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
57
+ fs.appendFileSync(indexPath, `${JSON.stringify({ ...entry, runStatus: entry.runStatus ?? manifest.status })}\n`, "utf-8");
58
+ } catch (error) {
59
+ logInternalError("deadletter.index-append", error, `taskId=${entry.taskId}`);
60
+ }
30
61
  }
31
62
 
32
63
  export function readDeadletter(manifest: TeamRunManifest, maxEntries = 1000): DeadletterEntry[] {
@@ -45,3 +76,14 @@ export function readDeadletter(manifest: TeamRunManifest, maxEntries = 1000): De
45
76
  }
46
77
  });
47
78
  }
79
+
80
+ /**
81
+ * US-003 AC-3: `handleStatus` surfacing — a Deadletter line, only when entries
82
+ * exist (silent otherwise; a zero line would be noise on every healthy run).
83
+ */
84
+ export function deadletterStatusLine(manifest: TeamRunManifest): string | undefined {
85
+ const entries = readDeadletter(manifest);
86
+ if (!entries.length) return undefined;
87
+ const reasons = [...new Set(entries.map((entry) => entry.reason))].join(", ");
88
+ return `Deadletter: ${entries.length} (${reasons}) — ${deadletterPath(manifest)}`;
89
+ }
@@ -95,8 +95,27 @@ export function grandchildArtifactsRoot(cwd: string, runId: string, parentTaskId
95
95
  return path.join(cwd, ".crew", "artifacts", runId, parentTaskId, "nested", subId);
96
96
  }
97
97
 
98
+ /** RR-023 F3 (2026-09-29 battery finding #3): derive the relayed grandchild
99
+ * result text from a ChildPiRunResult — pure so it is unit-testable without
100
+ * spawning. Same precedence as the original inline expression at the capture
101
+ * site: rawFinalText → stdout → stderr (failure only).
102
+ * Diagnosability guard: exit 0 with ZERO assistant text (run
103
+ * team_20260929041427: the model-less gc fell through to the pi global default
104
+ * and returned empty completions) used to fence an EMPTY mailbox body —
105
+ * indistinguishable from a relay bug. An explicit marker keeps the relay
106
+ * honest; the real fix (parent-model inheritance) lives in the broker. */
107
+ export function grandchildResultText(result: ChildPiRunResult): string {
108
+ const ok = result.exitCode === 0;
109
+ const text = (result.rawFinalText ?? result.stdout ?? "").trim() || (ok ? "" : result.stderr.trim());
110
+ return ok && text === "" ? "[grandchild exited 0 with no assistant output]" : text;
111
+ }
112
+
98
113
  export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Promise<GrandchildSpawnResult> {
99
- const artifactsRoot = path.join(input.cwd, ".crew", "artifacts", input.runId, input.parentTaskId, "nested", input.subId);
114
+ // RR-012 F03: single artifacts formula — grandchildArtifactsRoot(input.cwd, …).
115
+ // input.cwd is the PARENT TASK cwd threaded by the broker (the admitted
116
+ // worktree in worktree mode), so artifacts land under the admitted cwd —
117
+ // never under the leader/broker workspace.
118
+ const artifactsRoot = grandchildArtifactsRoot(input.cwd, input.runId, input.parentTaskId, input.subId);
100
119
  fs.mkdirSync(artifactsRoot, { recursive: true });
101
120
 
102
121
  const abort = new AbortController();
@@ -133,8 +152,7 @@ export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Prom
133
152
  onSpawn: input.onSpawn,
134
153
  });
135
154
  const ok = result.exitCode === 0;
136
- const text = (result.rawFinalText ?? result.stdout ?? "").trim() || (ok ? "" : result.stderr.trim());
137
- return { ok, resultText: text, ...(usageTokens !== undefined ? { usageTokens } : {}) };
155
+ return { ok, resultText: grandchildResultText(result), ...(usageTokens !== undefined ? { usageTokens } : {}) };
138
156
  } catch (error) {
139
157
  const timedOut = abort.signal.aborted;
140
158
  return {
@@ -17,9 +17,21 @@
17
17
  * parse error downstream. We don't double-report parse errors.
18
18
  */
19
19
 
20
- import { parse } from "acorn";
20
+ import { createRequire } from "node:module";
21
21
  import { getCrewEnv } from "../config/env-vars.ts";
22
22
 
23
+ // RR-021 WI-2.1: acorn parses ONLY when the determinism check actually runs —
24
+ // every dynamic-workflow-runner import was paying acorn's load cost up front.
25
+ // Lazy sync accessor (model: ui/syntax-highlight.ts); acorn stays external in
26
+ // the bundle and resolves through this shim on first use.
27
+ const require = createRequire(import.meta.url);
28
+ type AcornModule = typeof import("acorn");
29
+ let cachedAcorn: AcornModule | undefined;
30
+ function acorn(): AcornModule {
31
+ if (!cachedAcorn) cachedAcorn = require("acorn") as AcornModule;
32
+ return cachedAcorn;
33
+ }
34
+
23
35
  const NONDETERMINISM_ERROR =
24
36
  "Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable. These introduce non-reproducible behavior across runs. Use ctx.vars for cached state, or pass a fixed seed via ctx.setArgs(). To bypass this check (escape hatch), set PI_CREW_DWF_SKIP_DETERMINISM_CHECK=1.";
25
37
 
@@ -38,7 +50,7 @@ export class DeterminismError extends Error {
38
50
  export function assertDeterministicScript(script: string): void {
39
51
  let ast: AstNode;
40
52
  try {
41
- ast = parse(script, {
53
+ ast = acorn().parse(script, {
42
54
  ecmaVersion: "latest",
43
55
  sourceType: "module",
44
56
  allowAwaitOutsideFunction: true,