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.
- package/CHANGELOG.md +225 -0
- package/README.md +70 -31
- 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/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -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/cross-extension-rpc.ts +48 -0
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +4 -3
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +3 -1
- package/src/extension/registration/team-tool.ts +3 -1
- package/src/extension/registration/viewers.ts +3 -2
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +12 -7
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/config-patch.ts +15 -1
- 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 +175 -47
- package/src/hooks/registry.ts +84 -12
- package/src/hooks/types.ts +12 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -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/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +86 -3
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +67 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +165 -38
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +6 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +4 -5
- package/src/runtime/dynamic-script-runner.ts +9 -6
- package/src/runtime/foreground-watchdog.ts +3 -3
- 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/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parallel-utils.ts +2 -1
- 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 +11 -4
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +42 -21
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +109 -64
- package/src/runtime/skill-instructions.ts +114 -33
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +265 -53
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-packet.ts +13 -1
- 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 +98 -67
- package/src/runtime/team-runner.ts +186 -20
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- 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/contracts.ts +2 -1
- 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 +270 -75
- package/src/state/health-store.ts +71 -0
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +109 -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 +333 -44
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- 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 +4 -3
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +287 -10
- package/src/worktree/cleanup.ts +117 -26
- 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
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
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
|
+
});
|
|
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
|
-
|
|
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);
|