pi-crew 0.11.52 → 0.11.53

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.11.52",
3
+ "version": "0.11.53",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -1210,6 +1210,8 @@ interface BufferedAppend {
1210
1210
  }
1211
1211
  const bufferedQueues = new Map<string, BufferedAppend[]>();
1212
1212
  const bufferedTimers = new Map<string, ReturnType<typeof setTimeout>>();
1213
+ /** Per-path in-flight flush (started, not yet settled) — see flushOneEventLogBuffer. */
1214
+ const inflightBufferFlushes = new Map<string, Promise<void>>();
1213
1215
  const DEFAULT_BUFFER_MS = 20;
1214
1216
 
1215
1217
  export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent, bufferMs = DEFAULT_BUFFER_MS): Promise<TeamEvent> {
@@ -1253,61 +1255,95 @@ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
1253
1255
  const queue = bufferedQueues.get(eventsPath);
1254
1256
  bufferedQueues.delete(eventsPath);
1255
1257
  const timer = bufferedTimers.get(eventsPath);
1256
- // Timer is cleared in the finally block to ensure cleanup happens even on error
1257
- try {
1258
- if (!queue || queue.length === 0) return;
1258
+ // PERF-CORRECTNESS (2026-10-31, macos CI run 41147573288): track the
1259
+ // in-flight flush so flushEventLogBuffer()/drainEventLogWriters() can AWAIT
1260
+ // it. Previously those helpers were no-ops for a path whose flush had just
1261
+ // STARTED (the queue is deleted from the map above BEFORE the multi-turn
1262
+ // async lock+append completes), so `await flushEventLogBuffer()` could
1263
+ // return while the appendFile was still in flight — the interrupt-guard
1264
+ // detached test read the log before the event landed (macos runner fs).
1265
+ // Same window on the exit path: flushBufferedQueuesSync sees an empty map
1266
+ // and cannot recover an in-flight batch.
1267
+ const doFlush = (async (): Promise<void> => {
1268
+ // Timer is cleared in the finally block to ensure cleanup happens even on error
1269
+ try {
1270
+ if (!queue || queue.length === 0) return;
1259
1271
 
1260
- // FIX (Round 14, H3): When truncating the queue, explicitly reject the
1261
- // dropped entries' promises. Previously `queue.splice()` silently
1262
- // discarded the oldest items, and their associated Promises were never
1263
- // resolved or rejected — causing callers to await forever and leaking
1264
- // memory. We now reject with a clear error so callers can fall back.
1265
- if (queue.length > 1000) {
1266
- const dropped = queue.splice(0, queue.length - 500);
1267
- overflowCounter++;
1268
- // FIX: Include first/last dropped event type and sequence number in error
1269
- // message to make debugging easier when events are dropped.
1270
- const firstDroppedMeta = dropped[0]?.event.metadata;
1271
- const lastDroppedMeta = dropped[dropped.length - 1]?.event.metadata;
1272
- logInternalError(
1273
- "event-log.buffer-overflow",
1274
- new Error(
1275
- `Buffer overflow #${overflowCounter}: Dropped ${dropped.length} events: first seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}, last seq=${lastDroppedMeta?.seq} type=${dropped[dropped.length - 1]?.event.type}`,
1276
- ),
1277
- `${eventsPath}: ${queue.length + dropped.length} entries > 1000 cap`,
1278
- );
1279
- for (const item of dropped) {
1280
- item.reject(
1272
+ // FIX (Round 14, H3): When truncating the queue, explicitly reject the
1273
+ // dropped entries' promises. Previously `queue.splice()` silently
1274
+ // discarded the oldest items, and their associated Promises were never
1275
+ // resolved or rejected — causing callers to await forever and leaking
1276
+ // memory. We now reject with a clear error so callers can fall back.
1277
+ if (queue.length > 1000) {
1278
+ const dropped = queue.splice(0, queue.length - 500);
1279
+ overflowCounter++;
1280
+ // FIX: Include first/last dropped event type and sequence number in error
1281
+ // message to make debugging easier when events are dropped.
1282
+ const firstDroppedMeta = dropped[0]?.event.metadata;
1283
+ const lastDroppedMeta = dropped[dropped.length - 1]?.event.metadata;
1284
+ logInternalError(
1285
+ "event-log.buffer-overflow",
1281
1286
  new Error(
1282
- `Event log buffer overflow: ${queue.length + dropped.length} entries > 1000 cap; oldest ${dropped.length} dropped to keep memory bounded; first dropped seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}`,
1287
+ `Buffer overflow #${overflowCounter}: Dropped ${dropped.length} events: first seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}, last seq=${lastDroppedMeta?.seq} type=${dropped[dropped.length - 1]?.event.type}`,
1283
1288
  ),
1289
+ `${eventsPath}: ${queue.length + dropped.length} entries > 1000 cap`,
1284
1290
  );
1291
+ for (const item of dropped) {
1292
+ item.reject(
1293
+ new Error(
1294
+ `Event log buffer overflow: ${queue.length + dropped.length} entries > 1000 cap; oldest ${dropped.length} dropped to keep memory bounded; first dropped seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}`,
1295
+ ),
1296
+ );
1297
+ }
1285
1298
  }
1286
- }
1287
1299
 
1288
- // FIX (Issue 2): Use async lock instead of withEventLogLockSync to avoid
1289
- // blocking the event loop. The sync lock uses sleepSync which blocks for
1290
- // up to 5s and prevents AbortSignal handlers from firing.
1291
- // FIX (P0 follow-up): Batch the file write + fsync + persistSequence across
1292
- // the whole queue. Previously each event triggered its own fsyncSync,
1293
- // turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
1294
- // 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
1295
- await withEventLogLockAsync(eventsPath, async () => {
1296
- // R7-2 (DR-04): `.alock` HELD here (async family) — the batch's inline
1297
- // compaction must not re-acquire it.
1298
- await appendEventBatchInsideLock(eventsPath, queue, { holdsAppendLock: true });
1299
- });
1300
- } catch (error) {
1301
- // Lock acquire failed — fail every queued item so callers can fall back.
1302
- if (queue) for (const item of queue) item.reject(error);
1303
- } finally {
1304
- bufferedTimers.delete(eventsPath);
1305
- }
1300
+ // FIX (Issue 2): Use async lock instead of withEventLogLockSync to avoid
1301
+ // blocking the event loop. The sync lock uses sleepSync which blocks for
1302
+ // up to 5s and prevents AbortSignal handlers from firing.
1303
+ // FIX (P0 follow-up): Batch the file write + fsync + persistSequence across
1304
+ // the whole queue. Previously each event triggered its own fsyncSync,
1305
+ // turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
1306
+ // 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
1307
+ await withEventLogLockAsync(eventsPath, async () => {
1308
+ // R7-2 (DR-04): `.alock` HELD here (async family) — the batch's inline
1309
+ // compaction must not re-acquire it.
1310
+ await appendEventBatchInsideLock(eventsPath, queue, { holdsAppendLock: true });
1311
+ });
1312
+ } catch (error) {
1313
+ // Lock acquire failed — fail every queued item so callers can fall back.
1314
+ if (queue) for (const item of queue) item.reject(error);
1315
+ } finally {
1316
+ bufferedTimers.delete(eventsPath);
1317
+ }
1318
+ })();
1319
+ // Chain per-path: a second flush must wait for the in-flight one (keeps the
1320
+ // tracker single-entry and resolves followers with the leader's outcome).
1321
+ const prevInflight = inflightBufferFlushes.get(eventsPath) ?? Promise.resolve();
1322
+ const tracked = prevInflight.then(
1323
+ () => doFlush,
1324
+ () => doFlush,
1325
+ );
1326
+ inflightBufferFlushes.set(
1327
+ eventsPath,
1328
+ tracked.finally(() => {
1329
+ if (inflightBufferFlushes.get(eventsPath) === tracked) inflightBufferFlushes.delete(eventsPath);
1330
+ }),
1331
+ );
1332
+ await tracked;
1306
1333
  }
1307
1334
 
1308
1335
  /** Asynchronously flush every queued buffered event across all paths. */
1309
1336
  export async function flushEventLogBuffer(): Promise<void> {
1310
- for (const eventsPath of [...bufferedQueues.keys()]) await flushOneEventLogBuffer(eventsPath);
1337
+ for (const eventsPath of [...bufferedQueues.keys()]) {
1338
+ // PERF-CORRECTNESS (2026-10-31): await any IN-FLIGHT flush for this path
1339
+ // first — the timer-led flush may hold events that are no longer in
1340
+ // bufferedQueues (removed at flush start). Without this, a caller that
1341
+ // awaits flushEventLogBuffer() during an in-flight flush observes a log
1342
+ // that does not yet contain those events (deterministic-await guarantee).
1343
+ const inflight = inflightBufferFlushes.get(eventsPath);
1344
+ if (inflight) await inflight.catch(() => undefined);
1345
+ await flushOneEventLogBuffer(eventsPath);
1346
+ }
1311
1347
  }
1312
1348
 
1313
1349
  /**
@@ -1402,6 +1438,12 @@ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEv
1402
1438
  export async function drainEventLogWriters(eventsPath?: string): Promise<void> {
1403
1439
  if (eventsPath !== undefined) {
1404
1440
  const tail = asyncQueues.get(eventsPath) ?? Promise.resolve();
1441
+ // PERF-CORRECTNESS (2026-10-31): the buffered flush may be IN FLIGHT with
1442
+ // the queue already removed from the map — await the tracker, then flush
1443
+ // whatever landed in the queue since (deterministic drain for tests and
1444
+ // teardown paths; see flushOneEventLogBuffer).
1445
+ const inflight = inflightBufferFlushes.get(eventsPath);
1446
+ if (inflight) await inflight.catch(() => undefined);
1405
1447
  await Promise.allSettled([tail]);
1406
1448
  try {
1407
1449
  await flushOneEventLogBuffer(eventsPath);
@@ -1410,6 +1452,7 @@ export async function drainEventLogWriters(eventsPath?: string): Promise<void> {
1410
1452
  }
1411
1453
  return;
1412
1454
  }
1455
+ await flushEventLogBuffer().catch(() => undefined);
1413
1456
  await Promise.allSettled([...asyncQueues.values()]);
1414
1457
  try {
1415
1458
  await flushEventLogBuffer();