mirror of
https://github.com/Heretek-AI/opencode-stack-bench.git
synced 2026-11-08 17:16:27 -10:00
270 lines
12 KiB
Python
270 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Run every task once for one arm (stock or stack) in one wave.
|
|
|
|
Each task gets a fresh git workspace and a fresh HOME/XDG tree, so nothing leaks
|
|
between tasks or arms. The only difference between arms is OPENCODE_CONFIG_DIR:
|
|
`arms/stock` (empty) vs `arms/stack` (your opencode.json, agents, skills, plugins).
|
|
|
|
Results are appended as JSON lines to <out>/<arm>-w<wave>.jsonl, with raw event
|
|
logs under <out>/logs/.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import random
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
TASKS = ROOT / "tasks"
|
|
CHECK_TIMEOUT_S = 180
|
|
|
|
|
|
def load_tasks(selector):
|
|
tasks = sorted(p for p in TASKS.iterdir() if (p / "task.json").exists())
|
|
if selector and selector != "all":
|
|
wanted = {s.strip() for s in selector.split(",") if s.strip()}
|
|
unknown = wanted - {p.name for p in tasks}
|
|
if unknown:
|
|
sys.exit(f"unknown task(s): {', '.join(sorted(unknown))}")
|
|
tasks = [p for p in tasks if p.name in wanted]
|
|
return tasks
|
|
|
|
|
|
def git(ws, *args):
|
|
subprocess.run(["git", *args], cwd=ws, check=True, capture_output=True)
|
|
|
|
|
|
def make_workspace(task_dir, ws):
|
|
shutil.copytree(task_dir / "repo", ws)
|
|
git(ws, "init", "-q", "-b", "main")
|
|
(ws / ".git" / "info" / "exclude").write_text("__pycache__/\n*.pyc\nnode_modules/\n.bench_hidden/\n")
|
|
git(ws, "-c", "user.name=bench", "-c", "[email protected]", "add", "-A")
|
|
git(ws, "-c", "user.name=bench", "-c", "[email protected]", "commit", "-qm", "task fixture")
|
|
|
|
|
|
def isolated_env(scratch, arm_dir, shared_config, cache_dir):
|
|
env = {k: v for k, v in os.environ.items() if not k.startswith("OPENCODE_")}
|
|
home = scratch / "home"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
env.update(
|
|
HOME=str(home),
|
|
XDG_CONFIG_HOME=str(home / ".config"),
|
|
XDG_DATA_HOME=str(home / ".local" / "share"),
|
|
XDG_STATE_HOME=str(home / ".local" / "state"),
|
|
XDG_CACHE_HOME=str(cache_dir),
|
|
OPENCODE_CONFIG_DIR=str(arm_dir),
|
|
OPENCODE_DISABLE_AUTOUPDATE="1",
|
|
# Ignore ~/.claude skills and CLAUDE.md so only what the arm declares counts.
|
|
OPENCODE_DISABLE_CLAUDE_CODE="1",
|
|
CI="1",
|
|
)
|
|
if shared_config and shared_config.exists():
|
|
env["OPENCODE_CONFIG"] = str(shared_config)
|
|
return env
|
|
|
|
|
|
def run_opencode(args, task_dir, ws, env, log_base, timeout_s):
|
|
prompt = (task_dir / "prompt.md").read_text()
|
|
cmd = [args.opencode_bin, "run", "--standalone", "--auto", "--format", "json",
|
|
"--title", f"{task_dir.name} {args.arm} w{args.wave}"]
|
|
if args.model:
|
|
cmd += ["-m", args.model]
|
|
if args.agent:
|
|
cmd += ["--agent", args.agent]
|
|
cmd.append(prompt)
|
|
# opencode resolves the project directory from $PWD, not the process cwd.
|
|
env = {**env, "PWD": str(ws)}
|
|
events_path = log_base.with_suffix(".events.jsonl")
|
|
stderr_path = log_base.with_suffix(".stderr.log")
|
|
start = time.monotonic()
|
|
timed_out = False
|
|
with open(events_path, "w") as out, open(stderr_path, "w") as err:
|
|
proc = subprocess.Popen(cmd, cwd=ws, env=env, stdout=out, stderr=err,
|
|
stdin=subprocess.DEVNULL, start_new_session=True)
|
|
try:
|
|
proc.wait(timeout=timeout_s)
|
|
except subprocess.TimeoutExpired:
|
|
timed_out = True
|
|
os.killpg(proc.pid, signal.SIGTERM)
|
|
try:
|
|
proc.wait(timeout=15)
|
|
except subprocess.TimeoutExpired:
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
proc.wait()
|
|
duration = time.monotonic() - start
|
|
return proc.returncode, timed_out, duration, events_path, stderr_path
|
|
|
|
|
|
def summarize_events(events_path):
|
|
"""Sum usage across `step_finish` events and count tool calls from `--format json` output."""
|
|
s = {"steps": 0, "tokens_in": 0, "tokens_out": 0, "tokens_reasoning": 0,
|
|
"tokens_cache_read": 0, "tokens_cache_write": 0, "cost_usd": 0.0,
|
|
"tool_calls": 0, "tool_errors": 0, "error_events": 0, "events": 0}
|
|
tools = Counter()
|
|
for line in events_path.read_text(errors="replace").splitlines():
|
|
try:
|
|
ev = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
s["events"] += 1
|
|
etype = ev.get("type")
|
|
part = ev.get("part") or {}
|
|
if etype == "step_finish":
|
|
s["steps"] += 1
|
|
tok = part.get("tokens") or {}
|
|
cache = tok.get("cache") or {}
|
|
s["tokens_in"] += tok.get("input") or 0
|
|
s["tokens_out"] += tok.get("output") or 0
|
|
s["tokens_reasoning"] += tok.get("reasoning") or 0
|
|
s["tokens_cache_read"] += cache.get("read") or 0
|
|
s["tokens_cache_write"] += cache.get("write") or 0
|
|
s["cost_usd"] += part.get("cost") or 0
|
|
elif etype == "tool_use":
|
|
s["tool_calls"] += 1
|
|
tools[part.get("tool", "?")] += 1
|
|
if (part.get("state") or {}).get("status") == "error":
|
|
s["tool_errors"] += 1
|
|
elif etype == "error":
|
|
s["error_events"] += 1
|
|
err = ev.get("error") or {}
|
|
s.setdefault("first_error", f"{err.get('type', '')}: {err.get('message', '')}"[:500])
|
|
s["tools"] = dict(tools)
|
|
return s
|
|
|
|
|
|
def diff_stats(ws):
|
|
subprocess.run(["git", "add", "-A"], cwd=ws, capture_output=True)
|
|
r = subprocess.run(["git", "diff", "--cached", "--numstat", "HEAD"], cwd=ws,
|
|
capture_output=True, text=True)
|
|
files = added = removed = 0
|
|
for line in r.stdout.splitlines():
|
|
a, d, _ = line.split("\t", 2)
|
|
files += 1
|
|
added += int(a) if a.isdigit() else 0
|
|
removed += int(d) if d.isdigit() else 0
|
|
return {"files_changed": files, "lines_added": added, "lines_removed": removed}
|
|
|
|
|
|
def check(task_dir, ws, runner):
|
|
hidden = ws / ".bench_hidden"
|
|
if hidden.exists():
|
|
shutil.rmtree(hidden)
|
|
shutil.copytree(task_dir / "hidden", hidden)
|
|
try:
|
|
if runner == "python":
|
|
r = subprocess.run([sys.executable, str(ROOT / "bench" / "pycheck.py"), str(hidden)],
|
|
cwd=ws, capture_output=True, text=True, timeout=CHECK_TIMEOUT_S)
|
|
last = (r.stdout.strip().splitlines() or ["{}"])[-1]
|
|
res = json.loads(last)
|
|
return res.get("passed", 0), res.get("total", 0), r.stdout[-4000:] + r.stderr[-4000:]
|
|
if runner == "node":
|
|
files = sorted(str(p) for p in hidden.glob("*.test.js"))
|
|
r = subprocess.run(["node", "--test", "--test-reporter=tap", *files],
|
|
cwd=ws, capture_output=True, text=True, timeout=CHECK_TIMEOUT_S)
|
|
counts = {}
|
|
for line in r.stdout.splitlines():
|
|
parts = line.split()
|
|
if len(parts) == 3 and parts[0] == "#" and parts[1] in ("pass", "fail", "cancelled") and parts[2].isdigit():
|
|
counts[parts[1]] = int(parts[2])
|
|
passed = counts.get("pass", 0)
|
|
return passed, passed + counts.get("fail", 0) + counts.get("cancelled", 0), r.stdout[-4000:] + r.stderr[-4000:]
|
|
except subprocess.TimeoutExpired:
|
|
return 0, 0, "check timed out"
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
return 0, 0, f"check crashed: {e}"
|
|
finally:
|
|
shutil.rmtree(hidden, ignore_errors=True)
|
|
raise ValueError(f"unknown runner {runner}")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--arm", required=True, help="directory name under arms/ (stock or stack)")
|
|
ap.add_argument("--wave", type=int, default=1)
|
|
ap.add_argument("--model", default=os.environ.get("BENCH_MODEL", ""), help="provider/model[#variant]; same for both arms")
|
|
ap.add_argument("--agent", default="", help="agent to pass with --agent (only use for the stack arm)")
|
|
ap.add_argument("--tasks", default="all", help="comma separated task ids, or 'all'")
|
|
ap.add_argument("--timeout", type=int, default=0, help="per-task timeout in seconds (default: task.json)")
|
|
ap.add_argument("--out", default="results")
|
|
ap.add_argument("--shared-config", default=str(ROOT / "shared" / "opencode.json"),
|
|
help="config file applied to BOTH arms via OPENCODE_CONFIG (providers etc.)")
|
|
ap.add_argument("--opencode-bin", default=os.environ.get("OPENCODE_BIN", "opencode"))
|
|
ap.add_argument("--mode", choices=["opencode", "solution", "noop"], default="opencode",
|
|
help="solution/noop skip the agent and are used to self-test the task suite")
|
|
ap.add_argument("--keep-workspaces", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
arm_src = ROOT / "arms" / args.arm
|
|
if not arm_src.is_dir():
|
|
sys.exit(f"no such arm: {arm_src}")
|
|
out = Path(args.out).resolve()
|
|
(out / "logs").mkdir(parents=True, exist_ok=True)
|
|
results_path = out / f"{args.arm}-w{args.wave}.jsonl"
|
|
|
|
tasks = load_tasks(args.tasks)
|
|
# Same shuffled order for both arms within a wave, different order across waves.
|
|
random.Random(args.wave).shuffle(tasks)
|
|
|
|
scratch_root = Path(tempfile.mkdtemp(prefix=f"bench-{args.arm}-w{args.wave}-"))
|
|
cache_dir = scratch_root / "cache" # shared within this job so plugin/npm installs are paid once
|
|
# Copy the arm so plugin installs or caches written next to the config never touch the repo.
|
|
arm_dir = scratch_root / "arm"
|
|
shutil.copytree(arm_src, arm_dir, ignore=shutil.ignore_patterns("README.md"))
|
|
|
|
print(f"arm={args.arm} wave={args.wave} model={args.model or '(config default)'} tasks={len(tasks)}", flush=True)
|
|
with open(results_path, "a") as results:
|
|
for task_dir in tasks:
|
|
meta = json.loads((task_dir / "task.json").read_text())
|
|
scratch = scratch_root / task_dir.name
|
|
ws = scratch / "ws"
|
|
make_workspace(task_dir, ws)
|
|
log_base = out / "logs" / f"{args.arm}-w{args.wave}-{task_dir.name}"
|
|
rec = {"task": task_dir.name, "arm": args.arm, "wave": args.wave, "model": args.model,
|
|
"agent": args.agent, "mode": args.mode}
|
|
if args.mode == "opencode":
|
|
env = isolated_env(scratch, arm_dir, Path(args.shared_config), cache_dir)
|
|
timeout_s = args.timeout or meta.get("timeout_s", 600)
|
|
code, timed_out, duration, events_path, stderr_path = run_opencode(
|
|
args, task_dir, ws, env, log_base, timeout_s)
|
|
usage = summarize_events(events_path)
|
|
rec.update(exit_code=code, timed_out=timed_out, duration_s=round(duration, 2), **usage)
|
|
# No model step at all means the run never really started (bad key, bad model id, crash).
|
|
rec["infra_error"] = usage["steps"] == 0 and usage["tool_calls"] == 0
|
|
if rec["infra_error"]:
|
|
rec["stderr_tail"] = stderr_path.read_text(errors="replace")[-1500:]
|
|
else:
|
|
if args.mode == "solution":
|
|
shutil.copytree(task_dir / "solution", ws, dirs_exist_ok=True)
|
|
rec.update(exit_code=0, timed_out=False, duration_s=0.0, infra_error=False)
|
|
rec.update(diff_stats(ws))
|
|
passed, total, check_log = check(task_dir, ws, meta["runner"])
|
|
expected = meta.get("tests") or total
|
|
total = max(total, expected)
|
|
rec.update(passed=passed, total=total, score=round(passed / total, 4) if total else 0.0,
|
|
solved=bool(total) and passed == total)
|
|
log_base.with_suffix(".check.log").write_text(check_log)
|
|
results.write(json.dumps(rec) + "\n")
|
|
results.flush()
|
|
flag = " INFRA-ERROR" if rec["infra_error"] else (" TIMEOUT" if rec["timed_out"] else "")
|
|
print(f" {task_dir.name:<22} {passed}/{total} {rec['duration_s']:>7.1f}s"
|
|
f" tok_in={rec.get('tokens_in', 0)} tok_out={rec.get('tokens_out', 0)}{flag}", flush=True)
|
|
if rec.get("first_error"):
|
|
print(f" error: {rec['first_error']}", flush=True)
|
|
if rec["infra_error"] and rec["stderr_tail"].strip():
|
|
print(" " + rec["stderr_tail"].strip().replace("\n", "\n ")[-800:], flush=True)
|
|
|
|
if not args.keep_workspaces:
|
|
shutil.rmtree(scratch_root, ignore_errors=True)
|
|
print(f"results -> {results_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|