pi-crew 0.6.0 → 0.6.3

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 (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  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/package.json +2 -2
  6. package/src/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. 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
@@ -80,21 +81,33 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
80
81
  const lockDir = `${eventsPath}.lock`;
81
82
  const pidFile = path.join(lockDir, "pid");
82
83
  const start = Date.now();
83
- const timeout = 120000; // 120s timeout for slow CI environments
84
+ // SECURITY (HIGH #2 fix): Reduced from 120s to 5s to prevent blocking the
85
+ // event loop indefinitely. 500 retries × 10ms = 5s max. After timeout, we
86
+ // throw a clear error instead of blocking forever. This ensures AbortSignal
87
+ // handlers, SIGTERM, and graceful shutdown can fire within seconds.
88
+ const timeout = 5000;
84
89
  const staleMs = 10000;
85
90
  let acquired = false;
86
91
  while (true) {
87
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.
88
99
  fs.mkdirSync(lockDir);
89
100
  try { fs.writeFileSync(pidFile, String(process.pid), "utf-8"); } catch { /* best-effort */ }
90
101
  acquired = true;
91
102
  break;
92
103
  } catch {
93
104
  if (Date.now() - start > timeout) {
94
- // Log error and continue without lock lock is held by live process.
95
- // Stale detection will clean up dead locks on next attempt.
96
- logInternalError("event-log.lock-timeout", new Error(`Event log lock timeout for ${eventsPath}`), `lockDir=${lockDir}`);
97
- break;
105
+ // SECURITY (HIGH #2 fix): Throw instead of continuing without lock.
106
+ // Previously this logged and broke out of the loop, executing the
107
+ // operation without lock protection. Now we throw so callers can retry.
108
+ throw new Error(
109
+ `Event log lock timeout for ${eventsPath}: could not acquire lock within ${timeout}ms`,
110
+ );
98
111
  }
99
112
  // Stale detection: if the owning process is dead, remove the stale lock.
100
113
  try {
@@ -127,13 +140,14 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
127
140
  }
128
141
 
129
142
  function evictOldestSequenceCacheEntries(): void {
130
- // 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.
131
145
  const toEvict = Math.ceil(MAX_SEQUENCE_CACHE_ENTRIES / 2);
132
- let evicted = 0;
133
- for (const key of sequenceCache.keys()) {
134
- if (evicted >= toEvict) break;
135
- sequenceCache.delete(key);
136
- 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]);
137
151
  }
138
152
  }
139
153
 
@@ -181,14 +195,16 @@ function nextSequence(eventsPath: string): number {
181
195
  return cached.seq + 1;
182
196
  }
183
197
  // FIX: Trust the sidecar seq file if it exists and the file is non-empty.
184
- // 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.
185
200
  const stored = readStoredSequence(eventsPath);
186
- if (stored !== undefined && (!cached || stat.size >= cached.size)) {
187
- 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() });
188
204
  return stored + 1;
189
205
  }
190
206
  const current = scanSequence(eventsPath);
191
- 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() });
192
208
  persistSequence(eventsPath, current);
193
209
  return current + 1;
194
210
  }
@@ -205,6 +221,32 @@ export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId"
205
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);
206
222
  }
