pi-crew 0.6.1 → 0.6.4

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.
Files changed (119) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +81 -33
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/docs/ui-optimization-plan.md +447 -0
  6. package/package.json +2 -2
  7. package/src/config/config.ts +106 -15
  8. package/src/errors.ts +107 -0
  9. package/src/extension/async-notifier.ts +6 -2
  10. package/src/extension/crew-cleanup.ts +8 -5
  11. package/src/extension/management.ts +464 -109
  12. package/src/extension/register.ts +213 -35
  13. package/src/extension/registration/brief-tool-overrides.ts +400 -0
  14. package/src/extension/registration/commands.ts +27 -2
  15. package/src/extension/registration/subagent-helpers.ts +2 -2
  16. package/src/extension/registration/subagent-tools.ts +9 -4
  17. package/src/extension/registration/team-tool.ts +17 -10
  18. package/src/extension/registration/viewers.ts +2 -2
  19. package/src/extension/team-tool/api.ts +3 -3
  20. package/src/extension/team-tool/cancel.ts +3 -3
  21. package/src/extension/team-tool/explain.ts +1 -1
  22. package/src/extension/team-tool/handle-schedule.ts +40 -0
  23. package/src/extension/team-tool/inspect.ts +3 -3
  24. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  25. package/src/extension/team-tool/respond.ts +2 -2
  26. package/src/extension/team-tool/run.ts +64 -14
  27. package/src/extension/team-tool/status.ts +1 -1
  28. package/src/extension/team-tool-types.ts +2 -0
  29. package/src/extension/team-tool.ts +173 -46
  30. package/src/hooks/registry.ts +77 -13
  31. package/src/hooks/types.ts +9 -0
  32. package/src/plugins/plugin-define.ts +6 -0
  33. package/src/plugins/plugin-registry.ts +32 -0
  34. package/src/plugins/plugins/index.ts +3 -0
  35. package/src/plugins/plugins/nextjs.ts +19 -0
  36. package/src/plugins/plugins/vite.ts +14 -0
  37. package/src/plugins/plugins/vitest.ts +14 -0
  38. package/src/runtime/async-runner.ts +35 -7
  39. package/src/runtime/background-runner.ts +529 -144
  40. package/src/runtime/chain-parser.ts +5 -1
  41. package/src/runtime/chain-runner.ts +9 -3
  42. package/src/runtime/checkpoint.ts +262 -180
  43. package/src/runtime/child-pi.ts +164 -37
  44. package/src/runtime/crash-recovery.ts +25 -14
  45. package/src/runtime/crew-agent-records.ts +2 -0
  46. package/src/runtime/diagnostic-export.ts +1 -1
  47. package/src/runtime/dynamic-script-runner.ts +10 -7
  48. package/src/runtime/foreground-watchdog.ts +1 -1
  49. package/src/runtime/heartbeat-watcher.ts +19 -2
  50. package/src/runtime/intercom-bridge.ts +7 -0
  51. package/src/runtime/iteration-hooks.ts +1 -1
  52. package/src/runtime/manifest-cache.ts +4 -0
  53. package/src/runtime/orphan-worker-registry.ts +444 -0
  54. package/src/runtime/parent-guard.ts +70 -16
  55. package/src/runtime/pi-args.ts +437 -14
  56. package/src/runtime/pi-spawn.ts +1 -0
  57. package/src/runtime/post-checks.ts +1 -1
  58. package/src/runtime/retry-runner.ts +9 -3
  59. package/src/runtime/run-tracker.ts +38 -10
  60. package/src/runtime/sandbox.ts +17 -2
  61. package/src/runtime/scheduler.ts +25 -2
  62. package/src/runtime/skill-effectiveness.ts +105 -62
  63. package/src/runtime/skill-instructions.ts +110 -32
  64. package/src/runtime/stale-reconciler.ts +310 -69
  65. package/src/runtime/subagent-manager.ts +251 -57
  66. package/src/runtime/task-health.ts +76 -0
  67. package/src/runtime/task-id.ts +20 -0
  68. package/src/runtime/task-runner/live-executor.ts +1 -1
  69. package/src/runtime/task-runner/progress.ts +1 -1
  70. package/src/runtime/task-runner/state-helpers.ts +110 -6
  71. package/src/runtime/task-runner.ts +92 -70
  72. package/src/runtime/team-runner.ts +186 -20
  73. package/src/schema/team-tool-schema.ts +27 -9
  74. package/src/skills/discover-skills.ts +9 -3
  75. package/src/state/active-run-registry.ts +170 -38
  76. package/src/state/artifact-store.ts +25 -13
  77. package/src/state/atomic-write-v2.ts +86 -0
  78. package/src/state/atomic-write.ts +346 -55
  79. package/src/state/blob-store.ts +178 -10
  80. package/src/state/crew-init.ts +161 -28
  81. package/src/state/decision-ledger.ts +172 -111
  82. package/src/state/event-log-rotation.ts +82 -52
  83. package/src/state/event-log.ts +254 -70
  84. package/src/state/health-store.ts +71 -0
  85. package/src/state/locks.ts +102 -20
  86. package/src/state/mailbox.ts +45 -7
  87. package/src/state/observation-store.ts +4 -1
  88. package/src/state/run-graph.ts +141 -130
  89. package/src/state/run-metrics.ts +24 -8
  90. package/src/state/session-state-map.ts +51 -0
  91. package/src/state/state-store.ts +330 -43
  92. package/src/ui/live-run-sidebar.ts +1 -1
  93. package/src/ui/loaders.ts +4 -0
  94. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  95. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  96. package/src/ui/powerbar-publisher.ts +1 -1
  97. package/src/ui/run-action-dispatcher.ts +2 -2
  98. package/src/ui/run-snapshot-cache.ts +1 -1
  99. package/src/ui/status-colors.ts +5 -1
  100. package/src/ui/theme-adapter.ts +80 -1
  101. package/src/ui/tool-progress-formatter.ts +9 -5
  102. package/src/ui/tool-render.ts +4 -0
  103. package/src/ui/tool-renderers/brief-mode.ts +207 -0
  104. package/src/ui/tool-renderers/index.ts +627 -0
  105. package/src/ui/widget/index.ts +224 -0
  106. package/src/ui/widget/widget-formatters.ts +148 -0
  107. package/src/ui/widget/widget-model.ts +90 -0
  108. package/src/ui/widget/widget-renderer.ts +130 -0
  109. package/src/ui/widget/widget-types.ts +37 -0
  110. package/src/utils/env-filter.ts +66 -0
  111. package/src/utils/file-coalescer.ts +9 -1
  112. package/src/utils/guards.ts +110 -0
  113. package/src/utils/paths.ts +80 -5
  114. package/src/utils/redaction.ts +6 -1
  115. package/src/utils/safe-paths.ts +281 -10
  116. package/src/worktree/cleanup.ts +112 -24
  117. package/src/worktree/worktree-manager.ts +128 -15
  118. package/test-bugs-all.mjs +10 -6
  119. package/test-integration-check.ts +4 -0
