pi-crew 0.6.1 → 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.
- package/CHANGELOG.md +160 -0
- package/README.md +70 -33
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +2 -2
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/registration/team-tool.ts +1 -1
- package/src/extension/registration/viewers.ts +2 -2
- package/src/extension/team-tool/api.ts +3 -3
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +173 -46
- package/src/hooks/registry.ts +77 -13
- package/src/hooks/types.ts +9 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/async-runner.ts +35 -7
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +9 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +164 -37
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +2 -0
- package/src/runtime/diagnostic-export.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +10 -7
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +1 -1
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +17 -2
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/skill-effectiveness.ts +105 -62
- package/src/runtime/skill-instructions.ts +110 -32
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +251 -57
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +92 -70
- package/src/runtime/team-runner.ts +186 -20
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +254 -70
- package/src/state/health-store.ts +71 -0
- package/src/state/locks.ts +102 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +330 -43
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +2 -2
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +281 -10
- package/src/worktree/cleanup.ts +112 -24
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/src/state/event-log.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
process.on("
|
|
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
|
+
}
|