pi-crew 0.10.2 → 0.10.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 (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -7,16 +7,13 @@ import { emitFromTeamEvent } from "../../ui/run-event-bus.ts";
7
7
  import { logInternalError } from "../../utils/internal-error.ts";
8
8
  import { redactSecrets } from "../../utils/redaction.ts";
9
9
  import { sleep, sleepSync } from "../../utils/sleep.ts";
10
- import { atomicWriteFile } from "../atomic-write.ts";
11
10
  import { applyCompactionUnlocked, needsRotation, prepareCompaction, rotateEventLogUnlocked } from "./event-log-rotation.ts";
12
11
  import {
13
12
  advanceSequenceCounter,
14
- evictOldestSequenceCacheEntries,
15
- MAX_SEQUENCE_CACHE_ENTRIES,
16
13
  persistSequenceMonotonic,
14
+ reservedSequenceEnd,
17
15
  reserveSequence,
18
16
  reserveSequenceUnderLockAsync,
19
- sequenceCache,
20
17
  } from "./sequence-cache.ts";
21
18
  import { appendFileViaWorker, isWorkerAtomicWriterEnabled } from "./worker-atomic-writer.ts";
22
19
 
@@ -134,9 +131,18 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
134
131
  // (reduced from 120s) is appropriate.
135
132
  fs.mkdirSync(lockDir);
136
133
  try {
137
- atomicWriteFile(pidFile, String(process.pid));
134
+ // PERF (2026-08-25): pid file is disposable + mtime-stale-detected, so the
135
+ // full atomicWriteFile (2 fsync) here was dead ceremony — the .alock async
136
+ // path already writes pid via "wx". Mirror it: O_EXCL open, plain write.
137
+ // The lock dir itself is the mutex.
138
+ const pidFd = fs.openSync(pidFile, "wx");
139
+ try {
140
+ fs.writeSync(pidFd, String(process.pid));
141
+ } finally {
142
+ fs.closeSync(pidFd);
143
+ }
138
144
  } catch {
139
- /* best-effort */
145
+ /* best-effort — e.g. EEXIST under a re-taken dir; stale pid is mtime-detected */
140
146
  }
141
147
  acquired = true;
142
148
  break;
@@ -330,8 +336,18 @@ async function withEventLogLockAsync<T>(
330
336
  try {
331
337
  await fs.promises.mkdir(lockDir);
332
338
  try {
333
- // P0-4: the lock pid file is disposable stale-lock state; best-effort.
334
- atomicWriteFile(pidFile, String(process.pid), { durability: "best-effort" });
339
+ // PERF (2026-08-24): "wx" (O_CREAT|O_EXCL) — fails rather than
340
+ // following a planted symlink, so no O_NOFOLLOW/temp/rename
341
+ // ceremony needed for this disposable, mtime-stale-detected
342
+ // 4-byte file. We own the lock dir (we just mkdir'd it), so
343
+ // EEXIST means a crashed holder's leftover under OUR fresh dir
344
+ // or an attack — either way, skip: the dir itself is the mutex.
345
+ const fh = await fs.promises.open(pidFile, "wx");
346
+ try {
347
+ await fh.write(String(process.pid));
348
+ } finally {
349
+ await fh.close();
350
+ }
335
351
  } catch {
336
352
  /* best-effort */
337
353
  }
@@ -566,9 +582,6 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
566
582
  logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
567
583
  }
568
584
 
569
- // FIND-10: post-append stat captured from the same fd (non-worker path)
570
- // for reuse in the cache update below, avoiding a redundant path stat.
571
- let postAppendStat: fs.Stats | undefined;
572
585
  if (!skippedDueToSize) {
573
586
  const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
574
587
  // Phase 1.5: when worker atomic writer is enabled, append via worker.
@@ -595,16 +608,6 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
595
608
  await fd.appendFile(line, "utf-8");
596
609
  // P0-4 (F3a mirror): skip the data fsync for non-terminal events.
597
610
  if (isTerminal) await fd.sync();
598
- // FIND-10 R1 fix: the cache-optimization fd.stat() must NOT sit in the
599
- // seq-durability critical path. If it threw (rare — fd invalidated),
600
- // it would skip persistSequence below and reopen the seq-reuse
601
- // window the fsync just closed. Guard it; fall back to undefined
602
- // (the later cache-update takes a path stat instead).
603
- try {
604
- postAppendStat = await fd.stat();
605
- } catch {
606
- postAppendStat = undefined;
607
- }
608
611
  } finally {
609
612
  await fd.close();
610
613
  }
@@ -618,11 +621,7 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
618
621
  // can never roll the sidecar back either.
619
622
  if (baseMetadata?.seq !== undefined) persistSequenceMonotonic(eventsPath, seq);
620
623
  }
621
- // FIND-10: track whether compaction happened after the append so the
622
- // cache-update stat can safely reuse postAppendStat (file unchanged).
623
- let compactedAfterAppend = false;
624
624
  if (tickAppendCounter(eventsPath) && needsRotation(eventsPath)) {
625
- compactedAfterAppend = true;
626
625
  try {
627
626
  const prepared = prepareCompaction(eventsPath);
628
627
  if (prepared) applyCompactionUnlocked(eventsPath, prepared);
@@ -641,39 +640,13 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
641
640
  }
642
641
  }
643
642
 
644
- // FIX: Sequence was persisted AFTER appendFile in the append block above.
645
- // Only update the cache here (the sidecar persist is already done).
646
- const finalSeq = fullEvent.metadata?.seq ?? 0;
647
- try {
648
- // FIND-10: reuse post-append fd stat when available and no compaction
649
- // happened after the append (file unchanged). Falls back to path stat
650
- // for the worker path, skipped events, or post-compaction cases.
651
- let statResult: fs.Stats | undefined;
652
- if (postAppendStat && !compactedAfterAppend) {
653
- statResult = postAppendStat;
654
- } else {
655
- try {
656
- statResult = await fs.promises.stat(eventsPath).catch(() => undefined);
657
- } catch {
658
- /* file may not exist */
659
- }
660
- }
661
- if (statResult) {
662
- if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
663
- evictOldestSequenceCacheEntries();
664
- }
665
- sequenceCache.set(eventsPath, {
666
- size: statResult.size,
667
- mtimeMs: statResult.mtimeMs,
668
- seq: finalSeq,
669
- lastAccessMs: Date.now(),
670
- });
671
- }
672
- // Note: persistSequence is NOT called here again - it was already called
673
- // after the append to ensure the sidecar is current after the event is written.
674
- } catch (error) {
675
- logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
676
- }
643
+ // PERF (2026-08-24): the per-append sequenceCache upkeep that lived here
644
+ // (post-append stat + Map set + occasional O(n log n) evict sort) fed no
645
+ // hot reader — sequenceCache is read only by nextSequence, which is
646
+ // consulted solely by seeding/test helpers (see the ST-12 note in
647
+ // sequence-cache.ts); all three append paths allocate seqs via
648
+ // reserveSequence, which reads the .seq sidecar + seqCounters instead.
649
+ // nextSequence() re-seeds via its sidecar/scan fallback when called.
677
650
  return fullEvent;
