pi-crew 0.9.21 → 0.9.22

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.21",
3
+ "version": "0.9.22",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -137,7 +137,7 @@ function isAllowedRpcRunParams(params: TeamToolParamsValue): {
137
137
  return { ok: true };
138
138
  }
139
139
 
140
- function on(events: EventBusLike, channel: string, handler: (raw: unknown) => void): () => void {
140
+ function on(events: EventBusLike, channel: string, handler: (raw: any) => unknown): () => void {
141
141
  const unsub = events.on(channel, handler);
142
142
  return typeof unsub === "function" ? unsub : () => {};
143
143
  }
@@ -90,7 +90,7 @@ export interface PruneResult {
90
90
  // Token estimation (rough char/4 heuristic, matching gajae-code)
91
91
  // ---------------------------------------------------------------------------
92
92
 
93
- import { countTokens } from "../utils/token-counter";
93
+ import { countTokens } from "../utils/token-counter.ts";
94
94
 
95
95
  function estimateTokens(text: string): number {
96
96
  return countTokens(text);
@@ -412,15 +412,11 @@ export function resetEventLogMode(): void {
412
412
  export async function appendEventAsync(eventsPath: string, event: AppendTeamEvent): Promise<TeamEvent> {
413
413
  // Non-terminal events: route through buffer for coalesced writes (20ms default).
414
414
  // This eliminates per-event fsync + persistSequence overhead for high-frequency events.
415
+ // The buffer timer is kept alive (ref'd) so standalone contexts (tests, short-lived
416
+ // scripts) get their events flushed before the loop drains — no per-event keepAlive
417
+ // intervals needed (which previously starved the event loop at high fan-out).
415
418
  if (!TERMINAL_EVENT_TYPES.has(event.type)) {
416
- const result = appendEventBuffered(eventsPath, event);
417
- // FIX: appendEventBuffered uses unref()'d timers. Keep the event loop alive
418
- // so the promise resolves in standalone/test contexts where nothing else
419
- // keeps the loop running. Without this, the event loop drains before the
420
- // timer fires and the promise never resolves.
421
- const keepAlive = setInterval(() => {}, 200);
422
- void result.finally(() => clearInterval(keepAlive));
423
- return result;
419
+ return appendEventBuffered(eventsPath, event);
424
420
  }
425
421
  // Terminal events: direct async path for immediate durability guarantee
426
422
  const queueKey = eventsPath;
@@ -618,6 +614,131 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
618
614
  * `withEventLogLockSync` for `eventsPath`. Used by `appendEventBuffered` to
619
615
  * write a whole batch of pending events under a single lock acquire.
620
616
  */
617
+ /**
618
+ * Batch variant used by the buffered flush path. Computes metadata for each
619
+ * event, writes the whole batch in a single appendFileSync + fsync, persists
620
+ * the sequence sidecar once with the last seq, and updates the sequence cache
621
+ * once. Resolves each item with its finalized event (carrying the assigned
622
+ * seq). This collapses N fsyncs into 1 for the buffered write path, which is
623
+ * the entire point of buffering — the previous per-event fsync made buffer
624
+ * coalescing useless and added ~30ms/event on tmpfs.
625
+ */
626
+ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
627
+ if (queue.length === 0) return;
628
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
629
+
630
+ // Pre-flight size check (mirrors appendEventInsideLock). We do it once for
631
+ // the batch instead of once per event.
632
+ try {
633
+ if (fs.existsSync(eventsPath)) {
634
+ const stat = fs.statSync(eventsPath);
635
+ if (stat.size > MAX_EVENTS_BYTES) {
636
+ try {
637
+ const prepared = prepareCompaction(eventsPath);
638
+ if (prepared) applyCompactionUnlocked(eventsPath, prepared);
639
+ } catch (error) {
640
+ logInternalError("event-log.batch-immediate-compact", error, `eventsPath=${eventsPath}`);
641
+ }
642
+ if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
643
+ rotateEventLogUnlocked(eventsPath);
644
+ }
645
+ }
646
+ }
647
+ } catch (error) {
648
+ logInternalError("event-log.batch-size-check", error, `eventsPath=${eventsPath}`);
649
+ }
650
+
651
+ // Phase 1: compute metadata + JSON lines for every event in the batch.
652
+ // Initialize nextSeq ONCE from nextSequence (or the first event's baseMetadata.seq),
653
+ // then increment locally for each subsequent event in the batch. Calling
654
+ // nextSequence() per-event would re-read file stat/sidecar with no writes
655
+ // in between — every call would see the same file state and return the same
656
+ // seq, breaking the "unique monotonic seq" contract. The cache update +
657
+ // persistSequence at the end refreshes the sidecar to the last assigned seq.
658
+ const startingSeq = queue[0]?.event.metadata?.seq ?? nextSequence(eventsPath);
659
+ let nextSeq = startingSeq;
660
+ const finalized: { item: BufferedAppend; line: string; fullEvent: TeamEvent }[] = [];
661
+ let lastSeq = 0;
662
+ for (const item of queue) {
663
+ const baseMetadata = item.event.metadata;
664
+ const seq = baseMetadata?.seq ?? nextSeq++;
665
+ let metadata: TeamEventMetadata = {
666
+ seq,
667
+ provenance: baseMetadata?.provenance ?? "team_runner",
668
+ ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
669
+ ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
670
+ ...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
671
+ ...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
672
+ ...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
673
+ ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
674
+ ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
675
+ ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
676
+ ...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
677
+ };
678
+ const fullEvent: TeamEvent = {
679
+ time: new Date().toISOString(),
680
+ ...item.event,
681
+ metadata,
682
+ };
683
+ if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
684
+ metadata = {
685
+ ...metadata,
686
+ fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent),
687
+ };
688
+ fullEvent.metadata = metadata;
689
+ }
690
+ finalized.push({ item, line: `${JSON.stringify(redactSecrets(fullEvent))}\n`, fullEvent });
691
+ lastSeq = seq;
692
+ }
693
+
694
+ // Phase 2: single appendFileSync + single fsync + single persistSequence.
695
+ // Before this fix, each event in the batch triggered its own fsync, which
696
+ // was the dominant cost on tmpfs and CI runners.
697
+ try {
698
+ if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
699
+ logInternalError(
700
+ "event-log.size-limit",
701
+ new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
702
+ `eventsPath=${eventsPath}`,
703
+ );
704
+ // Reject the batch — caller will surface the error per item.
705
+ for (const { item } of finalized) item.reject(new Error("event log size limit exceeded"));
706
+ return;
707
+ }
708
+ } catch (error) {
709
+ logInternalError("event-log.batch-size-check-post", error, `eventsPath=${eventsPath}`);
710
+ }
711
+
712
+ fs.appendFileSync(eventsPath, finalized.map((f) => f.line).join(""), "utf-8");
713
+ const fd = fs.openSync(eventsPath, "r+");
714
+ try {
715
+ fs.fsyncSync(fd);
716
+ } catch {
717
+ // EPERM on Windows CI: best-effort flush
718
+ } finally {
719
+ fs.closeSync(fd);
720
+ }
721
+ persistSequence(eventsPath, lastSeq);
722
+
723
+ // Phase 3: cache update + resolve all promises.
724
+ try {
725
+ const stat = fs.statSync(eventsPath);
726
+ if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
727
+ evictOldestSequenceCacheEntries();
728
+ }
729
+ sequenceCache.set(eventsPath, {
730
+ size: stat.size,
731
+ mtimeMs: stat.mtimeMs,
732
+ seq: lastSeq,
733
+ lastAccessMs: Date.now(),
734
+ });
735
+ } catch (error) {
736
+ logInternalError("event-log.batch-cache-update", error, `eventsPath=${eventsPath}`);
737
+ }
738
+
739
+ for (const { item, fullEvent } of finalized) item.resolve(fullEvent);
740
+ }
741
+
621
742
  function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): TeamEvent {
622
743
  fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
623
744
  const baseMetadata = event.metadata;
@@ -784,8 +905,15 @@ export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent,
784
905
  queue.push({ event, resolve, reject });
785
906
  bufferedQueues.set(eventsPath, queue);
786
907
  if (!bufferedTimers.has(eventsPath)) {
787
- const timer = setTimeout(() => flushOneEventLogBuffer(eventsPath), bufferMs);
788
- timer.unref();
908
+ // Wrap flush in async IIFE so the returned Promise is awaited (avoids
909
+ // "floating promise" warnings under --test-force-exit and prevents
910
+ // the timer from being treated as done before the flush actually
911
+ // completes its async work).
912
+ const timer = setTimeout(() => {
913
+ flushOneEventLogBuffer(eventsPath).catch((error) => {
914
+ logInternalError("event-log.buffered-flush", error, `eventsPath=${eventsPath}`);
915
+ });
916
+ }, bufferMs);
789
917
  bufferedTimers.set(eventsPath, timer);
790
918
  }
791
919
  });
