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,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { DEFAULT_MAILBOX } from "../../config/defaults.ts";
@@ -467,19 +468,20 @@ function readAllInboxMessages(manifest: TeamRunManifest): MailboxMessage[] {
467
468
  // FIND-01: in-process delivery cache to avoid O(N²) re-reads on every append.
468
469
  // Keyed by delivery file path; invalidated by mtime check on read + updated on
469
470
  // write. Team-runner is single-process so in-process caching is sufficient.
470
- const deliveryCache = new Map<string, { mtimeMs: number; state: MailboxDeliveryState }>();
471
+ const deliveryCache = new Map<string, { mtimeMs: number; size: number; state: MailboxDeliveryState }>();
471
472
  const MAX_DELIVERY_CACHE_ENTRIES = 256;
472
473
  // R1 review fix: setDeliveryCacheEntry stores an immutable snapshot (deep
473
474
  // copy of `messages`) so callers mutating the returned state cannot corrupt
474
475
  // the cache (TOCTOU race), and bounds the map size with FIFO eviction to
475
476
  // prevent unbounded growth across runs.
476
- function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; state: MailboxDeliveryState }): void {
477
+ function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; size: number; state: MailboxDeliveryState }): void {
477
478
  if (deliveryCache.size >= MAX_DELIVERY_CACHE_ENTRIES) {
478
479
  const oldest = deliveryCache.keys().next().value;
479
480
  if (oldest !== undefined) deliveryCache.delete(oldest);
480
481
  }
481
482
  deliveryCache.set(filePath, {
482
483
  mtimeMs: entry.mtimeMs,
484
+ size: entry.size,
483
485
  state: { ...entry.state, messages: { ...entry.state.messages } },
484
486
  });
485
487
  }
@@ -497,7 +499,7 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
497
499
  return { messages: {}, updatedAt: new Date().toISOString() };
498
500
  }
499
501
  const cached = deliveryCache.get(filePath);
500
- if (cached && cached.mtimeMs === stat.mtimeMs) {
502
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
501
503
  // R2 review fix: return a copy so callers mutating the result cannot
502
504
  // leak into the cached snapshot (residual TOCTOU: the cache holds the
503
505
  // snapshot until the next write replaces it; without this copy, a
@@ -517,7 +519,7 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
517
519
  messages,
518
520
  updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : new Date().toISOString(),
519
521
  };
520
- setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, state });
522
+ setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, state });
521
523
  return state;
522
524
  } catch (error) {
523
525
  // NEW-R4: a corrupt delivery.json was previously swallowed silently, returning
@@ -543,22 +545,146 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
543
545
  }
544
546
  }
545
547
 
