openenv/tbench2: per-task Daytona cloud-sandbox execution backend#1675
openenv/tbench2: per-task Daytona cloud-sandbox execution backend#1675nblintao wants to merge 14 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for per-task Daytona cloud sandboxes as an alternative to a shared environment server in the OpenEnv Terminal-Bench-2 adapter. It includes documentation updates, new standalone evaluation and validation scripts (eval_tbench2_via_api.py and scan_golden.py), and offline unit tests. The reviewer's feedback focuses on improving code robustness, such as replacing assert with ValueError for argument validation, using isinstance checks instead of parsing exception strings, and explicitly checking for environment variables rather than relying on exception handling.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
6fd9669 to
1f9ed50
Compare
1f9ed50 to
21d451f
Compare
5ce2c28 to
897118c
Compare
d63ca70 to
3e0b73e
Compare
3e0b73e to
677036c
Compare
0977094 to
8b5fbb2
Compare
677036c to
eff8e3d
Compare
b9b951f to
f48c837
Compare
8b5fbb2 to
2a23cdb
Compare
f48c837 to
231bca9
Compare
2a23cdb to
fb54b3f
Compare
231bca9 to
d992bbf
Compare
| "not upstream main" | ||
| ) | ||
| env["OPENENV_TB2_TASKS_DIR"] = args.openenv_tb2_tasks_dir | ||
| env["DAYTONA_API_KEY"] = args.daytona_api_key |
There was a problem hiding this comment.
Hmm if we save DAYTONA_API_KEY here, the ray job might print it out in the logs, which means that the key might be leaked.
| import os | ||
| import sys | ||
|
|
||
| from openai import AsyncOpenAI |
There was a problem hiding this comment.
Probably we should declare openai in Miles requirements?
There was a problem hiding this comment.
I'd suggest leaving openenv_agent_function.py as-is, and create another script for the Daytona code path. Daytona is only one of the many sandbox providers that users can choose from, and mixing it with the generic sandbox-agnostic code path feels a little bit weird 😂
fb54b3f to
58a443d
Compare
d992bbf to
6264a76
Compare
58a443d to
b99ecf0
Compare
6264a76 to
fd6fbe2
Compare
TB2 is a per-task-image benchmark: every task pins its official runtime image in task.toml, so a cloud sandbox serving the env must be built per task — the official task image plus a tbench2_env server layer, one layer, no DinD. This module owns that recipe, miles-side, next to its only consumer (the per-task Daytona backend of the openenv TB2 adapter). Provider-agnostic core: _server_layer_commands() emits plain shell commands (uv-managed venv at /opt/envserver running the installed tbench2_env package's source, embedded into the build; task dir staged from the tasks checkout's pinned-SHA GitHub tarball with solution/ excluded), so the same layers can back a Dockerfile or another provider. Daytona materialization: create_task_sandbox() creates per-episode declaratively from the Image definition — no named snapshots, no org snapshot quota; repeat creates hit Daytona's build cache — then execs server_cmd() and waits for /health. A bake CLI can pre-register named snapshots as a warm cache. The embedded env source is located from the installed tbench2_env package and requires an editable/checkout install (pyproject.toml must be present); _env_src_dir() fails fast with instructions instead of erroring mid-build. Nothing imports this module yet; the adapter's per-task backend adopts it in a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…half Review feedback on #1710: the module mixed two concerns its own docstring already kept apart. tb2_task_recipe.py now owns everything Daytona-agnostic (server layer commands, env-source embedding, task staging, server_cmd, task.toml reading, /health polling); tb2_task_sandbox.py keeps its name, CLI, and import surface but is purely the Daytona materialization (declarative Image build, Resources, create_task_sandbox, bake). The two symbols that crossed the new module boundary go public with the move: server_layer_commands and resolve_docker_image. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tbench2_env fixes are upstream now (huggingface/OpenEnv#965 + #972); embedding the installed source remains the mechanism that guarantees the sandbox runs exactly the version validated locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-ups on the per-task sandbox recipe: - _dir_tar_b64 is now byte-for-byte deterministic for identical source: gzip mtime=0 suppresses the header compression timestamp and _tar_filter zeroes per-entry mtimes/owners. The b64 is embedded in a build command, so the previous per-call drift changed the image definition on every episode create — defeating the provider build cache the declarative path relies on, and preventing pre-baked snapshots from ever matching. Guarded by a regression test. - rename the module pair to tb2_sandbox_recipe (provider-agnostic recipe) + tb2_sandbox_daytona (Daytona materialization): the subject of both is the sandbox, the trailing token is the role/provider, and future backends slot in as tb2_sandbox_<provider>. - drop the Daytona reference from _task_layer_command's docstring; the recipe module is provider-agnostic and the concrete ceiling is already documented at _MAX_INLINE_TAR_BYTES. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a second execution backend to the OpenEnv TB2 adapter: instead of a shared env server on a Docker host, every episode can run in its OWN Daytona cloud sandbox, built from the task's official image (task.toml docker_image) plus a tbench2_env server layer, and deleted when the episode ends. Same per-task image fidelity as docker mode with zero resident infrastructure (no Docker socket, no server to size or babysit) and zero cross-episode state leakage. Backend selection is episode-scoped in _multi_turn: set OPENENV_TB2_TASKS_DIR to a terminal-bench-2 checkout and each episode declaratively builds its sandbox from the task's Image definition (layer-cached by definition hash: first episode of a task ~10 min, repeats ~1 min; no named snapshots, so no org snapshot quota; sandboxes carry an openenv-tbench2-task=<task_id> label for safe sweeping in a shared org). Unset -> the existing OPENENV_ENV_URL shared-server path, behaviorally unchanged (the shared leg now reads OPENENV_ENV_URL at its own use site instead of threading it through _multi_turn's signature). The two backends deliberately score differently, each matching what its server provides: the shared-server leg keeps the adapter-driven canonical exec + reward-marker parse (compensates for an UNMODIFIED upstream server); the per-task leg uses the standard evaluate action, because the sandbox recipe (tbench2_env.task_snapshots -- tbench2_env is OpenEnv's Terminal-Bench-2 env package, envs/tbench2_env in huggingface/openenv; fixes proposed upstream in openenv#965 + #966) bakes a patched server that runs the same canonical tests/test.sh natively and resolves the task WORKDIR server-side (so no _apply_workdir prefix on this leg either -- task WORKDIRs are not uniformly /app: fix-git, prove-plus-comm). Sandbox creation is throttled process-wide with jittered backoff (Daytona rate-limits creates). Ships with the two tools that produced the validation evidence, both driving the adapter's own code paths so what they check is what training runs: scan_golden.py -- infra baseline without any LLM: replay each task's OFFICIAL solution/solve.sh (oracle-faithful: staged at /solution, DEBIAN_FRONTEND=noninteractive, task.toml [solution].env exported, task workdir cwd) through the same sandbox + evaluate scoring, expecting 1.0; --logs captures solve.log/test-log tails below 1.0. eval_tbench2_via_api.py -- the EXACT training agent-env loop (_multi_turn) with any OpenAI-compatible API standing in for the policy: no GPU, no Ray/Megatron. For end-to-end smokes and for measuring base-model solve rates when picking a variance-band training subset. plus offline unit tests (tests/, run manually: pytest examples/experimental/openenv/tests/ -q -- not collected by the repo-level suite) for the two things a live episode cannot cheaply prove: backend dispatch on both legs, and the create-throttling retry/backoff/give-up behavior that only triggers under production rate limits. Validation: - per-task leg, no LLM (scan_golden.py): golden sweep passes 82/89 of the TB2 suite (0 infra errors; the 7 remaining have upstream-broken solutions); golden regression on this exact merge (fix-git, chess-best-move, circuit-fibsqrt all 1.0 -- covering per-task WORKDIR dispatch, apt paths, long canonical evals). - per-task leg, end-to-end with a live API policy (eval_tbench2_via_api.py): full 89-task sweep with DeepSeek as the policy (single sample per task, 30-turn cap, up to 38 concurrent episodes) -- 33/89 solved, 0 systematic infra failures (10 non-scoring episodes: 8 heavyweight-task timeouts = reward 0 under training semantics, 1 transient registry network error, 1 sandbox-resource overrun). - shared-server leg: dispatch unit test asserts behavior identical to before this change (exec prefixed with cd /app, canonical-exec scoring, rm-hack present). - unit tests: 7 passed (dispatch both legs; throttle retry/give-up/passthrough; throttle-error classification incl. the typed DaytonaRateLimitError path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The daytona SDK is imported lazily by tbench2_env's task_snapshots (so docker-mode installs don't need it) and is not pulled in by `pip install -e tbench2_env`. On a fresh machine the per-task Daytona mode therefore fails at the WORST possible layer: every episode's sandbox start raises ModuleNotFoundError, the sample is aborted, the group is dropped by check_no_aborted, and the rollout loop refills forever -- a silent GPU-burning churn instead of an error (observed: 1900 identical failures before intervention). Fail fast instead: apply_optional_env_vars now imports daytona when OPENENV_TB2_TASKS_DIR requests the per-task backend and raises a RuntimeError with the install command at launch time. Also add the `pip install daytona` prerequisite to the README's per-task section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-task leg reached into the recipe module's private _make_daytona; openenv PR #966 promotes it to public API (same env-var contract: DAYTONA_API_KEY, optional DAYTONA_API_URL), so use the public name and stop depending on an underscore-private symbol that upstream review could rename without notice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
asyncio.to_thread is not cancellable: when an episode's wall-clock cap (asyncio.wait_for in run(), eval_tbench2_via_api, scan_golden) fires while create_task_sandbox is still building, the awaiting coroutine raises CancelledError but the worker thread keeps going; the finished (close_fn, url) was simply discarded, leaking a sandbox that never auto-stops (auto_stop_interval=0) until a label sweep finds it. First builds take ~10 min against 1800-3600s caps, so the window is real — the eval sweep's one "sandbox-resource overrun" is consistent with it. _create_once now records the create result thread-side; on cancellation a daemon reaper waits for the in-flight create to finish and deletes the orphan. Each retry attempt gets its own holder/event so a stale done-flag from a throttled attempt can't fool the reaper. Unit test cancels mid-create and asserts the orphan's close_fn runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The daytona-SDK preflight closed one launch-time gap but not the other half of the same failure mode: the per-task leg also hard-depends on tbench2_env.task_snapshots.make_daytona / create_task_sandbox, which only exist on the openenv #965/#972/#966 branch. An upstream-main tbench2_env install passes the daytona check, then every episode's sandbox start fails, the sample aborts, the group drops, and the rollout loop refills forever — exactly the silent GPU-burning churn the first preflight was written to prevent. Check the recipe symbols at launch and fail with the install instruction instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-task backend now materializes sandboxes through the sibling tb2_task_sandbox module instead of a recipe module inside the tbench2_env package, so the launcher's recipe-symbols preflight is moot: the recipe is always present. What still varies with the install is the env SERVER the recipe bakes into each task image, so preflight that instead — probe the installed server source for the per-task contract (canonical test.sh scoring / TB2_WITHHOLD_TESTS). An unpatched install would not fail per-episode; it would silently mis-score every episode. README: pin the tbench2_env checkout the per-task leg needs (editable install — the recipe embeds the package source, which needs pyproject.toml next to the package). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The task image withholds verifier assets — solution/ never enters it — so golden replay's `cp` from /opt/tb2-tasks/<task>/solution failed the exec chain before solve.sh ever ran (solve_exit=1, no solve.log). Push the LOCAL tasks checkout's solution/ into the sandbox at golden time instead: tar.gz → base64 → chunked printf appends (64KB per exec, so the largest suite solution stages in a handful of messages) → decode at /solution. Same stage-at-use model the official harness's oracle runs use. Validated: fix-git / chess-best-move / circuit-fibsqrt golden regression 3/3 at 1.0 (solve_exit=0; circuit's 43s canonical eval intact). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The recipe module split (review feedback on #1710) moved the provider-agnostic image recipe into tb2_task_recipe; the README and the adapter/eval docstrings still said it lives in tb2_task_sandbox. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The server-side fixes this backend scores through (canonical test.sh evaluate, server-side WORKDIR, TB2_WITHHOLD_TESTS) merged upstream as huggingface/OpenEnv#965 + #972, so the README no longer pins the fork (whose pinned sha was orphaned by a rebase anyway); the launcher preflight still fails fast on an older install. scan_golden now takes the test-log tail from the evaluate output itself — the patched server wipes the on-disk copy with the verify window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tb2_task_recipe -> tb2_sandbox_recipe, tb2_task_sandbox -> tb2_sandbox_daytona, across the adapter's import, docs, and README. Also drop the task_snapshots import alias: it dates from the pool/snapshot era and the module no longer registers snapshots on the episode path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fd6fbe2 to
d88d18c
Compare
Third of a three-PR stack: #1710 (per-task sandbox recipe) → #1711 (orphan TTL + ownership labels) → this. Pure additive change: the existing shared-server path is behaviorally unchanged (asserted by a dispatch unit test), and every #1487 mechanism —
_apply_workdir, canonical exec + reward-marker scoring, rm-hack,policy.close()— is preserved as-is.What
A second execution backend for the OpenEnv TB2 adapter: set
OPENENV_TB2_TASKS_DIRto a terminal-bench-2 checkout and every episode runs in its own Daytona cloud sandbox, built from the task's official image (task.tomldocker_image) plus a tbench2_env server layer, deleted when the episode ends.Same per-task image fidelity as docker mode, with zero resident infrastructure: no Docker socket, no shared env server to size or babysit (no
MAX_CONCURRENT_ENVS/ulimittuning), no cross-episode state. Cost profile: first episode of a task builds its image (~10 min, layer-cached by definition hash), repeats start in ~1 min; no named snapshots, so no org snapshot quota. Sandboxes carry ownership labels and a TTL dead-man's switch (#1711) for safe operation in a shared org, and creation is throttled process-wide with jittered backoff (Daytona rate-limits creates).Why the two backends score differently (on purpose)
Each leg matches what its env server actually provides:
evaluateaction. The sandbox recipe (tb2_task_sandbox, openenv/tbench2: per-task sandbox image recipe + Daytona materialization #1710) bakes the installedtbench2_envpackage into each task image; upstreamtbench2_env(main, ≥ the fix(tbench2_env): canonical test.sh scoring, real timeouts, task-image workdir huggingface/OpenEnv#965 + [fix] [test] attention_output_gate TP slice when num_kv_heads < TP #972 merge) carries the server-side features this leg scores through — the same canonicaltests/test.shrun natively, the task WORKDIR resolved server-side, and the verifier assets (tests/,solution/) withheld from the agent's filesystem outside the verify window (TB2_WITHHOLD_TESTS— the reward-hacking fix). The launcher preflights the installed server source for exactly this contract: an unpatched install wouldn't fail per-episode, it would silently mis-score every episode. No_apply_workdirprefix on this leg either — task WORKDIRs are not uniformly/app(fix-git=/app/personal-site,prove-plus-comm=/workspace), which the hardcoded prefix silently breaks.Once the shared-server deployment also upgrades to that server (now plain upstream main), a follow-up can retire the adapter-driven machinery and unify both legs on
evaluate.Tooling (ships with the backend; produced the validation evidence)
scan_golden.py— infra baseline with no LLM: replays each task's OFFICIALsolution/solve.sh(oracle-faithful: pushed from the LOCAL tasks checkout and staged at/solution— the image withholds verifier assets, so the solution is chunk-uploaded at golden time;DEBIAN_FRONTEND=noninteractive,[solution].envexported, task-workdir cwd) through the same sandbox +evaluatescoring;--logscaptures solve/test log tails for anything below 1.0.eval_tbench2_via_api.py— the EXACT training agent-env loop (_multi_turn) with any OpenAI-compatible API as the policy: no GPU, no Ray/Megatron. For end-to-end smokes and for measuring base-model solve rates when picking a variance-band training subset.tests/— offline unit tests (pytest examples/experimental/openenv/tests/ -q, not collected by the repo-level suite): backend dispatch on both legs, and the create-throttling retry/backoff/give-up behavior that only triggers under production rate limits.Validation
fix-git,chess-best-move,circuit-fibsqrtall 1.0 — covering per-task WORKDIR dispatch, apt paths, and long canonical evals.cd /app, canonical-exec scoring, rm-hack present,evaluatenever used).fix-git/chess-best-move/circuit-fibsqrt→ 3/3 at 1.0 (solve_exit=0; circuit's 43s canonical eval intact); live-sandbox contract check → ownership labels present (openenv-launcher/openenv-run-id/task), TTL armed (auto_stop=30 / auto_delete=120), keepalive thread beating, clean delete; API-policy sanity (DeepSeek-V4-Flash through the full_multi_turn+ per-task sandbox path) → 4/4 solved, 0 infra errors (fix-git14 turns,prove-plus-comm7,log-summary-date-ranges6,largest-eigenval30); full GRPO training smoke on 8×H200 (GLM-4.7-Flash colocated, TP=4 EP=2, 8-task subset, rollout-batch 4 × 4 samples) → rollout 16/16 episodes through per-task Daytona sandboxes, train stepoutcome=NORMAL, weight update, next rollout — full loop closed. raw_reward 0.1875 (3/16 solved, one prompt group with live GRPO signal), train/rollout logprob abs-diff 0.046 / KL 0.008 (token-faithful TITO recording), prefix-cache hit rate 0.96, sandboxes carried the TTL/ownership contract in production (launcher=tao-lin, stop=30, del=120, deleted on episode end).DaytonaRateLimitErrorpath; recipe hygiene, labels, TTL keepalive lifecycle from the stack below).🤖 Generated with Claude Code