207
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
+
208
250
  /**
209
251
  * @deprecated Prefer `appendEventAsync()` in async contexts. The sync lock uses
210
252
  * `sleepSync` which blocks the Node.js event loop, preventing AbortSignal handlers
@@ -217,6 +259,44 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
217
259
  // --- Async write queue (non-blocking alternative to withEventLogLockSync) ---
218
260
  const asyncQueues = new Map<string, Promise<unknown>>();
219
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
+
295
+ /** Reset event log mode (for testing only). */
296
+ export function resetEventLogMode(): void {
297
+ asyncQueues.clear();
298
+ }
299
+
220
300
  /**
221
301
  * Append an event to the event log using non-blocking async I/O.
222
302
  *
@@ -235,9 +315,25 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
235
315
  await fs.promises.mkdir(path.dirname(eventsPath), { recursive: true });
236
316
 
237
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.
238
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
+ }
239
335
  let metadata: TeamEventMetadata = {
240
- seq: baseMetadata?.seq ?? nextSequence(eventsPath),
336
+ seq,
241
337
  provenance: baseMetadata?.provenance ?? "team_runner",
242
338
  ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
243
339
  ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
@@ -262,24 +358,35 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
262
358
  // Overflow handling: same logic as sync path
263
359
  const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
264
360
  let skippedDueToSize = false;
265
- if (!isTerminal && fs.existsSync(eventsPath)) {
266
- 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;
267
367
  if (stat.size > MAX_EVENTS_BYTES) {
268
368
  try {
269
369
  compactEventLog(eventsPath);
270
370
  } catch (error) {
271
371
  logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
272
372
  }
273
- if (fs.existsSync(eventsPath)) {
274
- const afterCompact = fs.statSync(eventsPath);
275
- 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) {
276
379
  rotateEventLog(eventsPath);
277
380
  }
278
381
  }
279
382
  }
280
383
  }
384
+ let sizeCheckStat: fs.Stats | undefined;
385
+ try {
386
+ sizeCheckStat = await fs.promises.stat(eventsPath).catch(() => undefined);
387
+ } catch { /* file does not exist */ }
281
388
  try {
282
- if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
389
+ if (sizeCheckStat && sizeCheckStat.size > MAX_EVENTS_BYTES) {
283
390
  logInternalError("event-log.size-limit", new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`), `eventsPath=${eventsPath}`);
284
391
  skippedDueToSize = true;
285
392
  }
@@ -289,23 +396,43 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
289
396
 
290
397
  if (!skippedDueToSize) {
291
398
  const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
292
- 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);
293
414
  }
294
-
295
- appendCounter++;
296
415
  if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
297
416
  try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
298
417
  }
299
418
  try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
300
419
 
301
- 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;
302
423
  try {
303
- const stat = fs.statSync(eventsPath);
304
- if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
305
- 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() });
306
433
  }
307
- sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
308
- 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.
309
436
  } catch (error) {
310
437
  logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
311
438
  }
@@ -314,8 +441,17 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
314
441
  asyncQueues.set(queueKey, next.then(
315
442
  () => { asyncQueues.delete(queueKey); },
316
443
  (error) => {
317
- logInternalError("event-log.async-queue", error, eventsPath);
318
- 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());
319
455
  },
320
456
  ));
321
457
  return next;
@@ -385,25 +521,42 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
385
521
  } catch (error) {
386
522
  logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
387
523
  }
524
+ const seq = fullEvent.metadata?.seq ?? 0;
388
525
  if (!skippedDueToSize) {
389
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
+ }
390
554
  }
391
555
  appendCounter++;
392
556
  if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
393
557
  try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
394
558
  }
395
559
  try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
396
- const seq = fullEvent.metadata?.seq ?? 0;
397
- try {
398
- const stat = fs.statSync(eventsPath);
399
- if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
400
- evictOldestSequenceCacheEntries();
401
- }
402
- sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
403
- persistSequence(eventsPath, seq);
404
- } catch (error) {
405
- logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
406
- }
407
560
  return fullEvent;
408
561
  }
409
562
 
@@ -429,6 +582,13 @@ export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent,
429
582
  // FIX: Terminal events must bypass buffer to ensure they're written immediately.
430
583
  // Previously, terminal events like task.failed could be lost on process crash.
431
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
+ }
432
592
  // For terminal events, write synchronously to ensure durability
433
593
  return Promise.resolve(appendEvent(eventsPath, event));
434
594
  }
@@ -444,31 +604,42 @@ export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent,
444
604
  });
445
605
  }
446
606
 
447
- function flushOneEventLogBuffer(eventsPath: string): void {
607
+ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
448
608
  const queue = bufferedQueues.get(eventsPath);
449
609
  bufferedQueues.delete(eventsPath);
450
610
  const timer = bufferedTimers.get(eventsPath);
451
- if (timer) clearTimeout(timer);
452
- // MEDIUM-13: Delete timer entry only after successful flush (in finally block)
453
- bufferedTimers.delete(eventsPath);
454
- if (!queue || queue.length === 0) return;
455
-
456
- // FIX (Round 14, H3): When truncating the queue, explicitly reject the
457
- // dropped entries' promises. Previously `queue.splice()` silently
458
- // discarded the oldest items, and their associated Promises were never
459
- // resolved or rejected — causing callers to await forever and leaking
460
- // memory. We now reject with a clear error so callers can fall back.
461
- if (queue.length > 1000) {
462
- const dropped = queue.splice(0, queue.length - 500);
463
- for (const item of dropped) {
464
- item.reject(new Error(
465
- `Event log buffer overflow: ${queue.length + dropped.length} entries > 1000 cap; oldest ${dropped.length} dropped to keep memory bounded`,
466
- ));
467
- }
468
- }
469
-
611
+ // Timer is cleared in the finally block to ensure cleanup happens even on error
470
612
  try {
471
- 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 () => {
472
643
  for (const item of queue) {
473
644
  try {
474
645
  const ev = appendEventInsideLock(eventsPath, item.event);
@@ -480,13 +651,15 @@ function flushOneEventLogBuffer(eventsPath: string): void {
480
651
  });
481
652
  } catch (error) {
482
653
  // Lock acquire failed — fail every queued item so callers can fall back.
483
- 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);
484
657
  }
485
658
  }
486
659
 
487
- /** Synchronously flush every queued buffered event across all paths. */
488
- export function flushEventLogBuffer(): void {
489
- 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);
490
663
  }
491
664
 
492
665
  /**
@@ -495,14 +668,36 @@ export function flushEventLogBuffer(): void {
495
668
  * Use only for events whose return value is ignored (high-frequency `task.progress`).
496
669
  * Errors are logged via logInternalError.
497
670
  */
498
- export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent, _bufferMs = DEFAULT_BUFFER_MS): void {
671
+ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent): void {
499
672
  appendEventAsync(eventsPath, event).catch((error) => logInternalError("event-log.fire-and-forget", error, eventsPath));
500
673
  }
501
674
 
502
675
  // Auto-flush on process exit so buffered events do not silently leak.
503
- process.on("exit", () => flushEventLogBuffer());
504
- process.on("SIGTERM", () => flushEventLogBuffer());
505
- 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
+ });
506
701
 
507
702
  export function readEvents(eventsPath: string): TeamEvent[] {
508
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
+ }
@@ -80,7 +80,8 @@ crewHooks.register("run_completed", async (event) => {
80
80
  /**
81
81
  * Get instinct-based recommendations.
82
82
  */
83
- export async function getInstinctRecommendations() {
83
+ /** @internal */
84
+ async function getInstinctRecommendations() {
84
85
  try {
85
86
  const store = await getStore();
86
87
  return store.getInstincts().filter((i: { confidence: number }) => i.confidence >= 0.6);