@@ -57,9 +57,10 @@ export type AppendTeamEvent = Omit<TeamEvent, "time" | "metadata"> & { metadata?
57
57
  const TERMINAL_EVENT_TYPES = new Set<string>(DEFAULT_EVENT_LOG.terminalEventTypes);
58
58
  const MAX_EVENTS_BYTES = 50 * 1024 * 1024;
59
59
 
60
- const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
60
+ const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number; lastAccessMs: number }>();
61
61
  const MAX_SEQUENCE_CACHE_ENTRIES = 256;
62
62
  let appendCounter = 0;
63
+ let overflowCounter = 0;
63
64
 
64
65
  /** Simple cross-process lock for an eventsPath to prevent JSONL interleave on concurrent append.
65
66
  * Detects stale locks by checking the owner PID written inside the lock directory.
@@ -68,7 +69,7 @@ let appendCounter = 0;
68
69
  * uses `sleepSync` which blocks the event loop and prevents AbortSignal handlers from firing.
69
70
  *
70
71
  * SECURITY WARNING: This function uses `sleepSync` in its lock-acquire retry loop, which
71
- * blocks the Node.js event loop for up to 120s. During that time, AbortSignal handlers
72
+ * blocks the Node.js event loop for up to 5s. During that time, AbortSignal handlers
72
73
  * cannot fire, SIGTERM handlers are delayed, and the process appears unresponsive to
73
74
  * orchestrator health checks. Known callers include `appendEvent` (sync path),
74
75
  * `flushOneEventLogBuffer`, and `state/mailbox.ts`. Prefer the async alternative
@@ -89,6 +90,12 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
89
90
  let acquired = false;
90
91
  while (true) {
91
92
  try {
93
+ // NOTE: mkdir-based lock is acceptable here. On POSIX systems, directory
94
+ // creation via mkdir with O_CREAT|O_EXCL semantics is atomic — equivalent
95
+ // to O_EXCL file open. The stale detection below uses process.kill(pid, 0)
96
+ // which has a TOCTOU race, but O_EXCL is used to atomically verify-and-remove
97
+ // the stale lock in one operation, eliminating the race. The 5s timeout
98
+ // (reduced from 120s) is appropriate.
92
99
  fs.mkdirSync(lockDir);
93
100
  try { fs.writeFileSync(pidFile, String(process.pid), "utf-8"); } catch { /* best-effort */ }