@@ -830,15 +958,12 @@ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
830
958
  // FIX (Issue 2): Use async lock instead of withEventLogLockSync to avoid
831
959
  // blocking the event loop. The sync lock uses sleepSync which blocks for
832
960
  // up to 5s and prevents AbortSignal handlers from firing.
961
+ // FIX (P0 follow-up): Batch the file write + fsync + persistSequence across
962
+ // the whole queue. Previously each event triggered its own fsyncSync,
963
+ // turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
964
+ // 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
833
965
  await withEventLogLockAsync(eventsPath, async () => {
834
- for (const item of queue) {
835
- try {
836
- const ev = appendEventInsideLock(eventsPath, item.event);
837
- item.resolve(ev);
838
- } catch (error) {
839
- item.reject(error);
840
- }
841
- }
966
+ await appendEventBatchInsideLock(eventsPath, queue);
842
967
  });
843
968
  } catch (error) {
844
969
  // Lock acquire failed — fail every queued item so callers can fall back.
@@ -866,13 +991,26 @@ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEv
866
991
  // Auto-flush on process exit so buffered events do not silently leak.
867
992
  // Defense-in-depth: SIGTERM/SIGINT use setImmediate so the handler returns
868
993
  // immediately and the main thread is not blocked by sync I/O.
994
+ // FIX (P0 follow-up): Only call flushEventLogBuffer() / drainAsyncQueues() if
995
+ // there is actually pending work. Calling them unconditionally creates a new
996
+ // floating Promise that the test runner detects under --test-force-exit,
997
+ // failing tests with "Promise resolution is still pending but the event loop
998
+ // has already resolved" even when the test body completed cleanly.
869
999
  process.on("exit", () => {
870
- flushEventLogBuffer();
1000
+ if (bufferedQueues.size > 0) {
1001
+ flushEventLogBuffer().catch(() => {
1002
+ /* best-effort flush on exit */
1003
+ });
1004
+ }
871
1005
  // FIX (Issue 1): Drain asyncQueues on exit to minimize event loss.
872
1006
  // In-flight async writes are awaited (via Promise.allSettled) before
873
1007
  // the map is cleared. This reduces but does not eliminate event loss
874
1008
  // on crash — SIGKILL (kill -9) cannot be intercepted.
875
- drainAsyncQueues();
1009
+ if (asyncQueues.size > 0) {
1010
+ drainAsyncQueues().catch(() => {
1011
+ /* best-effort drain on exit */
1012
+ });
1013
+ }
876
1014
  asyncQueues.clear();
877
1015
  });
878
1016
  process.on("SIGTERM", () => setImmediate(() => flushEventLogBuffer()));
@@ -111,7 +111,6 @@ async function gitAsync(cwd: string, args: string[]): Promise<string> {
111
111
  const { stdout } = await execFileAsync("git", args, {
112
112
  cwd,
113
113
  encoding: "utf-8",
114
- stdio: ["ignore", "pipe", "pipe"],
115
114
  env: gitEnv(),
116
115
  windowsHide: true,
117
116
  });
@@ -445,7 +444,6 @@ async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ lo
445
444
  await execFileAsync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`], {
446
445
  cwd: repoRoot,
447
446
  encoding: "utf-8",
448
- stdio: ["ignore", "pipe", "pipe"],
449
447
  windowsHide: true,
450
448
  })
451
449
  ).stdout.trim();
@@ -459,7 +457,7 @@ async function pruneStaleWorktreesAsync(repoRoot: string): Promise<void> {
459
457
  try {
460
458
  await execFileAsync("git", ["worktree", "prune"], {
461
459
  cwd: repoRoot,
462
- stdio: ["ignore", "ignore", "ignore"],
460
+ windowsHide: true,
463
461
  });
464
462
  } catch {
465
463
  /* best-effort */