pi-crew 0.9.33 → 0.9.35

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.
@@ -435,11 +435,75 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
435
435
  // We skip mkdirSync here for security — adding it would create parent
436
436
  // directories during validation, contradicting the original design where
437
437
  // resolveRealContainedPath validates a pre-existing path.
438
- const fd = fs.openSync(safePath, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
438
+ // Async optimization: use fire-and-forget async write to avoid blocking the event loop.
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
443
+ // transcript file post-flush (e.g. integration tests at phase3-runtime:50 and
444
+ // phase4-runtime:37/:68/:103) would see ENOENT or empty content because the
445
+ // async file handle had not yet been opened / flushed.
446
+ trackTranscriptWrite(safePath, line);
447
+ }
448
+
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`;
439
452
  try {
440
- fs.writeSync(fd, `${redactJsonLine(line)}\n`, undefined, "utf-8");
441
- } finally {
442
- fs.closeSync(fd);
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
+ }
468
+ }
469
+
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();
484
+
485
+ function trackTranscriptWrite(safePath: string, line: string): void {
486
+ const p = appendTranscriptAsync(safePath, line).finally(() => {
487
+ pendingTranscriptWrites.delete(p);
488
+ });
489
+ pendingTranscriptWrites.add(p);
490
+ }
491
+
492
+ /**
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.
498
+ *
499
+ * Exported so external callers (e.g. integration tests that construct a
500
+ * ChildPiLineObserver directly) can drain explicitly if they need to read
501
+ * the transcript file outside of the observer's lifecycle.
502
+ */
503
+ export async function flushPendingTranscriptWrites(): Promise<void> {
504
+ while (pendingTranscriptWrites.size > 0) {
505
+ const drained = [...pendingTranscriptWrites];
506
+ await Promise.allSettled(drained);
443
507
  }
444
508
  }
445
509
 
@@ -632,11 +696,18 @@ export class ChildPiLineObserver {
632
696
  for (const line of lines) this.emitLine(line);
633
697
  }
634
698
 
635
- flush(): void {
636
- if (!this.buffer) return;
637
- const line = this.buffer;
638
- this.buffer = "";
639
- this.emitLine(line);
699
+ flush(): Promise<void> {
700
+ if (this.buffer) {
701
+ const line = this.buffer;
702
+ this.buffer = "";
703
+ this.emitLine(line);
704
+ }
705
+ // OPT-06 follow-up: appendTranscript is fire-and-forget async, so the file
706
+ // 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
708
+ // transcript file (e.g. integration tests at phase4-runtime:37/:68/:103
709
+ // after `await observer.flush()`) see the full content.
710
+ return flushPendingTranscriptWrites();
640
711
  }
641
712
 
642
713
  /** Last non-empty RAW assistant text (mirrors {@link parsePiJsonOutput}'s
@@ -715,11 +786,15 @@ export class ChildPiLineObserver {
715
786
  }
716
787
  }
717
788
 
718
- /** Mock-only path — real code path reuses a single observer. */
719
- function observeStdoutChunk(input: ChildPiRunInput, text: string): void {
789
+ /** Mock-only path — real code path reuses a single observer.
790
+ * OPT-06 follow-up: returns a Promise so callers can await the transcript
791
+ * drain before resolving runChildPi. Without this, mock-mode callers that
792
+ * read the transcript file post-run see ENOENT (the async file handle had
793
+ * not yet been opened). */
794
+ async function observeStdoutChunk(input: ChildPiRunInput, text: string): Promise<void> {
720
795
  const observer = new ChildPiLineObserver(input);
721
796
  observer.observe(text);
722
- observer.flush();
797
+ await observer.flush();
723
798
  }
724
799
 
725
800
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -779,7 +854,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
779
854
  logInternalError("child-pi.mock", new Error(`Mock mode active: ${mock}`), "NOT running real agents");
780
855
  if (mock === "success") {
781
856
  const stdout = `[MOCK] Success for ${input.agent.name}\n`;
782
- observeStdoutChunk(input, stdout);
857
+ await observeStdoutChunk(input, stdout);
783
858
  return { exitCode: 0, stdout, stderr: "" };
784
859
  }
785
860
  if (mock === "json-success" || mock === "adaptive-plan") {
@@ -834,7 +909,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
834
909
  })}\nADAPTIVE_PLAN_JSON_END`