678
651
  };
679
652
  // C-01: Two-tier lock — asyncQueues (in-process serialize) →
@@ -726,11 +699,13 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
726
699
  /**
727
700
  * Batch variant used by the buffered flush path. Computes metadata for each
728
701
  * event, writes the whole batch in a single appendFileSync + fsync, persists
729
- * the sequence sidecar once with the last seq, and updates the sequence cache
730
- * once. Resolves each item with its finalized event (carrying the assigned
731
- * seq). This collapses N fsyncs into 1 for the buffered write path, which is
732
- * the entire point of buffering — the previous per-event fsync made buffer
733
- * coalescing useless and added ~30ms/event on tmpfs.
702
+ * the sequence sidecar once with the last seq (no per-append sequenceCache
703
+ * upkeep — that cache no longer exists on the append paths; see the PERF
704
+ * 2026-08-24 note in appendEventAsync). Resolves each item with its finalized
705
+ * event (carrying the assigned seq). This collapses N fsyncs into 1 for the
706
+ * buffered write path, which is the entire point of buffering — the previous
707
+ * per-event fsync made buffer coalescing useless and added ~30ms/event on
708
+ * tmpfs.
734
709
  */
735
710
  async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
736
711
  if (queue.length === 0) return;
@@ -738,12 +713,23 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
738
713
  tickAppendCounter(eventsPath, queue.length);
739
714
  fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
740
715
 
716
+ // PERF (2026-08-24): one hoisted pre-append stat replaces the pre-flight
717
+ // existsSync+statSync pair below. The after-compaction re-checks further
718
+ // down keep their own fresh stats — compaction/rotation may have changed
719
+ // the file since preStat was taken, so they must not see a stale
720
+ // (pre-compaction) size.
721
+ let preStat: fs.Stats | undefined;
722
+ try {
723
+ preStat = fs.statSync(eventsPath);
724
+ } catch {
725
+ /* log absent — first append */
726
+ }
727
+
741
728
  // Pre-flight size check (mirrors appendEventInsideLock). We do it once for
