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
|
@@ -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
|
+
});
|