feat(cli): add gym sandbox debug - #2403
Conversation
|
🌿 Preview your docs: https://nvidia-preview-terryk-gym-sandbox-debug.docs.buildwithfern.com/nemo/gym Here are the markdown pages you've updated: |
Poking at a sandbox-backed task currently means standing up the whole stack -- model server, Ray, agent, rollout -- and hoping the failure reproduces. This boots the sandbox a server would create for a task, using the same provider, image and SandboxSpec a rollout would, runs a command or uploaded script in it, and writes a re-runnable trace. `--dry-run` answers "what image would this row get, and how would the command be wrapped" offline, in milliseconds, with no cluster and no credentials. `--list-tasks` reads only the dataset. Sandboxes get a short lifetime that renewal extends while the command runs, rather than a long one nobody remembers to clean up. Interrupt handling and an atexit backstop exist because the failure mode of getting that wrong is a leaked, paid-for sandbox: the first SIGINT unwinds each `finally`, and later ones are swallowed so a second Ctrl-C cannot orphan what the first was cleaning. Timing separates the command's own runtime from the round trip, so a slow command and a slow link stop looking alike. Signed-off-by: Terry Kong <terryk@nvidia.com>
Three figures in the example were illustrative and one was simply wrong. Replaced
with what the commands actually print, measured on a
sweb.eval.x86_64.django_1776_django-10973 sandbox:
* ttl_s is 900, not 18000 -- the dry-run reads it from the resolver's spec.
* The timing block now shows a real run. Boot dominates for a SWE-bench image
(44.8s) rather than the 7s a small image takes, which is worth seeing.
* `pip install torch` does NOT pull 2.7 GB here. The testbed env is Python 3.6,
so pip resolves to torch 1.10.2 -- the last release with cp36 wheels -- at
about 880 MB.
Also documents a trap that costs an hour if you meet it cold: when the egress
sidecar intercepts TLS, conda images fail with CERTIFICATE_VERIFY_FAILED. The CA
is installed into the system trust store, but conda consults its own at
/opt/miniconda3/envs/<env>/ssl/cert.pem. Both stores hold 151 CAs; only the
system one has the proxy's. Every SWE-bench image is conda-based, so this is the
common case, and SSL_CERT_FILE/REQUESTS_CA_BUNDLE fix it.
Signed-off-by: Terry Kong <terryk@nvidia.com>
CLI flags are meant to be the top layer of the resolution chain, above both the declarative sandbox_spec and a spec_resolver's output. Only --env was, so a server that computes its spec in Python could not be handed anything else from the command line: the resolver's spec wins outright over config, and there was no flag to override it. That made a whole class of sandbox unreachable from the debugger. Transparent egress interception, for one, needs a networkPolicy (a provider option) and an istio label (metadata) alongside its env var -- so debugging it meant abandoning the CLI and POSTing to the REST API by hand, which is exactly what this command exists to avoid. --provider-option decodes its value as JSON when it decodes, because provider options are not all strings: a network policy is a mapping and skip_health_check is a bool. Anything that is not valid JSON stays a string, so ids and names need no quoting ceremony. Signed-off-by: Terry Kong <terryk@nvidia.com>
These are the top layer of the resolution chain, which is only interesting for a server that computes its spec in Python: the resolver's spec wins outright over config, so without them there is no way to add anything to it from the command line. The worked example is the case that motivated them -- asking for a transparently-intercepting egress sidecar needs an env var, a metadata label and a provider option together. Signed-off-by: Terry Kong <terryk@nvidia.com>
60946b7 to
5908cd2
Compare
| (output_dir / "config.yaml").write_text(_redacted_config_yaml(global_config_dict)) | ||
| traces_path = output_dir / "traces.jsonl" | ||
| traces_path.write_text("") | ||
| rich.print(f"[dim]results: {output_dir}/[/dim]") |
There was a problem hiding this comment.
Is as_json is only handled for list/dry-run, so gym sandbox debug --json still reaches this rich.print, so the documented --json | jq '.timing' path won’t produce parseable JSON?
|
|
||
| stage, budget = "exec", config.timeout_total | ||
| exec_started = time.monotonic() | ||
| result = await sandbox.exec( |
There was a problem hiding this comment.
If run_plan() never calls _renew() before sandbox.exec, can a long --timeout-total outlive the default 900s TTL - should we renew here like gym sandbox exec ?
eg
if you run gym sandbox debug --command "long thing" --timeout-total 1800 the sandbox may expire after default 900s.
| Answering "what is actually inside this task's container?" normally means starting the whole | ||
| stack — model server, Ray, agent, rollout — and hoping the failure reproduces. `gym sandbox | ||
| debug` skips all of that: it boots the sandbox a server would create for a task, using the | ||
| same provider, image, and `SandboxSpec` a rollout gets, and runs whatever you ask inside it. |
There was a problem hiding this comment.
This sounds like gym sandbox debug can reproduce any sandbox-backed rollout, but envs computed sandbox logic need to opt into sandbox_task - should we either update all the sandbox envs to work, like AnySWE/AnyTerminal, or document this as an opt-in debug contract?
Top of a 5-PR stack (#2400 → #2401 → #2402 → #2407 → #2403). Review the three below first; this one is the consumer that makes them worth having.
Why
Poking at a sandbox-backed task currently means standing up the whole stack — model server, Ray, agent, rollout — and hoping the failure reproduces. There is no way to ask "what is actually inside that container?" without writing a harness.
What
gym sandbox debugboots the sandbox a server would create for a task — same provider, same image, sameSandboxSpeca rollout uses — runs a command or uploaded script inside it, and writes a re-runnable trace. Plusgym sandbox exec/rmfor reattaching to a kept sandbox.Two things need no cluster and no credentials:
--list-tasksreads only the dataset, and--dry-runresolves the image and command wrapping offline in milliseconds.Worked example: a mini-swe-agent-2 container running
pip install torchPoint at a server (nothing lands in a config file):
If the server runs with auth disabled (
api_key = ""plusOPENSANDBOX_INSECURE_SERVER=YES) there is no key to obtain — but the variable must still be set to something, because the config interpolates it and Hydra fails on an unset reference. For a cluster-internal server, port-forward and pointOPENSANDBOX_DOMAINat the local end.First, what would this task even get? No cluster needed:
The image was derived from
instance_id+subset— no row field carried it. That answer comes from #2402's hooks; before them it was unreachable without importing ray.Now actually run the install and time it:
{"boot": 44.812, "setup": 0.0, "exec": 22.303, "command": 21.024, "overhead": 1.279, "total": 67.115}commandis execd's measurement from inside the sandbox (#2400);overheadis what the round trip added. Only the first is a property of the task, so it is the number to compare across images, package indexes, or clusters — and here it is a third of the total, with boot dominating because SWE-bench images are large.Note which torch: this image's
testbedenv is Python 3.6, so pip resolves to torch 1.10.2 — the last release withcp36wheels — at about 880 MB, not a multi-GB modern build. Measured against a warm in-cluster package cache the same install runs 21s cold / 13s warm, which is what makes it a useful probe for whether a cache is actually being used.If the sandbox's egress sidecar is intercepting TLS
A conda-based image will not trust the proxy's CA and
pipfails withCERTIFICATE_VERIFY_FAILED. The CA is installed into the system trust store, but conda ships and consults its own at/opt/miniconda3/envs/<env>/ssl/cert.pem— both hold 151 CAs, only the system one has the proxy's. Every SWE-bench image is conda-based, so this is the common case:Debian-style images (
python:3.12-slim) read the system store and need none of this. Documented indebugging.mdx.Not leaking sandboxes
The failure mode of getting this wrong is a leaked, paid-for sandbox, so it is handled deliberately rather than incidentally:
finallyunwinds, and later signals are swallowed — a second Ctrl-C must not orphan what the first was cleaning up;atexitbackstop over aWeakSetcatches the case where the loop is already gone.Known limitations
No live streaming.
SandboxProvider.execreturns a completed result, so incremental output needs new provider surface. execd does stream over SSE, so this is a follow-up rather than a dead end.Requesting an egress sidecar.
--metadataand--provider-option(added here) are the top layer of the resolution chain, so aspec_resolver's spec can now be extended from the command line — previously only--envcould, which leftnetworkPolicyand the istio label unreachable. The provider side is #2407, directly below this PR, so the whole thing works as one command:Verified against a live cell: the wheel was served by the in-cluster cache (
cache=MISSon the first run,HITon the second) with nothing in the container configured to use it.Tests
260 tests across
test_cli_sandbox.py,test_cli_main.pyandtest_sandbox_hooks.py— resolution at each layer, the precedence chain,--bare,--dry-run, trace shape, classification, cleanup on failure and interrupt, and a resources-server case alongside an agent case so generality is enforced rather than asserted.