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.
- package/CHANGELOG.md +34 -0
- package/dist/build-meta.json +10 -10
- package/dist/index.mjs +85 -49
- package/dist/index.mjs.map +3 -3
- package/docs/optimization-plan-2026-07.md +366 -0
- package/docs/phase4-triage.md +58 -0
- package/package.json +1 -1
- package/src/runtime/adaptive-plan.ts +7 -7
- package/src/runtime/child-pi.ts +135 -69
- package/src/runtime/heartbeat-watcher.ts +5 -0
- package/src/runtime/live-agent-manager.ts +0 -27
package/src/runtime/child-pi.ts
CHANGED
|
@@ -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:
|
|
441
|
-
// lifecycle boundaries (ChildPiLineObserver.flush,
|
|
442
|
-
//
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* each
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
684
|
+
function nonJsonLineResult(line: string): {
|
|
648
685
|
persistedLine: string;
|
|
649
686
|
event?: unknown;
|
|
650
687
|
displayLine?: string;
|
|
651
688
|
json: boolean;
|
|
652
689
|
} {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
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
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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
|
|
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). */
|