742
729
  // the batch instead of once per event.
743
730
  try {
744
- if (fs.existsSync(eventsPath)) {
745
- const stat = fs.statSync(eventsPath);
746
- if (stat.size > MAX_EVENTS_BYTES) {
731
+ if (preStat) {
732
+ if (preStat.size > MAX_EVENTS_BYTES) {
747
733
  try {
748
734
  const prepared = prepareCompaction(eventsPath);
749
735
  if (prepared) applyCompactionUnlocked(eventsPath, prepared);
@@ -774,8 +760,8 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
774
760
  // then increment locally for each subsequent event in the batch. Calling
775
761
  // nextSequence() per-event would re-read file stat/sidecar with no writes
776
762
  // in between — every call would see the same file state and return the same
777
- // seq, breaking the "unique monotonic seq" contract. The cache update +
778
- // persistSequence at the end refreshes the sidecar to the last assigned seq.
763
+ // seq, breaking the "unique monotonic seq" contract. persistSequenceMonotonic
764
+ // at the end refreshes the sidecar to the last assigned seq.
779
765
  // B7: use reserveSequence for atomic seq assignment across all paths.
780
766
  // R16-B1 (Phase 3.6): reserve the WHOLE batch range (count = queue.length)
781
767
  // under the .seqlock in one acquire — the locally incremented nextSeq below
@@ -816,6 +802,13 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
816
802
  finalized.push({ item, line: `${JSON.stringify(redactSecrets(fullEvent))}\n`, fullEvent });
817
803
  lastSeq = seq;
818
804
  }
805
+ // PERF (2026-08-24): snapshot the in-process reservation end BEFORE the B7
806
+ // advance below — seqCounters is about to be raised to lastSeq, so reading
807
+ // reservedSequenceEnd() at the persist site would ALWAYS see lastSeq ≤
808
+ // counter and skip the persist even for explicit seqs the reservation never
809
+ // covered (sidecar would lag the file → cross-process re-reservation inside
810
+ // the file's true range — the exact R16-B1 duplicate-seq race).
811
+ const reservedEnd = reservedSequenceEnd(eventsPath);
819
812
  // B7: advance counter past the entire batch so next reserveSequence returns the correct value.
820
813
  advanceSequenceCounter(eventsPath, lastSeq);
821
814
 
@@ -838,41 +831,56 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
838
831
  }
839
832
 
840
833
  fs.appendFileSync(eventsPath, finalized.map((f) => f.line).join(""), "utf-8");
841
- const fd = fs.openSync(eventsPath, "r+");
842
- try {
843
- fs.fsyncSync(fd);
844
- } catch {
845
- // EPERM on Windows CI: best-effort flush
846
- } finally {
847
- fs.closeSync(fd);
834
+ // PERF (2026-08-25): skip the fsync when the whole batch is non-terminal.
835
+ // Terminal events never route through this buffer (appendEventBuffered
836
+ // bypasses to appendEvent, which fsyncs itself), so a batch here has no
837
+ // terminal event unless a caller deliberately mixed one in — mirror F3a:
838
+ // the event-reconstructor tolerates an inconsistent tail; the explicit
839
+ // persistSequenceMonotonic below still lands the reserved end range.
840
+ const hasTerminal = finalized.some((f) => TERMINAL_EVENT_TYPES.has(f.fullEvent.type));
841
+ if (hasTerminal) {
842
+ const fd = fs.openSync(eventsPath, "r+");
843
+ try {
844
+ fs.fsyncSync(fd);
845
+ } catch {
846
+ // EPERM on Windows CI: best-effort flush
847
+ } finally {
848
+ fs.closeSync(fd);
849
+ }
848
850
  }
849
851
  // R16-B1 (Phase 3.6): monotonic persist under the .seqlock — the reservation
850
852
  // already persisted the batch range end; this covers explicit (pre-assigned)
851
853
  // seqs that may exceed it and can never REGRESS the sidecar (the old bare
852
854
  // persistSequence could write a lower lastSeq over a higher reserved value).
853
- persistSequenceMonotonic(eventsPath, lastSeq);
854
-
855
- // Phase 3: cache update + resolve all promises.
856
- try {
857
- const stat = fs.statSync(eventsPath);
858
- if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
859
- evictOldestSequenceCacheEntries();
860
- }
861
- sequenceCache.set(eventsPath, {
862
- size: stat.size,
863
- mtimeMs: stat.mtimeMs,
864
- seq: lastSeq,
865
- lastAccessMs: Date.now(),
866
- });
867
- } catch (error) {
868
- logInternalError("event-log.batch-cache-update", error, `eventsPath=${eventsPath}`);
855
+ // PERF (2026-08-24): R16-B1 advance-on-reserve already persisted the
856
+ // reserved end inside the .seqlock at reservation time. Re-acquiring
857
+ // the seqlock (~12 syscalls) to conclude "no write needed" is pure
858
+ // overhead in the single-writer common case — skip when lastSeq is
859
+ // covered by the reservation snapshot above; explicit seqs beyond the
860
+ // reserved range still persist.
861
+ if (lastSeq > reservedEnd) {
862
+ persistSequenceMonotonic(eventsPath, lastSeq);
869
863
  }
870
864
 
865
+ // Phase 3: resolve all promises. (PERF 2026-08-24: the sequenceCache upkeep
866
+ // that used to live here fed no hot reader — see the note in
867
+ // appendEventAsync; nextSequence re-seeds via its sidecar/scan fallback.)
871
868
  for (const { item, fullEvent } of finalized) item.resolve(fullEvent);
872
869
  }
873
870
 
874
871
  function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): TeamEvent {
875
872
  fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
873
+ // PERF (2026-08-24): one hoisted pre-append stat replaces the overflow
874
+ // existsSync+statSync pair below. The after-compaction re-checks further
875
+ // down keep their own fresh stats — compaction/rotation may have changed
876
+ // the file since preStat was taken, so they must not see a stale
877
+ // (pre-compaction) size.
878
+ let preStat: fs.Stats | undefined;
879
+ try {
880
+ preStat = fs.statSync(eventsPath);
881
+ } catch {
882
+ /* log absent — first append */
883
+ }
876
884
  const baseMetadata = event.metadata;
877
885
  // B7: use reserveSequence for atomic seq assignment across all paths.
878
886
  const explicitSeq = baseMetadata?.seq;
@@ -909,9 +917,8 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
909
917
  // 3. After compact, if still over limit, rotate.
910
918
  const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
911
919
  let skippedDueToSize = false;
912
- if (!isTerminal && fs.existsSync(eventsPath)) {
913
- const stat = fs.statSync(eventsPath);
914
- if (stat.size > MAX_EVENTS_BYTES) {
920
+ if (!isTerminal && preStat) {
921
+ if (preStat.size > MAX_EVENTS_BYTES) {
915
922
  // Try immediate compact (not waiting for counter % 100).
916
923
  // Round 24 (BUG 1): we are INSIDE withEventLogLockSync. Use the unlocked
917
924
  // apply/rotate cores — the locked variants would deadlock (mkdir lock
@@ -967,7 +974,8 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
967
974
  } catch (error) {
968
975
  logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
969
976
  }
970
- // seq is already computed above via reserveSequence — reuse it for persist/cache.
977
+ // seq is already computed above via reserveSequence — reuse it for the
978
+ // explicit-seq persist below (no sequenceCache upkeep on append paths).
971
979
  // const seq declaration removed (B7: seq is now computed before metadata object).
972
980
  if (!skippedDueToSize) {
973
981
  fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
@@ -997,24 +1005,11 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
997
1005
  // Only EXPLICIT (pre-assigned) seqs bypassed the reservation — persist
998
1006
  // those, monotonically and under the .seqlock.
999
1007
  if (explicitSeq !== undefined) persistSequenceMonotonic(eventsPath, seq);
1000
- // FIX: Update cache AFTER append so cache and log are consistent with each other.
1001
- // This matches the async path behavior where cache is updated after the append.
1002
- // If a crash occurs after append but before cache update, the .seq file is
1003
- // already correct and nextSequence() will return the correct value on restart.
1004
- try {
1005
- const stat = fs.statSync(eventsPath);
1006
- if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
1007
- evictOldestSequenceCacheEntries();
1008
- }
1009
- sequenceCache.set(eventsPath, {
1010
- size: stat.size,
1011
- mtimeMs: stat.mtimeMs,
1012
- seq,
1013
- lastAccessMs: Date.now(),
1014
- });
1015
- } catch (error) {
1016
- logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
1017
- }
1008
+ // PERF (2026-08-24): the per-append sequenceCache upkeep that lived here
1009
+ // (post-append stat + Map set + occasional evict sort) fed no hot reader —
1010
+ // see the note in appendEventAsync. The .seq sidecar is already current
1011
+ // (advance-on-reserve / persistSequenceMonotonic above), and nextSequence()
1012
+ // re-seeds via its sidecar/scan fallback when called.
1018
1013
  }
1019
1014
  if (tickAppendCounter(eventsPath) && needsRotation(eventsPath)) {
1020
1015
  // Round 24 (BUG 1): we are INSIDE withEventLogLockSync here (called via
@@ -1190,6 +1185,18 @@ export function flushBufferedQueuesSync(): void {
1190
1185
  for (const eventsPath of [...bufferedTimers.keys()]) bufferedTimers.delete(eventsPath);
1191
1186
  }
1192
1187
 
1188
+ // Exported only for the buffered-batch unit tests. NOT part of the public API —
1189
+ // the `__test__` prefix follows the project convention used in atomic-write.ts,
1190
+ // state-store.ts, team-runner.ts, etc. `appendEventBuffered` rejects terminal
1191
+ // events BEFORE they reach the batch (see appendEventBuffered), so a batch that
1192
+ // deliberately mixes in a terminal event is unreachable through the public API;
1193
+ // this hook lets T2 assert the fsync gate directly. Both production call paths
1194
+ // (async flush via withEventLogLockAsync and the EL-2 sync flush via
1195
+ // withEventLogLockSync) route through appendEventBatchInsideLock.
1196
+ export function __test__appendBatchForUnitTest(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
1197
+ return appendEventBatchInsideLock(eventsPath, queue);
1198
+ }
1199
+
1193
1200
  /**
1194
1201
  * Schedule an async event append without waiting for the result.
1195
1202
  * Uses the non-blocking async queue to avoid blocking the event loop.
@@ -221,10 +221,20 @@ function withSeqLock<T>(eventsPath: string, fn: () => T): T {
221
221
  try {
222
222
  fs.mkdirSync(lockDir);
223
223
  try {
224
- // P0-4: the lock pid file is disposable stale-lock state; best-effort.
225
- atomicWriteFile(pidFile, String(process.pid), { durability: "best-effort" });
224
+ // PERF (2026-08-24): "wx" (O_CREAT|O_EXCL) — fails rather than
225
+ // following a planted symlink, so no O_NOFOLLOW/temp/rename
226
+ // ceremony needed for this disposable, mtime-stale-detected
227
+ // 4-byte file. We own the lock dir (we just mkdir'd it), so
228
+ // EEXIST means a crashed holder's leftover under OUR fresh dir
229
+ // or an attack — either way, skip: the dir itself is the mutex.
230
+ const fd = fs.openSync(pidFile, "wx");
231
+ try {
232
+ fs.writeSync(fd, String(process.pid));
233
+ } finally {
234
+ fs.closeSync(fd);
235
+ }
226
236
  } catch {
227
- /* best-effort */
237
+ /* best-effort — see withEventLogLockAsync note */
228
238
  }
229
239
  acquired = true;
230
240
  } catch {
@@ -286,6 +296,14 @@ export function persistSequenceMonotonic(eventsPath: string, seq: number): void
286
296
  });
287
297
  }
288
298
 
299
+ /** PERF (2026-08-24): callers that just reserved a range can skip the lock+read
300
+ * sidecar round-trip in persistSequenceMonotonic when their value is already
301
+ * covered by the in-process reservation (R16-B1 advance-on-reserve persisted it
302
+ * inside the .seqlock at reservation time). */
303
+ export function reservedSequenceEnd(eventsPath: string): number {
304
+ return seqCounters.get(eventsPath) ?? 0;
305
+ }
306
+
289
307
  // B7: single in-process monotonic sequence counter per eventsPath. The three
290
308
  // append paths — sync appendEvent (withEventLogLockSync file lock), buffered
291
309
  // flush (asyncLocks promise chain), and direct appendEventAsync (asyncQueues
@@ -26,7 +26,7 @@
26
26
  import * as fs from "node:fs";
27
27
  import * as path from "node:path";
28
28
  import { logInternalError } from "../../utils/internal-error.ts";
29
- import { atomicWriteJson, flushPendingAtomicWrites } from "../atomic-write.ts";
29
+ import { atomicWriteJson } from "../atomic-write.ts";
30
30
  import { withRunLockSync } from "../coordination/locks.ts";
31
31
  import type { TeamRunManifest } from "../types.ts";
32
32
 
@@ -100,9 +100,10 @@ export function readOwnershipMap(manifest: TeamRunManifest): OwnershipMapFile {
100
100
  export function upsertOwnershipEntry(manifest: TeamRunManifest, entry: OwnershipEntry): void {
101
101
  try {
102
102
  withRunLockSync(manifest, () => {
103
- // Defeat the atomic-write coalescer's stale-read window: force any
104
- // pending buffered write for THIS file to land before we reload.
105
- flushPendingAtomicWrites(ownershipMapPath(manifest));
103
+ // REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion (and
104
+ // its flush-before-reload guard). The map is small, written at spawn /
105
+ // steer frequency (not a hot loop), and round-trip readers expect
106
+ // read-your-writes immediately after upsert returns.
106
107
  const fresh = readOwnershipMap(manifest);
107
108
  const prev = fresh.entries[entry.taskId] ?? {};
108
109
  const merged: OwnershipEntry = {
@@ -74,6 +74,13 @@ export function getCurrentPlanRecord(manifest: TeamRunManifest): PlanRecord | un
74
74
  function writePlanFile(manifest: TeamRunManifest, revisions: PlanRecord[]): void {
75
75
  const file: PlanFile = { version: 1, revisions };
76
76
  fs.mkdirSync(path.dirname(planFilePath(manifest)), { recursive: true });
77
+ // REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion.
78
+ // `loadPlanRecords` is the PUBLIC read API and every caller expects
79
+ // read-your-writes (getCurrentPlanRecord, plan-replan, handleStatus); a
80
+ // 50ms coalesce window made the mutators' own reload-inside-lock read
81
+ // stale state (lost-update) and broke plan-store.test.ts 11/15. Plan
82
+ // writes are low-frequency (revision append / linkage / approval) — the
83
+ // coalesce win is negligible. Same exclusion rule as status.ts A2/A4.
77
84
  atomicWriteJson(planFilePath(manifest), file);
78
85
  }
79
86
 
@@ -141,6 +148,10 @@ export function appendPlanRevision(manifest: TeamRunManifest, record: PlanRecord
141
148
  revisions.push(record);
142
149
  writePlanFile(manifest, revisions);
143
150
  const dropped = record.items.filter((i) => i.status === "dropped").length;
151
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion — plan lifecycle
152
+ // events are low-frequency and consumers (plan-store tests, status display)
153
+ // expect read-your-writes; also removes the policy-A catch (sync errors
154
+ // propagate to the producer inside the lock, as at base).
144
155
  appendEvent(manifest.eventsPath, {
145
156
  type: record.version === 1 ? "plan.created" : "plan.revised",
146
157
  runId: manifest.runId,
@@ -199,6 +210,7 @@ export function setPlanApproval(
199
210
  // ADR-4 §9: events for approval MUTATIONS. `pending` emits none — the
200
211
  // request surface (ensurePlanApprovalRequested) appends its own
201
212
  // plan.approval_required event.
213
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion — see above.
202
214
  if (approval.status !== "pending") {
203
215
  appendEvent(manifest.eventsPath, {
204
216
  type: approval.status === "approved" ? "plan.approved" : "plan.rejected",
@@ -10,6 +10,7 @@ import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../../utils/paths.t
10
10
  import { assertSafePathId, resolveContainedRelativePath, resolveRealContainedPath } from "../../utils/safe-paths.ts";
11
11
  import { toPiSessionId } from "../../utils/session-utils.ts";
12
12
  import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
13
+ import type { WriteDurability } from "../atomic-write.ts";
13
14
  import {
14
15
  atomicWriteJson,
15
16
  atomicWriteJsonAsync,
@@ -207,6 +208,26 @@ function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
207
208
  return scopedPath;
208
209
  }
209
210
 
211
+ // PERF (2026-08-24): the artifacts containment verdict (existsSync + lstat +
212
+ // resolveRealContainedPath ≈ 10-25 syscalls) cannot change for a run dir that
213
+ // is not replaced — mirror the P1-12 runStateRootCache tradeoff: positive
214
+ // verdicts cached 10s, negatives never cached (a newly created artifacts dir
215
+ // must be found promptly). A stale positive is safe the same way P1-12 is:
216
+ // downstream manifest stat catches deleted runs, and any write through
217
+ // atomic-write re-runs isSymlinkSafeDirCached independently.
218
+ const artifactsVerdictCache = new Map<string, { expiresAt: number }>();
219
+ const ARTIFACTS_VERDICT_TTL_MS = 10_000;
220
+ const ARTIFACTS_VERDICT_CACHE_MAX = 256;
221
+
222
+ /** @internal — artifacts verdict cache introspection for unit tests. */
223
+ export function __test__artifactsVerdictCacheSize(): number {
224
+ return artifactsVerdictCache.size;
225
+ }
226
+ /** @internal */
227
+ export function __test__clearArtifactsVerdictCache(): void {
228
+ artifactsVerdictCache.clear();
229
+ }
230
+
210
231
  function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunManifest, stateRoot: string, tasksPath: string): boolean {
211
232
  // Issue 2 fix: Reject manifests missing status field to prevent undefined
212
233
  // behavior in callers like canTransitionRunStatus(manifest.status, newStatus).
@@ -221,6 +242,12 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
221
242
  const artifactsParent = path.join(scopeBaseRoot(cwd), DEFAULT_PATHS.state.artifactsSubdir);
222
243
  const expectedArtifactsRoot = resolveContainedRelativePath(artifactsParent, runId, "runId");
223
244
  if (manifest.artifactsRoot !== expectedArtifactsRoot) return false;
245
+ // PERF (2026-08-24): memoized verdict — see artifactsVerdictCache above.
246
+ // Hit only after the cheap manifest-identity checks above, so a tampered
247
+ // manifest (wrong paths/status) is still rejected without touching the memo.
248
+ const verdictKey = `${cwd}\0${runId}`;
249
+ const cachedVerdict = artifactsVerdictCache.get(verdictKey);
250
+ if (cachedVerdict && cachedVerdict.expiresAt > Date.now()) return true;
224
251
  // Always validate artifactsRoot is not a symlink, even when manifest has
225
252
  // no artifacts entries. A symlinked artifactsRoot pointing outside the
226
253
  // artifacts parent is a security violation (could write to attacker-
@@ -237,8 +264,18 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
237
264
  } else if (manifest.artifacts && manifest.artifacts.length > 0) {
238
265
  // Has artifacts entries but directory doesn't exist - benign state for
239
266
  // runs still in progress.
267
+ if (artifactsVerdictCache.size >= ARTIFACTS_VERDICT_CACHE_MAX) {
268
+ const oldest = artifactsVerdictCache.keys().next().value;
269
+ if (oldest !== undefined) artifactsVerdictCache.delete(oldest);
270
+ }
271
+ artifactsVerdictCache.set(verdictKey, { expiresAt: Date.now() + ARTIFACTS_VERDICT_TTL_MS });
240
272
  return true;
241
273
  }
274
+ if (artifactsVerdictCache.size >= ARTIFACTS_VERDICT_CACHE_MAX) {
275
+ const oldest = artifactsVerdictCache.keys().next().value;
276
+ if (oldest !== undefined) artifactsVerdictCache.delete(oldest);
277
+ }
278
+ artifactsVerdictCache.set(verdictKey, { expiresAt: Date.now() + ARTIFACTS_VERDICT_TTL_MS });
242
279
  return true;
243
280
  }
244
281
 
@@ -465,6 +502,11 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
465
502
  // which is always safe.
466
503
  invalidateRunCache(manifest.stateRoot);
467
504
  const manifestPath = path.join(manifest.stateRoot, "manifest.json");
505
+ // REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion —
506
+ // saveRunManifest is a SYNCHRONOUS persist by name/contract (tests assert
507
+ // it, broker loadRunManifestById is a cross-process reader, and the
508
+ // statSync-based cache repopulation below needs the real post-write
509
+ // mtime/size). The 50ms coalesce window broke all three.
468
510
  atomicWriteJson(manifestPath, manifest);
469
511
  // FIX: Re-populate cache with actual mtime/size so loadRunManifestById
470
512
  // doesn't miss the cache on next read. Without this, every load until
@@ -624,19 +666,52 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
624
666
  * tasks.json stale (showing "running") while events.jsonl already shows
625
667
  * the terminal event, causing false zombie detection / double-execution
626
668
  * on crash recovery.
669
+ *
670
+ * PERF round 2, Task 3: optional `durability` (default "full") overrides the
671
+ * coalesced entry's durability. When the caller wants a non-terminal checkpoint
672
+ * written without fsync (persistence.skipTasksFsync opt-in), pass
673
+ * `durability: "best-effort"` — the coalesced entry stores it and the flush
674
+ * forwards it to atomicWriteFile (see atomic-write.ts:997). Terminal
675
+ * transitions (skipCoalesce=true) always fall to atomicWriteJson with full
676
+ * durability regardless of this param.
627
677
  */
628
678
  /** @internal */
629
- export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[], skipCoalesce: boolean = false): void {
679
+ export function saveRunTasksCoalesced(
680
+ manifest: TeamRunManifest,
681
+ tasks: TeamTaskState[],
682
+ skipCoalesce: boolean = false,
683
+ durability: WriteDurability = "full",
684
+ ): void {
630
685
  // ST-4: refuse to persist [] over a previously-non-empty tasks file.
631
686
  if (!shouldPersistTasks(manifest, tasks)) return;
632
- // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
633
- invalidateRunCache(manifest.stateRoot);
687
+ // PERF (2026-08-24, Task 12): invalidating the WHOLE entry made every
688
+ // loadRunManifestById after a coalesced save re-read + re-parse
689
+ // manifest.json (24KB+) even though the manifest file did not change —
690
+ // persistSingleTaskUpdate's next call (~500ms later) always paid it. Keep
691
+ // the manifest half of the entry and zero only the tasks stamps, which is
692
+ // the exact pre-existing signal for "tasks on disk may be stale" (coalesced
693
+ // write not landed yet). The load path cooperates: the zeroed tasks stamps
694
+ // force the slow path (tasks are always re-read from disk), but when the
695
+ // retained manifest stamps still match a fresh stat of manifest.json — the
696
+ // same mtime/size verification the fast path performs — the retry loop
697
+ // reuses the cached manifest object and skips the re-read + re-parse.
698
+ // Crash safety is unchanged: a zeroed tasks stamp can only cause a miss,
699
+ // never a stale hit, and a concurrent manifest rewrite changes mtime/size
700
+ // so the manifest reuse never serves stale content. Generation semantics
701
+ // unchanged — setManifestCache stamps the CURRENT generation, so a
702
+ // concurrent writer's bump still invalidates us.
703
+ const cached = manifestCache.get(manifest.stateRoot);
704
+ if (cached) {
705
+ setManifestCache(manifest.stateRoot, { ...cached, tasks, tasksMtimeMs: 0, tasksSize: 0 });
706
+ } else {
707
+ invalidateRunCache(manifest.stateRoot);
708
+ }
634
709
  try {
635
710
  fs.statSync(manifest.stateRoot);
636
711
  } catch {
637
712
  return;
638
713
  }
639
- atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined, { compact: true }, skipCoalesce);
714
+ atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined, { compact: true, durability }, skipCoalesce);
640
715
  }
641
716
 
642
717
  export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
@@ -943,7 +1018,19 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
943
1018
  let tasks: TeamTaskState[] | undefined;
944
1019
  while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
945
1020
  const freshStat = fs.statSync(manifestPath);
946
- manifest = readJsonFile<TeamRunManifest>(manifestPath);
1021
+ // PERF (2026-08-24, Task 12 realized): after saveRunTasksCoalesced the
1022
+ // cache keeps the manifest half of the entry while zeroing only the
1023
+ // tasks stamps, so this slow path runs solely to refresh tasks. When the
1024
+ // retained manifest stamps still match the fresh stat — the exact
1025
+ // mtime/size verification the fast path performs — reuse the cached
1026
+ // manifest object instead of re-reading + re-parsing manifest.json.
1027
+ // Stamp verification is NOT bypassed: any manifest.json rewrite changes
1028
+ // mtime/size and falls back to the disk read. The tasks re-read below
1029
+ // still always happens — zeroed tasks stamps must force it.
1030
+ manifest =
1031
+ cached && cached.manifestMtimeMs === freshStat.mtimeMs && cached.manifestSize === freshStat.size
1032
+ ? cached.manifest
1033
+ : readJsonFile<TeamRunManifest>(manifestPath);
947
1034
  const freshTasksStat = fs.existsSync(tasksPath) ? fs.statSync(tasksPath) : undefined;
948
1035
  tasks = loadTasksWithRecovery(tasksPath, manifest?.eventsPath ?? path.join(stateRoot, "events.jsonl"), manifest?.runId ?? runId);
949
1036
  // If size/mtime didn't change between stat and read, we're consistent.
@@ -1085,7 +1172,17 @@ export async function loadRunManifestByIdAsync(
1085
1172
  let attempts = 0;
1086
1173
  while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
1087
1174
  const freshStat = await fs.promises.stat(manifestPath);
1088
- manifest = await readJsonFileAsync<TeamRunManifest>(manifestPath);
1175
+ // PERF (2026-08-24, Task 12 realized): async twin of the sync reuse —
1176
+ // after saveRunTasksCoalesced only the tasks stamps are zeroed, so when
1177
+ // the retained manifest stamps match the fresh stat (same verification
1178
+ // the fast path performs), reuse the cached manifest object instead of
1179
+ // re-reading + re-parsing manifest.json. A manifest.json rewrite changes
1180
+ // mtime/size and falls back to the disk read; the tasks re-read below
1181
+ // always happens.
1182
+ manifest =
1183
+ cached && cached.manifestMtimeMs === freshStat.mtimeMs && cached.manifestSize === freshStat.size
1184
+ ? cached.manifest
1185
+ : await readJsonFileAsync<TeamRunManifest>(manifestPath);
1089
1186
  const freshTasksStat = await fs.promises.stat(tasksPath).catch(() => undefined);
1090
1187
  tasks = await loadTasksWithRecoveryAsync(
1091
1188
  tasksPath,