94
101
  acquired = true;
@@ -133,13 +140,14 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
133
140
  }
134
141
 
135
142
  function evictOldestSequenceCacheEntries(): void {
136
- // Batch evict oldest 50% of entries when cache is full
143
+ // FIX: Evict by lastAccessMs (access time), not insertion order.
144
+ // Frequently accessed entries should be retained even if older.
137
145
  const toEvict = Math.ceil(MAX_SEQUENCE_CACHE_ENTRIES / 2);
138
- let evicted = 0;
139
- for (const key of sequenceCache.keys()) {
140
- if (evicted >= toEvict) break;
141
- sequenceCache.delete(key);
142
- evicted++;
146
+ // Sort entries by lastAccessMs ascending (oldest first)
147
+ const entries = [...sequenceCache.entries()].sort((a, b) => a[1].lastAccessMs - b[1].lastAccessMs);
148
+ // Evict the oldest half
149
+ for (let i = 0; i < toEvict && i < entries.length; i++) {
150
+ sequenceCache.delete(entries[i][0]);
143
151
  }
144
152
  }
145
153
 
@@ -187,14 +195,16 @@ function nextSequence(eventsPath: string): number {
187
195
  return cached.seq + 1;
188
196
  }
189
197
  // FIX: Trust the sidecar seq file if it exists and the file is non-empty.
190
- // Only fall back to O(n) scan if sidecar is missing or file shrunk unexpectedly.
198
+ // Explicitly check for file shrinkage (stat.size < cached.size) to trigger
199
+ // re-scan when rotation or compaction has occurred.
191
200
  const stored = readStoredSequence(eventsPath);
192
- if (stored !== undefined && (!cached || stat.size >= cached.size)) {
193
- sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: stored });
201
+ const fileShrunk = cached && stat.size < cached.size;
202
+ if (stored !== undefined && !fileShrunk) {
203
+ sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: stored, lastAccessMs: Date.now() });
194
204
  return stored + 1;
195
205
  }
196
206
  const current = scanSequence(eventsPath);
197
- sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: current });
207
+ sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: current, lastAccessMs: Date.now() });
198
208
  persistSequence(eventsPath, current);
199
209
  return current + 1;
200
210
  }
@@ -211,6 +221,32 @@ export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId"
211
221
  return createHash("sha256").update(JSON.stringify({ type: event.type, runId: event.runId, taskId: event.taskId, data: event.data ?? null })).digest("hex").slice(0, 16);
212
222
  }
213
223
 