548
+ const MAX_DELIVERY_MESSAGES = 10000;
549
+
550
+ /**
551
+ * F09 (RR-016): an `acknowledged` delivery entry is the ONLY durable record
552
+ * that a message was handled — the inbox line keeps `status: "queued"` forever
553
+ * (nothing writes the ack back to the message), so `replayPendingMailboxMessages`
554
+ * keys on the delivery map. The old prune sorted `queued(0) < delivered(1) <
555
+ * acknowledged(2)` and kept `slice(0, MAX)`, i.e. it evicted ACKNOWLEDGED entries
556
+ * FIRST — the moment the cap was crossed, an acked message became replayable
557
+ * again, and it never self-healed (replay only writes "delivered"), so it was
558
+ * re-delivered on EVERY resume.
559
+ *
560
+ * Fix: the prune may only drop an acknowledged entry when the message it refers
561
+ * to is provably no longer replayable (absent from the whole replayable history:
562
+ * live inbox files + retained archives — the exact set `replayPendingMailboxMessages`
563
+ * reads). Non-acknowledged entries keep the previous eviction semantics
564
+ * (queued → delivered → acknowledged, oldest-inserted first within a tier).
565
+ *
566
+ * Acknowledged entries are tiny (`"<id>":"acknowledged"`), but they must not
567
+ * grow without bound either: a sweep that drops the PROVABLY-dead acks is run
568
+ * once the ack set is large enough to matter, and at most once per
569
+ * ACK_SWEEP_MIN_INTERVAL_MS (the sweep reads the mailbox history, so it is
570
+ * throttled rather than run on every append).
571
+ */
572
+ const ACK_SWEEP_MIN_ACKS = 1000;
573
+ const ACK_SWEEP_FORCE_ACKS = 5000;
574
+ const ACK_SWEEP_MIN_INTERVAL_MS = 30_000;
575
+ // Bounded FIFO (the asyncAgentReaderCache pattern) so a long-running process
576
+ // cannot accumulate one timestamp per run ever seen.
577
+ const ACK_SWEEP_TIMESTAMP_MAX_ENTRIES = 256;
578
+ const lastAckSweepAt = new Map<string, number>();
579
+
580
+ function recordAckSweep(filePath: string, at: number): void {
581
+ if (lastAckSweepAt.has(filePath)) lastAckSweepAt.delete(filePath);
582
+ lastAckSweepAt.set(filePath, at);
583
+ while (lastAckSweepAt.size > ACK_SWEEP_TIMESTAMP_MAX_ENTRIES) {
584
+ const oldest = lastAckSweepAt.keys().next().value;
585
+ if (oldest === undefined) break;
586
+ lastAckSweepAt.delete(oldest);
587
+ }
588
+ }
589
+
590
+ /** Ids of every message `replayPendingMailboxMessages` could return (live inbox
591
+ * files + retained archives, run-level and per-task). Returns `undefined` when
592
+ * the history could not be READ (fail-closed, review MAJOR 1): an unreadable
593
+ * history must never be conflated with an empty one — the sweep treats an
594
+ * empty set as "every ack is dead" and would delete all acknowledged entries,
595
+ * replaying already-processed messages (the F09 bug class). */
596
+ function collectReplayableInboxIds(manifest: TeamRunManifest): Set<string> | undefined {
597
+ const ids = new Set<string>();
598
+ try {
599
+ for (const message of readAllInboxMessages(manifest)) ids.add(message.id);
600
+ } catch (error) {
601
+ logInternalError("mailbox.collect-replayable-ids", error, `runId=${manifest.runId}`);
602
+ return undefined;
603
+ }
604
+ return ids;
605
+ }
606
+
607
+ /** Drop acknowledged entries whose message can no longer be replayed. Returns
608
+ * the number of entries dropped. Never drops an entry whose message is still
609
+ * in the replayable history. FAIL-CLOSED (review MAJOR 1): if the replayable
610
+ * history cannot be read, the sweep aborts dropping NOTHING and does not
611
+ * consume the throttle window (`recordAckSweep` is only recorded after a
612
+ * successful read), so the next prune retries once the FS is readable. */
613
+ function sweepDeadAcknowledgements(manifest: TeamRunManifest, state: MailboxDeliveryState): number {
614
+ const ackedIds = Object.entries(state.messages)
615
+ .filter(([, status]) => status === "acknowledged")
616
+ .map(([id]) => id);
617
+ if (ackedIds.length === 0) return 0;
618
+ const filePath = deliveryFile(manifest, true);
619
+ const now = Date.now();
620
+ const last = lastAckSweepAt.get(filePath) ?? 0;
621
+ // The sweep reads the whole replayable history, so it is time-throttled:
622
+ // at most once per ACK_SWEEP_MIN_INTERVAL_MS below ACK_SWEEP_FORCE_ACKS,
623
+ // and immediately above it so the ack set cannot grow unbounded.
624
+ const shouldSweep =
625
+ ackedIds.length >= ACK_SWEEP_FORCE_ACKS || (ackedIds.length >= ACK_SWEEP_MIN_ACKS && now - last >= ACK_SWEEP_MIN_INTERVAL_MS);
626
+ if (!shouldSweep) return 0;
627
+ const replayable = collectReplayableInboxIds(manifest);
628
+ if (replayable === undefined) return 0; // unreadable history → abort, keep every ack
629
+ recordAckSweep(filePath, now);
630
+ let dropped = 0;
631
+ for (const id of ackedIds) {
632
+ if (replayable.has(id)) continue;
633
+ delete state.messages[id];
634
+ dropped++;
635
+ }
636
+ return dropped;
637
+ }
638
+
639
+ function pruneDeliveryMessages(manifest: TeamRunManifest, state: MailboxDeliveryState): void {
640
+ const entries = Object.entries(state.messages);
641
+ const ackedCount = entries.reduce((count, [, status]) => (status === "acknowledged" ? count + 1 : count), 0);
642
+ if (ackedCount > ACK_SWEEP_MIN_ACKS) sweepDeadAcknowledgements(manifest, state);
643
+ const remaining = Object.entries(state.messages);
644
+ if (remaining.length <= MAX_DELIVERY_MESSAGES) return;
645
+ // Stable sort: within a status tier the previous insertion order (which is
646
+ // the order entries were first written) is preserved, so the eviction choice
647
+ // for non-acknowledged entries is unchanged from before this fix.
648
+ const sorted = [...remaining].sort(([, a], [, b]) => {
649
+ const order = { queued: 0, delivered: 1, acknowledged: 2 };
650
+ return (order[a] ?? 3) - (order[b] ?? 3);
651
+ });
652
+ const ackedAfterSweep = sorted.filter(([, status]) => status === "acknowledged").length;
653
+ if (ackedAfterSweep > MAX_DELIVERY_MESSAGES) {
654
+ // Pathological: more acks than the cap and every one of them still refers
655
+ // to a replayable message. Correctness (an acked message must never
656
+ // replay) wins over the memory bound — keep them and make it visible.
657
+ logInternalError(
658
+ "mailbox.delivery-ack-over-cap",
659
+ new Error(`delivery.json holds ${ackedAfterSweep} acknowledged entries for replayable messages (cap ${MAX_DELIVERY_MESSAGES})`),
660
+ `runId=${manifest.runId}`,
661
+ "warn",
662
+ );
663
+ }
664
+ const keptIds = new Set<string>();
665
+ let evictableBudget = Math.max(0, MAX_DELIVERY_MESSAGES - ackedAfterSweep);
666
+ for (const [id, status] of sorted) {
667
+ if (status === "acknowledged") {
668
+ keptIds.add(id);
669
+ continue;
670
+ }
671
+ if (evictableBudget > 0) {
672
+ keptIds.add(id);
673
+ evictableBudget--;
674
+ }
675
+ }
676
+ // Filter the ORIGINAL entry order so surviving entries keep their positions.
677
+ state.messages = Object.fromEntries(remaining.filter(([id]) => keptIds.has(id)));
678
+ }
679
+
546
680
  function writeDeliveryState(
547
681
  manifest: TeamRunManifest,
548
682
  state: MailboxDeliveryState,
549
683
  options?: { durability?: "full" | "best-effort" },
550
684
  ): void {
551
685
  ensureRunMailbox(manifest);
552
- // Prune oldest entries if capped
553
- const MAX_DELIVERY_MESSAGES = 10000;
554
- if (Object.keys(state.messages).length > MAX_DELIVERY_MESSAGES) {
555
- const sorted = Object.entries(state.messages).sort(([, a], [, b]) => {
556
- const order = { queued: 0, delivered: 1, acknowledged: 2 };
557
- return (order[a] ?? 3) - (order[b] ?? 3);
558
- });
559
- const trimmed = sorted.slice(0, MAX_DELIVERY_MESSAGES);
560
- state.messages = Object.fromEntries(trimmed);
561
- }
686
+ // Prune oldest entries if capped (F09: acknowledged entries are protected).
687
+ pruneDeliveryMessages(manifest, state);
562
688
  // F4: mailbox delivery is informational — accept losing the very last write on
563
689
  // a hard crash (the next message will overwrite it on disk). Cheaper fsync on
564
690
  // the hot path; terminal/reply paths still pass full durability below.
@@ -572,7 +698,7 @@ function writeDeliveryState(
572
698
  // setDeliveryCacheEntry stores an immutable snapshot (deep copy of
573
699
  // messages) so subsequent read-modify-write callers mutating the
574
700
  // returned state cannot corrupt the cache.
575
- setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, state });
701
+ setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, size: postStat.size, state });
576
702
  } catch {
577
703
  deliveryCache.delete(filePath);
578
704
  }
