Skip to content

Commit 2f7fafd

Browse files
Add the eval-ops toolkit used to drive the SWE-Marathon sweeps (#1269)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent a406827 commit 2f7fafd

19 files changed

Lines changed: 1890 additions & 0 deletions

eval-ops/PLAN.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Standing orders — SWE-Marathon gpt-5.6-terra effort sweep
2+
3+
**Read this first after any container restart.** This happened three times
4+
(2026-08-05 ~00:40Z, ~19:50Z, 2026-08-06 ~02:29Z), wiping `/home/user/terra-run`
5+
entirely — scripts, state, dataset copies — along with the babysit cron.
6+
7+
**Root cause: the container is reclaimed on INACTIVITY.** Polling every 30
8+
minutes left gaps long enough to be reclaimed mid-run. The fix is a **20-minute
9+
heartbeat cron** that outputs a single letter and nothing else; with it, ordinary
10+
long-running loops survive and `run_forever.sh` can drive the whole fill. Keep
11+
that heartbeat alive — it is load-bearing, not cosmetic.
12+
13+
The repos and `/home/user/oddish/.env` survive a wipe. Server-side trials keep
14+
running throughout; only the babysitting stops.
15+
16+
**Recovery is a single command** — this whole directory is committed to the
17+
`oddish` repo, branch `claude/oddish-api-env-setup-f76jpp`, under `eval-ops/`:
18+
19+
```bash
20+
cp -r /home/user/oddish/eval-ops/* /home/user/terra-run/ # or git checkout
21+
cd /home/user/terra-run
22+
for a in low medium high; do cp -r /home/user/swe-marathon/tasks ds-$a; done
23+
bash cycle.sh # one idempotent pass: canary -> non-CUA dispatch -> throttled CUA fill
24+
25+
```
26+
27+
Then re-arm a 30-minute babysit cron (it is session-only and never survives).
28+
29+
## The three experiments
30+
31+
| Arm | Name | Experiment | `reasoning_effort` |
32+
|---|---|---|---|
33+
| LOW | `swem-terra-low` | `17b6f7d9` | `low` |
34+
| MEDIUM | `swem-terra-medium` | `c071229f` | `medium` |
35+
| HIGH | `swem-terra-high` | `a706e700` | `high` |
36+
37+
Agent `codex`, model `openai/gpt-5.6-terra`, 20 SWE-Marathon v1.1 tasks each.
38+
Links: https://www.oddish.app/experiments/{17b6f7d9,c071229f,a706e700}
39+
40+
## Charles's orders, in the order given
41+
42+
1. **k=8 on the 16 non-CUA tasks, all three arms.** Done 2026-08-04T23:09Z —
43+
48 cells verified at exactly 8. Completed clean: 384 trials, **0 infra**,
44+
35 passes (HIGH 21 / MEDIUM 9 / LOW 5).
45+
2. **CUA tasks excluded** from that fill. They held baselines only: nop ×1 +
46+
oracle ×1 per arm, 24 trials, all valid (nop 0.0 / oracle 1.0).
47+
3. **2026-08-05T18:12Z — "run 5 trials for all the cua trials rn"**, throttled
48+
to the §3.6 cap of ≤10 concurrent. Reached ~23/60 before the second wipe.
49+
4. **2026-08-06T01:44Z — "finish the trials off. make everything 10/10".**
50+
**Phase B, current:** every task, CUA and non-CUA, to **10 trials per cell**.
51+
Non-CUA 8 → 10 (+96 trials, `dispatch.sh`); CUA → 10 (`cua_loop.sh`, still
52+
throttled — the cap is a verifier-safety limit, not a target, and does
53+
**not** relax in phase B).
54+
5. **Babysit every 30 minutes** throughout.
55+
56+
`config.json` holds the live targets and phase; both scripts read it, so a
57+
phase change needs no relaunch.
58+
59+
## Non-negotiables (runbook + hard-won)
60+
61+
- `-e modal` always; `--override-memory-mb 65536` on every submission in every
62+
arm (uniform, so memory never confounds the effort comparison). Verified
63+
sufficient: **zero `exit 137` OOMs** across 384 trials at every effort level.
64+
- `--force` — preflight has a Rich-markup bug that flags every v1.1 task as
65+
unjustified open internet. False positive.
66+
- `--no-baseline-gate` — the nop/oracle gate **is active** on this deployment
67+
despite `gate_llm_on_baselines=False` in `config.py`. One flaky reward=0
68+
oracle silently `skipped` agent trials in both MEDIUM and HIGH.
69+
- `--ae ODDISH_EVAL_NONCE=<unique>` — without it a resubmit after a delete hits
70+
the 24 h sweep idempotency key, prints `Task submitted!`, and creates nothing.
71+
- **One task per command, serially, with backoff retries.** Multi-`-t` batch
72+
submits return HTTP 500 and commit *zero* — verified repeatedly by re-query.
73+
Singles 500 too, but clear on retry; this is why a fill pass takes ~30 min.
74+
- Sweeps are **target-based**: `--n-trials N` creates `N − existing`. Retries
75+
are therefore safe and can never double-submit. This is what makes the whole
76+
crash-recovery story work: just re-assert the target.
77+
- **Verify by data, never by exit code.** A submit that hits the tool timeout
78+
has usually still committed — re-query rather than assuming.
79+
- Classify by `error_message`, never job status. Timeouts appear as prose
80+
(`Agent execution timed out after N seconds`), not as `AgentTimeoutError`
81+
match both or a valid trial gets deleted as infra.
82+
- **Never** `--force-new-version`. If a submit reports a new task version, stop:
83+
the dataset copy has drifted from the uploaded task. Rebuilt copies from a
84+
fresh `swe-marathon` clone have been verified to reproduce
85+
"unchanged, reusing version N".
86+
- Degenerate short trials are **reported, never auto-pruned** — in LOW, short
87+
trajectories are the treatment effect, and pruning them biases pass@k.
88+
- Don't `pkill -f` on a pattern that also matches the calling shell's own
89+
command line. It kills the caller. (Learned the hard way.)
90+
91+
## CUA concurrency — SUPERSEDED, cap is now 25
92+
93+
Charles, 2026-08-06: *"the concurrency can go up to 25. just queue up all of the
94+
trials for the cua. trust me."* The wave-by-wave filler is retired; `cua_dispatch.sh`
95+
queues every CUA cell straight to target and Oddish's own queue does the
96+
throttling. Do **not** re-impose the ≤10 waves. The original rationale is kept
97+
below for context, since it explains what the failure mode looks like if it ever
98+
does appear.
99+
100+
## CUA throttle, original rationale (historical)
101+
102+
The browser verifier runs on the shared platform `ANTHROPIC_API_KEY`, and each
103+
CUA `task.toml` hard-fails (no reward file → the trial *errors* rather than
104+
scores) if the grader dies for an infra reason. Over-parallelising destroys
105+
trials rather than merely queueing them. `nop` does not count — its verifier
106+
short-circuits in ~1.6 s. `cua_fill.py` refills only up to the headroom each
107+
pass, holds a single-writer lock so overlapping cycles can't both spend the
108+
same headroom, and aborts rather than submitting blind if any CUA read fails.
109+
110+
Observed CUA behaviour: trials finish in ~15–30 min at $0.25–0.75 each, and
111+
have so far always produced a scored result — no `No reward file found` at a
112+
concurrency of 10, i.e. the cap is holding.
113+
114+
## Files
115+
116+
- `poll_all.py` — fetch + classify all 20 tasks → `state.json`
117+
- `babysit.py` — report → `babysit-latest.md` + `hb-fleet.txt`; `--delete`
118+
removes infra, `--topup` re-asserts targets on short non-CUA cells only
119+
- `cua_fill.py` — one throttled CUA pass (config-driven, locked)
120+
- `cua_loop.sh` — repeats `cua_fill.py` until all 12 CUA cells hold target
121+
- `dispatch.sh` — 3 arms in parallel, 16 non-CUA tasks serially per arm
122+
- `config.json` — phase + targets + cap
123+
- `ds-low/`, `ds-medium/`, `ds-high/` — per-arm dataset copies so concurrent
124+
submissions can't race on task files (not committed; rebuild from
125+
`/home/user/swe-marathon/tasks`)