835
910
  : `[MOCK] JSON success for ${input.agent.name}`;
836
911
  const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
837
- observeStdoutChunk(input, stdout);
912
+ await observeStdoutChunk(input, stdout);
838
913
  return { exitCode: 0, stdout, stderr: "" };
839
914
  }
840
915
  if (mock === "retryable-failure")
@@ -882,14 +957,14 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
882
957
  },
883
958
  };
884
959
  const stdout = `${JSON.stringify(failureEvent)}\n`;
885
- observeStdoutChunk(input, stdout);
960
+ await observeStdoutChunk(input, stdout);
886
961
  return { exitCode: 0, stdout, stderr: "" };
887
962
  }
888
963
  // Subsequent invocations: delegate to json-success shape so the
889
964
  // fallback chain's second attempt succeeds and the run completes.
890
965
  const text = `[MOCK] JSON success for ${input.agent.name}`;
891
966
  const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
892
- observeStdoutChunk(input, stdout);
967
+ await observeStdoutChunk(input, stdout);
893
968
  return { exitCode: 0, stdout, stderr: "" };
894
969
  }
895
970
  return { exitCode: 1, stdout: "", stderr: `[MOCK] failure: ${mock}` };
@@ -1107,7 +1182,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1107
1182
  process.kill(child.pid!, 0);
1108
1183
  // Child still alive — force settle with timeout error.
1109
1184
  const timeoutErr = `Child Pi produced no new output for ${responseTimeoutMs}ms; killed but did not exit within ${SAFETY_SETTLE_MS}ms (possible zombie).`;
