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.
Files changed (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +46 -1
  3. package/dist/index.mjs +316 -299
  4. package/package.json +3 -2
  5. package/scripts/analyze-run.mjs +1333 -0
  6. package/scripts/pty_probe.py +10 -8
  7. package/scripts/resource-sampler.mjs +482 -0
  8. package/skills/real-test-pi-crew/SKILL.md +6 -6
  9. package/src/observability/event-to-metric.ts +29 -0
  10. package/src/observability/metrics-primitives.ts +41 -3
  11. package/src/runtime/README.md +1 -1
  12. package/src/runtime/broker/crew-broker.ts +0 -16
  13. package/src/runtime/effectiveness.ts +23 -1
  14. package/src/runtime/merge-gate.ts +202 -0
  15. package/src/runtime/model/model-fallback.ts +11 -0
  16. package/src/runtime/model/provider-extensions.ts +31 -12
  17. package/src/runtime/output/progress-tracker.ts +3 -33
  18. package/src/runtime/scratchpad/engine.ts +40 -2
  19. package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
  20. package/src/runtime/team-runner.ts +128 -203
  21. package/src/schema/team-tool-schema.ts +2 -0
  22. package/src/teams/discover-teams.ts +2 -0
  23. package/src/teams/team-config.ts +7 -0
  24. package/src/teams/team-serializer.ts +1 -0
  25. package/src/ui/mascot.ts +1 -14
  26. package/teams/default.team.md +1 -0
  27. package/teams/fast-fix.team.md +1 -0
  28. package/src/observability/event-bus.ts +0 -86
  29. package/src/plugins/plugin-define.ts +0 -6
  30. package/src/plugins/plugin-registry.ts +0 -32
  31. package/src/plugins/plugins/index.ts +0 -3
  32. package/src/plugins/plugins/nextjs.ts +0 -19
  33. package/src/plugins/plugins/vite.ts +0 -10
  34. package/src/plugins/plugins/vitest.ts +0 -9
  35. package/src/runtime/child-pi/child-pi-pool.ts +0 -68
  36. package/src/runtime/iteration-hooks.ts +0 -305
@@ -0,0 +1,1333 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pi-crew run analyzer — phân tích run THẬT và tô đậm chỗ chậm/lỗi/vấn đề.
4
+ *
5
+ * Usage:
6
+ * node scripts/analyze-run.mjs <runId> [--crew-root <path>] [--resources <path>]
7
+ *
8
+ * Đọc: events.jsonl (timeline), transcripts/*.jsonl (token/cost/model thật),
9
+ * manifest.json, tasks.json, agents.json. Reads are streamed line-by-line but
10
+ * parsed objects are held in memory (O(file size)) — fine for realistic runs
11
+ * (largest seen: ~1MB transcript). Would need true streaming aggregation for
12
+ * pathological multi-GB runs, which pi-crew does not produce.
13
+ *
14
+ * Output:
15
+ * docs/perf-report-<runId>.md — báo cáo tiếng Việt, bảng + 🔴 highlight
16
+ * bench/results/<runId>.json — structured JSON
17
+ */
18
+
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, createReadStream } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { createInterface } from "node:readline";
22
+
23
+ // ---------- CLI ----------
24
+ function parseArgs(argv) {
25
+ const args = { runId: null, crewRoot: null, resources: null, events: false, agents: false };
26
+ for (let i = 2; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === "--crew-root") args.crewRoot = argv[++i];
29
+ else if (a === "--resources") args.resources = argv[++i];
30
+ else if (a === "--events") args.events = true;
31
+ else if (a === "--agents") args.agents = true;
32
+ else if (a === "-h" || a === "--help") {
33
+ process.stderr.write("Usage: analyze-run.mjs <runId> [--crew-root <path>] [--resources <path>] [--events]\n");
34
+ process.exit(0);
35
+ } else if (!a.startsWith("--")) {
36
+ args.runId = a;
37
+ }
38
+ }
39
+ if (!args.runId) {
40
+ process.stderr.write("Error: runId required.\nUsage: analyze-run.mjs <runId> [--crew-root <path>]\n");
41
+ process.exit(1);
42
+ }
43
+ args.crewRoot = args.crewRoot || join(process.env.HOME || "/home/bom", ".crew");
44
+ return args;
45
+ }
46
+
47
+ const ms = (iso) => (iso ? new Date(iso).getTime() : null);
48
+ const mb = (b) => `${((b || 0) / 1024 / 1024).toFixed(1)}MB`;
49
+ const fmtMs = (n) => {
50
+ if (n == null || Number.isNaN(n)) return "—";
51
+ if (n < 1000) return `${Math.round(n)}ms`;
52
+ const s = n / 1000;
53
+ if (s < 60) return `${s.toFixed(1)}s`;
54
+ const m = Math.floor(s / 60);
55
+ return `${m}m${Math.round(s % 60)}s`;
56
+ };
57
+ const money = (n) => (n ? `$${n.toFixed(4)}` : "$0");
58
+ const esc = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ").slice(0, 80);
59
+
60
+ // ---------- line-by-line JSONL reader ----------
61
+ async function readJsonl(path) {
62
+ const out = [];
63
+ if (!existsSync(path)) return out;
64
+ const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
65
+ for await (const line of rl) {
66
+ const t = line.trim();
67
+ if (!t) continue;
68
+ try {
69
+ out.push(JSON.parse(t));
70
+ } catch {
71
+ /* skip malformed */
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function readJson(path) {
78
+ try {
79
+ return JSON.parse(readFileSync(path, "utf8"));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // ---------- per-event timeline (--events) ----------
86
+ // Builds a per-event timeline: each event with elapsed-since-run-start, gap
87
+ // from the previous event, type, taskId, and (if resource samples are
88
+ // available) the owning worker's RSS/CPU nearest to that event's timestamp.
89
+ // Note: per-event TOKENS are NOT available — events.jsonl redacts tokens to
90
+ // "***" and transcript records carry no timestamp, so only timing + resource
91
+ // can be resolved per event (tokens remain per-task totals).
92
+ // percentile (0-100) of a numeric array; 0 if empty. Used to contextualize
93
+ // peak vs typical-high (p95) for per-subagent resource.
94
+ function pctl(values, p) {
95
+ const v = values.filter((x) => typeof x === "number").sort((a, b) => a - b);
96
+ if (!v.length) return 0;
97
+ const idx = Math.min(v.length - 1, Math.max(0, Math.ceil((p / 100) * v.length) - 1));
98
+ return v[idx];
99
+ }
100
+
101
+ // Detect performance/health anomalies from recorded run data so they can be
102
+ // surfaced as ⚠️ warnings in the report. Thresholds are grounded in real
103
+ // pi-crew runs (see docs/perf-report-team_20260806114118). Returns a list of
104
+ // { severity: 'HIGH'|'MEDIUM'|'LOW', category, subagent, message }.
105
+ // Severity guide: HIGH = action needed (failure/stuck/leak/sustained load),
106
+ // MEDIUM = investigate (slow phase, big gap, retries), LOW = informational
107
+ // (transient spike — real but brief).
108
+ function detectAnomalies(subagents, resources, problems, runCtx) {
109
+ const runTerminalType = runCtx?.runTerminalType;
110
+ const wallMs = runCtx?.wallMs;
111
+ const out = [];
112
+ const rsByTask = new Map((resources?.perSubagent || []).map((ps) => [ps.taskId, ps]));
113
+ for (const s of subagents) {
114
+ const tid = s.taskId;
115
+ const ph = s.timeline?.phases || {};
116
+ const rs = rsByTask.get(tid);
117
+
118
+ // 1. Task failure / non-clean exit
119
+ if (s.status && s.status !== "completed" && s.status !== "ok") {
120
+ out.push({ severity: "HIGH", category: "task_failed", subagent: tid, message: `subagent exited status="${s.status}" (exitCode=${s.exitCode})${s.error ? ": " + s.error : ""}` });
121
+ } else if (s.exitCode && s.exitCode !== 0) {
122
+ out.push({ severity: "HIGH", category: "task_failed", subagent: tid, message: `non-zero exitCode=${s.exitCode}${s.error ? ": " + s.error : ""}` });
123
+ }
124
+
125
+ // 1b. Missing transcript — no transcript means token/cost for this
126
+ // subagent is 0, a potential SILENT undercount of the run total (e.g.
127
+ // worker crashed before writing its transcript). Defensive: grounded runs
128
+ // always have transcripts, so this only fires on the crash edge case.
129
+ if (s.hasTranscript === false && (s.messageEndCount || 0) === 0) {
130
+ out.push({ severity: "MEDIUM", category: "missing_transcript", subagent: tid, message: "no transcript found — token/cost reported as 0 (potential silent undercount if the subagent did real work)" });
131
+ }
132
+
133
+ // 2. Model retry / fallback CASCADE — escalate to HIGH when pathological
134
+ // (many attempts / many failed models / long chain). Grounded in a real
135
+ // run that cascaded through 45 models (44 failed) on a 429 rate-limit storm.
136
+ const chain = s.modelAttempts || [];
137
+ const failedModels = chain.filter((m) => m && m.success === false);
138
+ const uniqModels = [...new Set(chain.map((m) => m.model || "?"))];
139
+ if ((s.attempts || 1) > 1 || chain.length > 1 || failedModels.length) {
140
+ const pathological = (s.attempts || 1) > 5 || failedModels.length > 3 || uniqModels.length > 5;
141
+ const sev = pathological ? "HIGH" : "MEDIUM";
142
+ // truncate the chain so a 45-model cascade doesn't bloat the report
143
+ const shown = uniqModels.length <= 4 ? uniqModels.join(" → ") : `${uniqModels.slice(0, 2).join(" → ")} → … → ${uniqModels[uniqModels.length - 1]} (+${uniqModels.length - 3} more)`;
144
+ out.push({ severity: sev, category: pathological ? "model_cascade" : "model_retry", subagent: tid, message: `model retry/fallback: ${s.attempts} attempt(s), ${uniqModels.length} model(s) [${shown}]${failedModels.length ? ` (${failedModels.length} failed)` : ""}` });
145
+ }
146
+
147
+ // 3. Large inter-event gap (single turn/tool stalled)
148
+ const gapMs = s.timeline?.maxGap || 0;
149
+ if (gapMs > 15000) {
150
+ out.push({ severity: "MEDIUM", category: "large_gap", subagent: tid, message: `single event-gap of ${(gapMs / 1000).toFixed(1)}s (>15s) — an LLM turn or tool stalled` });
151
+ }
152
+
153
+ // 4. Slow phase
154
+ const slowPhase = ["activeWork", "total", "startup"].find((p) => (ph[p] || 0) > (p === "startup" ? 5000 : 40000));
155
+ if (slowPhase) {
156
+ const sev = slowPhase === "startup" ? "LOW" : "MEDIUM";
157
+ const thr = slowPhase === "startup" ? 5000 : 40000;
158
+ out.push({ severity: sev, category: "slow_phase", subagent: tid, message: `phase "${slowPhase}" took ${(ph[slowPhase] / 1000).toFixed(1)}s (>${thr / 1000}s)` });
159
+ }
160
+
161
+ // 4e. Launch delay (task.started → worker.spawned) — broker scheduling /
162
+ // spawn latency. Skip when respawn churn already flagged (last spawn skews
163
+ // launch). Grounded: normal runs launch in 4-8s; >15s = broker backlog.
164
+ if ((s.spawnCount || 0) <= 2 && (ph.launch || 0) > 15000) {
165
+ out.push({ severity: "MEDIUM", category: "launch_delay", subagent: tid, message: `launch (task→spawn) took ${(ph.launch / 1000).toFixed(1)}s (>15s) — broker scheduling/spawn backlog` });
166
+ }
167
+ // 4f. Drain stall (last progress → worker.exit) — worker hung after work
168
+ // finished. Grounded: clean runs drain in ~0ms; >5s = stuck cleanup.
169
+ if ((ph.drain || 0) > 5000) {
170
+ out.push({ severity: "LOW", category: "drain_stall", subagent: tid, message: `drain (last progress→exit) took ${(ph.drain / 1000).toFixed(1)}s (>5s) — worker hung after finishing work` });
171
+ }
172
+ // 4g. Token imbalance — input ≫ output (huge context, little output).
173
+ // Grounded: normal coding agents run in/out 6-19; >80 with large input is
174
+ // anomalous (agent re-reading context without producing output).
175
+ const u = s.usage || {};
176
+ if ((u.output || 0) > 0 && (u.input || 0) > 20000 && u.input / u.output > 80) {
177
+ out.push({ severity: "LOW", category: "token_imbalance", subagent: tid, message: `input/output ratio ${(u.input / u.output).toFixed(0)}× (${u.input} in → ${u.output} out) — huge context, little output (possible stuck re-read)` });
178
+ }
179
+ // 4h. Cache not warming — 0 cache-read across a task with substantial
180
+ // input means every turn re-processes full context (cost/latency).
181
+ if ((u.cacheRead || 0) === 0 && (u.input || 0) > 5000 && (s.messageEndCount || 0) > 1) {
182
+ out.push({ severity: "LOW", category: "no_cache", subagent: tid, message: `0 cache-read on ${u.input} input tokens across ${s.messageEndCount} turns — cache not warming (every turn re-processes context)` });
183
+ }
184
+
185
+ // 4b. Worker respawn churn — worker.spawned count >> 1 means the worker
186
+ // kept crashing & respawning within one task. Grounded in a real run with
187
+ // 49 spawns/task (exit-code-1 storm).
188
+ if ((s.spawnCount || 0) > 3) {
189
+ const sev = s.spawnCount > 10 ? "HIGH" : "MEDIUM";
190
+ out.push({ severity: sev, category: "worker_respawn_churn", subagent: tid, message: `${s.spawnCount} worker.spawned events for one task (worker respawned ${s.spawnCount - 1}×) — likely crash/restart loop (check exitCodes)` });
191
+ }
192
+
193
+ // 4c. API error storm — majority of LLM turns returned an errorMessage
194
+ // (429/500/abort). Grounded in a run where every turn 429'd.
195
+ const me = s.messageEndCount || 0;
196
+ if (me >= 3 && (s.apiErrors || 0) / me > 0.5) {
197
+ const pct = Math.round(((s.apiErrors || 0) / me) * 100);
198
+ out.push({ severity: "HIGH", category: "api_error_storm", subagent: tid, message: `${s.apiErrors}/${me} LLM turns (${pct}%) returned an API error (429/5xx/abort) — provider rate-limit or outage` });
199
+ }
200
+
201
+ // 4d. Zero-output completion — task marked completed but produced ~0
202
+ // output tokens (all turns failed). A silent failure: status lies "ok".
203
+ if ((s.status === "completed" || s.status === "ok") && me > 0 && (s.usage?.output || 0) === 0) {
204
+ out.push({ severity: "HIGH", category: "zero_output_completion", subagent: tid, message: `marked completed but 0 output tokens across ${me} turn(s) — likely all-API-errors (silent failure)` });
205
+ }
206
+
207
+ // 4j. Tool churn — many tool calls in one subagent (chatty/expensive).
208
+ if ((s.toolCalls || 0) > 30) {
209
+ out.push({ severity: "LOW", category: "tool_churn", subagent: tid, message: `${s.toolCalls} tool calls — chatty/expensive agent (check for tool-use loops)` });
210
+ }
211
+
212
+ // 4k. Sampler coverage gap — sampler collected samples for the run but
213
+ // NONE attributed to this subagent (0 samples). Missing resource data
214
+ // should be flagged, not silently blank. LOW: timing (attach-late) is a
215
+ // legitimate cause.
216
+ if (rs && !rs.attributed && (resources?.sampleCount || 0) > 0) {
217
+ out.push({ severity: "LOW", category: "sampler_gap", subagent: tid, message: "no resource samples attributed (sampler missed this subagent or attached late)" });
218
+ }
219
+
220
+ if (!rs) continue;
221
+ // 5. Sustained high CPU (p95 > 150% = CPU-bound, possible loop)
222
+ if ((rs.p95CpuPct || 0) > 150) {
223
+ const sev = rs.p95CpuPct > 300 ? "HIGH" : "MEDIUM";
224
+ out.push({ severity: sev, category: "sustained_cpu", subagent: tid, message: `sustained high CPU ${rs.p95CpuPct}% (p95) — likely CPU-bound (loop / heavy tool)` });
225
+ }
226
+ // 6. Transient CPU spike (peak ≫ p95 = brief, not sustained)
227
+ if ((rs.peakCpuPct || 0) > 120 && (rs.p95CpuPct || 0) > 0 && rs.peakCpuPct > rs.p95CpuPct * 1.8) {
228
+ out.push({ severity: "LOW", category: "transient_cpu_spike", subagent: tid, message: `transient CPU spike ${rs.peakCpuPct}% (p95 only ${rs.p95CpuPct}%, avg ${rs.avgCpuPct}%) — brief, NOT sustained` });
229
+ }
230
+ // 7. Transient RSS spike
231
+ if ((rs.peakRssBytes || 0) > 300_000_000 && (rs.p95RssBytes || 0) > 0 && rs.peakRssBytes > rs.p95RssBytes * 1.8) {
232
+ out.push({ severity: "LOW", category: "transient_rss_spike", subagent: tid, message: `transient RSS spike ${mb(rs.peakRssBytes)} (p95 only ${mb(rs.p95RssBytes)}, avg ${mb(rs.avgRssBytes)}) — brief, NOT sustained` });
233
+ }
234
+ // 8. RSS growth / possible leak (worker-own only, filter out tool subprocess
235
+ // noise). Conservative thresholds: normal V8 heap warmup (+tens of MB over
236
+ // a short worker) must NOT fire — only substantial, sustained bloat.
237
+ const own = (rs.trajectory || []).filter((t) => !t.isDescendant).map((t) => t.rssBytes).filter((x) => x > 0);
238
+ if (own.length >= 6) {
239
+ const first = own[0];
240
+ const last = own[own.length - 1];
241
+ const delta = last - first;
242
+ if (first > 0 && delta > 150_000_000 && last > first * 1.6) {
243
+ out.push({ severity: "LOW", category: "rss_growth", subagent: tid, message: `worker RSS grew ${mb(first)} → ${mb(last)} (+${mb(delta)}, ${((last / first) * 100 - 100).toFixed(0)}% over ${own.length} samples) — possible bloat (verify sustained)` });
244
+ }
245
+ }
246
+ }
247
+
248
+ // ---- run-level anomalies ----
249
+ // 9. Run did not complete cleanly (cancelled / blocked / failed)
250
+ if (runTerminalType && runTerminalType !== "run.completed") {
251
+ out.push({ severity: "HIGH", category: "run_not_completed", subagent: "(run)", message: `run terminated via ${runTerminalType} (not run.completed) — aborted/blocked/failed` });
252
+ }
253
+
254
+ // 9b. High failure rate — majority of subagents failed. Defensive: grounded
255
+ // runs all complete cleanly, so this only fires on genuinely broken runs.
256
+ if (subagents.length >= 2) {
257
+ const fails = subagents.filter((s) => (s.status && s.status !== "completed" && s.status !== "ok") || (s.exitCode && s.exitCode !== 0)).length;
258
+ if (fails / subagents.length > 0.5) {
259
+ out.push({ severity: "HIGH", category: "high_failure_rate", subagent: "(run)", message: `${fails}/${subagents.length} subagents failed (${Math.round((fails / subagents.length) * 100)}%) — run broadly broken` });
260
+ }
261
+ }
262
+
263
+ // 9d. Cost unreported — provider did not report cost (all transcripts have
264
+ // cost.total=0 despite tokens consumed). The $0 is NOT "free"; it is unknown.
265
+ // Grounded: zai/glm-5.2 + opencode-go providers report cost=0 on every turn.
266
+ const totalTok = subagents.reduce((a, s) => a + (s.usage?.totalTokens || 0), 0);
267
+ const totalCost = subagents.reduce((a, s) => a + (s.cost?.total || 0), 0);
268
+ if (totalTok > 0 && totalCost === 0) {
269
+ out.push({ severity: "LOW", category: "cost_unreported", subagent: "(run)", message: `cost is $0 despite ${totalTok.toLocaleString()} tokens consumed — provider does not report cost; the cost metric is UNAVAILABLE (not free)` });
270
+ }
271
+
272
+ // 9c. Run idle / low parallelism — run wall ≫ Σ subagent wall means most of
273
+ // the run was NOT subagent work (scheduling gaps / sequential execution).
274
+ // NOTE: phases.wall is the FULL subagent lifetime (first-spawn → exit), so
275
+ // respawn/churn time IS counted here — a 429-storm run that spent 87s/task
276
+ // respawning correctly does NOT trip this (ratio ~1.14). Only genuine idle
277
+ // (broker stalls, sequential-when-parallel-possible) fires. Grounded:
278
+ // healthy parallel runs run ~1.1-1.3×; a sequential run hits ≥2×.
279
+ if (subagents.length >= 2 && wallMs) {
280
+ const sumWall = subagents.reduce((a, s) => a + (s.timeline?.phases?.wall || 0), 0);
281
+ const idle = wallMs - sumWall;
282
+ if (sumWall > 0 && wallMs > sumWall * 2 && idle > 60_000) {
283
+ out.push({ severity: "MEDIUM", category: "run_idle", subagent: "(run)", message: `run wall ${(wallMs / 1000).toFixed(0)}s but Σ subagent wall only ${(sumWall / 1000).toFixed(0)}s — ${(idle / 1000).toFixed(0)}s idle (scheduling gaps / sequential / pre-success churn)` });
284
+ }
285
+ }
286
+
287
+ // 10. Surface event-level problems already detected by the analyzer
288
+ // (phase_guard_blocked, task.failed, workflow.phase_failed, recovery.*,
289
+ // adaptive.plan_*, response timeouts, deliverable_warning) as anomalies so
290
+ // they appear in the ⚠️ section. Problems severity is 1 (worst) .. 5.
291
+ // Skip "retry"/"exit_code" — already covered by rules 1-2 above.
292
+ const seen = new Set(out.map((a) => `${a.subagent}:${a.category}`));
293
+ for (const p of problems || []) {
294
+ if (p.type === "retry" || p.type === "exit_code") continue;
295
+ const cat = String(p.type).replace(/[.].*$/, "");
296
+ if (seen.has(`${p.taskId || "(run)"}:${cat}`)) continue;
297
+ seen.add(`${p.taskId || "(run)"}:${cat}`);
298
+ const sev = p.severity <= 2 ? "HIGH" : p.severity === 3 ? "MEDIUM" : "LOW";
299
+ out.push({ severity: sev, category: cat, subagent: p.taskId || "(run)", message: `${p.type}: ${p.message || ""}` });
300
+ }
301
+
302
+ // severity order for stable display
303
+ const rank = { HIGH: 0, MEDIUM: 1, LOW: 2 };
304
+ out.sort((a, b) => (rank[a.severity] - rank[b.severity]) || a.subagent.localeCompare(b.subagent));
305
+ return out;
306
+ }
307
+
308
+ function nearestSample(samplesForPid, ts) {
309
+ let best = null;
310
+ let bd = Infinity;
311
+ for (const s of samplesForPid) {
312
+ const d = Math.abs(s.ts - ts);
313
+ if (d < bd) {
314
+ bd = d;
315
+ best = s;
316
+ }
317
+ }
318
+ return best;
319
+ }
320
+
321
+ function buildEventTimeline(events, samplesByPid, taskPid) {
322
+ if (!events.length) return { rows: [], topGaps: [] };
323
+ const start = ms(events[0].time);
324
+ let prev = start;
325
+ const rows = [];
326
+ for (const e of events) {
327
+ const t = ms(e.time);
328
+ const dt = t - prev;
329
+ prev = t;
330
+ const pid = e.taskId ? taskPid[e.taskId] : null;
331
+ let rss = null;
332
+ let cpu = null;
333
+ if (pid && samplesByPid?.has(pid)) {
334
+ const n = nearestSample(samplesByPid.get(pid), t);
335
+ if (n) {
336
+ rss = n.rssBytes || 0;
337
+ cpu = n.cpuPct || 0;
338
+ }
339
+ }
340
+ rows.push({
341
+ seq: e.metadata?.seq ?? null,
342
+ elapsedMs: t - start,
343
+ deltaMs: dt,
344
+ type: e.type,
345
+ taskId: e.taskId || null,
346
+ workerPid: pid ?? null,
347
+ rssBytes: rss,
348
+ cpuPct: cpu,
349
+ });
350
+ }
351
+ // top gaps (>1s, sorted desc) — where wall time actually went
352
+ const topGaps = rows
353
+ .filter((r) => r.deltaMs > 1000)
354
+ .map((r) => ({ deltaMs: r.deltaMs, elapsedMs: r.elapsedMs, type: r.type, taskId: r.taskId }))
355
+ .sort((a, b) => b.deltaMs - a.deltaMs)
356
+ .slice(0, 15);
357
+ return { rows, topGaps };
358
+ }
359
+
360
+ // ---------- analyze events ----------
361
+ function analyzeEvents(events, runId) {
362
+ const ev = events.filter((e) => e.runId === runId);
363
+ const tasks = new Map(); // taskId -> timeline data
364
+
365
+ for (const e of ev) {
366
+ const tid = e.taskId;
367
+ if (!tid) continue;
368
+ if (!tasks.has(tid)) tasks.set(tid, { taskId: tid, progress: [], progressTimes: [] });
369
+ const t = tasks.get(tid);
370
+ switch (e.type) {
371
+ case "task.started":
372
+ t.startedTime = ms(e.time);
373
+ t.role = e.data?.role;
374
+ t.agent = e.data?.agent;
375
+ t.runtime = e.data?.runtime;
376
+ break;
377
+ case "worker.spawned":
378
+ t.spawnTime = ms(e.time);
379
+ t.pid = e.data?.pid;
380
+ t.spawnCount = (t.spawnCount || 0) + 1;
381
+ // firstSpawnTime is NOT overwritten — needed for accurate launch latency
382
+ // and full-lifetime wall (spawnTime is the LAST spawn, which on a
383
+ // respawn/churn run understates wall and overstates launch).
384
+ if (t.firstSpawnTime == null) t.firstSpawnTime = t.spawnTime;
385
+ break;
386
+ case "worker.exit":
387
+ t.exitTime = ms(e.time);
388
+ t.exitCode = e.data?.exitCode;
389
+ t.diagnostic = e.data?.diagnostic;
390
+ break;
391
+ case "task.completed":
392
+ t.completedTime = ms(e.time);
393
+ break;
394
+ case "task.progress":
395
+ t.progress.push(e);
396
+ t.progressTimes.push(ms(e.time));
397
+ break;
398
+ }
399
+ }
400
+
401
+ // phase guard blocks + other run-level events
402
+ const phaseGuards = ev.filter((e) => e.type === "workflow.phase_guard_blocked");
403
+ const runCreated = ev.find((e) => e.type === "run.created");
404
+ // R1 (audit): a run may terminate via run.completed OR run.cancelled /
405
+ // run.blocked / run.failed. Failed/cancelled runs are exactly the ones we
406
+ // most want to diagnose, so accept any terminal event; fall back to the
407
+ // last event's timestamp if no terminal event is present at all.
408
+ const runTerminal = ev.find((e) =>
409
+ ["run.completed", "run.cancelled", "run.blocked", "run.failed"].includes(e.type),
410
+ );
411
+ const firstEv = ev.length ? ev[0] : null;
412
+ const lastEv = ev.length ? ev[ev.length - 1] : null;
413
+ const responseTimeouts = ev.filter((e) => e.type.includes("timeout") || e.type.includes("abort"));
414
+ const deliverableWarnings = ev.filter((e) => e.type.includes("deliverable") && e.type.includes("warning"));
415
+ // R14 (audit): surface notable problem events the analyzer previously
416
+ // dropped: task.failed, workflow.phase_failed, recovery.attempted, and the
417
+ // adaptive-plan failures (plan_repair_failed / plan_missing are often the
418
+ // ROOT CAUSE of a blocked run — without these the report only says
419
+ // "blocked" with no reason).
420
+ const notable = ev.filter((e) =>
421
+ /^(task\.failed|workflow\.phase_failed|recovery\.|adaptive\.plan_(repair_failed|missing))/.test(e.type),
422
+ );
423
+
424
+ // event type histogram
425
+ const typeCounts = {};
426
+ for (const e of ev) typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
427
+
428
+ return {
429
+ tasks,
430
+ phaseGuards,
431
+ responseTimeouts,
432
+ deliverableWarnings,
433
+ notable,
434
+ typeCounts,
435
+ eventCount: ev.length,
436
+ runStart: runCreated ? ms(runCreated.time) : firstEv ? ms(firstEv.time) : null,
437
+ runEnd: runTerminal ? ms(runTerminal.time) : lastEv ? ms(lastEv.time) : null,
438
+ runTerminalType: runTerminal ? runTerminal.type : null,
439
+ };
440
+ }
441
+
442
+ // ---------- compute timeline phases ----------
443
+ function computeTimeline(t) {
444
+ const p = t.progressTimes;
445
+ const firstProg = p.length ? p[0] : null;
446
+ const lastProg = p.length ? p[p.length - 1] : null;
447
+ const phases = {};
448
+ const def = (name, a, b) => {
449
+ // clamp tiny negative deltas (event ordering jitter) to 0
450
+ phases[name] = a != null && b != null ? Math.max(0, b - a) : null;
451
+ };
452
+ def("launch", t.startedTime, t.firstSpawnTime ?? t.spawnTime); // task.started → FIRST spawn
453
+ def("respawn", t.firstSpawnTime, t.spawnTime); // FIRST → LAST spawn (churn window; 0 for single-spawn)
454
+ def("startup", t.spawnTime, firstProg); // last spawn → first progress
455
+ def("activeWork", firstProg, lastProg); // first → last progress
456
+ def("drain", lastProg, t.exitTime); // last progress → exit
457
+ def("finalize", t.exitTime, t.completedTime); // exit → completed
458
+ phases.total = t.startedTime != null && t.completedTime != null ? t.completedTime - t.startedTime : null;
459
+ // wall = FULL subagent lifetime (FIRST spawn → exit), so respawn/churn time
460
+ // is captured — using last-spawn would undercount wall and make run_idle
461
+ // mis-flag churn as idle.
462
+ const wallStart = t.firstSpawnTime ?? t.spawnTime;
463
+ phases.wall = wallStart != null && t.exitTime != null ? t.exitTime - wallStart : null;
464
+
465
+ // largest intra-progress gap
466
+ let maxGap = 0;
467
+ let maxGapAt = null;
468
+ for (let i = 1; i < p.length; i++) {
469
+ const g = p[i] - p[i - 1];
470
+ if (g > maxGap) {
471
+ maxGap = g;
472
+ maxGapAt = new Date(p[i]).toISOString();
473
+ }
474
+ }
475
+ return { phases, firstProg, lastProg, maxGap, maxGapAt, progressCount: p.length };
476
+ }
477
+
478
+ // ---------- analyze transcripts (token/cost/model) ----------
479
+ async function analyzeTranscripts(artifactsDir) {
480
+ const tdir = join(artifactsDir, "transcripts");
481
+ const files = existsSync(tdir) ? readdirSync(tdir).filter((f) => f.endsWith(".jsonl")) : [];
482
+ const result = new Map(); // taskId -> usage data
483
+
484
+ for (const file of files) {
485
+ // filename: 02_execute.attempt-0.jsonl → taskId = 02_execute
486
+ const taskId = file.replace(/\.attempt-\d+\.jsonl$/, "").replace(/\.jsonl$/, "");
487
+ const lines = await readJsonl(join(tdir, file));
488
+ let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
489
+ let cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
490
+ const models = new Set();
491
+ let assistantMsgs = 0;
492
+ let toolResultMsgs = 0;
493
+ let toolCalls = 0;
494
+ let messageEndCount = 0;
495
+ let apiErrors = 0; // message_end carrying an errorMessage (429/500/abort…)
496
+
497
+ for (const rec of lines) {
498
+ if (rec.type !== "message_end" || !rec.message) continue;
499
+ messageEndCount++;
500
+ const msg = rec.message;
501
+ if (msg.errorMessage) apiErrors++;
502
+ const u = msg.usage;
503
+ if (!u) continue;
504
+ usage.input += u.input || 0;
505
+ usage.output += u.output || 0;
506
+ usage.cacheRead += u.cacheRead || 0;
507
+ usage.cacheWrite += u.cacheWrite || 0;
508
+ usage.totalTokens += u.totalTokens || 0;
509
+ if (u.cost) {
510
+ cost.input += u.cost.input || 0;
511
+ cost.output += u.cost.output || 0;
512
+ cost.cacheRead += u.cost.cacheRead || 0;
513
+ cost.cacheWrite += u.cost.cacheWrite || 0;
514
+ cost.total += u.cost.total || 0;
515
+ }
516
+ // model: check message.model (confirmed path) then top-level
517
+ const model = msg.model || rec.model;
518
+ if (model) models.add(model);
519
+ if (msg.role === "assistant") {
520
+ assistantMsgs++;
521
+ if (Array.isArray(msg.content)) {
522
+ toolCalls += msg.content.filter((c) => c.type === "toolCall").length;
523
+ }
524
+ } else if (msg.role === "toolResult") {
525
+ toolResultMsgs++;
526
+ }
527
+ }
528
+ // R2 (audit): a task may have MULTIPLE attempt files (attempt-0 failed,
529
+ // attempt-1 retried). The previous code did result.set(taskId, ...) which
530
+ // OVERWROTE earlier attempts → undercounted tokens/cost for retried tasks.
531
+ // Merge across attempts instead: sum usage/cost, union models, sum counts.
532
+ const prev = result.get(taskId);
533
+ if (prev) {
534
+ prev.usage.input += usage.input;
535
+ prev.usage.output += usage.output;
536
+ prev.usage.cacheRead += usage.cacheRead;
537
+ prev.usage.cacheWrite += usage.cacheWrite;
538
+ prev.usage.totalTokens += usage.totalTokens;
539
+ prev.cost.input += cost.input;
540
+ prev.cost.output += cost.output;
541
+ prev.cost.cacheRead += cost.cacheRead;
542
+ prev.cost.cacheWrite += cost.cacheWrite;
543
+ prev.cost.total += cost.total;
544
+ for (const m of models) prev.models.add(m);
545
+ prev.assistantMsgs += assistantMsgs;
546
+ prev.toolResultMsgs += toolResultMsgs;
547
+ prev.toolCalls += toolCalls;
548
+ prev.messageEndCount += messageEndCount;
549
+ prev.apiErrors = (prev.apiErrors || 0) + apiErrors;
550
+ prev.lineCount += lines.length;
551
+ prev.attemptFiles.push(file);
552
+ } else {
553
+ result.set(taskId, {
554
+ taskId,
555
+ file,
556
+ usage,
557
+ cost,
558
+ models,
559
+ assistantMsgs,
560
+ toolResultMsgs,
561
+ toolCalls,
562
+ messageEndCount,
563
+ apiErrors,
564
+ lineCount: lines.length,
565
+ attemptFiles: [file],
566
+ });
567
+ }
568
+ }
569
+ return result;
570
+ }
571
+
572
+ // ---------- resources ----------
573
+ function analyzeResources(path, subagents) {
574
+ if (!path || !existsSync(path)) return null;
575
+ const samples = readJsonlSync(path);
576
+ if (!samples.length) return null;
577
+ let peakRss = 0;
578
+ let peakCpu = 0;
579
+ const byPid = new Map();
580
+ // Per-subagent attribution: previously exact-PID match only — so a
581
+ // worker's TOOL subprocesses (test-runner, tsc, bash, … = grandchildren)
582
+ // were NOT counted, understating tool-heavy subagents. The sampler records
583
+ // ppid per sample and pi-crew's setsid does NOT reparent to init, so the
584
+ // ppid chain stays intact (verified: 1GB test-runner chains up to its
585
+ // worker). Fix: walk the ppid tree (first-seen ppid per pid) to find the
586
+ // owning worker, then attribute within that worker's window.
587
+ const perSubagent = new Map();
588
+ for (const s of subagents) {
589
+ if (s.pid != null) perSubagent.set(s.pid, { taskId: s.taskId, pid: s.pid, spawnMs: s.spawnMs, exitMs: s.exitMs, samples: 0, peakRss: 0, peakCpu: 0, rssSum: 0, cpuSum: 0, descendantPids: new Set(), ownRssSum: 0, ownCpuSum: 0, ownSamples: 0, ownCpuSamples: 0, trajectory: [] });
590
+ }
591
+ const workerPids = new Set(perSubagent.keys());
592
+ // first-seen ppid per pid (spawn-time parent; robust while parent alive)
593
+ const firstPpid = new Map();
594
+ for (const s of samples) if (!firstPpid.has(s.pid)) firstPpid.set(s.pid, s.ppid);
595
+ const ownerOf = (pid) => {
596
+ if (workerPids.has(pid)) return pid;
597
+ let cur = firstPpid.get(pid);
598
+ const seen = new Set();
599
+ while (cur != null && !seen.has(cur)) {
600
+ if (workerPids.has(cur)) return cur;
601
+ seen.add(cur);
602
+ cur = firstPpid.get(cur);
603
+ }
604
+ return null;
605
+ };
606
+ for (const s of samples) {
607
+ peakRss = Math.max(peakRss, s.rssBytes || 0);
608
+ peakCpu = Math.max(peakCpu, s.cpuPct || 0);
609
+ if (!byPid.has(s.pid)) byPid.set(s.pid, { pid: s.pid, label: s.label, peakRss: 0, peakCpu: 0, firstRss: s.rssBytes || 0, lastRss: s.rssBytes || 0 });
610
+ const e = byPid.get(s.pid);
611
+ e.peakRss = Math.max(e.peakRss, s.rssBytes || 0);
612
+ e.peakCpu = Math.max(e.peakCpu, s.cpuPct || 0);
613
+ e.lastRss = s.rssBytes || 0;
614
+ // attribute to owning worker (exact PID OR ppid-tree descendant)
615
+ const ownerPid = ownerOf(s.pid);
616
+ const sa = ownerPid != null ? perSubagent.get(ownerPid) : null;
617
+ if (sa) {
618
+ const lo = sa.spawnMs ?? -Infinity;
619
+ const hi = sa.exitMs ?? Infinity;
620
+ if (s.ts >= lo && s.ts <= hi) {
621
+ sa.samples++;
622
+ sa.peakRss = Math.max(sa.peakRss, s.rssBytes || 0);
623
+ sa.peakCpu = Math.max(sa.peakCpu, s.cpuPct || 0);
624
+ sa.rssSum += s.rssBytes || 0;
625
+ sa.cpuSum += s.cpuPct || 0;
626
+ sa.trajectory.push({ ts: s.ts, pid: s.pid, rssBytes: s.rssBytes || 0, cpuPct: s.cpuPct || 0, isDescendant: s.pid !== ownerPid, firstSample: !!s.firstSample });
627
+ if (s.pid !== ownerPid) {
628
+ sa.descendantPids.add(s.pid);
629
+ } else {
630
+ // R9 (audit): avg reflects the agent process itself, not dragged
631
+ // down by many short-lived low-RSS tool samples.
632
+ sa.ownRssSum += s.rssBytes || 0;
633
+ sa.ownSamples++;
634
+ // firstSample (cpuPct=0 by construction — no previous tick to diff)
635
+ // must NOT drag the CPU average down; track CPU samples separately.
636
+ if (!s.firstSample) {
637
+ sa.ownCpuSum += s.cpuPct || 0;
638
+ sa.ownCpuSamples++;
639
+ }
640
+ }
641
+ }
642
+ }
643
+ }
644
+ const perSubagentList = [...perSubagent.values()].map((sa) => ({
645
+ taskId: sa.taskId,
646
+ pid: sa.pid,
647
+ samples: sa.samples,
648
+ peakRssBytes: sa.peakRss,
649
+ peakCpuPct: sa.peakCpu,
650
+ // p95 over the SAME population as peak (all attributed: worker + tool
651
+ // subprocesses) — contextualizes the peak so a single-sample spike is
652
+ // distinguishable from a sustained high load. Computed from the full
653
+ // trajectory (before the 80-point cap applied below).
654
+ p95RssBytes: pctl(sa.trajectory.map((t) => t.rssBytes), 95),
655
+ p95CpuPct: pctl(sa.trajectory.filter((t) => !t.firstSample).map((t) => t.cpuPct), 95),
656
+ avgRssBytes: sa.ownSamples ? Math.round(sa.ownRssSum / sa.ownSamples) : 0,
657
+ avgCpuPct: sa.ownCpuSamples ? Math.round((sa.ownCpuSum / sa.ownCpuSamples) * 10) / 10 : 0,
658
+ attributed: sa.samples > 0,
659
+ descendantPids: [...sa.descendantPids],
660
+ // trajectory: cap to ~80 points (even stride) so per-agent files stay readable
661
+ trajectory: sa.trajectory.length <= 80 ? sa.trajectory : sa.trajectory.filter((_, i) => i % Math.ceil(sa.trajectory.length / 80) === 0),
662
+ }));
663
+ return {
664
+ sampleCount: samples.length,
665
+ peakRssBytes: peakRss,
666
+ peakCpuPct: peakCpu,
667
+ // R3 (audit): RSS growth is meaningless across different PIDs (first/last
668
+ // sample are usually different processes). Compute per-PID growth then sum.
669
+ rssGrowthBytes: [...byPid.values()].reduce((g, p) => g + ((p.lastRss || 0) - (p.firstRss || 0)), 0),
670
+ spanMs: samples.length > 1 ? samples[samples.length - 1].ts - samples[0].ts : 0,
671
+ byPid: [...byPid.values()],
672
+ perSubagent: perSubagentList,
673
+ firstSample: samples[0],
674
+ lastSample: samples[samples.length - 1],
675
+ };
676
+ }
677
+ function readJsonlSync(path) {
678
+ const out = [];
679
+ for (const line of readFileSync(path, "utf8").split("\n")) {
680
+ const t = line.trim();
681
+ if (!t) continue;
682
+ try {
683
+ out.push(JSON.parse(t));
684
+ } catch {
685
+ /* skip */
686
+ }
687
+ }
688
+ return out;
689
+ }
690
+
691
+ // ---------- exit code meaning ----------
692
+ function exitMeaning(code) {
693
+ if (code === 0) return "OK";
694
+ if (code === 143) return "SIGTERM (143)";
695
+ if (code === 137) return "OOM/SIGKILL (137)";
696
+ if (code === 1) return "Error (1)";
697
+ return `Exit ${code}`;
698
+ }
699
+
700
+ // ---------- main ----------
701
+ async function main() {
702
+ const args = parseArgs(process.argv);
703
+ const runId = args.runId;
704
+ const stateDir = join(args.crewRoot, "state", "runs", runId);
705
+ const artifactsDir = join(args.crewRoot, "artifacts", runId);
706
+
707
+ // F1 (audit): validate runId to prevent path injection — runId flows into
708
+ // join(crewRoot, "state", "runs", runId) AND into output filenames
709
+ // (docs/perf-report-<runId>.md, bench/results/<runId>.json). Without this,
710
+ // runId="../../tmp/x" would read/write outside the intended dirs.
711
+ if (!/^[A-Za-z0-9_.-]+$/.test(runId)) {
712
+ process.stderr.write(`Error: invalid runId (must be alphanumeric/_/./-): ${runId}\n`);
713
+ process.exit(1);
714
+ }
715
+
716
+ if (!existsSync(stateDir)) {
717
+ process.stderr.write(`Error: run state not found: ${stateDir}\n`);
718
+ process.exit(1);
719
+ }
720
+
721
+ // load
722
+ const events = await readJsonl(join(stateDir, "events.jsonl"));
723
+ const manifest = readJson(join(stateDir, "manifest.json"));
724
+ const tasksJson = readJson(join(stateDir, "tasks.json"));
725
+ // R10 (audit): agents.json was read (41KB) but never used — removed.
726
+ const ea = analyzeEvents(events, runId);
727
+ const transcripts = await analyzeTranscripts(artifactsDir);
728
+
729
+ // merge per-subagent
730
+ const subagents = [];
731
+ const taskList = tasksJson
732
+ ? Object.values(tasksJson).sort((a, b) => (a.id || "").localeCompare(b.id || ""))
733
+ : [...ea.tasks.values()].sort((a, b) => a.taskId.localeCompare(b.taskId));
734
+
735
+ for (const t of ea.tasks.values()) {
736
+ const tr = transcripts.get(t.taskId);
737
+ const tl = computeTimeline(t);
738
+ const taskMeta = taskList.find((x) => x.id === t.taskId);
739
+ subagents.push({
740
+ taskId: t.taskId,
741
+ role: t.role || taskMeta?.role,
742
+ agent: t.agent || taskMeta?.agent,
743
+ pid: t.pid,
744
+ spawnMs: t.spawnTime,
745
+ exitMs: t.exitTime,
746
+ exitCode: t.exitCode ?? taskMeta?.exitCode,
747
+ usage: tr?.usage || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
748
+ cost: tr?.cost || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
749
+ models: tr ? [...tr.models] : [],
750
+ modelRouting: taskMeta?.modelRouting?.resolved,
751
+ // R11 (audit): retry detection must count model-level retries too.
752
+ // `attempts` only records the final attempt; `modelAttempts` records
753
+ // each model try (incl. model fallback on failure). A task that
754
+ // retried via model fallback has attempts=1 but modelAttempts=2, so
755
+ // the old `attempts.length` check MISSED those retries.
756
+ attempts: Math.max(taskMeta?.attempts?.length || 0, taskMeta?.modelAttempts?.length || 0),
757
+ modelAttempts: (taskMeta?.modelAttempts || []).map((ma) => ({
758
+ model: ma.model,
759
+ success: ma.success,
760
+ exitCode: ma.exitCode,
761
+ error: ma.error,
762
+ })),
763
+ verification: taskMeta?.verification,
764
+ status: taskMeta?.status,
765
+ error: taskMeta?.error,
766
+ assistantMsgs: tr?.assistantMsgs || 0,
767
+ toolCalls: tr?.toolCalls || 0,
768
+ messageEndCount: tr?.messageEndCount || 0,
769
+ apiErrors: tr?.apiErrors || 0,
770
+ spawnCount: t.spawnCount || 0,
771
+ timeline: tl,
772
+ hasTranscript: !!tr,
773
+ });
774
+ }
775
+
776
+ // run totals
777
+ const totalUsage = subagents.reduce(
778
+ (acc, s) => {
779
+ acc.input += s.usage.input;
780
+ acc.output += s.usage.output;
781
+ acc.cacheRead += s.usage.cacheRead;
782
+ acc.cacheWrite += s.usage.cacheWrite;
783
+ acc.totalTokens += s.usage.totalTokens;
784
+ return acc;
785
+ },
786
+ { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
787
+ );
788
+ const totalCost = subagents.reduce((a, s) => a + s.cost.total, 0);
789
+ const modelBreakdown = {};
790
+ for (const s of subagents) for (const m of s.models) modelBreakdown[m] = (modelBreakdown[m] || 0) + 1;
791
+ const wallMs = ea.runStart && ea.runEnd ? ea.runEnd - ea.runStart : null;
792
+ const outSec = wallMs ? wallMs / 1000 : 0;
793
+ const tokensPerSec = outSec ? totalUsage.output / outSec : 0;
794
+ const eventsPerSec = outSec ? ea.eventCount / outSec : 0;
795
+
796
+ // problems
797
+ const problems = [];
798
+ for (const s of subagents) {
799
+ const code = s.exitCode;
800
+ if (code != null && code !== 0) {
801
+ // R5 (audit): include the REAL error text from tasks.json (e.g. "Child Pi
802
+ // exited with 143", "response_timeout", …) — previously dropped, leaving
803
+ // only a generic label.
804
+ const detail = s.error ? ` — ${s.error}` : "";
805
+ problems.push({ severity: 1, type: "exit_code", taskId: s.taskId, message: `Worker exit ${exitMeaning(code)}${detail}`, value: code });
806
+ }
807
+ if (s.attempts > 1) {
808
+ problems.push({ severity: 4, type: "retry", taskId: s.taskId, message: `${s.attempts} attempts (retry)`, value: s.attempts });
809
+ }
810
+ if (s.status && !["completed", "succeeded", "done"].includes(s.status)) {
811
+ problems.push({ severity: 2, type: "task_status", taskId: s.taskId, message: `Task status: ${s.status}`, value: s.status });
812
+ }
813
+ // R12 (audit): a task may be status=completed but FAIL verification
814
+ // (verification.satisfied===false) — that is a real "task ran but didn't
815
+ // meet its goal" problem the analyzer previously ignored entirely.
816
+ if (s.verification?.satisfied === false) {
817
+ const note = s.verification.notes ? ` — ${s.verification.notes}` : "";
818
+ problems.push({
819
+ severity: 2,
820
+ type: "verification_failed",
821
+ taskId: s.taskId,
822
+ message: `Verification failed (required ${s.verification.requiredGreenLevel ?? "?"}, observed ${s.verification.observedGreenLevel ?? "?"})${note}`,
823
+ });
824
+ }
825
+ }
826
+ for (const pg of ea.phaseGuards) {
827
+ problems.push({
828
+ severity: 3,
829
+ type: "phase_guard_blocked",
830
+ phaseName: pg.data?.phaseName,
831
+ message: pg.message || `Phase guard blocked: ${pg.data?.reason}`,
832
+ value: pg.data?.reason,
833
+ seq: pg.metadata?.seq,
834
+ });
835
+ }
836
+ for (const rt of ea.responseTimeouts) {
837
+ problems.push({ severity: 2, type: rt.type, taskId: rt.taskId, message: rt.message, seq: rt.metadata?.seq });
838
+ }
839
+ for (const dw of ea.deliverableWarnings) {
840
+ problems.push({ severity: 5, type: "deliverable_warning", taskId: dw.taskId, message: dw.message, seq: dw.metadata?.seq });
841
+ }
842
+ for (const n of ea.notable) {
843
+ // R14: surface task.failed / workflow.phase_failed / recovery.attempted /
844
+ // adaptive.plan_repair_failed|missing — these carry the real reason a
845
+ // run struggled or blocked.
846
+ const sev = n.type === "task.failed" || n.type === "workflow.phase_failed" ? 2 : 3;
847
+ problems.push({
848
+ severity: sev,
849
+ type: n.type,
850
+ taskId: n.taskId,
851
+ message: n.message || n.data?.reason || n.data?.message || n.type,
852
+ seq: n.metadata?.seq,
853
+ });
854
+ }
855
+ problems.sort((a, b) => a.severity - b.severity);
856
+
857
+ // bottlenecks (slow spots)
858
+ const bottlenecks = [];
859
+ for (const s of subagents) {
860
+ const ph = s.timeline.phases;
861
+ // R2 (audit): only flag genuinely slow totals (>30s) as bottlenecks;
862
+ // previously EVERY subagent total was listed and all marked 🔴, even 24s
863
+ // ones, contradicting the legend ("🔴 = >30s").
864
+ if (ph.total != null && ph.total > 30000) bottlenecks.push({ taskId: s.taskId, phase: "total", durationMs: ph.total, label: `${s.role} total wall` });
865
+ if (s.timeline.maxGap > 30000) bottlenecks.push({ taskId: s.taskId, phase: "intra_gap", durationMs: s.timeline.maxGap, label: `gap @ ${s.timeline.maxGapAt}` });
866
+ if (ph.activeWork != null && ph.activeWork > 60000) bottlenecks.push({ taskId: s.taskId, phase: "activeWork", durationMs: ph.activeWork, label: `${s.role} active work` });
867
+ }
868
+ bottlenecks.sort((a, b) => b.durationMs - a.durationMs);
869
+
870
+ // resources
871
+ const resources = analyzeResources(args.resources, subagents);
872
+
873
+ // per-event timeline (--events): needs taskId→pid map + samples-by-pid for
874
+ // resource snapshots. Samples come from the --resources file (read once).
875
+ let eventTimeline = null;
876
+ if (args.events) {
877
+ const taskPid = {};
878
+ for (const [tid, t] of ea.tasks) if (t.pid != null) taskPid[tid] = t.pid;
879
+ let samplesByPid = null;
880
+ if (args.resources && existsSync(args.resources)) {
881
+ samplesByPid = new Map();
882
+ for (const s of readJsonlSync(args.resources)) {
883
+ if (!samplesByPid.has(s.pid)) samplesByPid.set(s.pid, []);
884
+ samplesByPid.get(s.pid).push(s);
885
+ }
886
+ }
887
+ eventTimeline = buildEventTimeline(events, samplesByPid, taskPid);
888
+ }
889
+
890
+ // ---------- emit JSON ----------
891
+ const anomalies = detectAnomalies(subagents, resources, problems, { runTerminalType: ea.runTerminalType, wallMs });
892
+ const report = {
893
+ runId,
894
+ generatedAt: new Date().toISOString(),
895
+ summary: {
896
+ wallMs,
897
+ totalCost,
898
+ totalTokens: totalUsage,
899
+ modelBreakdown,
900
+ subagentCount: subagents.length,
901
+ eventCount: ea.eventCount,
902
+ tokensPerSec: Math.round(tokensPerSec * 100) / 100,
903
+ eventsPerSec: Math.round(eventsPerSec * 100) / 100,
904
+ workflow: manifest?.workflow,
905
+ goal: manifest?.goal?.slice(0, 200),
906
+ parentModel: manifest?.modelContext?.parentModel,
907
+ },
908
+ subagents: subagents.map((s) => ({
909
+ taskId: s.taskId,
910
+ role: s.role,
911
+ model: s.models.join(", "),
912
+ modelRouting: s.modelRouting,
913
+ usage: s.usage,
914
+ cost: s.cost,
915
+ timeline: { phases: s.timeline.phases, progressCount: s.timeline.progressCount, maxGap: s.timeline.maxGap },
916
+ tools: s.toolCalls,
917
+ assistantMsgs: s.assistantMsgs,
918
+ messageEndCount: s.messageEndCount,
919
+ apiErrors: s.apiErrors,
920
+ spawnCount: s.spawnCount,
921
+ exitCode: s.exitCode,
922
+ error: s.error,
923
+ attempts: s.attempts,
924
+ modelAttempts: s.modelAttempts,
925
+ status: s.status,
926
+ pid: s.pid,
927
+ spawnMs: s.spawnMs,
928
+ exitMs: s.exitMs,
929
+ hasTranscript: s.hasTranscript,
930
+ resource: resources?.perSubagent?.find((ps) => ps.taskId === s.taskId) || undefined,
931
+ })),
932
+ problems,
933
+ bottlenecks,
934
+ resources,
935
+ anomalies,
936
+ eventTimeline: eventTimeline ? { eventCount: eventTimeline.rows.length, topGaps: eventTimeline.topGaps } : undefined,
937
+ typeCounts: ea.typeCounts,
938
+ };
939
+
940
+ const resultsDir = join(process.cwd(), "bench", "results");
941
+ mkdirSync(resultsDir, { recursive: true });
942
+ const docsDir = join(process.cwd(), "docs");
943
+ mkdirSync(docsDir, { recursive: true });
944
+ const jsonPath = join(resultsDir, `${runId}.json`);
945
+ writeFileSync(jsonPath, JSON.stringify(report, null, 2));
946
+
947
+ // per-event timeline CSV (--events)
948
+ if (args.events && eventTimeline) {
949
+ const csvPath = join(resultsDir, `${runId}.events-timeline.csv`);
950
+ const csv = [
951
+ "seq,elapsed_s,delta_ms,type,taskId,workerPid,workerRSS,workerCPU",
952
+ ...eventTimeline.rows.map((r) =>
953
+ [r.seq, (r.elapsedMs / 1000).toFixed(1), r.deltaMs, r.type, r.taskId ?? "", r.workerPid ?? "", r.rssBytes ?? "", r.cpuPct ?? ""].join(","),
954
+ ),
955
+ ].join("\n");
956
+ writeFileSync(csvPath, csv + "\n");
957
+ process.stderr.write(`[analyze-run] wrote ${csvPath}\n`);
958
+ }
959
+
960
+ // per-subagent detail files (--agents): one markdown per subagent
961
+ if (args.agents) {
962
+ const agentsDir = join(resultsDir, `${runId}.agents`);
963
+ mkdirSync(agentsDir, { recursive: true });
964
+ for (const sa of report.subagents) {
965
+ const rows = eventTimeline ? eventTimeline.rows.filter((r) => r.taskId === sa.taskId) : [];
966
+ const mdSa = renderSubagentFile(sa, report, rows);
967
+ writeFileSync(join(agentsDir, `${sa.taskId}.md`), mdSa + "\n");
968
+ }
969
+ process.stderr.write(`[analyze-run] wrote ${report.subagents.length} per-agent files → ${agentsDir}/\n`);
970
+ }
971
+
972
+ // ---------- emit markdown ----------
973
+ const md = renderMarkdown(report, ea, subagents, args.agents);
974
+ const mdPath = join(docsDir, `perf-report-${runId}.md`);
975
+ writeFileSync(mdPath, md);
976
+
977
+ process.stderr.write(`[analyze-run] wrote ${mdPath}\n[analyze-run] wrote ${jsonPath}\n`);
978
+ }
979
+
980
+ // ---------- markdown renderer ----------
981
+ // ---------- per-subagent detail file (--agents) ----------
982
+ // One markdown file per subagent: identity, timeline, tokens, model attempts,
983
+ // per-event rows (this task), resource trajectory. Drill-down from main report.
984
+ function renderSubagentFile(sa, report, eventRows) {
985
+ const L = [];
986
+ const ph = sa.timeline.phases;
987
+ const mb = (b) => `${(b / 1024 / 1024).toFixed(1)}MB`;
988
+ L.push(`# Subagent \`${sa.taskId}\` — run \`${report.runId}\``);
989
+ L.push("");
990
+ L.push("## Nhận dạng");
991
+ L.push("");
992
+ L.push("| Trường | Giá trị |");
993
+ L.push("|--------|---------|");
994
+ L.push(`| Vai trò | ${esc(sa.role)} |
995
+ `);
996
+ L.push(`| PID | ${sa.pid ?? "—"} |
997
+ `);
998
+ L.push(`| Model | ${esc(sa.model) || esc(sa.modelRouting) || "—"} |
999
+ `);
1000
+ L.push(`| Trạng thái | ${sa.status ?? "—"} (exit ${sa.exitCode ?? "—"}) |
1001
+ `);
1002
+ L.push(`| Lỗi | ${sa.error ? esc(sa.error) : "—"} |
1003
+ `);
1004
+ L.push(`| Attempts | ${sa.attempts} | verification: ${sa.verification ? (sa.verification.satisfied ? "✓" : "❌ " + esc(sa.verification.notes || "")) : "—"} |`);
1005
+ L.push("");
1006
+ // per-agent anomalies (filtered from the run-wide anomaly list)
1007
+ const myAnoms = (report.anomalies || []).filter((a) => a.subagent === sa.taskId);
1008
+ if (myAnoms.length) {
1009
+ const icon = (sev) => (sev === "HIGH" ? "🔴" : sev === "MEDIUM" ? "🟡" : "🔵");
1010
+ L.push("## ⚠️ Cảnh báo bất thường");
1011
+ L.push("");
1012
+ for (const a of myAnoms) L.push(`- ${icon(a.severity)} **${a.category}**: ${esc(a.message)}`);
1013
+ L.push("");
1014
+ }
1015
+ // Timeline phases
1016
+ L.push("## Timeline (phases)");
1017
+ L.push("");
1018
+ L.push("| Phase | ms |");
1019
+ L.push("|-------|----|");
1020
+ for (const [name, v] of Object.entries(ph)) L.push(`| ${name} | ${v ?? "—"} |`);
1021
+ L.push(`| maxGap | ${sa.timeline.maxGap ?? "—"} @ ${sa.timeline.maxGapAt ?? ""} |`);
1022
+ L.push("");
1023
+ // Tokens
1024
+ L.push("## Token / Cost");
1025
+ L.push("");
1026
+ L.push("| input | output | cacheRead | cacheWrite | cost | tool calls | msgs |");
1027
+ L.push("|-------|--------|-----------|------------|------|-----------|------|");
1028
+ L.push(`| ${sa.usage.input?.toLocaleString()} | ${sa.usage.output?.toLocaleString()} | ${sa.usage.cacheRead?.toLocaleString()} | ${sa.usage.cacheWrite?.toLocaleString()} | $${(sa.cost?.total ?? 0).toFixed(4)} | ${sa.tools} | ${sa.assistantMsgs} |`);
1029
+ if (sa.modelAttempts && sa.modelAttempts.length > 1) {
1030
+ L.push("");
1031
+ L.push("**Model attempts (retry/fallback):**");
1032
+ for (const ma of sa.modelAttempts) L.push(`- ${ma.model}${ma.success === false ? " ❌" : ma.success === true ? " ✓" : ""}${ma.exitCode ? ` (exit ${ma.exitCode})` : ""}${ma.error ? ` — ${esc(ma.error)}` : ""}`);
1033
+ }
1034
+ L.push("");
1035
+ // Resource
1036
+ if (sa.resource) {
1037
+ const r = sa.resource;
1038
+ L.push("## Tài nguyên (gồm tool subprocess qua ppid-tree)");
1039
+ L.push("");
1040
+ L.push(`Peak RSS **${mb(r.peakRssBytes)}** (avg worker-own ${mb(r.avgRssBytes)}), Peak CPU **${r.peakCpuPct}%** (avg ${r.avgCpuPct}%), ${r.samples} samples, ${r.descendantPids?.length || 0} tool subprocess.`);
1041
+ if (r.trajectory && r.trajectory.length) {
1042
+ L.push("");
1043
+ L.push("**Trajectory (RSS/CPU theo thời gian):**");
1044
+ L.push("");
1045
+ L.push("| elapsed | pid | RSS | CPU | loại |");
1046
+ L.push("|---------|-----|-----|-----|------|");
1047
+ const base = r.trajectory[0].ts;
1048
+ for (const t of r.trajectory.slice(0, 40)) L.push(`| ${((t.ts - base) / 1000).toFixed(1)}s | ${t.pid} | ${mb(t.rssBytes)} | ${t.cpuPct}% | ${t.isDescendant ? "tool" : "worker"} |`);
1049
+ if (r.trajectory.length > 40) L.push(`| … | | | | (${r.trajectory.length - 40} mẫu nữa, xem CSV) |`);
1050
+ }
1051
+ L.push("");
1052
+ }
1053
+ // Per-event rows for this task
1054
+ if (eventRows && eventRows.length) {
1055
+ L.push("## Per-event (task này)");
1056
+ L.push("");
1057
+ L.push("| seq | elapsed | Δms | type | RSS | CPU |");
1058
+ L.push("|-----|---------|-----|------|-----|-----|");
1059
+ for (const r of eventRows.slice(0, 50)) L.push(`| ${r.seq} | ${(r.elapsedMs / 1000).toFixed(1)}s | ${r.deltaMs} | ${esc(r.type)} | ${r.rssBytes != null ? mb(r.rssBytes) : "—"} | ${r.cpuPct != null ? r.cpuPct + "%" : "—"} |`);
1060
+ L.push("");
1061
+ }
1062
+ return L.join("\n");
1063
+ }
1064
+
1065
+ function renderMarkdown(report, ea, subagents, perAgent = false) {
1066
+ const L = [];
1067
+ const flag = (ms) => (ms != null && ms > 30000 ? " 🔴" : "");
1068
+ const s = report.summary;
1069
+
1070
+ L.push(`# Báo cáo hiệu năng — Run \`${report.runId}\``);
1071
+ L.push("");
1072
+ L.push(`> Sinh bởi \`scripts/analyze-run.mjs\` lúc ${report.generatedAt}. Số liệu THẬT từ events.jsonl + transcripts.`);
1073
+ L.push("");
1074
+
1075
+ // Tóm tắt
1076
+ L.push("## 📋 Tóm tắt");
1077
+ L.push("");
1078
+ L.push(`| Chỉ số | Giá trị |`);
1079
+ L.push(`|--------|---------|`);
1080
+ L.push(`| Workflow | ${esc(s.workflow)} |`);
1081
+ L.push(`| Số subagent | ${s.subagentCount} |`);
1082
+ L.push(`| Tổng sự kiện | ${s.eventCount} |`);
1083
+ L.push(`| Thời gian chạy (wall) | ${fmtMs(s.wallMs)} |`);
1084
+ L.push(`| Tổng token (input+output+cache) | ${s.totalTokens.totalTokens?.toLocaleString()} |`);
1085
+ L.push(`| — Input | ${s.totalTokens.input?.toLocaleString()} |`);
1086
+ L.push(`| — Output | ${s.totalTokens.output?.toLocaleString()} |`);
1087
+ L.push(`| — Cache read | ${s.totalTokens.cacheRead?.toLocaleString()} |`);
1088
+ L.push(`| Tổng cost | ${s.totalCost > 0 || (s.totalTokens.totalTokens || 0) === 0 ? money(s.totalCost) : "— (provider không report cost)"} |`);
1089
+ L.push(`| Token/s (output) | ${s.tokensPerSec} |`);
1090
+ L.push(`| Sự kiện/s | ${s.eventsPerSec} |`);
1091
+ L.push(`| Parent model | ${esc(s.parentModel)} |`);
1092
+ L.push("");
1093
+
1094
+ // Anomaly warnings — prominent, right after summary
1095
+ const an = report.anomalies || [];
1096
+ L.push("## ⚠️ Cảnh báo bất thường (anomaly)");
1097
+ L.push("");
1098
+ if (!an.length) {
1099
+ L.push("✅ Không phát hiện bất thường đáng kể (không failure/stuck/leak/spike bất thường). Anomaly detection tự động dựa trên ngưỡng từ run thật.");
1100
+ } else {
1101
+ const high = an.filter((a) => a.severity === "HIGH").length;
1102
+ const med = an.filter((a) => a.severity === "MEDIUM").length;
1103
+ const low = an.filter((a) => a.severity === "LOW").length;
1104
+ const icon = (sev) => (sev === "HIGH" ? "🔴" : sev === "MEDIUM" ? "🟡" : "🔵");
1105
+ L.push(`> ${high} HIGH 🔴 · ${med} MEDIUM 🟡 · ${low} LOW 🔵 (transient spike = thật nhưng ngắn, không sustained)`);
1106
+ L.push("");
1107
+ L.push("| Mức | Loại | Subagent | Chi tiết |");
1108
+ L.push("|-----|------|----------|----------|");
1109
+ for (const a of an) L.push(`| ${icon(a.severity)} ${a.severity} | ${a.category} | ${a.subagent} | ${esc(a.message)} |`);
1110
+ }
1111
+ L.push("");
1112
+
1113
+ // Timeline
1114
+ L.push("## ⏱️ Timeline từng subagent");
1115
+ L.push("");
1116
+ L.push("| Subagent | Vai trò | PID | Launch | Respawn | Startup | Active work 🔴>30s | Drain | Finalize | Total | Exit |");
1117
+ L.push("|----------|---------|-----|--------|---------|---------|-------------------|-------|----------|-------|------|");
1118
+ for (const sa of report.subagents) {
1119
+ const ph = sa.timeline.phases;
1120
+ L.push(
1121
+ `| ${sa.taskId} | ${esc(sa.role)} | ${sa.pid ?? "—"} | ${fmtMs(ph.launch)} | ${fmtMs(ph.respawn)}${flag(ph.respawn)} | ${fmtMs(ph.startup)} | ${fmtMs(ph.activeWork)}${flag(ph.activeWork)} | ${fmtMs(ph.drain)} | ${fmtMs(ph.finalize)} | ${fmtMs(ph.total)} | ${exitMeaning(sa.exitCode)} |`,
1122
+ );
1123
+ }
1124
+ L.push("");
1125
+ L.push(`*(🔴 = phase vượt 30s — điểm chậm)*`);
1126
+ L.push("");
1127
+
1128
+ // Token/Cost/Model
1129
+ L.push("## 💰 Token / Cost / Model thật (từ transcript)");
1130
+ L.push("");
1131
+ L.push("| Subagent | Model | Input | Output | Cache read | Cost | Msg (assistant) | Tool calls |");
1132
+ L.push("|----------|-------|-------|--------|------------|------|-----------------|------------|");
1133
+ for (const sa of report.subagents) {
1134
+ L.push(
1135
+ `| ${sa.taskId} | ${esc(sa.model) || esc(sa.modelRouting) || "—"} | ${sa.usage.input?.toLocaleString()} | ${sa.usage.output?.toLocaleString()} | ${sa.usage.cacheRead?.toLocaleString()} | ${money(sa.cost.total)} | ${sa.assistantMsgs} | ${sa.tools} |`,
1136
+ );
1137
+ }
1138
+ L.push(
1139
+ `| **TỔNG** | — | **${s.totalTokens.input?.toLocaleString()}** | **${s.totalTokens.output?.toLocaleString()}** | **${s.totalTokens.cacheRead?.toLocaleString()}** | **${s.totalCost > 0 || (s.totalTokens.totalTokens || 0) === 0 ? money(s.totalCost) : "— (không report)"}** | — | — |`,
1140
+ );
1141
+ L.push("");
1142
+ // R11 (audit): surface per-model attempts when a task tried >1 model (retry /
1143
+ // fallback). modelAttempts carries {model, success, exitCode, error} per try.
1144
+ const retried = report.subagents.filter((sa) => sa.modelAttempts && sa.modelAttempts.length > 1);
1145
+ if (retried.length) {
1146
+ L.push("**Thử model (retry/fallback):**");
1147
+ L.push("");
1148
+ for (const sa of retried) {
1149
+ const chain = sa.modelAttempts
1150
+ .map((ma) => `${ma.model}${ma.success === false ? " ❌" : ma.success === true ? " ✓" : ""}${ma.exitCode ? ` (exit ${ma.exitCode})` : ""}`)
1151
+ .join(" → ");
1152
+ L.push(`- ${sa.taskId}: ${chain}`);
1153
+ }
1154
+ L.push("");
1155
+ }
1156
+ if (report.subagents.some((sa) => !sa.hasTranscript)) {
1157
+ L.push(`> ⚠️ Một số subagent không có transcript (usage=0).`);
1158
+ L.push("");
1159
+ }
1160
+
1161
+ // Top bottlenecks
1162
+ L.push("## 🐌 Top Bottlenecks (chậm)");
1163
+ L.push("");
1164
+ if (report.bottlenecks.length) {
1165
+ L.push("| Hạng | Subagent | Phase | Thời lượng | Ghi chú |");
1166
+ L.push("|------|----------|-------|-----------|---------|");
1167
+ report.bottlenecks.slice(0, 8).forEach((b, i) => {
1168
+ L.push(`| ${i + 1} | ${b.taskId} | ${b.phase} | **${fmtMs(b.durationMs)}**${b.durationMs > 30000 ? " 🔴" : ""} | ${esc(b.label)} |`);
1169
+ });
1170
+ } else {
1171
+ L.push("_Không phát hiện bottleneck (>60s)._");
1172
+ }
1173
+ L.push("");
1174
+
1175
+ // Problems
1176
+ L.push("## 🚨 Lỗi & Vấn đề");
1177
+ L.push("");
1178
+ if (report.problems.length) {
1179
+ L.push("| Mức | Loại | Subagent | Chi tiết |");
1180
+ L.push("|-----|------|----------|----------|");
1181
+ const sevLabel = (n) => (n <= 1 ? "🔴🔴 Nghiêm trọng" : n === 2 ? "🔴 Lỗi" : n === 3 ? "🟡 Blocked" : n === 4 ? "🟡 Retry" : "⚪ Warning");
1182
+ report.problems.forEach((p) => {
1183
+ L.push(`| ${sevLabel(p.severity)} | ${esc(p.type)} | ${esc(p.taskId || p.phaseName)} | ${esc(p.message)} |`);
1184
+ });
1185
+ } else {
1186
+ L.push("_✅ Không phát hiện lỗi/vấn đề._");
1187
+ }
1188
+ L.push("");
1189
+
1190
+ // Resources
1191
+ L.push("## 📊 Tài nguyên (CPU/RAM)");
1192
+ L.push("");
1193
+ if (report.resources) {
1194
+ const r = report.resources;
1195
+ const mb = (b) => `${(b / 1024 / 1024).toFixed(1)}MB`;
1196
+ L.push(`| Chỉ số | Giá trị |`);
1197
+ L.push(`|--------|---------|`);
1198
+ L.push(`| Sample count | ${r.sampleCount} |`);
1199
+ L.push(`| Peak RSS | ${mb(r.peakRssBytes)} |`);
1200
+ L.push(`| Peak CPU% | ${r.peakCpuPct}% |`);
1201
+ L.push(`| RSS growth | ${mb(r.rssGrowthBytes)} |`);
1202
+ L.push(`| Span | ${fmtMs(r.spanMs)} |`);
1203
+ L.push("");
1204
+ // R4 (audit): the aggregate peak spans ALL sampled PIDs, including tool
1205
+ // subprocesses workers spawn (test-runners, tsc, …) which are NOT
1206
+ // attributed to any subagent. So aggregate peak >> any single subagent.
1207
+ // Point readers at the per-subagent table for the per-agent cost.
1208
+ L.push("> ⚠️ Peak/CPU tổng gồm runner (leader) + infra pi-crew (broker, scanner…) — không thuộc subagent nào. Tool subprocess mà worker spawn ĐƯỢC attribute qua ppid-tree (xem cột Tools). Xem **per-subagent** cho chi phí mỗi agent.");
1209
+ L.push("");
1210
+ L.push("**Per-PID:**");
1211
+ L.push("");
1212
+ L.push("| PID | Label | Peak RSS | Peak CPU% |");
1213
+ L.push("|-----|-------|----------|-----------|");
1214
+ for (const p of r.byPid) {
1215
+ L.push(`| ${p.pid} | ${p.label} | ${mb(p.peakRss)} | ${p.peakCpu}% |`);
1216
+ }
1217
+ // Per-subagent attribution (PID + time-window join) — the core answer to
1218
+ // "mỗi subagent tốn tài nguyên ra sao".
1219
+ if (r.perSubagent && r.perSubagent.length) {
1220
+ L.push("");
1221
+ L.push("**Per-subagent (RSS/CPU — gồm tool subprocess qua ppid-tree):**");
1222
+ L.push("");
1223
+ L.push("| Subagent | PID | Samples | Peak RSS | p95 RSS | Avg RSS | Peak CPU% | p95 CPU% | Avg CPU% | Tools |");
1224
+ L.push("|----------|-----|---------|----------|---------|---------|-----------|----------|----------|-------|");
1225
+ for (const sa of r.perSubagent) {
1226
+ if (sa.attributed) {
1227
+ const tools = sa.descendantPids ? sa.descendantPids.length : 0;
1228
+ L.push(`| ${sa.taskId} | ${sa.pid} | ${sa.samples} | ${mb(sa.peakRssBytes)} | ${mb(sa.p95RssBytes)} | ${mb(sa.avgRssBytes)} | ${sa.peakCpuPct}% | ${sa.p95CpuPct}% | ${sa.avgCpuPct}% | ${tools} |`);
1229
+ } else {
1230
+ L.push(`| ${sa.taskId} | ${sa.pid} | 0 | — | — | — | — | — | — | — |`);
1231
+ }
1232
+ }
1233
+ const unattributed = r.perSubagent.filter((sa) => !sa.attributed);
1234
+ if (unattributed.length) {
1235
+ L.push("");
1236
+ L.push(`> ⚠️ ${unattributed.length} subagent không khớp sampler.`);
1237
+ }
1238
+ L.push("");
1239
+ L.push("> **Peak** = max 1-sample (spike, gồm tool transient); **p95** = percentle 95 (typical high, cùng pop peak); **Avg** = worker-own (steady agent, loại tool). Peak≫p95≫avg = spike ngắn, không sustained.");
1240
+ }
1241
+ if (perAgent) {
1242
+ L.push("");
1243
+ L.push("→ **Drill-down mỗi agent** (timeline + token + model-chain + resource trajectory + per-event): bench/results/" + report.runId + ".agents/<taskId>.md");
1244
+ }
1245
+ } else {
1246
+ L.push("> _Không có data tài nguyên._ Để thu thập, chạy song song khi start run:");
1247
+ L.push(">");
1248
+ L.push("");
1249
+ L.push("```bash");
1250
+ L.push(`node scripts/resource-sampler.mjs --watch-parent <leader-pid> --run-id ${report.runId}`);
1251
+ L.push(`# sau đó: node scripts/analyze-run.mjs ${report.runId} --resources bench/results/${report.runId}.resources.jsonl`);
1252
+ L.push("```");
1253
+ L.push("");
1254
+ }
1255
+ L.push("");
1256
+
1257
+ // Throughput
1258
+ L.push("## ⚡ Throughput");
1259
+ L.push("");
1260
+ L.push("| Chỉ số | Giá trị |");
1261
+ L.push("|--------|---------|");
1262
+ L.push(`| Sự kiện/s | ${s.eventsPerSec} |`);
1263
+ L.push(`| Token output/s | ${s.tokensPerSec} |`);
1264
+ L.push(`| Tổng sự kiện | ${s.eventCount} |`);
1265
+ const totalTools = report.subagents.reduce((a, sa) => a + sa.tools, 0);
1266
+ const totalMsgs = report.subagents.reduce((a, sa) => a + sa.assistantMsgs, 0);
1267
+ L.push(`| Tổng tool calls | ${totalTools} |`);
1268
+ L.push(`| Tổng assistant messages | ${totalMsgs} |`);
1269
+ L.push("");
1270
+
1271
+ // Event type histogram (top)
1272
+ L.push("## 📈 Phân bố sự kiện (top loại)");
1273
+ L.push("");
1274
+ L.push("| Loại sự kiện | Số lượng |");
1275
+ L.push("|--------------|----------|");
1276
+ const sortedTypes = Object.entries(report.typeCounts).sort((a, b) => b[1] - a[1]).slice(0, 12);
1277
+ for (const [type, count] of sortedTypes) {
1278
+ L.push(`| ${esc(type)} | ${count} |`);
1279
+ }
1280
+ L.push("");
1281
+
1282
+ // Khuyến nghị
1283
+ L.push("## 💡 Khuyến nghị");
1284
+ L.push("");
1285
+ const recs = [];
1286
+ if (report.bottlenecks.length) {
1287
+ recs.push(`- 🔴 Bottleneck lớn nhất: **${report.bottlenecks[0].taskId}** (${fmtMs(report.bottlenecks[0].durationMs)}) — kiểm tra độ trễ model / số token / retry.`);
1288
+ }
1289
+ const blocks = report.problems.filter((p) => p.type === "phase_guard_blocked");
1290
+ if (blocks.length) {
1291
+ recs.push(`- 🟡 ${blocks.length}× phase guard bị block (lý do: "${esc(blocks[0].value)}") — đảm bảo subagent tạo artifact yêu cầu trước khi kết thúc phase.`);
1292
+ }
1293
+ const nonZeroExit = report.problems.filter((p) => p.type === "exit_code");
1294
+ if (nonZeroExit.length) {
1295
+ recs.push(`- 🔴 ${nonZeroExit.length} subagent exit ≠ 0 — kiểm tra log worker.`);
1296
+ }
1297
+ if (!report.resources) {
1298
+ recs.push("- ⚪ Chưa có data CPU/RAM — chạy resource-sampler song song ở lần sau.");
1299
+ }
1300
+ if (!recs.length) recs.push("- ✅ Run sạch, không phát hiện vấn đề đáng kể.");
1301
+ L.push(...recs);
1302
+ L.push("");
1303
+ // Per-event top gaps (--events) — where wall time actually went
1304
+ if (report.eventTimeline && report.eventTimeline.topGaps?.length) {
1305
+ L.push("## 🔍 Per-event — top gaps (đâu mất thời gian)");
1306
+ L.push("");
1307
+ L.push(`Timeline đầy đủ: \`bench/results/${report.runId}.events-timeline.csv\` (${report.eventTimeline.eventCount} events).`);
1308
+ L.push("");
1309
+ L.push("| Δms | elapsed | event | task | ý nghĩa thường |");
1310
+ L.push("|-----|---------|-------|------|----------------|");
1311
+ const meaning = (g) => {
1312
+ if (g.type === "worker.spawned") return "spawn + pi boot (~1.2s) + schedule";
1313
+ if (g.type === "worker.exit") return "LLM turn cuối trước exit";
1314
+ if (g.type === "task.progress") return "LLM turn / tool execution";
1315
+ return "—";
1316
+ };
1317
+ for (const g of report.eventTimeline.topGaps.slice(0, 10)) {
1318
+ L.push(`| ${g.deltaMs} | ${(g.elapsedMs / 1000).toFixed(1)}s | ${esc(g.type)} | ${esc(g.taskId || "")} | ${meaning(g)} |`);
1319
+ }
1320
+ L.push("");
1321
+ L.push("> Per-event **token** không có (events.jsonl redact `\"***\"`, transcript không timestamp) — chỉ timing + resource per-event. Token vẫn là per-task tổng.");
1322
+ L.push("");
1323
+ }
1324
+ L.push("---");
1325
+ L.push(`*Báo cáo tự động — dữ liệu từ \`${esc(ea.eventCount)}\` sự kiện và transcripts.*`);
1326
+
1327
+ return L.join("\n");
1328
+ }
1329
+
1330
+ main().catch((e) => {
1331
+ process.stderr.write(`[analyze-run] fatal: ${e.stack || e}\n`);
1332
+ process.exit(1);
1333
+ });