run_evals.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.9"
  4. # ///
  5. """Run eval cases through the configured platform adapter.
  6. A case is `input + rubric + optional state_prefix + optional files`. This
  7. runner does the runtime-specific part of an eval: it stages the skill under
  8. test and the case's fixture files into a clean working directory, builds the
  9. prompt the adapter understands, runs it, and records the transcript plus
  10. timing and token usage. Grading happens elsewhere; the grader subagent reads
  11. the transcript and artifacts this runner leaves behind.
  12. What this runner deliberately does NOT do:
  13. - No Docker, no PTY, no keychain staging, no dual-isolation strategy.
  14. - No hardcoded model. Everything runtime-specific comes from the adapter.
  15. Modes (--mode) decide which configs each case runs under:
  16. quality : one config, "skill" — the skill staged in the cwd.
  17. baseline : two configs per case — "skill" (skill staged) and "bare"
  18. (nothing staged), same input, so the bare-model floor is
  19. measured under identical conditions.
  20. variant : two configs — "skill" (--skill-path) and "variant"
  21. (--variant-path, the stripped or prior-version skill).
  22. Run layout: <run-dir>/<config>/<case-id>/ (plus /run-N/ when --runs > 1),
  23. so `aggregate_benchmark.py --baseline <run-dir>/bare --variant
  24. <run-dir>/skill` compares configs directly from the timing.json files.
  25. Skill staging: the skill directory is copied (symlink where possible) into
  26. <case-cwd>/<skill_dir>/<skill-name>/ before the adapter is invoked, where
  27. skill_dir comes from the adapter (default ".claude/skills"). Without this
  28. every config would measure the bare model.
  29. Fixtures: each path in a case's `files` list is staged into the case cwd at
  30. its own relative path. Sources resolve against --project-root, then the cases
  31. file's directory, then as absolute paths.
  32. Isolation: the subprocess env is built from scratch, never inherited. It
  33. holds PATH, a fresh empty HOME at <case>/.home, CLAUDE_CONFIG_DIR inside
  34. that HOME, the adapter's auth_env var ONLY if set non-empty in the host env
  35. (setting it to "" would break the runtime's own credential fallback), and any
  36. adapter `env_passthrough` keys present in the host env. Nothing else crosses.
  37. The adapter config file (JSON) — schema and discovery rules in
  38. references/platform-adapter.md, working example in
  39. assets/adapter-claude-code.json:
  40. invocation : argv template. "{prompt}" -> composed case prompt,
  41. "{cwd}" -> clean working directory.
  42. auth_env : env var name carrying auth (e.g. "ANTHROPIC_API_KEY").
  43. transcript : {"format": "stdout-jsonl"} or
  44. {"format": "file", "path": "transcript.jsonl"}.
  45. skill_dir : where the runtime discovers skills under the cwd.
  46. env_passthrough : optional list of extra host env vars to forward.
  47. If no adapter config is found, the runner degrades gracefully: it stages every
  48. case (clean cwd, skill, fixtures, prompt with state_prefix applied) and writes
  49. a manifest, but records each result as "skipped: no runtime adapter
  50. configured" instead of crashing. A human or a configured runtime can then
  51. complete the run.
  52. state_prefix handling: when a case carries a state_prefix, it is PREPENDED to
  53. the input to place the skill mid-workflow in one shot. The composed prompt is
  54. recorded so the grader sees exactly what ran.
  55. Usage:
  56. python3 run_evals.py \\
  57. --cases CASES.json \\
  58. --skill-path SKILL_DIR \\
  59. --output-dir DIR \\
  60. [--mode quality|baseline|variant] \\
  61. [--variant-path SKILL_DIR] \\
  62. [--project-root DIR] \\
  63. [--adapter ADAPTER.json] \\
  64. [--case-ids A1,B3] [--runs N] [--timeout SECS] [--workers N] [--quiet]
  65. CASES.json is either a list of cases or {"cases": [...]}. Each case:
  66. {"id": "...", "input": "...", "rubric": [...],
  67. "state_prefix": "..."?, "files": ["..."]?}
  68. """
  69. from __future__ import annotations
  70. import argparse
  71. import json
  72. import os
  73. import shutil
  74. import subprocess
  75. import sys
  76. import time
  77. from collections.abc import Mapping
  78. from concurrent.futures import ThreadPoolExecutor, as_completed
  79. from datetime import datetime, timezone
  80. from pathlib import Path
  81. # --- small self-contained helpers (no Docker/keychain imports) -------------
  82. def utc_now_iso() -> str:
  83. return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  84. def new_run_id(label: str) -> str:
  85. return f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{label}"
  86. def write_json(path: Path, data: object) -> None:
  87. path.parent.mkdir(parents=True, exist_ok=True)
  88. path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
  89. def read_json(path: Path) -> object:
  90. return json.loads(path.read_text(encoding="utf-8"))
  91. # --- adapter ----------------------------------------------------------------
  92. def find_adapter(explicit: Path | None, cases_file: Path) -> Path | None:
  93. """Locate the adapter config. Returns None when none is configured."""
  94. if explicit is not None:
  95. return explicit if explicit.is_file() else None
  96. env_path = os.environ.get("BMAD_EVAL_ADAPTER")
  97. if env_path and Path(env_path).is_file():
  98. return Path(env_path)
  99. for candidate in (
  100. cases_file.parent / "adapter.json",
  101. cases_file.parent / ".bmad-eval-adapter.json",
  102. ):
  103. if candidate.is_file():
  104. return candidate
  105. return None
  106. def load_adapter(path: Path) -> dict:
  107. cfg = read_json(path)
  108. if not isinstance(cfg, dict):
  109. raise ValueError(f"adapter config must be a JSON object: {path}")
  110. if "invocation" not in cfg or not isinstance(cfg["invocation"], list):
  111. raise ValueError("adapter config missing 'invocation' argv list")
  112. return cfg
  113. def build_argv(invocation: list, prompt: str, cwd: str) -> list[str]:
  114. argv: list[str] = []
  115. for tok in invocation:
  116. tok = str(tok)
  117. tok = (tok.replace("{prompt}", prompt)
  118. .replace("{query}", prompt)
  119. .replace("{cwd}", cwd))
  120. argv.append(tok)
  121. return argv
  122. def build_case_env(adapter: Mapping | None, home_dir: Path,
  123. host_env: Mapping[str, str]) -> dict[str, str]:
  124. """Build the subprocess environment from scratch — never from os.environ.
  125. Inheriting the host env would leak shell config, tokens, and runtime
  126. state into the clean room. The env holds exactly: PATH, a fresh HOME,
  127. CLAUDE_CONFIG_DIR inside it, the adapter's auth var ONLY when set
  128. non-empty in the host (an empty-string auth var breaks the runtime's own
  129. credential fallback), and any adapter env_passthrough keys present in
  130. the host env.
  131. """
  132. adapter = adapter or {}
  133. env = {
  134. "PATH": host_env.get("PATH", ""),
  135. "HOME": str(home_dir),
  136. "CLAUDE_CONFIG_DIR": str(home_dir / ".claude"),
  137. }
  138. auth_env = adapter.get("auth_env")
  139. if auth_env:
  140. val = host_env.get(str(auth_env))
  141. if val:
  142. env[str(auth_env)] = val
  143. for key in adapter.get("env_passthrough") or []:
  144. val = host_env.get(str(key))
  145. if val is not None:
  146. env[str(key)] = val
  147. return env
  148. # --- staging: skill under test + fixtures ------------------------------------
  149. def stage_skill(skill_path: Path, cwd: Path, skills_subdir: str) -> Path:
  150. """Place the skill where the runtime discovers skills inside the cwd.
  151. Symlink when possible (cheap, and the skill is read-only to the run);
  152. copy as the fallback.
  153. """
  154. dest_root = cwd / skills_subdir
  155. dest_root.mkdir(parents=True, exist_ok=True)
  156. dest = dest_root / skill_path.name
  157. if not dest.exists():
  158. try:
  159. os.symlink(skill_path, dest)
  160. except OSError:
  161. shutil.copytree(skill_path, dest, dirs_exist_ok=True)
  162. return dest
  163. def resolve_fixtures(files: list, project_root: Path,
  164. cases_dir: Path) -> list[tuple[Path, str]]:
  165. """Map each `files` entry to (source, dest-relative-path).
  166. The entry's own relative path is preserved inside the cwd, so a bare
  167. filename lands at the workspace root and a nested path keeps its
  168. directory structure — matching the path the case input references.
  169. """
  170. out: list[tuple[Path, str]] = []
  171. for entry in files or []:
  172. entry = str(entry)
  173. for candidate in (
  174. (project_root / entry).resolve(),
  175. (cases_dir / entry).resolve(),
  176. Path(entry).resolve(),
  177. ):
  178. if candidate.is_file():
  179. out.append((candidate, entry))
  180. break
  181. else:
  182. print(f"Warning: fixture not found: {entry}", file=sys.stderr)
  183. return out
  184. def stage_fixtures(fixtures: list[tuple[Path, str]], cwd: Path) -> None:
  185. for src, dest_rel in fixtures:
  186. dest = cwd / dest_rel
  187. dest.parent.mkdir(parents=True, exist_ok=True)
  188. shutil.copy2(src, dest)
  189. # --- case composition -------------------------------------------------------
  190. def compose_prompt(case: dict) -> str:
  191. """Apply state_prefix by prepending it to the input.
  192. The state_prefix is a bracketed prime that places the skill mid-workflow in
  193. one shot. Prepending keeps the input intact and visible to the grader.
  194. """
  195. input_text = str(case.get("input", ""))
  196. prefix = case.get("state_prefix")
  197. if prefix:
  198. return f"{str(prefix).rstrip()}\n\n{input_text}"
  199. return input_text
  200. # --- transcript + token accounting -----------------------------------------
  201. def read_transcript(transcript_cfg: dict, captured_stdout: bytes,
  202. cwd: Path) -> tuple[str, str]:
  203. """Return (transcript_text, source). Source names where it came from."""
  204. fmt = (transcript_cfg or {}).get("format", "stdout-jsonl")
  205. if fmt == "file":
  206. rel = (transcript_cfg or {}).get("path", "transcript.jsonl")
  207. f = cwd / rel
  208. if f.is_file():
  209. return f.read_text(encoding="utf-8", errors="replace"), f"file:{rel}"
  210. return "", f"file:{rel} (missing)"
  211. return captured_stdout.decode("utf-8", errors="replace"), "stdout"
  212. def account_transcript(transcript_text: str) -> dict:
  213. """Pull timing/token usage from a JSONL transcript when present.
  214. Reads usage out of the completion notification immediately, so tokens are
  215. captured at run time rather than recomputed later. Recognizes the common
  216. `result` event with a usage block and per-message usage blocks; unknown
  217. shapes degrade to zero counts without failing.
  218. """
  219. input_tokens = 0
  220. output_tokens = 0
  221. total_steps = 0
  222. tool_calls: dict[str, int] = {}
  223. found_usage = False
  224. for raw in transcript_text.splitlines():
  225. raw = raw.strip()
  226. if not raw:
  227. continue
  228. try:
  229. evt = json.loads(raw)
  230. except json.JSONDecodeError:
  231. continue
  232. if not isinstance(evt, dict):
  233. continue
  234. etype = evt.get("type")
  235. if etype == "assistant":
  236. total_steps += 1
  237. msg = evt.get("message", {})
  238. usage = msg.get("usage") if isinstance(msg, dict) else None
  239. if isinstance(usage, dict):
  240. found_usage = True
  241. input_tokens += int(usage.get("input_tokens", 0) or 0)
  242. output_tokens += int(usage.get("output_tokens", 0) or 0)
  243. for item in (msg.get("content", []) if isinstance(msg, dict) else []):
  244. if isinstance(item, dict) and item.get("type") == "tool_use":
  245. name = item.get("name", "?")
  246. tool_calls[name] = tool_calls.get(name, 0) + 1
  247. elif etype == "result":
  248. usage = evt.get("usage")
  249. if isinstance(usage, dict):
  250. found_usage = True
  251. # result usage is authoritative; prefer it over the running sum
  252. input_tokens = int(usage.get("input_tokens", input_tokens) or input_tokens)
  253. output_tokens = int(usage.get("output_tokens", output_tokens) or output_tokens)
  254. return {
  255. "input_tokens": input_tokens,
  256. "output_tokens": output_tokens,
  257. "total_tokens": input_tokens + output_tokens,
  258. "tokens_reported": found_usage,
  259. "total_steps": total_steps,
  260. "tool_calls": tool_calls,
  261. "total_tool_calls": sum(tool_calls.values()),
  262. }
  263. # --- per-case execution -----------------------------------------------------
  264. def run_case(case: dict, case_dir: Path, run_dir: Path,
  265. adapter: dict | None, timeout: int, config: str,
  266. skill_path: Path | None,
  267. fixtures: list[tuple[Path, str]]) -> dict:
  268. case_id = str(case.get("id", "unnamed"))
  269. cwd = case_dir / "cwd"
  270. cwd.mkdir(parents=True, exist_ok=True)
  271. stage_fixtures(fixtures, cwd)
  272. if skill_path is not None:
  273. skills_subdir = (adapter or {}).get("skill_dir", ".claude/skills")
  274. stage_skill(skill_path, cwd, skills_subdir)
  275. prompt = compose_prompt(case)
  276. (case_dir / "prompt.txt").write_text(prompt, encoding="utf-8")
  277. write_json(case_dir / "case.json", case)
  278. if adapter is None:
  279. result = {
  280. "case_id": case_id,
  281. "config": config,
  282. "status": "skipped",
  283. "reason": "no runtime adapter configured",
  284. "prompt_chars": len(prompt),
  285. "cwd": str(cwd.relative_to(run_dir)),
  286. }
  287. write_json(case_dir / "timing.json", {
  288. "case_id": case_id, "config": config, "status": "skipped",
  289. "captured_at": utc_now_iso(),
  290. })
  291. return result
  292. transcript_path = case_dir / "transcript.jsonl"
  293. argv = build_argv(adapter["invocation"], prompt, str(cwd))
  294. home_dir = case_dir / ".home"
  295. (home_dir / ".claude").mkdir(parents=True, exist_ok=True)
  296. env = build_case_env(adapter, home_dir, os.environ)
  297. start = time.time()
  298. captured = b""
  299. return_code = 0
  300. error_tail = ""
  301. status = "ok"
  302. try:
  303. proc = subprocess.run(
  304. argv,
  305. stdout=subprocess.PIPE,
  306. stderr=subprocess.PIPE,
  307. cwd=str(cwd),
  308. env=env,
  309. timeout=timeout,
  310. )
  311. captured = proc.stdout or b""
  312. return_code = proc.returncode
  313. error_tail = (proc.stderr or b"").decode("utf-8", errors="replace")[-2000:]
  314. if return_code != 0:
  315. status = "error"
  316. except FileNotFoundError as e:
  317. # Adapter invocation command is not on PATH: degrade, do not crash.
  318. elapsed = time.time() - start
  319. write_json(case_dir / "timing.json", {
  320. "case_id": case_id, "config": config, "status": "adapter-missing",
  321. "elapsed_s": round(elapsed, 3), "captured_at": utc_now_iso(),
  322. })
  323. return {
  324. "case_id": case_id,
  325. "config": config,
  326. "status": "adapter-missing",
  327. "reason": f"invocation command not found: {e}",
  328. "cwd": str(cwd.relative_to(run_dir)),
  329. }
  330. except subprocess.TimeoutExpired as e:
  331. captured = e.stdout or b""
  332. return_code = -1
  333. status = "timeout"
  334. error_tail = f"TIMEOUT after {timeout}s"
  335. elapsed = time.time() - start
  336. transcript_text, source = read_transcript(
  337. adapter.get("transcript", {}), captured, cwd
  338. )
  339. transcript_path.write_text(transcript_text, encoding="utf-8")
  340. accounting = account_transcript(transcript_text)
  341. # Capture timing/tokens immediately to timing.json (run-time snapshot).
  342. timing = {
  343. "case_id": case_id,
  344. "config": config,
  345. "status": status,
  346. "elapsed_s": round(elapsed, 3),
  347. "return_code": return_code,
  348. "transcript_source": source,
  349. "input_tokens": accounting["input_tokens"],
  350. "output_tokens": accounting["output_tokens"],
  351. "total_tokens": accounting["total_tokens"],
  352. "tokens_reported": accounting["tokens_reported"],
  353. "total_steps": accounting["total_steps"],
  354. "total_tool_calls": accounting["total_tool_calls"],
  355. "captured_at": utc_now_iso(),
  356. }
  357. write_json(case_dir / "timing.json", timing)
  358. return {
  359. "case_id": case_id,
  360. "config": config,
  361. "status": status,
  362. "elapsed_s": round(elapsed, 3),
  363. "return_code": return_code,
  364. "transcript": str(transcript_path.relative_to(run_dir)),
  365. "cwd": str(cwd.relative_to(run_dir)),
  366. "tokens": accounting["total_tokens"],
  367. "tool_calls": accounting["tool_calls"],
  368. "error_tail": error_tail,
  369. }
  370. # --- main -------------------------------------------------------------------
  371. def load_cases(cases_file: Path) -> list[dict]:
  372. data = read_json(cases_file)
  373. if isinstance(data, dict) and "cases" in data:
  374. cases = data["cases"]
  375. elif isinstance(data, list):
  376. cases = data
  377. else:
  378. raise ValueError("cases file must be a list or {'cases': [...]}")
  379. if not isinstance(cases, list):
  380. raise ValueError("'cases' must be a list")
  381. return cases
  382. def main(argv: list[str] | None = None) -> int:
  383. p = argparse.ArgumentParser(
  384. description=__doc__,
  385. formatter_class=argparse.RawDescriptionHelpFormatter,
  386. )
  387. p.add_argument("--cases", required=True, type=Path)
  388. p.add_argument("--skill-path", required=True, type=Path,
  389. help="directory of the skill under test (contains SKILL.md)")
  390. p.add_argument("--output-dir", required=True, type=Path)
  391. p.add_argument("--mode", choices=("quality", "baseline", "variant"),
  392. default="quality")
  393. p.add_argument("--variant-path", type=Path, default=None,
  394. help="variant mode: the stripped or prior-version skill")
  395. p.add_argument("--project-root", type=Path, default=None,
  396. help="base for resolving fixture paths; defaults to the "
  397. "cases file's directory")
  398. p.add_argument("--adapter", type=Path, default=None,
  399. help="adapter config JSON; defaults to BMAD_EVAL_ADAPTER env "
  400. "or adapter.json beside the cases file")
  401. p.add_argument("--case-ids", default=None,
  402. help="comma-separated subset of case ids to run")
  403. p.add_argument("--runs", type=int, default=1,
  404. help="repeats per case per config for the variance benchmark")
  405. p.add_argument("--timeout", type=int, default=600)
  406. p.add_argument("--workers", type=int, default=4)
  407. p.add_argument("--label", default="evals", help="label for the run id")
  408. p.add_argument("--quiet", action="store_true")
  409. args = p.parse_args(argv)
  410. cases_file = args.cases.resolve()
  411. if not cases_file.is_file():
  412. print(f"cases file not found: {cases_file}", file=sys.stderr)
  413. return 2
  414. skill_path = args.skill_path.resolve()
  415. if not (skill_path / "SKILL.md").is_file():
  416. print(f"skill path has no SKILL.md: {skill_path}", file=sys.stderr)
  417. return 2
  418. if args.mode == "variant":
  419. if args.variant_path is None:
  420. print("--mode variant requires --variant-path", file=sys.stderr)
  421. return 2
  422. variant_path = args.variant_path.resolve()
  423. if not (variant_path / "SKILL.md").is_file():
  424. print(f"variant path has no SKILL.md: {variant_path}",
  425. file=sys.stderr)
  426. return 2
  427. else:
  428. variant_path = None
  429. project_root = (args.project_root.resolve() if args.project_root
  430. else cases_file.parent)
  431. # Each config is (name, skill-to-stage-or-None). Baseline runs every case
  432. # twice — skill staged and bare — so the floor is measured under
  433. # identical conditions.
  434. if args.mode == "baseline":
  435. configs: list[tuple[str, Path | None]] = [
  436. ("skill", skill_path), ("bare", None)]
  437. elif args.mode == "variant":
  438. configs = [("skill", skill_path), ("variant", variant_path)]
  439. else:
  440. configs = [("skill", skill_path)]
  441. cases = load_cases(cases_file)
  442. if args.case_ids:
  443. wanted = {x.strip() for x in args.case_ids.split(",") if x.strip()}
  444. cases = [c for c in cases if str(c.get("id")) in wanted]
  445. adapter_path = find_adapter(args.adapter, cases_file)
  446. adapter: dict | None = None
  447. adapter_note = "none"
  448. if adapter_path is not None:
  449. try:
  450. adapter = load_adapter(adapter_path)
  451. adapter_note = str(adapter_path)
  452. except Exception as e:
  453. print(f"adapter config invalid ({e}); degrading to skip-only",
  454. file=sys.stderr)
  455. adapter = None
  456. adapter_note = f"invalid: {e}"
  457. run_id = new_run_id(args.label)
  458. run_dir = (args.output_dir / run_id).resolve()
  459. run_dir.mkdir(parents=True, exist_ok=True)
  460. write_json(run_dir / "run.json", {
  461. "run_id": run_id,
  462. "cases_file": str(cases_file),
  463. "skill_path": str(skill_path),
  464. "variant_path": str(variant_path) if variant_path else None,
  465. "mode": args.mode,
  466. "configs": [name for name, _ in configs],
  467. "runs_per_case": args.runs,
  468. "adapter": adapter_note,
  469. "started_at": utc_now_iso(),
  470. "case_count": len(cases),
  471. })
  472. if adapter is None and not args.quiet:
  473. print("[run_evals] no runtime adapter configured; staging cases only "
  474. "(no crash). Configure an adapter to execute.", file=sys.stderr)
  475. results: list[dict] = []
  476. if not args.quiet:
  477. print(f"[run_evals] {len(cases)} cases x {len(configs)} configs x "
  478. f"{args.runs} runs, mode={args.mode}, run_dir={run_dir}",
  479. file=sys.stderr)
  480. jobs: list[tuple[str, dict, Path, Path | None]] = []
  481. for config_name, config_skill in configs:
  482. for c in cases:
  483. base = run_dir / config_name / str(c.get("id", "unnamed"))
  484. for i in range(max(1, args.runs)):
  485. case_dir = base / f"run-{i + 1}" if args.runs > 1 else base
  486. jobs.append((config_name, c, case_dir, config_skill))
  487. with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
  488. fut_to_case = {
  489. pool.submit(run_case, c, case_dir, run_dir, adapter,
  490. int(c.get("timeout", args.timeout)), config_name,
  491. config_skill,
  492. resolve_fixtures(c.get("files", []), project_root,
  493. cases_file.parent)): c
  494. for config_name, c, case_dir, config_skill in jobs
  495. }
  496. for fut in as_completed(fut_to_case):
  497. c = fut_to_case[fut]
  498. try:
  499. res = fut.result()
  500. except Exception as e:
  501. res = {"case_id": str(c.get("id")), "status": "exception",
  502. "reason": str(e)}
  503. results.append(res)
  504. if not args.quiet:
  505. print(f" [{res.get('status')}] {res.get('config', '?')}/"
  506. f"{res.get('case_id')} ({res.get('elapsed_s', 0)}s)",
  507. file=sys.stderr)
  508. summary = {
  509. "run_id": run_id,
  510. "completed_at": utc_now_iso(),
  511. "mode": args.mode,
  512. "total": len(jobs),
  513. "executed": sum(1 for r in results if r.get("status") == "ok"),
  514. "skipped": sum(1 for r in results if r.get("status") == "skipped"),
  515. "failures": sum(1 for r in results
  516. if r.get("status") in ("error", "timeout", "exception",
  517. "adapter-missing")),
  518. "run_dir": str(run_dir),
  519. "results": results,
  520. }
  521. write_json(run_dir / "execution-summary.json", summary)
  522. print(json.dumps(summary, indent=2))
  523. return 0
  524. if __name__ == "__main__":
  525. sys.exit(main())