pi-crew 0.9.65 → 0.9.66

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.
@@ -1,482 +0,0 @@
1
- /**
2
- * pi-crew resource sampler — external process monitor.
3
- *
4
- * Samples RSS / heap / CPU% for a PID and all its descendants, writing one
5
- * JSONL line per PID per tick. Runs as a standalone external process so it
6
- * works regardless of pi-crew's bundle/runtime (no src/ instrumentation).
7
- *
8
- * Modes:
9
- * --watch-parent <pid> [--run-id <id>] [--interval 2000] [--out <path>]
10
- * Polls <pid> + children until SIGINT/SIGTERM.
11
- * --wrap <cmd...> [--run-id <id>] [--interval 2000] [--out <path>]
12
- * Spawns <cmd>, samples it + children until exit, exits with child code.
13
- *
14
- * Output JSONL line: {ts,pid,ppid,label,rssBytes,heapBytes,cpuPct}
15
- *
16
- * Linux path uses /proc; non-Linux falls back to `ps -o rss=,pcpu=`.
17
- *
18
- * Run: node scripts/resource-sampler.mjs --wrap pi --version
19
- */
20
-
21
- import { spawn, spawnSync } from "node:child_process";
22
- import { readFileSync, existsSync, mkdirSync, appendFileSync, readdirSync } from "node:fs";
23
- import { join } from "node:path";
24
-
25
- // ---------- CLI parsing ----------
26
- // Known options may appear ANYWHERE (before or after --wrap). Everything
27
- // after --wrap that is NOT a known option becomes the wrapped command.
28
- const KNOWN_FLAGS = new Set(["--watch-parent", "--watch-run", "--crew-root", "--wrap", "--interval", "--run-id", "--out", "--no-live-warn", "-h", "--help"]);
29
-
30
- function parseArgs(argv) {
31
- const args = { interval: 2000, mode: null, parentPid: null, wrap: [], runId: null, out: null, watchRun: null, crewRoot: null, liveWarn: true };
32
- let wrapStarted = false;
33
- for (let i = 2; i < argv.length; i++) {
34
- const a = argv[i];
35
- // Once --wrap is seen, collect non-flag tokens as the command. A known
36
- // flag is still parsed as an option (so `--wrap sleep 5 --interval 500`
37
- // works), but an unknown token is treated as part of the command.
38
- if (wrapStarted && !KNOWN_FLAGS.has(a)) {
39
- args.wrap.push(a);
40
- continue;
41
- }
42
- if (a === "--watch-parent") {
43
- args.mode = "watch";
44
- args.parentPid = Number.parseInt(argv[++i], 10);
45
- } else if (a === "--watch-run") {
46
- // auto-resolve the run's leader/runner PID from pi-crew state (no manual
47
- // pgrep). Reads async.pid / manifest.async.pid / heartbeat.json.
48
- args.mode = "watch";
49
- args.watchRun = argv[++i];
50
- if (!args.runId) args.runId = args.watchRun;
51
- } else if (a === "--crew-root") {
52
- args.crewRoot = argv[++i];
53
- } else if (a === "--wrap") {
54
- args.mode = "wrap";
55
- wrapStarted = true;
56
- } else if (a === "--interval") {
57
- // R13 (audit): reject NaN and clamp tiny intervals — setInterval(fn, 0)
58
- // or setInterval(fn, NaN) spins ~60-130×/s, writing huge files and
59
- // burning CPU (each tick also scans all of /proc).
60
- const raw = argv[++i];
61
- const parsed = Number.parseInt(raw, 10);
62
- if (Number.isNaN(parsed)) {
63
- process.stderr.write(`Error: --interval must be a number (ms), got "${raw}"\n`);
64
- process.exit(1);
65
- }
66
- if (parsed < 100) {
67
- process.stderr.write(`[resource-sampler] --interval ${parsed}ms < 100ms; clamping to 100ms\n`);
68
- args.interval = 100;
69
- } else {
70
- args.interval = parsed;
71
- }
72
- } else if (a === "--run-id") {
73
- args.runId = argv[++i];
74
- } else if (a === "--out") {
75
- args.out = argv[++i];
76
- } else if (a === "--no-live-warn") {
77
- args.liveWarn = false;
78
- } else if (a === "-h" || a === "--help") {
79
- printHelp();
80
- process.exit(0);
81
- } else if (wrapStarted) {
82
- // unknown token after --wrap → part of the command
83
- args.wrap.push(a);
84
- }
85
- }
86
- if (!args.mode) {
87
- printHelp();
88
- process.exit(1);
89
- }
90
- return args;
91
- }
92
-
93
- function printHelp() {
94
- process.stderr.write(
95
- [
96
- "Usage:",
97
- " resource-sampler.mjs --watch-parent <pid> [--run-id <id>] [--interval 2000] [--out <path>]",
98
- " resource-sampler.mjs --wrap <cmd...> [--run-id <id>] [--interval 2000] [--out <path>]",
99
- "",
100
- ].join("\n"),
101
- );
102
- }
103
-
104
- // ---------- /proc readers (Linux) ----------
105
- const CLK_TCK = 100; // sysconf(_SC_CLK_TCK) on essentially all Linux/x86/arm
106
-
107
- function readProcStat(pid) {
108
- // /proc/<pid>/stat — comm (field 2) may contain spaces inside parens.
109
- let raw;
110
- try {
111
- raw = readFileSync(`/proc/${pid}/stat`, "utf8");
112
- } catch {
113
- return null;
114
- }
115
- const open = raw.indexOf("(");
116
- const close = raw.lastIndexOf(")");
117
- if (open < 0 || close < 0) return null;
118
- const comm = raw.slice(open + 1, close);
119
- const rest = raw.slice(close + 2).trim().split(/\s+/);
120
- // rest[0] = field3 (state), rest[1] = field4 (ppid), rest[11] = utime(f14), rest[12] = stime(f15), rest[19] = starttime(f22)
121
- return {
122
- pid,
123
- comm,
124
- state: rest[0],
125
- ppid: Number.parseInt(rest[1], 10) || 0,
126
- utime: Number.parseInt(rest[11], 10) || 0,
127
- stime: Number.parseInt(rest[12], 10) || 0,
128
- starttime: Number.parseInt(rest[19], 10) || 0,
129
- };
130
- }
131
-
132
- function readProcStatus(pid) {
133
- let raw;
134
- try {
135
- raw = readFileSync(`/proc/${pid}/status`, "utf8");
136
- } catch {
137
- return null;
138
- }
139
- let rssKb = 0;
140
- let dataKb = 0;
141
- for (const line of raw.split("\n")) {
142
- if (line.startsWith("VmRSS:")) rssKb = Number.parseInt(line.slice(6).trim(), 10) || 0;
143
- else if (line.startsWith("VmData:")) dataKb = Number.parseInt(line.slice(7).trim(), 10) || 0;
144
- }
145
- return { rssKb, dataKb };
146
- }
147
-
148
- // ---------- fallback via ps (non-Linux) ----------
149
- function readPs(pid) {
150
- const res = spawnSync("ps", ["-o", "rss=,pcpu=", "-p", String(pid)], { encoding: "utf8" });
151
- if (res.status !== 0 || !res.stdout.trim()) return null;
152
- const parts = res.stdout.trim().split(/\s+/);
153
- return {
154
- pid,
155
- ppid: 0,
156
- rssKb: Number.parseInt(parts[0], 10) || 0,
157
- dataKb: 0,
158
- cpuPct: Number.parseFloat(parts[1]) || 0,
159
- ps: true,
160
- };
161
- }
162
-
163
- // ---------- child discovery ----------
164
- function findDescendants(rootPid) {
165
- // BFS over /proc to find all PIDs whose ppid chain leads to rootPid.
166
- if (!existsSync("/proc")) return [rootPid];
167
- const all = [];
168
- try {
169
- for (const name of readdirSync("/proc")) {
170
- if (/^\d+$/.test(name)) all.push(Number.parseInt(name, 10));
171
- }
172
- } catch {
173
- return [rootPid];
174
- }
175
- const ppidOf = new Map();
176
- for (const pid of all) {
177
- const s = readProcStat(pid);
178
- if (s) ppidOf.set(pid, s.ppid);
179
- }
180
- const result = new Set([rootPid]);
181
- let frontier = [rootPid];
182
- while (frontier.length) {
183
- const next = [];
184
- for (const [pid, ppid] of ppidOf) {
185
- if (result.has(ppid) && !result.has(pid)) {
186
- result.add(pid);
187
- next.push(pid);
188
- }
189
- }
190
- frontier = next;
191
- }
192
- return [...result];
193
- }
194
-
195
- // ---------- sampling ----------
196
- const cpuPrev = new Map(); // pid -> {ticks, ts}
197
-
198
- // ---- live anomaly warnings (stderr, rate-limited) ----
199
- // Emitted DURING sampling so the user sees resource anomalies in real time,
200
- // not only in the post-hoc analyze-run report. Rate-limited per (pid,category)
201
- // so a sustained spike doesn't spam every tick.
202
- const rssPrev = new Map(); // pid -> { rss, ts } (for one-interval RSS jump)
203
- const rssHist = new Map(); // pid -> number[] (recent rssBytes, for slow-leak trend)
204
- const warnCooldown = new Map(); // `${pid}:${cat}` -> last emit ts
205
- const LIVE_CPU_PCT = 300; // single-process CPU% (multi-core: 300% = 3 cores)
206
- const LIVE_RSS_JUMP = 200_000_000; // +200MB in one interval (fast spike)
207
- const LIVE_RSS_HIGH = 1_000_000_000; // 1GB absolute
208
- const LIVE_LEAK_WINDOW = 30; // samples to evaluate slow-leak trend (30s at 1s interval)
209
- const LIVE_LEAK_GROWTH = 100_000_000; // +100MB net over the window = leak
210
- const LIVE_LEAK_MONOTONIC = 0.75; // ≥75% of pairs increasing = monotonic-ish
211
- const WARN_COOLDOWN_MS = 10_000;
212
- let liveWarnEnabled = true;
213
- function liveWarn(pid, label, cat, msg) {
214
- if (!liveWarnEnabled) return;
215
- const key = `${pid}:${cat}`;
216
- const now = Date.now();
217
- if (now - (warnCooldown.get(key) || 0) < WARN_COOLDOWN_MS) return;
218
- warnCooldown.set(key, now);
219
- process.stderr.write(`[resource-sampler] ⚠️ LIVE ${new Date(now).toISOString().slice(11, 19)} ${cat} pid=${pid}${label ? ` (${label})` : ""}: ${msg}\n`);
220
- }
221
-
222
- function samplePid(pid, rootPid) {
223
- // Linux /proc path
224
- if (existsSync(`/proc/${pid}/stat`)) {
225
- const stat = readProcStat(pid);
226
- const status = readProcStatus(pid);
227
- if (!stat) return null;
228
- const now = Date.now();
229
- const ticks = stat.utime + stat.stime;
230
- let cpuPct = 0;
231
- const prev = cpuPrev.get(pid);
232
- // firstSample: no usable baseline yet (first-ever sample OR PID reused).
233
- // cpuPct is 0 here by construction — consumers should exclude it from CPU
234
- // averages so short-lived subagents aren't dragged down by the first tick.
235
- const firstSample = !prev || prev.starttime !== stat.starttime;
236
- // PID-reuse guard: if starttime changed, a NEW process recycled this PID
237
- // (common under respawn churn) — the old cpuPrev ticks are stale and would
238
- // underreport. Treat as a first sample (cpuPct=0) and reset the baseline.
239
- if (!firstSample) {
240
- const dtick = ticks - prev.ticks;
241
- const dsec = (now - prev.ts) / 1000;
242
- if (dsec > 0) cpuPct = (dtick / CLK_TCK / dsec) * 100;
243
- }
244
- cpuPrev.set(pid, { ticks, ts: now, starttime: stat.starttime });
245
- return {
246
- ts: now,
247
- pid,
248
- ppid: stat.ppid,
249
- label: pid === rootPid ? "root" : "child",
250
- rssBytes: status ? status.rssKb * 1024 : 0,
251
- heapBytes: status ? status.dataKb * 1024 : 0,
252
- cpuPct: Math.max(0, Math.round(cpuPct * 10) / 10),
253
- firstSample,
254
- };
255
- }
256
- // fallback: ps
257
- const ps = readPs(pid);
258
- if (!ps) return null;
259
- return {
260
- ts: Date.now(),
261
- pid,
262
- ppid: ps.ppid,
263
- label: pid === rootPid ? "root" : "child",
264
- rssBytes: ps.rssKb * 1024,
265
- heapBytes: 0,
266
- cpuPct: Math.round(ps.cpuPct * 10) / 10,
267
- };
268
- }
269
-
270
- function tick(rootPid, outPath) {
271
- const pids = findDescendants(rootPid);
272
- const seen = new Set(pids);
273
- for (const pid of pids) {
274
- const sample = samplePid(pid, rootPid);
275
- if (sample) {
276
- appendFileSync(outPath, JSON.stringify(sample) + "\n");
277
- // live anomaly checks (rate-limited, stderr) — real-time observability
278
- // zombie/dead child still in /proc (parent not reaping) — flag it live
279
- if (pid !== rootPid && !isAlive(pid)) liveWarn(pid, sample.label, "proc_zombie", "process zombie/dead (state Z/X) — parent not reaping child");
280
- if (sample.cpuPct >= LIVE_CPU_PCT) liveWarn(pid, sample.label, "high_cpu", `CPU ${sample.cpuPct}% (≥${LIVE_CPU_PCT}%)`);
281
- const rp = rssPrev.get(pid);
282
- if (rp) {
283
- const jump = sample.rssBytes - rp.rss;
284
- if (jump >= LIVE_RSS_JUMP) liveWarn(pid, sample.label, "rss_jump", `RSS +${(jump / 1024 / 1024).toFixed(0)}MB (${(rp.rss / 1024 / 1024).toFixed(0)}→${(sample.rssBytes / 1024 / 1024).toFixed(0)}MB) in one interval`);
285
- }
286
- rssPrev.set(pid, { rss: sample.rssBytes, ts: sample.ts });
287
- if (sample.rssBytes >= LIVE_RSS_HIGH) liveWarn(pid, sample.label, "rss_high", `RSS ${(sample.rssBytes / 1024 / 1024).toFixed(0)}MB (≥1GB)`);
288
- // slow-leak trend: gradual monotonic growth that no single-interval jump
289
- // catches (e.g. +5MB/interval × 50). LONG window (30 samples) so warmup
290
- // growth (V8 heap fill ~10-15s then plateau) does NOT fire — only
291
- // SUSTAINED growth (30s+) is a leak signal. Validated on real e2e data:
292
- // subagent warmup plateaus (no fire), long-lived session growth fires.
293
- const h = rssHist.get(pid) || [];
294
- h.push(sample.rssBytes);
295
- if (h.length > LIVE_LEAK_WINDOW) h.shift();
296
- rssHist.set(pid, h);
297
- if (h.length >= LIVE_LEAK_WINDOW) {
298
- const growth = h[h.length - 1] - h[0];
299
- let inc = 0;
300
- for (let i = 1; i < h.length; i++) if (h[i] > h[i - 1]) inc++;
301
- if (growth >= LIVE_LEAK_GROWTH && inc / (h.length - 1) >= LIVE_LEAK_MONOTONIC) {
302
- liveWarn(pid, sample.label, "rss_leak", `slow RSS leak +${(growth / 1024 / 1024).toFixed(0)}MB over ${h.length} samples (${(h[0] / 1024 / 1024).toFixed(0)}→${(h[h.length - 1] / 1024 / 1024).toFixed(0)}MB, monotonic) — possible memory leak`);
303
- }
304
- }
305
- } else {
306
- rssPrev.delete(pid);
307
- rssHist.delete(pid);
308
- }
309
- }
310
- // F4 (audit): prune cpuPrev entries for PIDs no longer alive — prevents the
311
- // Map from accumulating stale entries for short-lived children over a long
312
- // sampling session.
313
- for (const pid of cpuPrev.keys()) {
314
- if (!seen.has(pid)) {
315
- cpuPrev.delete(pid);
316
- rssPrev.delete(pid);
317
- rssHist.delete(pid);
318
- }
319
- }
320
- // prune warnCooldown for gone PIDs (keep rootPid entry)
321
- for (const key of warnCooldown.keys()) {
322
- const pid = Number(key.split(":")[0]);
323
- if (!seen.has(pid) && pid !== rootPid) warnCooldown.delete(key);
324
- }
325
- }
326
-
327
- // R4/sampler-test (audit): robust liveness — a process that exited but whose
328
- // parent can't reap it yet is a ZOMBIE, and /proc/<pid>/stat still exists for
329
- // zombies. Checking only file existence would never detect death in that
330
- // case. Treat state 'Z' (zombie) and 'X' (dead) as not-alive.
331
- function isAlive(pid) {
332
- if (existsSync("/proc")) {
333
- const stat = readProcStat(pid);
334
- if (!stat) return false;
335
- return stat.state !== "Z" && stat.state !== "X";
336
- }
337
- return readPs(pid) != null;
338
- }
339
- // Resolve a run's leader/runner PID from pi-crew state. Tries async.pid (a
340
- // dedicated JSON file), manifest.async.pid, and heartbeat.json.pid. Polls
341
- // briefly (the file may not exist the instant the run starts).
342
- function resolveRunnerPid(runDir) {
343
- const readJsonSafe = (p) => {
344
- try {
345
- return JSON.parse(readFileSync(p, "utf8"));
346
- } catch {
347
- return null;
348
- }
349
- };
350
- for (let i = 0; i < 20; i++) {
351
- const asyncPid = readJsonSafe(join(runDir, "async.pid"));
352
- if (asyncPid && typeof asyncPid.pid === "number") return asyncPid.pid;
353
- const manifest = readJsonSafe(join(runDir, "manifest.json"));
354
- if (manifest && manifest.async && typeof manifest.async.pid === "number") return manifest.async.pid;
355
- const hb = readJsonSafe(join(runDir, "heartbeat.json"));
356
- if (hb && typeof hb.pid === "number") return hb.pid;
357
- // R7 (audit): block-wait instead of spawning a throwaway node process
358
- // every poll. Atomics.wait on the main thread is permitted in Node.
359
- const waitBuf = new Int32Array(new SharedArrayBuffer(4));
360
- Atomics.wait(waitBuf, 0, 0, 500);
361
- }
362
- return null;
363
- }
364
- function main() {
365
- const args = parseArgs(process.argv);
366
- liveWarnEnabled = args.liveWarn;
367
- const outDir = join(process.cwd(), "bench", "results");
368
- mkdirSync(outDir, { recursive: true });
369
- const outFile =
370
- args.out ||
371
- join(outDir, `${args.runId || "wrap-" + Date.now()}.resources.jsonl`);
372
- process.stderr.write(`[resource-sampler] writing → ${outFile}\n`);
373
-
374
- if (args.mode === "wrap") {
375
- if (args.wrap.length === 0) {
376
- process.stderr.write("--wrap requires a command\n");
377
- process.exit(1);
378
- }
379
- const child = spawn(args.wrap[0], args.wrap.slice(1), { stdio: "inherit" });
380
- const rootPid = child.pid;
381
- let exited = false;
382
- // sample immediately, then on interval
383
- tick(rootPid, outFile);
384
- const handle = setInterval(() => tick(rootPid, outFile), args.interval);
385
- const cleanup = () => {
386
- clearInterval(handle);
387
- // F3 (audit): kill the wrapped child so it is not orphaned when the
388
- // sampler is signalled (SIGINT/SIGTERM). tryKill is best-effort.
389
- try {
390
- if (!exited) child.kill("SIGTERM");
391
- } catch {
392
- /* already gone */
393
- }
394
- };
395
- process.on("SIGINT", () => {
396
- cleanup();
397
- process.exit(130);
398
- });
399
- process.on("SIGTERM", () => {
400
- cleanup();
401
- process.exit(143);
402
- });
403
- child.on("exit", (code, signal) => {
404
- exited = true;
405
- clearInterval(handle);
406
- // final sample
407
- tick(rootPid, outFile);
408
- process.stderr.write(`[resource-sampler] child exited code=${code} signal=${signal}\n`);
409
- process.exit(code ?? 1);
410
- });
411
- } else {
412
- // watch-parent (or --watch-run, which resolves the runner PID from state)
413
- let rootPid = args.parentPid;
414
- if (args.watchRun) {
415
- const crew = args.crewRoot || join(process.env.HOME || "/home/bom", ".crew");
416
- const runDir = join(crew, "state", "runs", args.watchRun);
417
- rootPid = resolveRunnerPid(runDir);
418
- if (!rootPid) {
419
- process.stderr.write(`--watch-run: could not resolve runner PID for ${args.watchRun} in ${runDir} (async.pid / manifest.async.pid / heartbeat.json)\n`);
420
- process.exit(1);
421
- }
422
- process.stderr.write(`[resource-sampler] --watch-run ${args.watchRun} → runner PID ${rootPid}\n`);
423
- }
424
- if (!rootPid) {
425
- process.stderr.write("--watch-parent requires a PID\n");
426
- process.exit(1);
427
- }
428
- // R4 (audit): auto-stop when the watched tree dies. Previously the sampler
429
- // kept ticking forever (writing nothing) after rootPid exited, until the
430
- // user remembered to Ctrl-C. Now: if rootPid is not alive for 3
431
- // consecutive ticks, stop cleanly. rootPid (the team leader) outlives the
432
- // whole run, so its death = run over.
433
- // PLUS (perf-obs): in --watch-run mode the runner PID may be the
434
- // long-lived foreground pi process (sync runs) — it stays alive after the
435
- // run, so ALSO stop when the run's manifest.json reaches a terminal
436
- // status (completed/failed/cancelled/blocked).
437
- const runDir = args.watchRun ? join(args.crewRoot || join(process.env.HOME || "/home/bom", ".crew"), "state", "runs", args.watchRun) : null;
438
- const runIsTerminal = () => {
439
- if (!runDir) return false;
440
- try {
441
- const m = JSON.parse(readFileSync(join(runDir, "manifest.json"), "utf8"));
442
- return ["completed", "failed", "cancelled", "blocked"].includes(m.status);
443
- } catch {
444
- return false;
445
- }
446
- };
447
- let deadTicks = 0;
448
- const tickWatch = () => {
449
- const alive = isAlive(rootPid);
450
- if (alive) {
451
- deadTicks = 0;
452
- if (runIsTerminal()) {
453
- if (handle) clearInterval(handle);
454
- process.stderr.write(`[resource-sampler] run ${args.watchRun} reached terminal status — stopping\n`);
455
- process.exit(0);
456
- }
457
- tick(rootPid, outFile);
458
- } else {
459
- deadTicks++;
460
- if (deadTicks === 1) liveWarn(rootPid, "root", "proc_died", `watched PID ${rootPid} no longer alive`);
461
- if (deadTicks >= 3) {
462
- clearInterval(handle);
463
- process.stderr.write(`[resource-sampler] watched PID ${rootPid} gone — stopping\n`);
464
- process.exit(0);
465
- }
466
- }
467
- };
468
- let handle;
469
- tickWatch();
470
- handle = setInterval(tickWatch, args.interval);
471
- const shutdown = () => {
472
- clearInterval(handle);
473
- tick(rootPid, outFile);
474
- process.stderr.write("[resource-sampler] stopped\n");
475
- process.exit(0);
476
- };
477
- process.on("SIGINT", shutdown);
478
- process.on("SIGTERM", shutdown);
479
- }
480
- }
481
-
482
- main();