@@ -601,7 +727,9 @@ export function appendMailboxMessage(
601
727
  else ensureRunMailbox(manifest);
602
728
  const createdAt = new Date().toISOString();
603
729
  const complete: MailboxMessage = {
604
- id: message.id ?? `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
730
+ // RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
731
+ // collision-free under the msg_ prefix, no clock-ordering leakage.
732
+ id: message.id ?? `msg_${randomUUID()}`,
605
733
  runId: manifest.runId,
606
734
  direction: message.direction,
607
735
  from: message.from,
@@ -729,7 +857,9 @@ export async function appendMailboxMessageAsync(
729
857
  else ensureRunMailbox(manifest);
730
858
  const createdAt = new Date().toISOString();
731
859
  const complete: MailboxMessage = {
732
- id: message.id ?? `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
860
+ // RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
861
+ // collision-free under the msg_ prefix, no clock-ordering leakage.
862
+ id: message.id ?? `msg_${randomUUID()}`,
733
863
  runId: manifest.runId,
734
864
  direction: message.direction,
735
865
  from: message.from,
@@ -13,10 +13,20 @@
13
13
  * The `node:path` import is retained as a *fallback* (only used when the
14
14
  * binding is healthy). Don't add new dependencies on other pi-crew modules.
15
15
  *
16
+ * TWO sanctioned exceptions (RR-020):
17
+ * - `node:os` — a builtin like fs/path, used ONLY inside try/catch so a
18
+ * jiti namespace race degrades to "no home/tmp boundary" (the pre-RR-020
19
+ * walk) instead of crashing ensureCrewDirectory.
20
+ * - `../utils/project-markers.ts` — a zero-import module of plain string
21
+ * literals, so it cannot itself suffer the namespace race and it keeps
22
+ * this resolver from drifting away from `src/utils/paths.ts`.
23
+ *
16
24
  * See: https://github.com/baphuongna/pi-crew/issues/28
17
25
  */
18
26
  import * as fs from "node:fs";
27
+ import * as os from "node:os";
19
28
  import * as path from "node:path";
29
+ import { PROJECT_DIR_MARKERS, PROJECT_FILE_MARKERS } from "../utils/project-markers.ts";
20
30
  import { atomicWriteFile } from "./atomic-write.ts";
21
31
  import { updateGitignore } from "./gitignore-manager.ts";
22
32
 
@@ -164,29 +174,94 @@ function safeResolve(p: string, pathDep?: typeof path): string {
164
174
  }
165
175
 
166
176
  function findProjectRoot(start: string, pathDep?: typeof path): string | undefined {
167
- const dirMarkers = [".git", ".hg", ".svn"];
168
- const fileMarkers = ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
169
- // Use `parseRoot` (inlined above) to avoid `path.parse` for the critical
170
- // termination root — fixes the jiti namespace race in issue #28.
171
- const root = parseRoot(start);
177
+ // Marker lists come from src/utils/project-markers.ts (RR-020 Fix 1) so this
178
+ // resolver can never drift from src/utils/paths.ts:computeRepoRoot again —
179
+ // the drift resolved `parent/subproject` (.pi) to `parent/.crew` here while
180
+ // paths.ts resolved the same cwd to `subproject/.pi/teams` (two roots).
181
+ // The module has NO imports of its own, so it is safe under the jiti
182
+ // namespace race documented at the top of this file.
183
+ // RR-020 hardening (cold-verify round 2): the marker arrays are the ONE
184
+ // critical-path dependency on a static import binding. Under the jiti
185
+ // namespace race (issue #28) a binding can arrive undefined — degrade to the
186
+ // narrow `.git`-only probe (findProjectRoot then returns undefined more
187
+ // often, so computeCrewRoot anchors the crew root at the cwd — the same
188
+ // fallback paths.ts uses) instead of throwing inside ensureCrewDirectory,
189
+ // the very function hardened for #28. Mirrors the defensive style of
190
+ // safeJoin/safeDirname/parseRoot and the lazy updateGitignore import.
191
+ function markerLists(): { dirs: string[]; files: string[] } {
192
+ try {
193
+ if (Array.isArray(PROJECT_DIR_MARKERS) && Array.isArray(PROJECT_FILE_MARKERS)) {
194
+ return { dirs: PROJECT_DIR_MARKERS as string[], files: PROJECT_FILE_MARKERS as string[] };
195
+ }
196
+ } catch {
197
+ // namespace unavailable — fall through to the narrow probe
198
+ }
199
+ return { dirs: [".git"], files: [] };
200
+ }
201
+ const markers = markerLists();
202
+ const hasMarker = (dir: string): boolean =>
203
+ markers.dirs.some((marker) => fs.existsSync(safeJoin(dir, marker))) ||
204
+ markers.files.some((marker) => fs.existsSync(safeJoin(dir, marker)));
172
205
  let current = safeResolve(start, pathDep);
206
+ // RR-020 cold-verify follow-up: match findRepoRoot (paths.ts) which
207
+ // realpaths the start BEFORE walking, so the boundary comparisons below
208
+ // compare canonical-to-canonical (macOS /var -> /private/var). Best-effort:
209
+ // ENOENT keeps the lexical path, exactly like findRepoRoot's fallback.
210
+ try {
211
+ current = fs.realpathSync(current);
212
+ } catch {
213
+ // keep the lexical resolution
214
+ }
215
+ // Use `parseRoot` (inlined above) to avoid `path.parse` for the critical
216
+ // termination root — fixes the jiti namespace race in issue #28. Computed
217
+ // from the RESOLVED `current` (post-realpath) so a root-prefix change
218
+ // through a symlink cannot desync `current !== root` (paths.ts computes
219
+ // path.parse on the realpath'd start for the same reason).
220
+ const root = parseRoot(current);
221
+ // RR-020 cold-verify follow-up (bug-029 parity): home/tmp boundary STOP.
222
+ // computeRepoRoot (paths.ts) refuses to check markers at $HOME or the temp
223
+ // root; this walk did not, so once the marker lists were unified onto the
224
+ // wide set, `$HOME/.pi` (created by userPiRoot()) became a marker HERE — a
225
+ // MARKERLESS cwd under $HOME resolved crew-init to $HOME (⇒
226
+ // $HOME/.pi/teams) while projectCrewRoot resolved to <cwd>/.crew: the
227
+ // two-roots bug Fix 1 was meant to kill, in a new shape. `os` access is
228
+ // defensive: under the jiti namespace race (issue #28) the binding can be
229
+ // undefined — degrade to "no boundary" (the pre-RR-020 walk), never crash.
230
+ let home: string | undefined;
231
+ let tempRoot: string | undefined;
232
+ try {
233
+ home = canonicalBoundary(os.homedir());
234
+ tempRoot = canonicalBoundary(os.tmpdir());
235
+ } catch {
236
+ home = undefined;
237
+ tempRoot = undefined;
238
+ }
239
+ const atBoundary = (dir: string): boolean => (home !== undefined && dir === home) || (tempRoot !== undefined && dir === tempRoot);
173
240
  // Walk up to find project root
174
241
  while (current !== root) {
175
- for (const marker of dirMarkers) {
176
- if (fs.existsSync(safeJoin(current, marker))) return current;
177
- }
178
- for (const marker of fileMarkers) {
179
- if (fs.existsSync(safeJoin(current, marker))) return current;
180
- }
242
+ // Stop walking before checking markers at home or temp root.
243
+ if (atBoundary(current)) return undefined;
244
+ if (hasMarker(current)) return current;
181
245
  const parent = safeDirname(current);
182
246
  if (parent === current) break;
183
247
  current = parent;
184
248
  }
185
249
  // Check root as fallback
186
- if (dirMarkers.some((m) => fs.existsSync(safeJoin(root, m)))) return root;
250
+ if (atBoundary(root)) return undefined;
251
+ if (hasMarker(root)) return root;
187
252
  return undefined;
188
253
  }
189
254
 
255
+ /** Canonicalize a boundary dir for comparison with the (realpath'd) walk.
256
+ * Best-effort: an unresolvable path stays lexical. */
257
+ function canonicalBoundary(p: string): string {
258
+ try {
259
+ return fs.realpathSync(p);
260
+ } catch {
261
+ return p;
262
+ }
263
+ }
264
+
190
265
  /**
191
266
  * Compute the crew root directory for a given working directory.
192
267
  * Matches src/utils/paths.ts:projectCrewRoot() logic.
@@ -114,6 +114,10 @@ export function readEvents(eventsPath: string): TeamEvent[] {
114
114
 
115
115
  export interface EventCursorOptions {
116
116
  sinceSeq?: number;
117
+ /** Delivery cap for one read. When `limit` truncates the result, `events` is
118
+ * a PREFIX of everything available — resume from the returned `nextSeq`
119
+ * (never from `nextByteOffset`; see the continuation contract on
120
+ * `readEventsCursor`). */
117
121
  limit?: number;
118
122
  fromByteOffset?: number;
119
123
  /** R-03: generation the caller captured on its previous read. When set, a
@@ -528,6 +532,30 @@ export function clearEventsCursorTailCache(eventsPath?: string): void {
528
532
  cursorTailCache.delete(eventsPath);
529
533
  }
530
534
 
535
+ /**
536
+ * Read team events with an optional incremental anchor (`sinceSeq`) plus a
537
+ * delivery cap (`limit`).
538
+ *
539
+ * Continuation / backpressure contract (BR-08):
540
+ * - `nextSeq` is the continuation token for events that CARRY `metadata.seq`
541
+ * (scheduler-emitted events): the max seq among the events DELIVERED by
542
+ * this call — never a watermark past an undelivered event. Resume with
543
+ * `{ sinceSeq: nextSeq }` (`>` comparison) to receive the rest.
544
+ * LIMITS (cold-verify correction — documented, not fixed here): events
545
+ * WITHOUT `metadata.seq` (e.g. the worker progress channel) are filtered
546
+ * out of the sinceSeq path entirely, and DUPLICATE seqs cannot be paged
547
+ * through by seq. Those cases need the `fromByteOffset` anchor.
548
+ * - `total` is the number of events AVAILABLE for this read (after the
549
+ * sinceSeq filter, before the `limit` slice) — EXCEPT that the tail cap
550
+ * (TAIL_EVENT_CAP) may drop a prefix first, in which case `total` counts
551
+ * only the retained suffix.
552
+ * - `nextByteOffset` is an OPTIONAL incremental-read accelerator, and is only
553
+ * returned when it is a SAFE resume anchor: on the `fromByteOffset` path it
554
+ * is OMITTED whenever `limit` truncated the delta, because the underlying
555
+ * offset then points past the ENTIRE delta (undelivered tail included) —
556
+ * resuming from it would silently skip events. The default (non-byte)
557
+ * path never returns it at all.
558
+ */
531
559
  export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): EventCursorResult {
532
560
  // Incremental byte-offset path: read only new bytes since last known offset
533
561
  if (options.fromByteOffset !== undefined) {
@@ -558,11 +586,19 @@ export function readEventsCursor(eventsPath: string, options: EventCursorOptions
558
586
  const limit = positiveInteger(options.limit);
559
587
  const events = limit !== undefined ? merged.slice(0, limit) : merged;
560
588
  const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
589
+ // BR-08: `newState.byteOffset` sits past the ENTIRE delta read above —
590
+ // including the events the `limit` slice just dropped. Handing that back
591
+ // as `nextByteOffset` let a caller resume PAST events it never saw (silent
592
+ // loss: the delta read then returns an empty tail). Only expose the
593
+ // accelerator when this call delivered the whole delta; under truncation
594
+ // the caller must continue from `nextSeq` (see the doc comment on
595
+ // readEventsCursor).
596
+ const deliveredWholeDelta = limit === undefined || merged.length <= limit;
561
597
  return {
562
598
  events,
563
599
  nextSeq: returnedMaxSeq,
564
600
  total: merged.length,
565
- nextByteOffset: newState.byteOffset,
601
+ ...(deliveredWholeDelta ? { nextByteOffset: newState.byteOffset } : {}),
566
602
  generation: liveGen,
567
603
  };
568
604
  }
@@ -169,18 +169,83 @@ export interface CompactionResult {
169
169
  * 6. Return compaction stats
170
170
  */
171
171
  export function compactEventLog(eventsPath: string, config?: Partial<RotationConfig>): CompactionResult | undefined {
172
- const prepared = prepareCompaction(eventsPath, config);
173
- if (!prepared) return undefined;
174
- // FIX: Wrap entire read-compact-write-recover sequence in lock to prevent
175
- // event loss during compaction. Without lock, events can be appended between
176
- // read and write, lost silently.
172
+ // US-001 (2026-09-22): lock-scope reduction. The expensive part is the READ +
173
+ // parse of the whole log (prepareCompaction). It is read-only, so it does NOT
174
+ // need the append lock: hold the lock ONLY for the write+recover phase
175
+ // (applyCompactionUnlocked), which is what must be atomic w.r.t. appends.
176
+ //
177
+ // Why this is safe: appenders take the SAME lock, so an append can only land
178
+ // before our prepareCompaction read or after our applyCompactionUnlocked
179
+ // write — never inside the write. applyCompactionUnlocked already re-reads
180
+ // after the write and splices back any events that arrived in the window
181
+ // (C2 recovery), so no event is lost; it only becomes slightly more likely to
182
+ // splice, which is exactly the case it was built for.
183
+ //
184
+ // No concurrent-rotator guard is needed: this function is fully SYNCHRONOUS
185
+ // (prepareCompaction and withEventLogLockSync both block), so two calls on the
186
+ // same path cannot interleave within a process. An in-flight flag was tried
187
+ // and removed — it was untestable dead code (mutation survived), and the
188
+ // synchronous execution already serializes rotators. Cross-process rotators
189
+ // are serialized by the on-disk lock in withEventLogLockSync.
177
190
  //
178
191
  // NOTE (Round 24 BUG 1): callers ALREADY holding the event-log lock (e.g.
179
- // appendEventInsideLock in event-log.ts) must call applyCompactionUnlocked
192
+ // appendEventInsideLock in event-log.ts) must still call applyCompactionUnlocked
180
193
  // directly — calling compactEventLog from inside the lock deadlocks (the
181
194
  // mkdir lock is not re-entrant → 5s timeout → compaction never ran → the
182
195
  // log grew unbounded until events were silently dropped past 50MB).
183
- return withEventLogLockSync(eventsPath, () => applyCompactionUnlocked(eventsPath, prepared));
196
+ const prepared = prepareCompaction(eventsPath, config);
197
+ if (!prepared) return undefined;
198
+ return compactPreparedEventLog(eventsPath, prepared);
199
+ }
200
+
201
+ /**
202
+ * US-001 phase 3 — the LOCKED write phase, extracted so the read→write race is
203
+ * deterministically testable: a caller (test) can run prepareCompaction, append
204
+ * to the file (simulating another process landing events in the window), then
205
+ * call this and assert nothing was lost.
206
+ *
207
+ * prepareCompaction reads OUTSIDE the lock, so an appender may have added events
208
+ * between that read and this write. applyCompactionUnlocked writes exactly
209
+ * `prepared.lines` (the kept window from the snapshot), which would CLOBBER those
210
+ * newer events; its C2 recovery only re-splices events that were in `kept`, not
211
+ * ones it never saw. So under the lock we read the bytes appended after the
212
+ * snapshot offset and append them to the write. Appenders hold the same lock,
213
+ * so this read is a complete tail snapshot.
214
+ */
215
+ export function compactPreparedEventLog(
216
+ eventsPath: string,
217
+ prepared: { lines: string; originalSize: number; originalCount: number; kept: TeamEvent[] },
218
+ ): CompactionResult | undefined {
219
+ return withEventLogLockSync(eventsPath, () => {
220
+ const merged = { ...prepared, lines: prepared.lines + readTailSince(eventsPath, prepared.originalSize) };
221
+ return applyCompactionUnlocked(eventsPath, merged);
222
+ });
223
+ }
224
+
225
+ /**
226
+ * US-001: return the raw text appended to `filePath` after `offset`, or "" when
227
+ * nothing was appended / the file was replaced by a smaller one (a foreign
228
+ * rotation — caller then proceeds without a splice, which is the pre-US-001
229
+ * behaviour). Must be called while holding the event-log lock.
230
+ */
231
+ function readTailSince(filePath: string, offset: number): string {
232
+ try {
233
+ const size = fs.statSync(filePath).size;
234
+ if (size <= offset) return "";
235
+ const fd = fs.openSync(filePath, "r");
236
+ try {
237
+ const length = size - offset;
238
+ const buf = Buffer.alloc(length);
239
+ const read = fs.readSync(fd, buf, 0, length, offset);
240
+ const tail = buf.subarray(0, read).toString("utf-8");
241
+ // Only splice complete lines (a partial trailing write would corrupt JSONL).
242
+ return tail.endsWith("\n") ? tail : tail.slice(0, tail.lastIndexOf("\n") + 1);
243
+ } finally {
244
+ fs.closeSync(fd);
245
+ }
246
+ } catch {
247
+ return "";
248
+ }
184
249
  }
185
250
 
186
251
  /** Round 24 (BUG 1): the lock-free pre-read for compaction. Safe to run
@@ -61,6 +61,14 @@ function removeStaleRegistryLock(lockPath: string, staleMs: number): boolean {
61
61
  }
62
62
 
63
63
  function withRegistryLock<T>(fn: () => T): T {
64
+ // US-010 (2026-09-22): sleepSync below is INTENTIONAL — this lock is sync-only
65
+ // (fn is synchronous, and its callers at :352/:378 are sync). Converting the
66
+ // backoff to `await sleep` here would repeat the documented v0.9.26
67
+ // starvation: the sync spinner's sleepSync blocks the event loop while an
68
+ // in-process async holder's `await`-based release can never run. The lock is
69
+ // held only for a sub-millisecond read-modify-write and the loop is bounded by
70
+ // a 10s deadline, so blocking is bounded and rare (contention is cross-process
71
+ // only). See src/state/event-log/sequence-cache.ts:270 for the same reasoning.
64
72
  const filePath = registryLockPath();
65
73
  const staleMs = 30_000;
66
74
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -84,7 +92,11 @@ function withRegistryLock<T>(fn: () => T): T {
84
92
  break;
85
93
  } catch (error) {
86
94
  const code = (error as NodeJS.ErrnoException).code;
87
- if (code !== "EEXIST") throw error;
95
+ // Windows lock contention surfaces as EPERM/EACCES/EBUSY (not EEXIST) while
96
+ // another handle holds the lock file open — cross-process contention in
97
+ // CI (run 36019884258). Treat as contention: bounded retry via the 10s
98
+ // deadline below, mirroring isLockContention() in coordination/locks.ts.
99
+ if (code !== "EEXIST" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") throw error;
88
100
  if (!removeStaleRegistryLock(filePath, staleMs) && Date.now() > deadline)
89
101
  throw new Error("Active-run registry is locked by another operation.");
90
102
  sleepSync(Math.min(250, 25 * 2 ** attempt));