pi-crew 0.9.40 → 0.9.41

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.
@@ -437,9 +437,9 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
437
437
  // resolveRealContainedPath validates a pre-existing path.
438
438
  // Async optimization: use fire-and-forget async write to avoid blocking the event loop.
439
439
  // The caller does not need to await this — transcript writes are best-effort telemetry.
440
- // OPT-06 follow-up: we still track the write promise in a module-scoped Set so
441
- // lifecycle boundaries (ChildPiLineObserver.flush, runChildPi settle) can drain
442
- // them before returning. Without that drain, callers that immediately read the
440
+ // OPT-06 follow-up: lines are buffered in a module-scoped Map and flushed
441
+ // periodically (50ms debounce) or on lifecycle boundaries (ChildPiLineObserver.flush,
442
+ // runChildPi settle). Without that drain, callers that immediately read the
443
443
  // transcript file post-flush (e.g. integration tests at phase3-runtime:50 and
444
444
  // phase4-runtime:37/:68/:103) would see ENOENT or empty content because the
445
445
  // async file handle had not yet been opened / flushed.
@@ -447,66 +447,103 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
447
447
  }
448
448
 
449
449
  /** Async version of appendTranscript — fire-and-forget for non-blocking writes. */
450
- async function appendTranscriptAsync(safePath: string, line: string): Promise<void> {
451
- const content = `${redactJsonLine(line)}\n`;
452
- try {
453
- // Use async file handle for better performance when many writes occur.
454
- // O_NOFOLLOW | O_CREAT | O_APPEND ensures security and atomicity.
455
- const fd = await fs.promises.open(
456
- safePath,
457
- fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_APPEND,
458
- 0o600,
459
- );
460
- try {
461
- await fd.write(content, undefined, "utf-8");
462
- } finally {
463
- await fd.close();
464
- }
465
- } catch (error) {
466
- logInternalError("child-pi.transcript-write-failed", error as Error, `path=${safePath}`);
467
- }
450
+ // ── Transcript batch buffer (OPT-PHASE3) ────────────────────────────────
451
+ // Instead of open/write/close per line (3 syscalls × N), accumulate lines
452
+ // in a module-scoped buffer and flush them in one open/write/close per path
453
+ // every TRANSCRIPT_FLUSH_MS. Lifecycle boundaries (observer.flush, settle)
454
+ // force-flush the buffer before returning so transcript reads are complete.
455
+ //
456
+ // Ordering: lines are appended to the per-path array in call order. The flush
457
+ // writes the joined array, preserving intra-batch ordering. Inter-batch
458
+ // ordering is not guaranteed but transcript is append-only telemetry.
459
+ //
460
+ // Security: O_NOFOLLOW | O_CREAT | O_APPEND flags preserved on every flush.
461
+ const transcriptBatches = new Map<string, string[]>();
462
+ let transcriptFlushTimer: ReturnType<typeof setTimeout> | undefined;
463
+ const TRANSCRIPT_FLUSH_MS = 50;
464
+
465
+ function scheduleTranscriptFlush(): void {
466
+ if (transcriptFlushTimer) return;
467
+ transcriptFlushTimer = setTimeout(() => {
468
+ transcriptFlushTimer = undefined;
469
+ void flushTranscriptBatches();
470
+ }, TRANSCRIPT_FLUSH_MS);
471
+ transcriptFlushTimer.unref?.();
468
472
  }
469
473
 
470
- /**
471
- * Module-scoped set of in-flight transcript-write promises. Each call to
472
- * {@link trackTranscriptWrite} registers its promise here; the promise
473
- * removes itself on completion via `.finally`. Lifecycle boundaries
474
- * (ChildPiLineObserver.flush, runChildPi settle) call
475
- * {@link flushPendingTranscriptWrites} before returning so callers that
476
- * immediately read the transcript file after `runChildPi` / `observer.flush()`
477
- * await see the full content (and a non-ENOENT file).
478
- *
479
- * Module-scoped rather than per-input because all transcript writes for a
480
- * given run share the same Node fs event loop and serialise through a single
481
- * fs handle per path; one drain per lifecycle boundary is sufficient.
482
- */
483
- const pendingTranscriptWrites: Set<Promise<void>> = new Set();
474
+ async function flushTranscriptBatches(): Promise<void> {
475
+ const entries = [...transcriptBatches.entries()];
476
+ transcriptBatches.clear();
477
+ await Promise.allSettled(
478
+ entries.map(async ([safePath, lines]) => {
479
+ if (lines.length === 0) return;
480
+ const content = lines.join("");
481
+ try {
482
+ const fd = await fs.promises.open(
483
+ safePath,
484
+ fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_APPEND,
485
+ 0o600,
486
+ );
487
+ try {
488
+ await fd.write(content, undefined, "utf-8");
489
+ } finally {
490
+ await fd.close();
491
+ }
492
+ } catch (error) {
493
+ logInternalError("child-pi.transcript-write-failed", error as Error, `path=${safePath}`);
494
+ }
495
+ }),
496
+ );
497
+ }
484
498
 
485
499
  function trackTranscriptWrite(safePath: string, line: string): void {
486
- const p = appendTranscriptAsync(safePath, line).finally(() => {
487
- pendingTranscriptWrites.delete(p);
488
- });
489
- pendingTranscriptWrites.add(p);
500
+ const content = `${redactJsonLine(line)}\n`;
501
+ let batch = transcriptBatches.get(safePath);
502
+ if (!batch) {
503
+ batch = [];
504
+ transcriptBatches.set(safePath, batch);
505
+ }
506
+ batch.push(content);
507
+ scheduleTranscriptFlush();
490
508
  }
491
509
 
492
510
  /**
493
- * Drain all currently-pending transcript writes. Awaits every promise in the
494
- * set; if new writes are scheduled during the drain (i.e. the `.finally`
495
- * callback has not yet removed them), the loop re-checks `size > 0` and
496
- * awaits them too. Idempotent and safe to call concurrently / reentrantly —
497
- * each pending promise is tracked exactly once.
511
+ * Drain the transcript batch buffer and await any remaining in-flight writes.
512
+ * Called by lifecycle boundaries (ChildPiLineObserver.flush, runChildPi settle)
513
+ * so that transcript files are complete before callers read them.
514
+ *
515
+ * Uses a while loop to re-check the buffer after each flush — new lines may
516
+ * arrive during the async I/O window (trackTranscriptWrite → scheduleTranscriptFlush).
498
517
  *
499
518
  * Exported so external callers (e.g. integration tests that construct a
500
519
  * ChildPiLineObserver directly) can drain explicitly if they need to read
501
520
  * the transcript file outside of the observer's lifecycle.
502
521
  */
503
522
  export async function flushPendingTranscriptWrites(): Promise<void> {
504
- while (pendingTranscriptWrites.size > 0) {
505
- const drained = [...pendingTranscriptWrites];
506
- await Promise.allSettled(drained);
523
+ // Force-flush the buffer synchronously (clear timer, write immediately).
524
+ if (transcriptFlushTimer) {
525
+ clearTimeout(transcriptFlushTimer);
526
+ transcriptFlushTimer = undefined;
527
+ }
528
+ // Re-check loop: new lines may be appended to transcriptBatches during
529
+ // the async flushTranscriptBatches I/O. Loop until the buffer is empty.
530
+ while (transcriptBatches.size > 0) {
531
+ await flushTranscriptBatches();
507
532
  }
508
533
  }
509
534
 
535
+ /**
536
+ * Reset the module-scoped transcript batch state. Exported for test isolation
537
+ * only — production code should never call this.
538
+ */
539
+ export function resetTranscriptBatchState(): void {
540
+ if (transcriptFlushTimer) {
541
+ clearTimeout(transcriptFlushTimer);
542
+ transcriptFlushTimer = undefined;
543
+ }
544
+ transcriptBatches.clear();
545
+ }
546
+
510
547
  export function compactString(value: string, maxChars = MAX_COMPACT_CONTENT_CHARS, opts: { preserveImportant?: boolean } = {}): string {
511
548
  if (value.length <= maxChars) return value;
512
549
  // L4: head + tail instead of head-only. Keeps closing markdown structure
@@ -644,24 +681,45 @@ function displayTextFromCompactEvent(event: unknown): string | undefined {
644
681
  return text || (typeof record.text === "string" ? record.text : undefined);
645
682
  }
646
683
 
647
- function compactChildPiLine(line: string): {
684
+ function nonJsonLineResult(line: string): {
648
685
  persistedLine: string;
649
686
  event?: unknown;
650
687
  displayLine?: string;
651
688
  json: boolean;
652
689
  } {
653
- try {
654
- const parsed = JSON.parse(line);
655
- const compact = compactChildPiEvent(parsed);
656
- return {
657
- json: true,
658
- event: compact,
659
- persistedLine: compact ? JSON.stringify(compact) : "",
660
- displayLine: displayTextFromCompactEvent(compact),
661
- };
662
- } catch {
663
- return { json: false, persistedLine: line, displayLine: line };
690
+ return { json: false, persistedLine: line, displayLine: line };
691
+ }
692
+
693
+ function compactChildPiLine(
694
+ line: string,
695
+ preParsed?: unknown,
696
+ ): {
697
+ persistedLine: string;
698
+ event?: unknown;
699
+ displayLine?: string;
700
+ json: boolean;
701
+ } {
702
+ // OPT-PHASE2: when the caller (emitLine) already parsed the line, pass the
703
+ // result via preParsed to avoid a redundant JSON.parse. Standalone callers
704
+ // without a preParsed fall back to their own parse+catch (DRY: single
705
+ // compact+return path for both branches).
706
+ let parsed: unknown;
707
+ if (preParsed !== undefined) {
708
+ parsed = preParsed;
709
+ } else {
710
+ try {
711
+ parsed = JSON.parse(line);
712
+ } catch {
713
+ return nonJsonLineResult(line);
714
+ }
664
715
  }
716
+ const compact = compactChildPiEvent(parsed);
717
+ return {
718
+ json: true,
719
+ event: compact,
720
+ persistedLine: compact ? JSON.stringify(compact) : "",
721
+ displayLine: displayTextFromCompactEvent(compact),
722
+ };
665
723
  }
666
724
 
667
725
  export class ChildPiLineObserver {
@@ -704,7 +762,7 @@ export class ChildPiLineObserver {
704
762
  }
705
763
  // OPT-06 follow-up: appendTranscript is fire-and-forget async, so the file
706
764
  // may not exist on disk by the time this returns. Drain the module-scoped
707
- // pending-write set before resolving so callers that immediately read the
765
+ // transcript batch buffer before resolving so callers that immediately read the
708
766
  // transcript file (e.g. integration tests at phase4-runtime:37/:68/:103
709
767
  // after `await observer.flush()`) see the full content.
710
768
  return flushPendingTranscriptWrites();
@@ -736,12 +794,20 @@ export class ChildPiLineObserver {
736
794
 
737
795
  private emitLine(line: string): void {
738
796
  if (!line.trim()) return;
739
- // Parse the RAW line once so we can BOTH compact it (telemetry transcript,
740
- // 16K-capped memory bound) AND capture the uncapped assistant text for the
741
- // authoritative result. Non-JSON lines contribute no assistant text.
797
+ // OPT-PHASE2: parse the line EXACTLY ONCE. The parsed value feeds both
798
+ // (a) raw assistant-text extraction for the authoritative result and
799
+ // (b) compaction for the telemetry transcript — previously each path
800
+ // called JSON.parse independently (2 parses/line). When the line is not
801
+ // valid JSON, parsed stays undefined and compactChildPiLine runs its own
802
+ // catch path to produce the json:false fallback.
803
+ let parsed: unknown;
742
804
  try {
743
- const rawParsed = JSON.parse(line);
744
- const rawTexts = extractText(rawParsed);
805
+ parsed = JSON.parse(line);
806
+ } catch {
807
+ parsed = undefined;
808
+ }
809
+ if (parsed !== undefined) {
810
+ const rawTexts = extractText(parsed);
745
811
  if (rawTexts.length > 0) {
746
812
  // F9: trim from the front if the push would exceed the cap. Slice's
747
813
  // second arg excludes the index, so this drops the oldest entries
@@ -758,10 +824,10 @@ export class ChildPiLineObserver {
758
824
  if (findingsOverflow > 0) this.intermediateFindings.splice(0, findingsOverflow);
759
825
  }
760
826
  }
761
- } catch {
762
- // Not valid JSON — compactChildPiLine handles the raw-text fallback below.
763
827
  }
764
- const compact = compactChildPiLine(line);
828
+ // OPT-PHASE2: construct the non-JSON fallback directly when parsing failed,
829
+ // so a broken line triggers exactly ONE (failed) parse instead of two.
830
+ const compact = parsed !== undefined ? compactChildPiLine(line, parsed) : nonJsonLineResult(line);
765
831
  if (compact.event !== undefined) {
766
832
  try {
767
833
  this.input.onJsonEvent?.(compact.event);
@@ -1400,7 +1466,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1400
1466
  settled = true;
1401
1467
  clearChildPiTimeouts();
1402
1468
  // OPT-06 follow-up: lineObserver.flush() is now async (returns
1403
- // Promise<void>) and drains the module-scoped pending-write set
1469
+ // Promise<void>) and drains the module-scoped transcript batch buffer
1404
1470
  // before resolving. We must await it before calling `resolve()`
1405
1471
  // below so callers that read the transcript file post-`runChildPi`
1406
1472
  // see all written lines. Caller invocations of `settle` from
@@ -90,6 +90,11 @@ export class HeartbeatWatcher {
90
90
  if (!fs.existsSync(run.stateRoot)) continue;
91
91
  const loaded = loadRunManifestById(this.opts.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
92
92
  if (!loaded) continue;
93
+ // Defensive guard: cache may return stale "running" while disk says terminal.
94
+ // Re-read manifest from disk to verify before entering the heavy loop.
95
+ // This closes the false-positive alert loop when a run completes but the
96
+ // manifestCache.list(50) still serves a pre-completion snapshot.
97
+ if (loaded.manifest.status !== "running") continue;
93
98
  for (const task of loaded.tasks) {
94
99
  if (task.status !== "running") continue;
95
100
  const key = `${run.runId}:${task.id}`;
@@ -83,14 +83,6 @@ export function listLiveAgentsByWorkspace(workspaceId: string): LiveAgentHandle[
83
83
  return listLiveAgents().filter((a) => a.workspaceId === workspaceId);
84
84
  }
85
85
 
86
- /**
87
- * List only active agents (running/queued/waiting) for a specific workspace.
88
- */
89
- /** @internal */
90
- function listActiveLiveAgentsByWorkspace(workspaceId: string): LiveAgentHandle[] {
91
- return listActiveLiveAgents().filter((a) => a.workspaceId === workspaceId);
92
- }
93
-
94
86
  export function registerLiveAgent(
95
87
  input: Omit<LiveAgentHandle, "createdAt" | "updatedAt" | "pendingSteers" | "pendingFollowUps" | "pendingMessages" | "activity"> & {
96
88
  workspaceId: string;
@@ -189,15 +181,6 @@ function safeDisposeLiveSession(handle: LiveAgentHandle): void {
189
181
  }
190
182
  }
191
183
 
192
- /** @internal */
193
- function removeLiveAgentHandle(agentId: string): LiveAgentHandle | undefined {
194
- const handle = liveAgents.get(agentId);
195
- if (!handle) return undefined;
196
- liveAgents.delete(agentId);
197
- safeDisposeLiveSession(handle);
198
- return handle;
199
- }
200
-
201
184
  export function disposeLiveAgentSession(agentIdOrTaskId: string): void {
202
185
  const handle = getLiveAgent(agentIdOrTaskId);
203
186
  if (!handle) return;
@@ -492,16 +475,6 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
492
475
  return recipients;
493
476
  }
494
477
 
495
- /** Phase 7: Get pending IRC messages for an agent (and clear them). */
496
- /** @internal */
497
- function drainIrcMessages(agentIdOrTaskId: string): IrcMessage[] {
498
- const handle = getLiveAgent(agentIdOrTaskId);
499
- if (!handle) return [];
500
- const messages = [...handle.pendingMessages];
501
- handle.pendingMessages.length = 0;
502
- return messages;
503
- }
504
-
505
478
  /* ── IRC reply support (side-channel Q&A) ─────────────────────────── */
506
479
 
507
480
  /** Default timeout for awaiting a side-channel reply (60s). */