1110
- settle({
1185
+ void settle({
1111
1186
  exitCode: null,
1112
1187
  stdout,
1113
1188
  stderr,
@@ -1320,46 +1395,106 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1320
1395
  clearPostExitGuard();
1321
1396
  };
1322
1397
 
1323
- const settle = (result: ChildPiRunResult): void => {
1324
- if (settled) return;
1398
+ const settle = (result: ChildPiRunResult): Promise<void> => {
1399
+ if (settled) return Promise.resolve();
1325
1400
  settled = true;
1326
1401
  clearChildPiTimeouts();
1327
- lineObserver.flush();
1328
- input.signal?.removeEventListener("abort", abort);
1329
- input.signal?.removeEventListener("abort", onParentAbort);
1330
- try {
1331
- cleanupTempDir(built.tempDir);
1332
- } catch (error) {
1333
- cleanupErrors.push(error instanceof Error ? error.message : String(error));
1334
- }
1335
- // Catch all errors from settle to prevent unhandled rejection from propagating
1336
- try {
1337
- resolve({
1338
- ...result,
1339
- rawFinalText: lineObserver.getRawFinalText(),
1340
- intermediateFindings: lineObserver.getIntermediateFindings(),
1341
- exitStatus: result.exitStatus ?? {
1342
- exitCode: result.exitCode,
1343
- cancelled: abortRequested,
1344
- timedOut: responseTimeoutHit,
1345
- killed: hardKilled,
1346
- // Phase-0 diagnostic (HB-003a): surface the final-drain race state.
1347
- // finalDrainArmed lets Phase 1 decide whether a signal-death (exitCode=null)
1348
- // should be treated as a forced final drain. READ-ONLY for now.
1349
- ...(finalDrainArmed || forcedFinalDrain
1350
- ? {
1351
- finalDrainArmed,
1352
- forcedFinalDrain,
1353
- finalDrainFiredMonotonicMs,
1354
- }
1355
- : {}),
1356
- cleanupErrors,
1357
- finalDrainMs,
1358
- },
1402
+ // OPT-06 follow-up: lineObserver.flush() is now async (returns
1403
+ // Promise<void>) and drains the module-scoped pending-write set
1404
+ // before resolving. We must await it before calling `resolve()`
1405
+ // below so callers that read the transcript file post-`runChildPi`
1406
+ // see all written lines. Caller invocations of `settle` from
1407
+ // sync event handlers (`child.on('close'|'exit'|'error')`,
1408
+ // safety timer) use `void settle(...)` — errors are caught and
1409
+ // logged inside, and `resolve()` only fires after the drain
1410
+ // completes, so runChildPi's outer Promise resolves with a
1411
+ // durable transcript on disk.
1412
+ return lineObserver
1413
+ .flush()
1414
+ .then(() => {
1415
+ input.signal?.removeEventListener("abort", abort);
1416
+ input.signal?.removeEventListener("abort", onParentAbort);
1417
+ try {
1418
+ cleanupTempDir(built.tempDir);
1419
+ } catch (error) {
1420
+ cleanupErrors.push(error instanceof Error ? error.message : String(error));
1421
+ }
1422
+ // Catch all errors from settle to prevent unhandled rejection from propagating
1423
+ try {
1424
+ resolve({
1425
+ ...result,
1426
+ rawFinalText: lineObserver.getRawFinalText(),
1427
+ intermediateFindings: lineObserver.getIntermediateFindings(),
1428
+ exitStatus: result.exitStatus ?? {
1429
+ exitCode: result.exitCode,
1430
+ cancelled: abortRequested,
1431
+ timedOut: responseTimeoutHit,
1432
+ killed: hardKilled,
1433
+ // Phase-0 diagnostic (HB-003a): surface the final-drain race state.
1434
+ // finalDrainArmed lets Phase 1 decide whether a signal-death (exitCode=null)
1435
+ // should be treated as a forced final drain. READ-ONLY for now.
1436
+ ...(finalDrainArmed || forcedFinalDrain
1437
+ ? {
1438
+ finalDrainArmed,
1439
+ forcedFinalDrain,
1440
+ finalDrainFiredMonotonicMs,
1441
+ }
1442
+ : {}),
1443
+ cleanupErrors,
1444
+ finalDrainMs,
1445
+ },
1446
+ });
1447
+ } catch (resolveError) {
1448
+ logInternalError(
1449
+ "child-pi.settle-resolve",
1450
+ resolveError,
1451
+ `result=${JSON.stringify({ exitCode: result.exitCode })}`,
1452
+ );
1453
+ }
1454
+ })
1455
+ .catch((flushError) => {
1456
+ // Drain failed — log and still resolve so runChildPi doesn't hang.
1457
+ logInternalError(
1458
+ "child-pi.settle-flush-failed",
1459
+ flushError,
1460
+ `result=${JSON.stringify({ exitCode: result.exitCode })}`,
1461
+ );
1462
+ input.signal?.removeEventListener("abort", abort);
1463
+ input.signal?.removeEventListener("abort", onParentAbort);
1464
+ try {
1465
+ cleanupTempDir(built.tempDir);
1466
+ } catch (error) {
1467
+ cleanupErrors.push(error instanceof Error ? error.message : String(error));
1468
+ }
1469
+ try {
1470
+ resolve({
1471
+ ...result,
1472
+ rawFinalText: lineObserver.getRawFinalText(),
1473
+ intermediateFindings: lineObserver.getIntermediateFindings(),
1474
+ exitStatus: result.exitStatus ?? {
1475
+ exitCode: result.exitCode,
1476
+ cancelled: abortRequested,
1477
+ timedOut: responseTimeoutHit,
1478
+ killed: hardKilled,
1479
+ ...(finalDrainArmed || forcedFinalDrain
1480
+ ? {
1481
+ finalDrainArmed,
1482
+ forcedFinalDrain,
1483
+ finalDrainFiredMonotonicMs,
1484
+ }
1485
+ : {}),
1486
+ cleanupErrors,
1487
+ finalDrainMs,
1488
+ },
1489
+ });
1490
+ } catch (resolveError) {
1491
+ logInternalError(
1492
+ "child-pi.settle-resolve",
1493
+ resolveError,
1494
+ `result=${JSON.stringify({ exitCode: result.exitCode })}`,
1495
+ );
1496
+ }
1359
1497
  });
1360
- } catch (resolveError) {
1361
- logInternalError("child-pi.settle-resolve", resolveError, `result=${JSON.stringify({ exitCode: result.exitCode })}`);
1362
- }
1363
1498
  };
1364
1499
 
1365
1500
  const abort = (): void => {
@@ -1446,7 +1581,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1446
1581
  } catch (err) {
1447
1582
  logInternalError("child-pi.on-lifecycle-event", err, `event=error, pid=${child.pid}`);
1448
1583
  }
1449
- settle({
1584
+ void settle({
1450
1585
  exitCode: null,
1451
1586
  stdout,
1452
1587
  stderr,
@@ -1585,7 +1720,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1585
1720
  spawnError: undefined,
1586
1721
  stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined,
1587
1722
  });
1588
- settle({
1723
+ void settle({
1589
1724
  exitCode: finalExitCode,
1590
1725
  stdout,
1591
1726
  stderr,
@@ -5,7 +5,7 @@ import { resolveToolPolicy } from "../agents/agent-config.ts";
5
5
  import type { CrewRuntimeConfig } from "../config/config.ts";
6
6
  import { loadConfig } from "../config/config.ts";
7
7
  import { DEFAULT_LIVE_SESSION } from "../config/defaults.ts";
8
- import { appendEvent } from "../state/event-log.ts";
8
+ import { appendEvent, appendEventFireAndForget } from "../state/event-log.ts";
9
9
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
10
10
  import { logInternalError } from "../utils/internal-error.ts";
11
11
  import { redactSecrets } from "../utils/redaction.ts";
@@ -151,8 +151,20 @@ type LiveSessionLike = {
151
151
 
152
152
  function appendTranscript(filePath: string | undefined, event: unknown): void {
153
153
  if (!filePath) return;
154
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
155
- fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets(event))}\n`, "utf-8");
154
+ // Fire-and-forget async write to avoid blocking the event loop.
155
+ // Transcript writes are best-effort telemetry — callers do not need to await.
156
+ void appendTranscriptAsync(filePath, event);
157
+ }
158
+
159
+ /** Async version of appendTranscript — fire-and-forget for non-blocking writes. */
160
+ async function appendTranscriptAsync(filePath: string, event: unknown): Promise<void> {
161
+ try {
162
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
163
+ const content = `${JSON.stringify(redactSecrets(event))}\n`;
164
+ await fs.promises.appendFile(filePath, content, "utf-8");
165
+ } catch (error) {
166
+ logInternalError("live-session.transcript-write-failed", error as Error, `path=${filePath}`);
167
+ }
156
168
  }
157
169
 
158
170
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -588,13 +600,20 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
588
600
  jsonEvents: 0,
589
601
  error: "createAgentSession export is unavailable.",
590
602
  };
603
+ // OPT-03: compute yield-enabled flag early to conditionally allocate
604
+ // collectedJsonEvents — avoids ~1KB array allocation per task when yield
605
+ // detection is disabled. Pattern mirrors task-runner.ts:165. Uses the same
606
+ // `enabled !== false` semantics as the late yield-detection block below so
607
+ // the array is always allocated when (and only when) it will be read.
608
+ const yieldConfig = input.runtimeConfig?.yield ?? { enabled: DEFAULT_YIELD_CONFIG.enabled };
609
+ const yieldEnabled = yieldConfig.enabled !== false;
591
610
  let session: LiveSessionLike | undefined;
592
611
  let unsubscribe: (() => void) | undefined;
593
612
  let unsubscribeControlRealtime: (() => void) | undefined;
594
613
  let controlTimer: ReturnType<typeof setInterval> | undefined;
595
614
  let stdout = "";
596
615
  let jsonEvents = 0;
597
- const collectedJsonEvents: Record<string, unknown>[] = [];
616
+ const collectedJsonEvents: Record<string, unknown>[] | undefined = yieldEnabled ? [] : undefined;
598
617
  const maxCollectedJsonEvents = 1000;
599
618
  let yieldResult: YieldResult | undefined;
600
619
 
@@ -676,7 +695,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
676
695
  customTools,
677
696
  });
678
697
  session = created.session;
679
- appendEvent(input.manifest.eventsPath, {
698
+ // P7 (perf): fire-and-forget — return value not needed, blocks the
699
+ // event loop less than the sync appendEvent under file-lock contention.
700
+ appendEventFireAndForget(input.manifest.eventsPath, {
680
701
  type: "live-session.session_created",
681
702
  runId: input.manifest.runId,
682
703
  taskId: input.task.id,
@@ -696,7 +717,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
696
717
  ]);
697
718
  } catch (bindError) {
698
719
  const msg = bindError instanceof Error ? bindError.message : String(bindError);
699
- appendEvent(input.manifest.eventsPath, {
720
+ // P7: fire-and-forget — return value not needed.
721
+ appendEventFireAndForget(input.manifest.eventsPath, {
700
722
  type: "live-session.bind_extensions_error",
701
723
  runId: input.manifest.runId,
702
724
  taskId: input.task.id,
@@ -851,7 +873,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
851
873
  trackLiveAgentToolEnd(agentId, toolName);
852
874
  }
853
875
  // Phase 1: collect events for yield detection
854
- if (event && typeof event === "object" && !Array.isArray(event)) {
876
+ if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event)) {
855
877
  collectedJsonEvents.push(event as Record<string, unknown>);
856
878
  if (collectedJsonEvents.length > maxCollectedJsonEvents)
857
879
  collectedJsonEvents.splice(0, collectedJsonEvents.length - maxCollectedJsonEvents);
@@ -879,7 +901,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
879
901
 
880
902
  // Diagnostic: log prompt size and timing
881
903
  const promptStart = Date.now();
882
- appendEvent(input.manifest.eventsPath, {
904
+ // P7: fire-and-forget — return value not needed.
905
+ appendEventFireAndForget(input.manifest.eventsPath, {
883
906
  type: "live-session.prompt_start",
884
907
  runId: input.manifest.runId,
885
908
  taskId: input.task.id,
@@ -896,7 +919,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
896
919
  await promptWithTimeout(session, effectivePrompt, sessionTimeoutMs, "Live-session");
897
920
  } catch (promptError) {
898
921
  const msg = promptError instanceof Error ? promptError.message : String(promptError);
899
- appendEvent(input.manifest.eventsPath, {
922
+ // P7: fire-and-forget — return value not needed.
923
+ appendEventFireAndForget(input.manifest.eventsPath, {
900
924
  type: "live-session.prompt_error",
901
925
  runId: input.manifest.runId,
902
926
  taskId: input.task.id,
@@ -916,7 +940,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
916
940
  }
917
941
  throw promptError;
918
942
  }
919
- appendEvent(input.manifest.eventsPath, {
943
+ // P7: fire-and-forget — return value not needed.
944
+ appendEventFireAndForget(input.manifest.eventsPath, {
920
945
  type: "live-session.prompt_done",
921
946
  runId: input.manifest.runId,
922
947
  taskId: input.task.id,
@@ -930,16 +955,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
930
955
  // --- Phase 1: Yield enforcement loop ---
931
956
  // After the initial prompt completes, check if the worker called submit_result.
932
957
  // Priority: 1) custom tool callback (G1), 2) JSON event detection (legacy).
933
- const yieldConfig = input.runtimeConfig?.yield ?? {
934
- enabled: DEFAULT_YIELD_CONFIG.enabled,
935
- };
936
- const yieldEnabled = yieldConfig.enabled !== false;
937
958
  if (yieldEnabled && session) {
938
959
  // Check custom tool callback first (G1)
939
960
  if (customToolYieldResolved && customToolYieldResult) {
940
961
  yieldResult = customToolYieldResult;
941
- } else {
942
- // Legacy: detect from JSON events
962
+ } else if (collectedJsonEvents) {
963
+ // Legacy: detect from JSON events (only when collection is enabled)
943
964
  const alreadyYielded = hasYieldInOutput(collectedJsonEvents);
944
965
  if (alreadyYielded) {
945
966
  const yieldEvent = collectedJsonEvents.find((e) => isYieldEvent(e));
@@ -970,7 +991,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
970
991
  // Check again after schema reminder
971
992
  if (customToolYieldResolved && customToolYieldResult) {
972
993
  yieldResult = customToolYieldResult;
973
- } else {
994
+ } else if (collectedJsonEvents) {
974
995
  const newEvents = collectedJsonEvents.slice(-10);
975
996
  if (hasYieldInOutput(newEvents)) {
976
997
  const yieldEvent = newEvents.find((e) => isYieldEvent(e));
@@ -1021,7 +1042,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1021
1042
  break;
1022
1043
  }
1023
1044
  // Legacy: check JSON events
1024
- if (hasYieldInOutput(collectedJsonEvents.slice(-10))) {
1045
+ if (collectedJsonEvents && hasYieldInOutput(collectedJsonEvents.slice(-10))) {
1025
1046
  const yieldEvent = collectedJsonEvents.slice(-10).find((e) => isYieldEvent(e));
1026
1047
  if (yieldEvent) yieldResult = extractYieldResult(yieldEvent);
1027
1048
  break;
@@ -43,6 +43,11 @@ export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
43
43
  }
44
44
 
45
45
  export async function mapConcurrent<T, R>(items: T[], limit: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
46
+ // P12 (perf): single-item fast path. Skip the Promise.all + worker-pool
47
+ // setup when there's exactly one item — just call fn directly. Same return
48
+ // shape, no concurrency overhead, no micro-task allocation.
49
+ if (items.length === 1) return [await fn(items[0], 0)];
50
+ if (items.length === 0) return [];
46
51
  const safeLimit = Math.max(1, Math.floor(limit) || 1);
47
52
  const results: R[] = new Array(items.length);
48
53
  let next = 0;
@@ -20,8 +20,35 @@ const MAX_SKILL_CHARS = 1500;
20
20
  const MAX_TOTAL_CHARS = 6000;
21
21
  const MAX_SKILL_NAME_CHARS = 80;
22
22
  const MAX_SELECTED_SKILLS = 32;
23
+
24
+ // ── Cache sizing rationale (OPT-05 analysis, 2026-07-13) ───────────────────
25
+ // The package ships 30 SKILL.md files; 11 are referenced by DEFAULT_ROLE_SKILLS.
26
+ // Cache key = `${cwd}:${skillName}`, so a single-cwd run needs ≤30 entries.
27
+ // Multi-cwd scenarios (worktree isolation) multiply this, but rarely exceed 90.
28
+ // 128 provides 4× headroom over the skill count, ~1.4× over a 3-cwd scenario.
29
+ // Memory per entry ≈ 5KB (raw + compacted body), so 128 entries ≈ 640KB — negligible.
30
+ // Conclusion: 128 is well-tuned. Evictions are near-zero in practice.
23
31
  const SKILL_CACHE_MAX_ENTRIES = 128;
24
32
 
33
+ export interface SkillCacheStats {
34
+ hits: number;
35
+ misses: number;
36
+ evictions: number;
37
+ currentSize: number;
38
+ maxEntries: number;
39
+ /** Computed hit-rate: hits / (hits + misses), 0 when no lookups yet. */
40
+ hitRate: number;
41
+ }
42
+
43
+ const skillCacheStats: SkillCacheStats = {
44
+ hits: 0,
45
+ misses: 0,
46
+ evictions: 0,
47
+ currentSize: 0,
48
+ maxEntries: SKILL_CACHE_MAX_ENTRIES,
49
+ hitRate: 0,
50
+ };
51
+
25
52
  const DEFAULT_ROLE_SKILLS: Record<string, string[]> = {
26
53
  explorer: ["read-only-explorer", "context-artifact-hygiene"],
27
54
  analyst: ["read-only-explorer", "requirements-to-task-packet"],
@@ -146,16 +173,43 @@ const skillReadCache = new Map<string, CachedSkillMarkdown>();
146
173
  function rememberSkill(key: string, value: CachedSkillMarkdown): CachedSkillMarkdown {
147
174
  if (skillReadCache.has(key)) skillReadCache.delete(key);
148
175
  skillReadCache.set(key, value);
149
- while (skillReadCache.size > SKILL_CACHE_MAX_ENTRIES) {
176
+ while (skillReadCache.size > skillCacheStats.maxEntries) {
150
177
  const oldest = skillReadCache.keys().next().value;
151
178
  if (!oldest) break;
152
179
  skillReadCache.delete(oldest);
180
+ skillCacheStats.evictions++;
153
181
  }
182
+ skillCacheStats.currentSize = skillReadCache.size;
154
183
  return value;
155
184
  }
156
185
 
157
186
  export function clearSkillInstructionCache(): void {
158
187
  skillReadCache.clear();
188
+ skillCacheStats.currentSize = 0;
189
+ }
190
+
191
+ export function getSkillCacheStats(): SkillCacheStats {
192
+ const total = skillCacheStats.hits + skillCacheStats.misses;
193
+ return {
194
+ ...skillCacheStats,
195
+ currentSize: skillReadCache.size,
196
+ hitRate: total > 0 ? skillCacheStats.hits / total : 0,
197
+ };
198
+ }
199
+
200
+ export function resetSkillCacheStats(): void {
201
+ skillCacheStats.hits = 0;
202
+ skillCacheStats.misses = 0;
203
+ skillCacheStats.evictions = 0;
204
+ skillCacheStats.currentSize = skillReadCache.size;
205
+ }
206
+
207
+ /**
208
+ * Test-only: temporarily override the cache capacity to exercise eviction logic.
209
+ * Always restore to `SKILL_CACHE_MAX_ENTRIES` after the test.
210
+ */
211
+ export function _setSkillCacheMaxEntriesForTesting(max: number): void {
212
+ skillCacheStats.maxEntries = max;
159
213
  }
160
214
 
161
215
  function cachedSkillFresh(value: CachedSkillMarkdown): boolean {
@@ -181,8 +235,13 @@ function readSkillMarkdown(
181
235
  if (!isValidSkillName(name)) return undefined;
182
236
  const cacheKey = `${path.resolve(cwd)}:${name}`;
183
237
  const cached = skillReadCache.get(cacheKey);
184
- if (cached && cachedSkillFresh(cached)) return cached;
238
+ if (cached && cachedSkillFresh(cached)) {
239
+ skillCacheStats.hits++;
240
+ return cached;
241
+ }
185
242
  if (cached) skillReadCache.delete(cacheKey);
243
+ skillCacheStats.misses++;
244
+ skillCacheStats.currentSize = skillReadCache.size;
186
245
  for (const entry of candidateSkillDirs(cwd)) {
187
246
  try {
188
247
  const relative = path.join(name, "SKILL.md");
@@ -74,6 +74,9 @@ function checkResultFile(manifest: TeamRunManifest, tasks: TeamTaskState[]): { f
74
74
  const onlyCancelledOrSkipped = tasks.every((t) => t.status === "cancelled" || t.status === "skipped");
75
75
  manifest.status = hasFailed ? "failed" : onlyCancelledOrSkipped ? "cancelled" : "completed";
76
76
  // Persist manifest status change immediately to make checkResultFile self-contained.
77
+ // NOTE (OPT-02): Remains sync — reconcileStaleRun → checkResultFile is called from
78
+ // withRunLockSync in crash-recovery.ts and from sync reconcileOrphanedTempWorkspaces.
79
+ // Converting to async would require cascading through all callers including sync UI hooks.
77
80
  saveRunManifest(manifest);
78
81
  // Sync agent records even when tasks are already terminal
79
82
  // (e.g., a previous reconcile fixed tasks but crashed before updating agents)
@@ -329,6 +332,8 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
329
332
  // checkResultFile sets manifest.status='completed' and saves it,
330
333
  // but we re-save to ensure the completed status is persisted
331
334
  // before returning (avoids TOCTOU where caller might re-read stale data)
335
+ // NOTE (OPT-02): Remains sync — reconcileStaleRun is called from withRunLockSync in
336
+ // crash-recovery.ts and from sync reconcileOrphanedTempWorkspaces; cannot await here.
332
337
  saveRunManifest(manifest);
333
338
  return {
334
339
  runId,