eval-ops/audit_exceptions.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env python3
2+
"""Tally why trials actually failed, using Harbor's own classification.
3+
4+
The authoritative answer is result.harbor_exception.exception_type -- Harbor
5+
names the failure itself. The error_message string is a red herring: it always
6+
opens with "Command failed (exit 1): opencode ..." regardless of cause, and its
7+
middle is elided server-side, so grepping it tells you very little.
8+
9+
Also counts trials that carry a real verifier result despite the exception,
10+
since those solved the task and are being discarded by the validity rule.
11+
"""
12+
import json, subprocess, sys, collections
13+
14+
ODDISH = "/home/user/oddish/oddish/.venv/bin/oddish"
15+
EXP = "826d7d88"
16+
PENDING = ("pending", "queued", "running", "blocked", "preparing", "submitted",
17+
"claimed", "in_progress", "initializing", "retrying")
18+
19+
20+
def api(args, tries=5):
21+
for _ in range(tries):
22+
try:
23+
r = subprocess.run([ODDISH] + args, capture_output=True, text=True,
24+
timeout=600)
25+
if r.returncode == 0 and r.stdout.strip():
26+
return json.loads(r.stdout)
27+
except Exception:
28+
pass
29+
return None
30+
31+
32+
d = api(["status", EXP, "--json"])
33+
if d is None:
34+
sys.exit("experiment fetch FAILED")
35+
tasks = {t["id"]: t["name"] for t in d.get("tasks") or []}
36+
37+
kinds = collections.Counter()
38+
by_task = collections.defaultdict(collections.Counter)
39+
scored_anyway = [] # exception raised, but the verifier still produced a reward
40+
missing = []
41+
42+
for tid, tname in tasks.items():
43+
td = api(["status", tid, "--json"])
44+
if td is None:
45+
missing.append(tname)
46+
continue
47+
for tr in td.get("trials") or []:
48+
if tr.get("experiment_id") != EXP or tr.get("superseded_by_trial_id"):
49+
continue
50+
if (tr.get("status") or "").lower() in PENDING:
51+
continue
52+
res = tr.get("result") or {}
53+
exc = (res.get("harbor_exception") or {}).get("exception_type")
54+
if not exc:
55+
kinds["(no exception -- clean run)"] += 1
56+
continue
57+
kinds[exc] += 1
58+
by_task[tname][exc] += 1
59+
rew = tr.get("reward")
60+
main = (res.get("main") or {})
61+
if rew is not None or main:
62+
scored_anyway.append((tname, tr.get("id"), exc, rew,
63+
main.get("passed"), main.get("total"),
64+
(res.get("holdout") or {}).get("pass_rate")))
65+
66+
if missing:
67+
print(f"WARNING: {len(missing)} task(s) unreadable: {missing}")
68+
69+
print("=== why trials ended, by Harbor's own exception_type ===")
70+
for k, v in kinds.most_common():
71+
print(f" {v:>4} {k}")
72+
73+
print("\n=== exceptions by task ===")
74+
for tname in sorted(by_task):
75+
row = ", ".join(f"{k}={v}" for k, v in by_task[tname].most_common())
76+
print(f" {tname:<32} {row}")
77+
78+
print(f"\n=== trials that raised an exception but STILL have a verifier result "
79+
f"({len(scored_anyway)}) ===")
80+
for tname, tid, exc, rew, passed, total, hold in sorted(scored_anyway,
81+
key=lambda r: -(r[3] or 0)):
82+
print(f" {tid:<40} rew={rew} main={passed}/{total} holdout_rate={hold} {exc}")

