pi-crew 0.9.64 → 0.9.65
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 +13 -0
- package/README.md +46 -1
- package/dist/index.mjs +316 -299
- package/package.json +3 -2
- package/scripts/analyze-run.mjs +1333 -0
- package/scripts/pty_probe.py +10 -8
- package/scripts/resource-sampler.mjs +482 -0
- package/skills/real-test-pi-crew/SKILL.md +6 -6
- package/src/observability/event-to-metric.ts +29 -0
- package/src/observability/metrics-primitives.ts +41 -3
- package/src/runtime/README.md +1 -1
- package/src/runtime/broker/crew-broker.ts +0 -16
- package/src/runtime/effectiveness.ts +23 -1
- package/src/runtime/merge-gate.ts +202 -0
- package/src/runtime/model/model-fallback.ts +11 -0
- package/src/runtime/model/provider-extensions.ts +31 -12
- package/src/runtime/output/progress-tracker.ts +3 -33
- package/src/runtime/scratchpad/engine.ts +40 -2
- package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
- package/src/runtime/team-runner.ts +128 -203
- package/src/schema/team-tool-schema.ts +2 -0
- package/src/teams/discover-teams.ts +2 -0
- package/src/teams/team-config.ts +7 -0
- package/src/teams/team-serializer.ts +1 -0
- package/src/ui/mascot.ts +1 -14
- package/teams/default.team.md +1 -0
- package/teams/fast-fix.team.md +1 -0
- package/src/observability/event-bus.ts +0 -86
- package/src/plugins/plugin-define.ts +0 -6
- package/src/plugins/plugin-registry.ts +0 -32
- package/src/plugins/plugins/index.ts +0 -3
- package/src/plugins/plugins/nextjs.ts +0 -19
- package/src/plugins/plugins/vite.ts +0 -10
- package/src/plugins/plugins/vitest.ts +0 -9
- package/src/runtime/child-pi/child-pi-pool.ts +0 -68
- package/src/runtime/iteration-hooks.ts +0 -305
package/scripts/pty_probe.py
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""pty_probe.py — bulk-key
|
|
2
|
+
"""pty_probe.py — bulk-key probe for pi-crew TUI components.
|
|
3
3
|
|
|
4
4
|
Spawns a real `pi` session under a pty, sends a sequence of keys with
|
|
5
5
|
short sleeps, captures the resulting output. Useful for verifying that
|
|
6
6
|
keystrokes reached the component's handleInput after a ui/ change.
|
|
7
7
|
|
|
8
|
+
NOTE (2026-08-10): the per-keystroke diag env var (PI_CREW_BROKER_DIAG_UI)
|
|
9
|
+
was REMOVED in e3ee6fe2 (PR-B5/UI-8 — "remove TEMP DIAGNOSTIC from
|
|
10
|
+
run-dashboard"). There is no replacement in src/. Keystroke arrival is now
|
|
11
|
+
proven by SCREEN-CHANGE evidence: capture the output frames before/after
|
|
12
|
+
each key and diff them — a key that changes screen state reached the TUI.
|
|
13
|
+
|
|
8
14
|
Requires Python 3.x on PATH. Unix only (Linux + macOS) — uses POSIX `pty.fork`.
|
|
9
15
|
Does NOT work on native Windows (no `pty` module); use WSL or Tier 5 (tmux) instead.
|
|
10
16
|
|
|
11
17
|
Usage:
|
|
12
18
|
python3 scripts/pty_probe.py [--keys j,k,q] [--cwd /path/to/repo]
|
|
13
19
|
|
|
14
|
-
Env:
|
|
15
|
-
PI_CREW_BROKER_DIAG_UI=1 enable diag stderr writes from run-dashboard
|
|
16
|
-
(only component wired; see src/ui/run-dashboard.ts:831)
|
|
17
|
-
|
|
18
20
|
Examples:
|
|
19
21
|
# Default probe (vim nav + arrow keys + quit)
|
|
20
22
|
python3 scripts/pty_probe.py
|
|
@@ -22,8 +24,8 @@ Examples:
|
|
|
22
24
|
# Custom probe: only arrow keys
|
|
23
25
|
python3 scripts/pty_probe.py --keys '\x1bOA,\x1bOB,\x1bOC,\x1bOD,q,q'
|
|
24
26
|
|
|
25
|
-
# Capture to a file
|
|
26
|
-
python3 scripts/pty_probe.py 2>&1 | tee /tmp/
|
|
27
|
+
# Capture to a file (diff frames for screen-change evidence)
|
|
28
|
+
python3 scripts/pty_probe.py 2>&1 | tee /tmp/pty-probe.log
|
|
27
29
|
"""
|
|
28
30
|
import argparse
|
|
29
31
|
import os
|
|
@@ -99,7 +101,7 @@ def main() -> int:
|
|
|
99
101
|
pass # use as-is if decode fails
|
|
100
102
|
keys.append(k)
|
|
101
103
|
|
|
102
|
-
env =
|
|
104
|
+
env = dict(os.environ) # keystroke diag env var removed (see module docstring)
|
|
103
105
|
|
|
104
106
|
pid, fd = pty.fork()
|
|
105
107
|
if pid == 0:
|
|
@@ -0,0 +1,482 @@
|
|
|
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();
|
|
@@ -303,7 +303,7 @@ tmux capture-pane -t pi -p > /tmp/screen-after-up.txt
|
|
|
303
303
|
import os, sys, time
|
|
304
304
|
|
|
305
305
|
CMD = ['pi']
|
|
306
|
-
ENV =
|
|
306
|
+
ENV = dict(os.environ) # keystroke diag env var REMOVED (see note below)
|
|
307
307
|
|
|
308
308
|
pid, fd = pty.fork()
|
|
309
309
|
if pid == 0:
|
|
@@ -331,14 +331,14 @@ else:
|
|
|
331
331
|
> ```
|
|
332
332
|
> The inline code works for a quick one-off but **leaks a zombie `pi` process** on exit.
|
|
333
333
|
|
|
334
|
-
|
|
334
|
+
**Keystroke diag env var REMOVED (2026-08-10)**: `PI_CREW_BROKER_DIAG_UI=1` made `run-dashboard`'s `handleInput` write a `[PI-CREW-DIAG]` line to stderr per keystroke. It was removed in `e3ee6fe2` (PR-B5: remove TEMP DIAGNOSTIC from run-dashboard, UI-8) — there is no replacement in `src/`. **To prove keystroke arrival now, rely on screen-change evidence** (Tier 5 tmux `capture-pane` before/after each key, or the pty output diff): a key that changes screen state reached the TUI; a key that does not was consumed or never arrived. Capture the probe output to a file with `2>&1 | tee /tmp/pty-probe.log` and diff the rendered frames.
|
|
335
335
|
|
|
336
336
|
**References**:
|
|
337
337
|
|
|
338
338
|
| What | Where |
|
|
339
339
|
|---|---|
|
|
340
|
-
|
|
|
341
|
-
| Reduced-noise commit | `00e8ba0 chore(broker): strip diagnostic noise from focused-field fix` — diag calls left in but no longer noisy |
|
|
340
|
+
| Keystroke diag env var | **REMOVED** — `e3ee6fe2` (PR-B5/UI-8). No replacement; use screen-change evidence |
|
|
341
|
+
| Reduced-noise commit | `00e8ba0 chore(broker): strip diagnostic noise from focused-field fix` — diag calls left in but no longer noisy (pre-removal) |
|
|
342
342
|
| Original probe | `84944f7 test(probe): add invalidate() to control object so typecheck passes` |
|
|
343
343
|
|
|
344
344
|
---
|
|
@@ -637,7 +637,7 @@ The skill mentions specific commits, line numbers, and version pins. As the code
|
|
|
637
637
|
|---|---|---|
|
|
638
638
|
| Verify line refs after each `src/` commit | Every commit touching the cited file | `git log -p -- src/extension/registration/lifecycle-handlers.ts \| grep effectiveEnabled` — if line moved, update the skill |
|
|
639
639
|
| Verify commit hashes still exist | Quarterly or before major edits | `git log --oneline -1 <hash>` — if gone, find the equivalent newer commit |
|
|
640
|
-
| Verify version pins (v0.9.46, etc.) | Each release | `git log --oneline -- src/ui/run-dashboard.ts \| head -5` —
|
|
640
|
+
| Verify version pins (v0.9.46, etc.) | Each release | `git log --oneline -- src/ui/run-dashboard.ts \| head -5` — confirm diag removal history (e3ee6fe2) still accurate |
|
|
641
641
|
| Verify `test:critical` still has 14 files | Each `src/runtime/crew-broker*.ts` edit | `cat package.json \| grep test:critical` — adjust the file list |
|
|
642
642
|
| Verify Tier 7 verifier prompts still say `test:critical` | Each workflow file edit | `grep "Run FAST checks" workflows/*.workflow.md` |
|
|
643
643
|
|
|
@@ -662,7 +662,7 @@ readlink ../node_modules/pi-crew # dev: → ../pi-crew
|
|
|
662
662
|
readlink "$(npm root -g)"/pi-crew # global install
|
|
663
663
|
# Tier 5 (tmux probe)
|
|
664
664
|
tmux -S /tmp/sock new-session -d -x 160 -y 50 -s pi \
|
|
665
|
-
"cd ${PWD} &&
|
|
665
|
+
"cd ${PWD} && exec pi 2>&1"
|
|
666
666
|
tmux send-keys -t pi '<key>' ; sleep 0.5
|
|
667
667
|
tmux capture-pane -t pi -p
|
|
668
668
|
# Tier 6 (pty probe)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { MetricRegistry } from "./metric-registry.ts";
|
|
3
|
+
import { getCardinalityEvictions } from "./metrics-primitives.ts";
|
|
3
4
|
|
|
4
5
|
function recordValue(value: unknown): Record<string, unknown> {
|
|
5
6
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
@@ -41,6 +42,19 @@ export function wireEventToMetrics(events: ExtensionAPI["events"] | undefined, r
|
|
|
41
42
|
const deadletterCount = registry.counter("crew.task.deadletter_total", "Deadletter triggers by reason");
|
|
42
43
|
const overflowCount = registry.counter("crew.task.overflow_phase_total", "Overflow recovery phase transitions");
|
|
43
44
|
const supervisorContactCount = registry.counter("crew.task.supervisor_contact_total", "Supervisor contact requests by reason");
|
|
45
|
+
const unboundedConcurrencyCount = registry.counter(
|
|
46
|
+
"crew.limits.unbounded_total",
|
|
47
|
+
"Runs that enabled allowUnboundedConcurrency (advisory; bypasses hard cap)",
|
|
48
|
+
);
|
|
49
|
+
// Gauge reflecting cumulative label-combination evictions across all
|
|
50
|
+
// metrics in this process. Updated lazily on every metric event so the
|
|
51
|
+
// value stays fresh without a separate timer. Non-zero = aggregation
|
|
52
|
+
// for high-cardinality labels is unreliable. See metrics-primitives.ts
|
|
53
|
+
// `enforceLabelCap`.
|
|
54
|
+
const cardinalityEvictedGauge = registry.gauge(
|
|
55
|
+
"crew.metrics.cardinality_evicted",
|
|
56
|
+
"Cumulative label-combination evictions (non-zero = unreliable aggregation)",
|
|
57
|
+
);
|
|
44
58
|
registry.gauge("crew.heartbeat.staleness_ms", "Heartbeat elapsed since last seen, milliseconds");
|
|
45
59
|
const runDuration = registry.histogram(
|
|
46
60
|
"crew.run.duration_ms",
|
|
@@ -146,11 +160,26 @@ export function wireEventToMetrics(events: ExtensionAPI["events"] | undefined, r
|
|
|
146
160
|
});
|
|
147
161
|
},
|
|
148
162
|
],
|
|
163
|
+
[
|
|
164
|
+
"crew.limits.unbounded",
|
|
165
|
+
() => {
|
|
166
|
+
// Fires when a run enables allowUnboundedConcurrency (bypasses the
|
|
167
|
+
// hard cap of 8 and the worker cap). One emit per affected run.
|
|
168
|
+
unboundedConcurrencyCount.inc({});
|
|
169
|
+
},
|
|
170
|
+
],
|
|
149
171
|
];
|
|
150
172
|
|
|
151
173
|
const unsubscribers: Array<() => void> = [];
|
|
152
174
|
for (const [event, handler] of handlers) {
|
|
153
175
|
const unsubscribe = events?.on?.(event, (data: unknown) => {
|
|
176
|
+
// Refresh the cardinality-eviction gauge on every event so the
|
|
177
|
+
// value reflects the latest eviction state without a timer.
|
|
178
|
+
try {
|
|
179
|
+
cardinalityEvictedGauge.set({}, getCardinalityEvictions());
|
|
180
|
+
} catch {
|
|
181
|
+
/* gauge refresh must never break event delivery */
|
|
182
|
+
}
|
|
154
183
|
try {
|
|
155
184
|
handler(data);
|
|
156
185
|
} catch {
|
|
@@ -36,13 +36,51 @@ interface StoredHistogram {
|
|
|
36
36
|
|
|
37
37
|
export const DEFAULT_HISTOGRAM_BUCKETS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000] as const;
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Maximum number of unique label combinations per metric.
|
|
41
|
+
*
|
|
42
|
+
* When this cap is reached, the oldest label combination is silently
|
|
43
|
+
* evicted (FIFO by insertion order, with MRU promotion on use). Read
|
|
44
|
+
* {@link getCardinalityEvictions} to detect when aggregation has become
|
|
45
|
+
* unreliable for high-cardinality labels.
|
|
46
|
+
*/
|
|
40
47
|
const MAX_LABEL_COMBINATIONS = 10_000;
|
|
41
48
|
|
|
42
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Cumulative count of label-combination evictions across all metrics in
|
|
51
|
+
* this process. Incremented every time {@link enforceLabelCap} drops an
|
|
52
|
+
* entry. Exported so observability wiring and OTLP/Prometheus exporters
|
|
53
|
+
* can surface `crew.metrics.cardinality_evicted` without risking the
|
|
54
|
+
* recursion of routing this signal through a `Counter` (a Counter itself
|
|
55
|
+
* consumes a label slot and could self-evict).
|
|
56
|
+
*/
|
|
57
|
+
let cardinalityEvictions = 0;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the cumulative number of metric label-combination evictions
|
|
61
|
+
* that have occurred in this process. A non-zero value means at least
|
|
62
|
+
* one metric exceeded {@link MAX_LABEL_COMBINATIONS} and silently dropped
|
|
63
|
+
* the oldest label combination — aggregation for high-cardinality labels
|
|
64
|
+
* is unreliable.
|
|
65
|
+
*/
|
|
66
|
+
export function getCardinalityEvictions(): number {
|
|
67
|
+
return cardinalityEvictions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Reset the eviction counter. Intended for tests only. */
|
|
71
|
+
export function _resetCardinalityEvictionsForTests(): void {
|
|
72
|
+
cardinalityEvictions = 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function enforceLabelCap(map: Map<string, unknown>, _metricName: string): void {
|
|
43
76
|
while (map.size > MAX_LABEL_COMBINATIONS) {
|
|
44
77
|
const firstKey = map.keys().next().value;
|
|
45
|
-
if (firstKey !== undefined)
|
|
78
|
+
if (firstKey !== undefined) {
|
|
79
|
+
map.delete(firstKey);
|
|
80
|
+
cardinalityEvictions++;
|
|
81
|
+
} else {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
46
84
|
}
|
|
47
85
|
}
|
|
48
86
|
|