224
+ /**
225
+ * Check for sequence gaps between the sidecar file and the events file.
226
+ * This detects situations where the sidecar records a sequence number that has
227
+ * no corresponding event in the file (e.g., due to a crash between
228
+ * persistSequence and appendFile in older code, or other corruption).
229
+ *
230
+ * Returns an array of gap info: for each gap found, { missing: n } indicates
231
+ * sequence n is recorded in sidecar but has no corresponding event.
232
+ * An empty array means no gaps were found.
233
+ */
234
+ export function checkSequenceGaps(eventsPath: string): { missing: number }[] {
235
+ if (!fs.existsSync(eventsPath)) return [];
236
+ const gaps: { missing: number }[] = [];
237
+ const storedSeq = readStoredSequence(eventsPath);
238
+ if (storedSeq === undefined) return [];
239
+ const maxInFile = scanSequence(eventsPath);
240
+ // If sidecar is ahead of file, report the missing sequences
241
+ // (sidecar stores the NEXT sequence to use, so storedSeq is the last written)
242
+ if (storedSeq > maxInFile) {
243
+ for (let i = maxInFile + 1; i <= storedSeq; i++) {
244
+ gaps.push({ missing: i });
245
+ }
246
+ }
247
+ return gaps;
248
+ }
249
+
214
250
  /**
215
251
  * @deprecated Prefer `appendEventAsync()` in async contexts. The sync lock uses
216
252
  * `sleepSync` which blocks the Node.js event loop, preventing AbortSignal handlers
@@ -223,6 +259,39 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
223
259
  // --- Async write queue (non-blocking alternative to withEventLogLockSync) ---
224
260
  const asyncQueues = new Map<string, Promise<unknown>>();
225
261
 
262
+ // --- Async lock for flush operations (non-blocking alternative to withEventLogLockSync) ---
263
+ // Uses promise-chain pattern to ensure sequential lock acquisition without blocking the event loop.
264
+ const asyncLocks = new Map<string, Promise<unknown>>();
265
+
266
+ /** Drain all pending async writes by awaiting all in-flight queue promises.
267
+ * Called on process exit to minimize event loss for crash-sensitive events.
268
+ * Note: SIGKILL (kill -9) cannot be intercepted and will still lose events.
269
+ */
270
+ async function drainAsyncQueues(): Promise<void> {
271
+ const promises = [...asyncQueues.values()];
272
+ if (promises.length === 0) return;
273
+ // Use allSettled to ensure a rejected promise doesn't prevent others from completing.
274
+ await Promise.allSettled(promises);
275
+ }
276
+
277
+ /** Async lock using promise-chain pattern to avoid blocking the Node.js event loop.
278
+ * Unlike withEventLogLockSync, this uses async I/O and does not use sleepSync,
279
+ * allowing AbortSignal handlers and SIGTERM handlers to proceed while waiting.
280
+ */
281
+ async function withEventLogLockAsync(eventsPath: string, fn: () => Promise<void>): Promise<void> {
282
+ const queueKey = eventsPath;
283
+ const prev = asyncLocks.get(queueKey) ?? Promise.resolve();
284
+ const next = prev.then(async (): Promise<void> => {
285
+ await fn();
286
+ });
287
+ asyncLocks.set(queueKey, next);
288
+ try {
289
+ await next;
290
+ } finally {
291
+ asyncLocks.delete(queueKey);
292
+ }
293
+ }
294
+
226
295
  /** Reset event log mode (for testing only). */