eval-ops/babysit.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env python3
2+
"""Babysit cycle for the three gpt-5.6-terra reasoning-effort experiments.
3+
4+
One cycle = poll -> classify -> report -> (delete infra) -> (re-top-up).
5+
6+
Targets live in config.json so they survive a container restart (see PLAN.md).
7+
Non-CUA cells are topped up directly; CUA cells are left to cua_fill.py, which
8+
respects the runbook 3.6 concurrency cap.
9+
10+
Degenerate trials are REPORTED but never auto-deleted: in the LOW arm short
11+
trajectories are the treatment effect being measured, and systematically
12+
pruning them biases pass@k (runbook 4b).
13+
14+
Usage: babysit.py [--delete] [--topup]
15+
"""
16+
import json, subprocess, os, sys, statistics, collections
17+
18+
HERE = "/home/user/terra-run"
19+
ODDISH = "/home/user/oddish/oddish/.venv/bin/oddish"
20+
ARMS = ["LOW", "MEDIUM", "HIGH"]
21+
EXP_OF = {"LOW": "17b6f7d9", "MEDIUM": "c071229f", "HIGH": "a706e700"}
22+
CUA = {"excel-clone", "mastodon-clone", "s3-clone", "slack-clone"}
23+
24+
CFG = json.load(open(f"{HERE}/config.json"))
25+
N_NONCUA, N_CUA, CUA_CAP = CFG["noncua_target"], CFG["cua_target"], CFG["cua_cap"]
26+
27+
28+
def load_env():
29+
env = dict(os.environ)
30+
for line in open("/home/user/oddish/.env"):
31+
line = line.strip()
32+
if line and not line.startswith("#") and "=" in line:
33+
k, v = line.split("=", 1)
34+
env[k.strip()] = v.strip().strip('"').strip("'")
35+
return env
36+
37+
38+
def main():
39+
do_delete, do_topup = "--delete" in sys.argv, "--topup" in sys.argv
40+
env = load_env()
41+
r = subprocess.run(["python3", f"{HERE}/poll_all.py"], capture_output=True,
42+
text=True, env=env, cwd=HERE, timeout=2400)
43+
print(r.stdout.strip() or r.stderr.strip()[:500])
44+
st = json.load(open(f"{HERE}/state.json"))
45+
46+
cells, now = st["cells"], st["polled_at"]
47+
tasks = sorted({k.split("|")[1] for k in cells})
48+
noncua = [t for t in tasks if t not in CUA]
49+
cua = [t for t in tasks if t in CUA]
50+
cua_live = sum(c["PENDING"] for k, c in cells.items()
51+
if k.split("|")[1] in CUA and k.split("|")[2] != "nop")
52+
53+
out = [f"# Babysit report — {now}", "",
54+
f"Phase **{CFG['phase']}** — targets: non-CUA **{N_NONCUA}**/cell, "
55+
f"CUA **{N_CUA}**/cell.", "",
56+
f"CUA in-flight (judge-consuming): **{cua_live}** / cap {CUA_CAP} "
57+
f"— {'OK' if cua_live <= CUA_CAP else 'OVER CAP'}", ""]
58+
59+
grand = collections.Counter()
60+
for label, tlist, target in (("non-CUA", noncua, N_NONCUA), ("CUA", cua, N_CUA)):
61+
for arm in ARMS:
62+
rows = [(t, cells.get(f"{arm}|{t}|terra",
63+
{"VALID": 0, "PENDING": 0, "INFRA": 0,
64+
"OTHER": 0, "SKIPPED": 0, "pass": 0}))
65+
for t in tlist]
66+
v = sum(c["VALID"] for _, c in rows)
67+
p = sum(c["PENDING"] for _, c in rows)
68+
i = sum(c["INFRA"] + c["OTHER"] + c["SKIPPED"] for _, c in rows)
69+
ps = sum(c["pass"] for _, c in rows)
70+
grand.update({"valid": v, "pending": p, "infra": i, "pass": ps,
71+
f"{label}_valid": v, f"{label}_pass": ps})
72+
done = sum(1 for _, c in rows if c["VALID"] >= target)
73+
out += [f"## {label}{arm} ({EXP_OF[arm]})", "",
74+
f"valid={v}/{len(tlist)*target} pending={p} infra={i} "
75+
f"pass={ps}{done}/{len(tlist)} tasks at target", "",
76+
"| task | valid | pending | infra | pass |", "|---|---|---|---|---|"]
77+
for t, c in rows:
78+
flag = "" if c["VALID"] >= target else " ⏳"
79+
out.append(f"| `{t}`{flag} | {c['VALID']} | {c['PENDING']} | "
80+
f"{c['INFRA']+c['OTHER']+c['SKIPPED']} | {c['pass']} |")
81+
out.append("")
82+
83+
out += [f"**Fleet:** valid={grand['valid']} pending={grand['pending']} "
84+
f"infra={grand['infra']} pass={grand['pass']}", ""]
85+
86+
bad = (st["trials"].get("INFRA", []) + st["trials"].get("OTHER", [])
87+
+ st["trials"].get("SKIPPED", []))
88+
if bad:
89+
out += ["## Infra / skipped — delete + rerun", ""]
90+
for tr in bad:
91+
out.append(f"- `{tr['id']}` {tr['arm']}/{tr['task']}/{tr['agent']} "
92+
f"[{tr['status']}] {tr['err']}")
93+
out.append("")
94+
95+
short = [tr for tr in st["trials"].get("VALID", [])
96+
if tr["kind"] == "terra" and (tr["steps"] or 0) < 15]
97+
if short:
98+
out += ["## Degenerate candidates (<15 steps) — reported, not deleted", ""]
99+
for tr in sorted(short, key=lambda x: x["steps"] or 0):
100+
out.append(f"- `{tr['id']}` {tr['arm']}/{tr['task']} "
101+
f"steps={tr['steps']} reward={tr['reward']}")
102+
out.append("")
103+
104+
allsteps = [tr["steps"] for tr in st["trials"].get("VALID", [])
105+
if tr["kind"] == "terra" and tr["steps"]]
106+
if allsteps:
107+
out.append(f"Step distribution (valid terra, n={len(allsteps)}): "
108+
f"min={min(allsteps)} p50={int(statistics.median(allsteps))} "
109+
f"max={max(allsteps)}")
110+
cost = sum(tr["cost"] or 0 for recs in st["trials"].values() for tr in recs
111+
if tr["kind"] == "terra")
112+
out.append(f"Terra spend so far: ${cost:,.2f}")
113+
114+
report = "\n".join(out) + "\n"
115+
open(f"{HERE}/babysit-latest.md", "w").write(report)
116+
print(report)
117+
with open(f"{HERE}/hb-fleet.txt", "w") as f:
118+
f.write(f"{now} | FLEET phase={CFG['phase']} | valid={grand['valid']} "
119+
f"pending={grand['pending']} infra={grand['infra']} | "
120+
f"cua_inflight={cua_live}/{CUA_CAP} | ${cost:,.0f}\n")
121+
122+
if do_delete and bad:
123+
denv = dict(env)
124+
denv["ODDISH_API_KEY"] = denv["ODDISH_ADMIN_API_KEY"]
125+
ids = [tr["id"] for tr in bad]
126+
for i in range(0, len(ids), 20):
127+
chunk = ids[i:i + 20]
128+
args = [ODDISH, "delete"] + sum([["-t", x] for x in chunk], []) + ["--json"]
129+
rr = subprocess.run(args, capture_output=True, text=True, env=denv,
130+
timeout=600)
131+
print(f"deleted {len(chunk)}: rc={rr.returncode} {rr.stdout[:200]}")
132+
133+
if do_topup:
134+
# Only non-CUA here; CUA refills go through cua_fill.py's throttle.
135+
shortc = []
136+
for arm in ARMS:
137+
for t in noncua:
138+
c = cells.get(f"{arm}|{t}|terra")
139+
held = sum(c[k] for k in ("VALID", "PENDING", "INFRA", "OTHER",
140+
"SKIPPED")) if c else 0
141+
if held < N_NONCUA:
142+
shortc.append((arm, t, N_NONCUA - held))
143+
if not shortc:
144+
print(f"topup: nothing short — all non-CUA cells hold {N_NONCUA}")
145+
for arm, t, gap in shortc:
146+
print(f"topup: {arm}/{t} short by {gap}")
147+
for attempt in range(1, 6):
148+
rr = subprocess.run(
149+
[ODDISH, "run", "-p", f"{HERE}/ds-{arm.lower()}", "-t", t,
150+
"-a", "codex", "-m", "openai/gpt-5.6-terra",
151+
"--n-trials", str(N_NONCUA), "-e", "modal",
152+
"-E", EXP_OF[arm], "--override-memory-mb", "65536",
153+
"--no-baseline-gate",
154+
"--ak", f"reasoning_effort={arm.lower()}",
155+
"--ae", f"ODDISH_EVAL_NONCE={arm}-{t}-{now}-{attempt}",
156+
"--background", "--force"],
157+
capture_output=True, text=True, env=env, timeout=400)
158+
if "new version" in rr.stdout.lower():
159+
print(f" ABORT {arm}/{t}: submit wants a NEW TASK VERSION")
160+
break
161+
if "Task submitted!" in rr.stdout:
162+
line = [l.strip() for l in rr.stdout.splitlines() if "Trials:" in l]
163+
print(f" ok {line[0] if line else ''}")
164+
break
165+
print(f" attempt {attempt} failed")
166+
if attempt < 5:
167+
subprocess.run(["sleep", str(5 * attempt * attempt)])
168+
169+
170+
if __name__ == "__main__":
171+
main()

0 commit comments

Comments
 (0)