Skip to content

Commit f60e4ed

Browse files
Resume TerminalBench Harbor runs from checkpoints (#768)
Checkpoint TerminalBench Harbor trials across retries Co-authored-by: neubig <neubig@users.noreply.github.com> Co-authored-by: openhands <openhands@all-hands.dev>
1 parent d69596a commit f60e4ed

3 files changed

Lines changed: 273 additions & 14 deletions

File tree

‎benchmarks/terminalbench/run_infer.py‎

Lines changed: 148 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import os
1414
import subprocess
1515
import sys
16+
import tempfile
17+
import threading
1618
from datetime import datetime, timezone
1719
from pathlib import Path
1820

@@ -21,6 +23,7 @@
2123
from benchmarks.utils.harbor import (
2224
HarborCredentialMode,
2325
check_harbor_installed as _check_harbor_installed,
26+
completed_harbor_task_ids,
2427
convert_harbor_to_eval_output,
2528
get_supported_task_filter_flag,
2629
run_harbor_evaluation as _run_harbor_evaluation,
@@ -33,6 +36,107 @@
3336

3437
# Output filename for results
3538
OUTPUT_FILENAME = "output.jsonl"
39+
CHECKPOINT_INTERVAL_SECONDS = 60
40+
41+
42+
def _checkpoint_gcs_uri() -> str | None:
43+
bucket = os.environ.get("RESULTS_BUCKET")
44+
model_slug = os.environ.get("MODEL_SLUG")
45+
run_id = os.environ.get("GITHUB_RUN_ID")
46+
if not bucket or not model_slug or not run_id:
47+
return None
48+
return f"gs://{bucket}/terminalbench/{model_slug}/{run_id}/checkpoint.tar.gz"
49+
50+
51+
def restore_harbor_checkpoint(structured_output_dir: Path) -> bool:
52+
"""Restore incremental Harbor results after a Kubernetes pod retry."""
53+
checkpoint_uri = _checkpoint_gcs_uri()
54+
if checkpoint_uri is None:
55+
return False
56+
stat = subprocess.run(
57+
["gsutil", "-q", "stat", checkpoint_uri],
58+
capture_output=True,
59+
text=True,
60+
)
61+
if stat.returncode != 0:
62+
return False
63+
64+
structured_output_dir.parent.mkdir(parents=True, exist_ok=True)
65+
with tempfile.TemporaryDirectory(prefix="terminalbench-checkpoint-") as tmpdir:
66+
archive = Path(tmpdir) / "checkpoint.tar.gz"
67+
subprocess.run(["gsutil", "cp", checkpoint_uri, str(archive)], check=True)
68+
subprocess.run(
69+
[
70+
"tar",
71+
"-xzf",
72+
str(archive),
73+
"-C",
74+
str(structured_output_dir.parent),
75+
],
76+
check=True,
77+
)
78+
logger.info("Restored Harbor checkpoint from %s", checkpoint_uri)
79+
return True
80+
81+
82+
def upload_harbor_checkpoint(
83+
harbor_output_dir: Path,
84+
output_path: Path,
85+
) -> bool:
86+
"""Convert and upload all complete Harbor trials seen so far."""
87+
checkpoint_uri = _checkpoint_gcs_uri()
88+
if checkpoint_uri is None or not harbor_output_dir.exists():
89+
return False
90+
completed_ids = completed_harbor_task_ids(harbor_output_dir)
91+
if not completed_ids:
92+
return False
93+
94+
convert_harbor_to_eval_output(
95+
harbor_output_dir=harbor_output_dir,
96+
eval_output_path=output_path,
97+
)
98+
with tempfile.TemporaryDirectory(prefix="terminalbench-checkpoint-") as tmpdir:
99+
archive = Path(tmpdir) / "checkpoint.tar.gz"
100+
subprocess.run(
101+
[
102+
"tar",
103+
"-czf",
104+
str(archive),
105+
"-C",
106+
str(output_path.parent.parent),
107+
output_path.parent.name,
108+
],
109+
check=True,
110+
)
111+
subprocess.run(
112+
[
113+
"gsutil",
114+
"-h",
115+
"Cache-Control:no-cache, no-store, must-revalidate",
116+
"cp",
117+
str(archive),
118+
checkpoint_uri,
119+
],
120+
check=True,
121+
)
122+
logger.info(
123+
"Uploaded resumable Harbor checkpoint with %d tasks to %s",
124+
len(completed_ids),
125+
checkpoint_uri,
126+
)
127+
return True
128+
129+
130+
def _checkpoint_loop(
131+
stop_event: threading.Event,
132+
harbor_output_dir: Path,
133+
output_path: Path,
134+
) -> None:
135+
while not stop_event.wait(CHECKPOINT_INTERVAL_SECONDS):
136+
try:
137+
upload_harbor_checkpoint(harbor_output_dir, output_path)
138+
except Exception:
139+
logger.exception("Failed to upload Harbor checkpoint; will retry")
36140

37141

38142
def check_harbor_installed() -> bool:
@@ -193,6 +297,11 @@ def main() -> None:
193297
)
194298

195299
logger.info(f"Output directory: {structured_output_dir}")
300+
structured_output_path = Path(structured_output_dir)
301+
try:
302+
restore_harbor_checkpoint(structured_output_path)
303+
except Exception:
304+
logger.exception("Failed to restore Harbor checkpoint; starting without it")
196305
os.makedirs(structured_output_dir, exist_ok=True)
197306

198307
# Save metadata
@@ -222,20 +331,51 @@ def main() -> None:
222331
if not args.skip_harbor:
223332
# Run harbor evaluation
224333
try:
225-
harbor_output_dir = run_harbor_evaluation(
226-
llm=llm,
227-
dataset=args.dataset,
228-
output_dir=structured_output_dir,
229-
num_workers=args.num_workers,
230-
task_ids=task_ids,
231-
n_limit=args.n_limit,
232-
)
334+
harbor_output_dir = Path(structured_output_dir) / "harbor_output"
335+
if task_ids and harbor_output_dir.exists():
336+
completed_ids = completed_harbor_task_ids(harbor_output_dir)
337+
if completed_ids:
338+
task_ids = [
339+
task_id for task_id in task_ids if task_id not in completed_ids
340+
]
341+
logger.info(
342+
"Restored %d completed Harbor tasks; %d selected tasks remain",
343+
len(completed_ids),
344+
len(task_ids),
345+
)
346+
347+
if task_ids is None or task_ids:
348+
stop_event = threading.Event()
349+
checkpoint_thread = threading.Thread(
350+
target=_checkpoint_loop,
351+
args=(stop_event, harbor_output_dir, output_path),
352+
name="terminalbench-checkpoint",
353+
daemon=True,
354+
)
355+
checkpoint_thread.start()
356+
try:
357+
harbor_output_dir = run_harbor_evaluation(
358+
llm=llm,
359+
dataset=args.dataset,
360+
output_dir=structured_output_dir,
361+
num_workers=args.num_workers,
362+
task_ids=task_ids,
363+
n_limit=args.n_limit,
364+
)
365+
finally:
366+
stop_event.set()
367+
checkpoint_thread.join()
368+
else:
369+
logger.info(
370+
"All selected tasks were restored; skipping Harbor execution"
371+
)
233372

234373
# Convert harbor output to standard format
235374
convert_harbor_to_eval_output(
236375
harbor_output_dir=harbor_output_dir,
237376
eval_output_path=output_path,
238377
)
378+
upload_harbor_checkpoint(harbor_output_dir, output_path)
239379

240380
except Exception as e:
241381
logger.error(f"Evaluation failed: {e}")

‎benchmarks/utils/harbor.py‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,27 @@ def _find_job_dir(harbor_output_dir: Path) -> Path:
204204
return sorted(candidates)[-1]
205205

206206

207+
def find_harbor_trial_result_files(harbor_output_dir: Path) -> list[Path]:
208+
"""Return trial results from every timestamped Harbor attempt."""
209+
return sorted(harbor_output_dir.glob("*/*/result.json"))
210+
211+
212+
def completed_harbor_task_ids(harbor_output_dir: Path) -> set[str]:
213+
"""Return task IDs with a complete, parseable Harbor trial result."""
214+
completed: set[str] = set()
215+
for result_file in find_harbor_trial_result_files(harbor_output_dir):
216+
try:
217+
with result_file.open() as stream:
218+
trial = json.load(stream)
219+
except (json.JSONDecodeError, OSError):
220+
continue
221+
task_name = trial.get("task_name")
222+
rewards = (trial.get("verifier_result") or {}).get("rewards") or {}
223+
if isinstance(task_name, str) and task_name and "reward" in rewards:
224+
completed.add(task_name)
225+
return completed
226+
227+
207228
def convert_harbor_to_eval_output(
208229
harbor_output_dir: Path,
209230
eval_output_path: Path,
@@ -214,17 +235,34 @@ def convert_harbor_to_eval_output(
214235
logger.info(f"Converting harbor output from {harbor_output_dir}")
215236

216237
canonicalize = canonicalize_instance_id or (lambda instance_id: instance_id)
217-
job_dir = _find_job_dir(harbor_output_dir)
218-
logger.info(f"Using harbor job directory: {job_dir}")
219-
220-
result_files = [f for f in job_dir.glob("*/result.json") if f.parent != job_dir]
238+
result_files = find_harbor_trial_result_files(harbor_output_dir)
221239
if not result_files:
240+
# Preserve the established distinction between no Harbor job at all
241+
# and a completed job that contains no trial results.
242+
job_dir = _find_job_dir(harbor_output_dir)
222243
raise RuntimeError(
223244
f"No trial result files found in {job_dir}. "
224-
f"Expected result.json files in trial subdirectories."
245+
"Expected result.json files in timestamp/trial subdirectories."
225246
)
226247

227-
logger.info(f"Found {len(result_files)} trial results in {job_dir}")
248+
logger.info(
249+
f"Found {len(result_files)} trial results across Harbor attempts in "
250+
f"{harbor_output_dir}"
251+
)
252+
253+
# A retry normally filters restored IDs, but a crash can occur between a
254+
# result write and the next checkpoint. Prefer the later timestamped copy.
255+
latest_result_by_instance: dict[str, Path] = {}
256+
unreadable_result_files: list[Path] = []
257+
for result_file in result_files:
258+
try:
259+
with result_file.open() as stream:
260+
trial = json.load(stream)
261+
instance_id = canonicalize(trial.get("task_name", result_file.parent.name))
262+
latest_result_by_instance[instance_id] = result_file
263+
except (json.JSONDecodeError, OSError):
264+
unreadable_result_files.append(result_file)
265+
result_files = list(latest_result_by_instance.values()) + unreadable_result_files
228266

229267
results: list[dict] = []
230268
errors: list[dict] = []

‎tests/test_terminalbench.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
from benchmarks.terminalbench.config import INFER_DEFAULTS
99
from benchmarks.terminalbench.eval_infer import process_terminalbench_results
1010
from benchmarks.terminalbench.run_infer import (
11+
_checkpoint_gcs_uri,
1112
convert_harbor_to_eval_output,
1213
run_harbor_evaluation,
14+
upload_harbor_checkpoint,
1315
)
16+
from benchmarks.utils.harbor import completed_harbor_task_ids
1417
from openhands.sdk import LLM
1518

1619

@@ -522,3 +525,81 @@ def test_discovery_finds_all_trials(self, tmp_path: Path) -> None:
522525
assert len(entries) == 5
523526
instance_ids = {e["instance_id"] for e in entries}
524527
assert instance_ids == {f"task-{i}" for i in range(5)}
528+
529+
def test_completed_ids_require_a_primary_reward(self, tmp_path: Path) -> None:
530+
harbor_dir = tmp_path / "harbor_output"
531+
attempt = harbor_dir / "2026-01-01__00-00-00"
532+
for task_name, payload in {
533+
"passed": {"verifier_result": {"rewards": {"reward": 1.0}}},
534+
"failed": {"verifier_result": {"rewards": {"reward": 0.0}}},
535+
"incomplete": {"verifier_result": {"rewards": {}}},
536+
}.items():
537+
trial_dir = attempt / task_name
538+
trial_dir.mkdir(parents=True)
539+
(trial_dir / "result.json").write_text(
540+
json.dumps({"task_name": task_name, **payload})
541+
)
542+
malformed_dir = attempt / "malformed"
543+
malformed_dir.mkdir()
544+
(malformed_dir / "result.json").write_text("{")
545+
546+
assert completed_harbor_task_ids(harbor_dir) == {"passed", "failed"}
547+
548+
def test_conversion_combines_attempts_and_prefers_latest(
549+
self, tmp_path: Path
550+
) -> None:
551+
harbor_dir = tmp_path / "harbor_output"
552+
for attempt_name, task_name, trial_name, reward in [
553+
("2026-01-01__00-00-00", "task-a", "task-a__old", 0.0),
554+
("2026-01-02__00-00-00", "task-b", "task-b__new", 1.0),
555+
("2026-01-02__00-00-00", "task-a", "task-a__new", 1.0),
556+
]:
557+
attempt = harbor_dir / attempt_name
558+
attempt.mkdir(parents=True, exist_ok=True)
559+
(attempt / "result.json").write_text(json.dumps({"id": attempt_name}))
560+
trial_dir = attempt / trial_name
561+
trial_dir.mkdir()
562+
(trial_dir / "result.json").write_text(
563+
json.dumps(
564+
{
565+
"task_name": task_name,
566+
"trial_name": trial_name,
567+
"agent_result": {},
568+
"verifier_result": {"rewards": {"reward": reward}},
569+
"exception_info": None,
570+
}
571+
)
572+
)
573+
574+
output_file = tmp_path / "output.jsonl"
575+
convert_harbor_to_eval_output(harbor_dir, output_file)
576+
entries = {
577+
entry["instance_id"]: entry
578+
for entry in map(json.loads, output_file.read_text().splitlines())
579+
}
580+
581+
assert set(entries) == {"task-a", "task-b"}
582+
assert entries["task-a"]["test_result"]["trial_name"] == "task-a__new"
583+
assert entries["task-a"]["test_result"]["passed"] is True
584+
585+
def test_checkpointing_is_disabled_without_run_coordinates(
586+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
587+
) -> None:
588+
monkeypatch.delenv("RESULTS_BUCKET", raising=False)
589+
monkeypatch.delenv("MODEL_SLUG", raising=False)
590+
monkeypatch.delenv("GITHUB_RUN_ID", raising=False)
591+
592+
assert _checkpoint_gcs_uri() is None
593+
assert upload_harbor_checkpoint(tmp_path, tmp_path / "output.jsonl") is False
594+
595+
def test_checkpoint_uri_uses_run_coordinates(
596+
self, monkeypatch: pytest.MonkeyPatch
597+
) -> None:
598+
monkeypatch.setenv("RESULTS_BUCKET", "results")
599+
monkeypatch.setenv("MODEL_SLUG", "model")
600+
monkeypatch.setenv("GITHUB_RUN_ID", "123")
601+
602+
assert (
603+
_checkpoint_gcs_uri()
604+
== "gs://results/terminalbench/model/123/checkpoint.tar.gz"
605+
)

0 commit comments

Comments
 (0)