227
296
  export function resetEventLogMode(): void {
228
297
  asyncQueues.clear();
@@ -246,9 +315,25 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
246
315
  await fs.promises.mkdir(path.dirname(eventsPath), { recursive: true });
247
316
 
248
317
  // Build metadata (same logic as appendEventInsideLock)
318
+ // FIX: Sequence is computed INSIDE the promise chain. We NO LONGER persist
319
+ // the sequence number before the append — that caused sequence reuse if
320
+ // appendFile failed after persistSequence succeeded. Instead, we persist
321
+ // ONLY AFTER successful appendFile, so the sidecar is only updated when
322
+ // the event is definitively written. If appendFile fails, the sidecar is
323
+ // not updated and nextSequence() will re-scan on next call, returning the
324
+ // correct value without reuse.
249
325
  const baseMetadata = event.metadata;
326
+ let seq: number;
327
+ if (baseMetadata?.seq !== undefined) {
328
+ seq = baseMetadata.seq;
329
+ } else {
330
+ seq = nextSequence(eventsPath);
331
+ // NOTE: We do NOT call persistSequence here. It will be called AFTER
332
+ // successful appendFile below to ensure sidecar is only updated when
333
+ // the event is actually written.
334
+ }
250
335
  let metadata: TeamEventMetadata = {
251
- seq: baseMetadata?.seq ?? nextSequence(eventsPath),
336
+ seq,
252
337
  provenance: baseMetadata?.provenance ?? "team_runner",
253
338
  ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
254
339
  ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
@@ -273,24 +358,35 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
273
358
  // Overflow handling: same logic as sync path
274
359
  const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
275
360
  let skippedDueToSize = false;
276
- if (!isTerminal && fs.existsSync(eventsPath)) {
277
- const stat = fs.statSync(eventsPath);
361
+ let fileStat: fs.Stats | undefined;
362
+ try {
363
+ fileStat = await fs.promises.stat(eventsPath).catch(() => undefined);
364
+ } catch { /* file does not exist */ }
365
+ if (!isTerminal && fileStat) {
366
+ const stat = fileStat;
278
367
  if (stat.size > MAX_EVENTS_BYTES) {
279
368
  try {
280
369
  compactEventLog(eventsPath);
281
370
  } catch (error) {
282
371
  logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
283
372
  }
284
- if (fs.existsSync(eventsPath)) {
285
- const afterCompact = fs.statSync(eventsPath);
286
- if (afterCompact.size > MAX_EVENTS_BYTES) {
373
+ let afterCompactStat: fs.Stats | undefined;
374
+ try {
375
+ afterCompactStat = await fs.promises.stat(eventsPath).catch(() => undefined);
376
+ } catch { /* file does not exist */ }
377
+ if (afterCompactStat) {
378
+ if (afterCompactStat.size > MAX_EVENTS_BYTES) {
287
379
  rotateEventLog(eventsPath);
288
380
  }
289
381
  }
290
382
  }
291
383
  }
384
+ let sizeCheckStat: fs.Stats | undefined;
385
+ try {
386
+ sizeCheckStat = await fs.promises.stat(eventsPath).catch(() => undefined);
387
+ } catch { /* file does not exist */ }
292
388
  try {
293
- if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
389
+ if (sizeCheckStat && sizeCheckStat.size > MAX_EVENTS_BYTES) {
294
390
  logInternalError("event-log.size-limit", new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`), `eventsPath=${eventsPath}`);
295
391
  skippedDueToSize = true;
296
392
  }
@@ -300,23 +396,43 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
300
396
 
301
397
  if (!skippedDueToSize) {
302
398
  const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
303
- await fs.promises.appendFile(eventsPath, line, { encoding: "utf-8" });
399
+ await fs.promises.appendFile(eventsPath, line, { encoding: "utf-8", flag: "a" });
400
+ // FIX: fsync to ensure event content is flushed to disk before persisting
401
+ // the sequence number. This closes the crash window between appendFile and
402
+ // persistSequence where sequence reuse could occur on restart.
403
+ const fd = await fs.promises.open(eventsPath, "r+");
404
+ try {
405
+ await fd.sync();
406
+ } finally {
407
+ await fd.close();
408
+ }
409
+ // FIX: Persist sequence AFTER successful appendFile to ensure sidecar
410
+ // is only updated when the event is definitively written. If appendFile
411
+ // threw, we would not reach here and the sidecar would not be updated,
412
+ // preventing sequence reuse on restart.
413
+ persistSequence(eventsPath, seq);
304
414
  }
305
-
306
- appendCounter++;
307
415
  if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
308
416
  try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
309
417
  }
310
418
  try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
311
419
 
312
- const seq = fullEvent.metadata?.seq ?? 0;
420
+ // FIX: Sequence was persisted AFTER appendFile in the append block above.
421
+ // Only update the cache here (the sidecar persist is already done).
422
+ const finalSeq = fullEvent.metadata?.seq ?? 0;
313
423
  try {
314
- const stat = fs.statSync(eventsPath);
315
- if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
316
- evictOldestSequenceCacheEntries();
424
+ let statResult: fs.Stats | undefined;
425
+ try {
426
+ statResult = await fs.promises.stat(eventsPath).catch(() => undefined);
427
+ } catch { /* file may not exist */ }
428
+ if (statResult) {
429
+ if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
430
+ evictOldestSequenceCacheEntries();
431
+ }
432
+ sequenceCache.set(eventsPath, { size: statResult.size, mtimeMs: statResult.mtimeMs, seq: finalSeq, lastAccessMs: Date.now() });
317
433
  }
318
- sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
319
- persistSequence(eventsPath, seq);
434
+ // Note: persistSequence is NOT called here again - it was already called
435
+ // after the append to ensure the sidecar is current after the event is written.
320
436
  } catch (error) {
321
437
  logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
322
438
  }
@@ -325,8 +441,17 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
325
441
  asyncQueues.set(queueKey, next.then(
326
442
  () => { asyncQueues.delete(queueKey); },
327
443
  (error) => {
328
- logInternalError("event-log.async-queue", error, eventsPath);
329
- asyncQueues.delete(queueKey);
444
+ // FIX: Wrap error handler in try-catch to ensure asyncQueues.delete
445
+ // always runs, even if logging itself throws.
446
+ try {
447
+ logInternalError("event-log.async-queue", error, eventsPath);
448
+ } catch {
449
+ // logging failed — ensure queue is still cleaned up
450
+ }
451
+ // FIX: Reset queue to a resolved state instead of deleting it.
452
+ // This prevents cascading failures where a single transient error
453
+ // (e.g., ENOSPC) causes all subsequent events on the same path to fail.
454
+ asyncQueues.set(queueKey, Promise.resolve());
330
455
  },
331
456
  ));
332
457
  return next;
@@ -396,25 +521,42 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
396
521
  } catch (error) {
397
522
  logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
398
523
  }
524
+ const seq = fullEvent.metadata?.seq ?? 0;
399
525
  if (!skippedDueToSize) {
400
526
  fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
527
+ // FIX: fsync to ensure event content is flushed to disk before persisting
528
+ // the sequence number. This closes the crash window between appendFileSync
529
+ // and persistSequence where sequence reuse could occur on restart.
530
+ const fd = fs.openSync(eventsPath, "r+");
531
+ try {
532
+ fs.fsyncSync(fd);
533
+ } catch {
534
+ // EPERM on Windows CI: best-effort flush
535
+ } finally {
536
+ fs.closeSync(fd);
537
+ }
538
+ // FIX: Persist sequence AFTER the event append to prevent sequence reuse
539
+ // on crash. Only update the sidecar when the event is definitively written.
540
+ persistSequence(eventsPath, seq);
541
+ // FIX: Update cache AFTER append so cache and log are consistent with each other.
542
+ // This matches the async path behavior where cache is updated after the append.
543
+ // If a crash occurs after append but before cache update, the .seq file is
544
+ // already correct and nextSequence() will return the correct value on restart.
545
+ try {
546
+ const stat = fs.statSync(eventsPath);
547
+ if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
548
+ evictOldestSequenceCacheEntries();
549
+ }
550
+ sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq, lastAccessMs: Date.now() });
551
+ } catch (error) {
552
+ logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
553
+ }
401
554
  }
402
555
  appendCounter++;
403
556
  if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
404
557
  try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
405
558
  }
406
559
  try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
407
- const seq = fullEvent.metadata?.seq ?? 0;
408
- try {
409
- const stat = fs.statSync(eventsPath);
410
- if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
411
- evictOldestSequenceCacheEntries();
412
- }
413
- sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
414
- persistSequence(eventsPath, seq);
415
- } catch (error) {
416
- logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
417
- }
418
560
  return fullEvent;
419
561
  }
420
562
 
@@ -440,6 +582,13 @@ export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent,
440
582
  // FIX: Terminal events must bypass buffer to ensure they're written immediately.
441
583
  // Previously, terminal events like task.failed could be lost on process crash.
442
584
  if (TERMINAL_EVENT_TYPES.has(event.type)) {
585
+ // FIX: Flush any pending buffered events before writing terminal event
586
+ // to ensure durability of events that precede the terminal event in the
587
+ // same flush cycle. Without this, a kill -9 after terminal event write
588
+ // but before buffer flush would lose the buffered events.
589
+ if (bufferedQueues.has(eventsPath)) {
590
+ flushOneEventLogBuffer(eventsPath);
591
+ }
443
592
  // For terminal events, write synchronously to ensure durability
444
593
  return Promise.resolve(appendEvent(eventsPath, event));
445
594
  }
@@ -455,31 +604,42 @@ export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent,
455
604
  });
456
605
  }
457
606
 
458
- function flushOneEventLogBuffer(eventsPath: string): void {
607
+ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
459
608
  const queue = bufferedQueues.get(eventsPath);
460
609
  bufferedQueues.delete(eventsPath);
461
610
  const timer = bufferedTimers.get(eventsPath);
462
- if (timer) clearTimeout(timer);
463
- // MEDIUM-13: Delete timer entry only after successful flush (in finally block)
464
- bufferedTimers.delete(eventsPath);
465
- if (!queue || queue.length === 0) return;
466
-
467
- // FIX (Round 14, H3): When truncating the queue, explicitly reject the
468
- // dropped entries' promises. Previously `queue.splice()` silently
469
- // discarded the oldest items, and their associated Promises were never
470
- // resolved or rejected — causing callers to await forever and leaking
471
- // memory. We now reject with a clear error so callers can fall back.
472
- if (queue.length > 1000) {
473
- const dropped = queue.splice(0, queue.length - 500);
474
- for (const item of dropped) {
475
- item.reject(new Error(
476
- `Event log buffer overflow: ${queue.length + dropped.length} entries > 1000 cap; oldest ${dropped.length} dropped to keep memory bounded`,
477
- ));
478
- }
479
- }
480
-
611
+ // Timer is cleared in the finally block to ensure cleanup happens even on error
481
612
  try {
482
- withEventLogLockSync(eventsPath, () => {
613
+ if (!queue || queue.length === 0) return;
614
+
615
+ // FIX (Round 14, H3): When truncating the queue, explicitly reject the
616
+ // dropped entries' promises. Previously `queue.splice()` silently
617
+ // discarded the oldest items, and their associated Promises were never
618
+ // resolved or rejected — causing callers to await forever and leaking
619
+ // memory. We now reject with a clear error so callers can fall back.
620
+ if (queue.length > 1000) {
621
+ const dropped = queue.splice(0, queue.length - 500);
622
+ overflowCounter++;
623
+ // FIX: Include first/last dropped event type and sequence number in error
624
+ // message to make debugging easier when events are dropped.
625
+ const firstDroppedMeta = dropped[0]?.event.metadata;
626
+ const lastDroppedMeta = dropped[dropped.length - 1]?.event.metadata;
627
+ logInternalError(
628
+ "event-log.buffer-overflow",
629
+ new Error(`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}`),
630
+ `${eventsPath}: ${queue.length + dropped.length} entries > 1000 cap`,
631
+ );
632
+ for (const item of dropped) {
633
+ item.reject(new Error(
634
+ `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}`,
635
+ ));
636
+ }
637
+ }
638
+
639
+ // FIX (Issue 2): Use async lock instead of withEventLogLockSync to avoid
640
+ // blocking the event loop. The sync lock uses sleepSync which blocks for
641
+ // up to 5s and prevents AbortSignal handlers from firing.
642
+ await withEventLogLockAsync(eventsPath, async () => {
483
643
  for (const item of queue) {
484
644
  try {
485
645
  const ev = appendEventInsideLock(eventsPath, item.event);
@@ -491,13 +651,15 @@ function flushOneEventLogBuffer(eventsPath: string): void {
491
651
  });
492
652
  } catch (error) {
493
653
  // Lock acquire failed — fail every queued item so callers can fall back.
494
- for (const item of queue) item.reject(error);
654
+ if (queue) for (const item of queue) item.reject(error);
655
+ } finally {
656
+ bufferedTimers.delete(eventsPath);
495
657
  }
496
658
  }
497
659
 
498
- /** Synchronously flush every queued buffered event across all paths. */
499
- export function flushEventLogBuffer(): void {
500
- for (const eventsPath of [...bufferedQueues.keys()]) flushOneEventLogBuffer(eventsPath);
660
+ /** Asynchronously flush every queued buffered event across all paths. */
661
+ export async function flushEventLogBuffer(): Promise<void> {
662
+ for (const eventsPath of [...bufferedQueues.keys()]) await flushOneEventLogBuffer(eventsPath);
501
663
  }
502
664
 
503
665
  /**
@@ -506,14 +668,36 @@ export function flushEventLogBuffer(): void {
506
668
  * Use only for events whose return value is ignored (high-frequency `task.progress`).
507
669
  * Errors are logged via logInternalError.
508
670
  */
509
- export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent, _bufferMs = DEFAULT_BUFFER_MS): void {
671
+ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent): void {
510
672
  appendEventAsync(eventsPath, event).catch((error) => logInternalError("event-log.fire-and-forget", error, eventsPath));
511
673
  }
512
674
 
513
675
  // Auto-flush on process exit so buffered events do not silently leak.
514
- process.on("exit", () => flushEventLogBuffer());
515
- process.on("SIGTERM", () => flushEventLogBuffer());
516
- process.on("SIGINT", () => flushEventLogBuffer());
676
+ // Defense-in-depth: SIGTERM/SIGINT use setImmediate so the handler returns
677
+ // immediately and the main thread is not blocked by sync I/O.
678
+ process.on("exit", () => {
679
+ flushEventLogBuffer();
680
+ // FIX (Issue 1): Drain asyncQueues on exit to minimize event loss.
681
+ // In-flight async writes are awaited (via Promise.allSettled) before
682
+ // the map is cleared. This reduces but does not eliminate event loss
683
+ // on crash — SIGKILL (kill -9) cannot be intercepted.
684
+ drainAsyncQueues();
685
+ asyncQueues.clear();
686
+ });
687
+ process.on("SIGTERM", () => setImmediate(() => flushEventLogBuffer()));
688
+ process.on("SIGINT", () => setImmediate(() => flushEventLogBuffer()));
689
+ // FIX (Issue 1): Handle uncaught exceptions to flush buffered events before
690
+ // the process terminates. The async queues use promise chains that will be
691
+ // abandoned on crash; clearing the map prevents memory leaks and stale state.
692
+ // Note: SIGKILL (kill -9) cannot be intercepted and is not handled.
693
+ process.on("uncaughtException", (error) => {
694
+ try { flushEventLogBuffer(); } catch { /* best-effort */ }
695
+ // FIX (Issue 1): Drain asyncQueues before clearing to minimize event loss.
696
+ drainAsyncQueues();
697
+ asyncQueues.clear();
698
+ // Re-throw to preserve default uncaught exception behavior (process exit)
699
+ throw error;
700
+ });
517
701
 
518
702
  export function readEvents(eventsPath: string): TeamEvent[] {
519
703
  if (!fs.existsSync(eventsPath)) return [];
@@ -0,0 +1,71 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { RunHealth } from "../runtime/task-health.ts";
4
+ import { computeRunHealth } from "../runtime/task-health.ts";
5
+ import type { ManifestSummary } from "../runtime/task-health.ts";
6
+
7
+ const HEALTH_DIR = ".crew/state/health";
8
+
9
+ export interface HealthSnapshot {
10
+ runId: string;
11
+ timestamp: number;
12
+ gitRef?: string;
13
+ score: number;
14
+ grade: string;
15
+ penalties: { reason: string; deduction: number }[];
16
+ }
17
+
18
+ export class HealthStore {
19
+ private readonly crewRoot: string;
20
+ constructor(crewRoot: string) {
21
+ this.crewRoot = crewRoot;
22
+ }
23
+
24
+ private healthDir(): string {
25
+ return path.join(this.crewRoot, HEALTH_DIR);
26
+ }
27
+
28
+ saveSnapshot(manifest: ManifestSummary): HealthSnapshot {
29
+ const health = computeRunHealth(manifest);
30
+ const snapshot: HealthSnapshot = {
31
+ runId: manifest.runId,
32
+ timestamp: Date.now(),
33
+ score: health.score,
34
+ grade: health.grade,
35
+ penalties: health.penalties,
36
+ };
37
+ const dir = this.healthDir();
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ const file = path.join(dir, `${manifest.runId}.json`);
40
+ fs.writeFileSync(file, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
41
+ return snapshot;
42
+ }
43
+
44
+ loadLatestSnapshot(): HealthSnapshot | null {
45
+ const dir = this.healthDir();
46
+ if (!fs.existsSync(dir)) return null;
47
+ const snapshots = this.loadAllSnapshots();
48
+ if (snapshots.length === 0) return null;
49
+ // Sort by timestamp descending (most recent first)
50
+ snapshots.sort((a, b) => b.timestamp - a.timestamp);
51
+ return snapshots[0];
52
+ }
53
+
54
+ loadAllSnapshots(): HealthSnapshot[] {
55
+ const dir = this.healthDir();
56
+ if (!fs.existsSync(dir)) return [];
57
+ const snapshots = fs.readdirSync(dir)
58
+ .filter(f => f.endsWith(".json"))
59
+ .map(f => {
60
+ try {
61
+ return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
62
+ } catch {
63
+ return null;
64
+ }
65
+ })
66
+ .filter(Boolean) as HealthSnapshot[];
67
+ // Sort by timestamp ascending (oldest first) for consistent ordering
68
+ snapshots.sort((a, b) => a.timestamp - b.timestamp);
69
+ return snapshots;
70
+ }
71
+ }