Sandbox Management
The lifecycle of a ROCK sandbox — from a per-instance SWE-Bench Docker image to a live pexpect bash session and back — as driven by the in-house SWE agent.
Beneath the Environment Engine overview, this page traces one ROCK sandbox end to end — created, executed against, torn down — and the knobs and failure modes that matter. Its consumer is the in-house SWE agent (rl_engine/examples/swe_agent/); three parts do the work:
Control plane — rock admin
A FastAPI server (rock/admin/main.py) on 127.0.0.1:8080 that takes sandbox REST calls and dispatches each to a dedicated Ray actor.
Data plane — rocklet
A second FastAPI server inside each container (rock/rocklet/server.py, port 8000), wrapping a real /bin/bash via pexpect — where commands actually execute.
Consumer — SWE agent
RockSandbox (rl_engine/rollout/sandbox/rock.py), a thin async client: one sandbox per trajectory, a bash / editor call per turn.
Every action crosses two HTTP hops: agent → admin on :8080, then the admin's Ray actor → the container's rocklet on :8000. Which node a sandbox lands on is decided upstream by the Task Scheduler's PlacementPool, not by ROCK (see §8).
Scope: the CPU SWE-Bench lane
Sandboxes in the training recipes are CPU-only SWE-Bench containers: a checked-out repo at /testbed plus a conda testbed env. The student and teacher LLMs run on separate GPU servers, never inside the sandbox. ROCK's runtime can host GPU or interactive-gym containers, but the training recipes never exercise those paths.
1 The moving parts
A sandbox is one Docker/Podman container plus the machinery that manages it — four processes across the two planes:
| Component | Where it runs | Source | Role |
|---|---|---|---|
SandboxManager / GemManager |
admin process (host) | rock/sandbox/sandbox_manager.py | Routes each REST call to the sandbox's Ray actor; tracks image + expiry in Redis; runs the reaper. |
SandboxActor |
Ray actor (host) | rock/sandbox/sandbox_actor.py | One actor per sandbox. Owns a DockerDeployment and shells out to docker run. |
DockerDeployment |
inside the actor | rock/deployments/docker.py | Image pull / tar-cache load, docker run with the resource flags, port mapping, auto-clear timer. |
rocklet + LocalSandboxRuntime |
inside the container | rock/rocklet/{server,local_sandbox}.py | FastAPI server on :8000 holding named BashSession objects (pexpect). Executes commands, reads/writes files. |
In --env local mode there is no Redis, so the expiry reaper is disabled and each container self-terminates on its own auto-clear timer instead. (Endpoints and admin flags are in §10.)
2 Lifecycle state machine
A sandbox moves through a fixed set of phases. Only start is expensive, dominated by the image step — a multi-minute first pull, then a ~10–25 s warm start. The timing chips below show every other phase is sub-second.
Sandbox lifecycle. Boxes are colored by plane; the dashed red lane collapses every abnormal exit (startup timeout, dead node, expired lease) onto teardown. Timing chips give each phase's wall-clock cost.
Phase notes
Start — the costly phase
SandboxManager.start_async builds a DockerDeploymentConfig from the REST body, mints the container name (the sandbox_id), and creates a named Ray actor in the xrl-sandbox namespace. A time.sleep(0..5) jitter then keeps a burst of concurrent starts from stampeding the daemon.
Run, then tear down
Once is_alive flips true the client opens a BashSession and runs commands sequentially, the shell keeping state across turns. Explicit RockSandbox.stop() (swe_agent.py:239) is the happy path; the auto-clear lease is the backstop against a leaked container.
3 SandboxConfig & the start call
SandboxConfig (rock/sdk/sandbox/config.py) is small and deliberately CPU-oriented: no GPU, volume, or network-mode fields. Bind mounts go through the raw docker_args escape hatch on the admin-side DockerDeploymentConfig instead.
| Field | Default | Meaning |
|---|---|---|
image | "python:3.11" | Container image tag. The SWE lane overrides this per instance (§6). |
memory | "8g" | Hard memory limit → --memory and --memory-swap (swap pinned to the same value, so no overflow). |
cpus | 2 | CPU quota → --cpus. The SWE lane uses 4. |
auto_clear_seconds | 14400 | Idle lease (4 h). Refreshed on every API call; a container idle past it is reaped. |
startup_timeout | ROCK_SANDBOX_STARTUP_TIMEOUT_SECONDS (180) | Ceiling on the create→alive poll loop. The SWE client passes 300 (see STACX_SANDBOX_STARTUP_TIMEOUT). |
route_key | None → random UUID | Sticky routing header so repeated calls reach the same sandbox. |
cluster | "zb" | Cluster selector sent as the X-Cluster header. |
user_id / experiment_id | None | Optional tags surfaced as X-User-Id / X-Experiment-Id for bookkeeping. |
SandboxGroupConfig adds size (2), start_concurrency (2), and start_retry_times (3) for starting a fleet with bounded concurrency and per-sandbox retry.
A worked example (the ROCK smoke test)
The getting-started health check exercises the full SDK path — start, session, command, stop:
import asyncio, time
from rock.actions import CreateBashSessionRequest
from rock.sdk.sandbox.client import Sandbox
from rock.sdk.sandbox.config import SandboxConfig
async def main():
cfg = SandboxConfig(
image="swebench/sweb.eval.x86_64.django_1776_django-10097:latest",
memory="4g", cpus=1.0,
)
sb = Sandbox(cfg)
await sb.start() # POST /start_async, then poll is_alive
await sb.create_session(CreateBashSessionRequest(session="bash-1"))
r = await sb.arun(cmd="cat /etc/os-release | head -3", session="bash-1")
print(r.output) # BashObservation.output
await sb.stop()
asyncio.run(main())
start() calls /start_async, then blocks polling get_status().is_alive. The SWE agent instead calls the synchronous /start (via RockSandbox.start) with its own retries; both reach the same SandboxManager.
4 Sessions & the action model
Two execution modes. A session is a long-lived /bin/bash REPL whose state (cwd, exported vars, jobs) survives across calls — what agents use; execute is a stateless one-shot subprocess.run. One sandbox can hold many named sessions at once (LocalSandboxRuntime._sessions).
Request & response types
Wire schema (rock/actions/sandbox/{request,response}.py):
| Type | Key fields | Purpose |
|---|---|---|
CreateBashSessionRequest | session, startup_source[], env_enable, env | Open a named REPL; optionally source files and inherit / inject env. |
BashAction | command, session, timeout, check | Run one command. check ∈ silent / raise / ignore controls exit-code handling. |
Command | command: str | list[str] | Stateless execute payload (no session). |
ReadFileRequest / WriteFileRequest | path, content, encoding | Structured file I/O without going through the shell. |
BashObservation | output, exit_code, failure_reason | The result of a session command (aliased Observation). |
SandboxStatusResponse | is_alive, host_ip, port_mapping, image | Health + placement metadata returned by get_status. |
Three call styles sit over these types: run_in_session (blocking, one command), arun (adds a nohup background mode — submit, poll kill -0 <pid>, return output), and execute (stateless). Output is fully buffered into one response (no streaming channel); the per-command timeout bounds its runtime.
Inside the container: how a command really runs
BashSession (rock/rocklet/local_sandbox.py) is a pexpect wrapper around /bin/bash. A few correctness tricks explain its quirks:
- Prompt & exit codes. A unique PS1 marker (
SHELLPS1PREFIX) and an inlineecho EXITCODESTART$?EXITCODEENDlet pexpect detect completion and read the status — so the SWE client strips that marker and(testbed)noise from output. Abash -ncheck runs first, so malformed commands fail fast. - Heredocs & pagers. Heredocs desync prompt detection, so
cat <<EOFis auto-rewritten to abase64 -dpipe;GIT_PAGER/PAGERare pinned tocatso no pager stalls a turn. For multi-line content, preferwrite_fileor the editor tool.
Isolation boundary
The container is the security boundary. The SWE lane runs each one --privileged (SWE-Bench images need it for their build tooling); commands run as the image's default user, with host access limited to the read-only entrypoint bind mount (§5) and an optional log mount. ROCK configures no network isolation for this lane — reproducibility comes from the pinned per-instance image.
5 How the SWE agent drives a sandbox
The agent handler (rl_engine/examples/swe_agent/agents/swe_agent.py and its DAgger subclass) drives one trajectory end to end:
- Placement.
node_ip = context.placement["node_ip"], chosen by the Task Scheduler; the handler addresseshttp://{node_ip}:8080. - Start.
RockSandbox.start(image, cpus=4, memory="16g", docker_args=[…]), image fromget_swe_bench_image(instance_id), up to 10 retries. - Repo setup. The SWE-Bench repo is already at
/testbed, sosetup_repo_in_sandboxjustgit checkouts the base commit;init_sessionactivates the condatestbedenv andcds in. - Turns. Each action becomes an
execute_swe_toolcall:execute_bash→run_in_session;str_replace_editor→ the in-container entrypoint. - Teardown. The handler stops the sandbox and, unless
STACX_KEEP_IMAGES=1, removes the per-instance image (§6).
One tool-call round trip: client → admin (:8080) → in-container rocklet (:8000) → pexpect, and the observation bubbles back. The scheduler's placement token (purple) is acquired once per trajectory and held across all turns — see the Task Scheduler.
The editor tool & the bind-mounted entrypoint
Bash runs through run_in_session, but structured edits (view / create / str_replace / insert) go to a small in-container script (rl_engine/examples/swe_agent/environment/execute_sandbox.py), bind-mounted read-only from SWE_ENTRYPOINT_LOCAL to /home/swe_execute_sandbox.py. It targets Python 3.5+ (no f-strings), since some SWE-Bench images ship an old interpreter.
6 Image management
Each SWE-Bench instance has its own pre-built image (2–8 GB), named from the instance id (swe_sandbox.py:get_swe_bench_image):
# django__django-16911 -> swebench/sweb.eval.x86_64.django_1776_django-16911:latest
image = f"{namespace}/sweb.eval.x86_64.{instance_id.lower().replace('__','_1776_')}:latest"
A run touches hundreds of unique instances, so image handling dominates first-run cost and disk. Three mechanisms manage it:
Registry pull + cache
First hit pulls from Docker Hub (minutes); later hits use the local Docker cache (~10–25 s). Pull policy defaults to missing (never / always also valid).
Tar cache
With DOCKER_IMAGE_CACHE_DIR set, _pull_image docker loads a pre-saved <image>.tar before falling back to a registry pull — useful on air-gapped or rate-limited nodes.
Cleanup
Unless STACX_KEEP_IMAGES=1, the handler calls remove_docker_image after each trajectory (POST /remove_image → docker rmi --force, 3 retries), keeping disk bounded.
Docker Hub authentication is mandatory
Anonymous pulls are rate-limited (~100 / 6 h per IP) and one run pulls far more. Run docker login first — ROCK inherits the daemon login, so one login covers the base image and every sandbox pull. See getting-started §2/§3e.
Warmup (rock/sandbox/service/warmup_service.py) pre-pulls a configured image list onto every alive Ray worker node at admin start (one WarmupActor per node); it does not keep a pool of running containers. The SWE recipes configure no warmup images, so this path is dormant — cold starts are amortized by the Docker cache and the optional tar cache instead.
7 GEM environments
GemManager (rock/sandbox/gem_manager.py) — the SandboxManager subclass the admin actually instantiates — adds env_make / env_step / env_reset / env_close to host a Gymnasium-style environment inside a sandbox (via gem.make(env_id) in LocalSandboxRuntime), one container per env.
Ephemeral, not pooled
SWE-lane sandboxes live and die with one trajectory. The only cross-trajectory reuse is the per-node Docker image cache, plus the sticky route_key that keeps a live sandbox's calls on the same instance.
8 Resource accounting: scheduler vs. runtime
Two layers account for resources:
Scheduler — admission
The rollout PlacementPool (rl_engine/rollout/scheduler/resource.py) hands out one token per running sample, each carrying a node_ip — deciding how many sandboxes run concurrently and where. See Task Scheduler.
Runtime — enforcement
ROCK enforces per-container limits at docker run time via --cpus and --memory/--memory-swap. It tracks no cluster-wide capacity, trusting the scheduler not to oversubscribe a node.
The DAgger evaluator budgets a node at cpu=96, memory_gb=320, gpu=0 and gives each sandbox 4 CPU / 16 GB — ~20 concurrent containers per node (evaluator_config_dagger.py). Per-actor start concurrency is capped by STACX_DAGGER_ENV_CONCURRENCY (default 4, lowered from 12) to stay under the daemon's start throughput.
9 Failure modes & ops
| Symptom | Cause & handling |
|---|---|
| Startup > 60 s | Almost always a cold image pull. Raise STACX_SANDBOX_STARTUP_TIMEOUT (default 300 s) on slow networks; pre-seed with a tar cache. |
Bursty ray.get timed out / reward=0 spikes |
Too many concurrent docker/podman starts. The client tracks per-node failures and fast-fails a node after 5 consecutive errors, re-probing after 5 min. Lower STACX_DAGGER_ENV_CONCURRENCY. |
| Sandbox reaped mid-turn | The auto-clear lease is refreshed per API call but not during an in-flight command or a long LLM-generation gap. A gap longer than the lease can expire a container; keep the lease (240 min) well above the longest expected stall. |
| Disk fills on env nodes | Leaked per-instance images. Confirm STACX_KEEP_IMAGES=0; watch for LEAKED IMAGE log lines when remove_image exhausts its retries. |
| Overlay layers accumulate (tmpfs graphroot) | --prune-env-between-steps runs podman system prune -af on all env nodes between rollout and training (rollout_manager.py:prune_env_nodes, env_engine/cleanup_server.py). |
| Command hangs the session | Interactive prompts or heredocs desync pexpect. Pagers are disabled and heredocs auto-rewritten; still, prefer non-interactive flags and write_file for multi-line content. |
Root-owned external/ dirs after a crash |
Docker created a bind-mount target as root. Pre-create output roots with your own user; to recover, run a short-lived container that chmods the affected directories. See getting-started §8. |
When infrastructure fails a turn — generation aborted, or the sandbox unreachable — the trajectory ends with _termination_reason = "aborted", distinct from a clean finish and from budget/loop/context-overflow exits. The finish-only filter (STACX_FINISH_ONLY_FILTER) then drops everything that is not finish, so it never reaches the trainer.
10 Knob & endpoint reference
Sandbox REST API (prefix /apis/envs/sandbox/v1, rock/admin/entrypoints/sandbox_api.py)
| Endpoint | Purpose |
|---|---|
POST /start · /start_async | Create a sandbox (sync waits for alive; async returns the id immediately). |
GET /get_status · /is_alive | Health, ports, image, host. |
POST /create_session · /close_session | Open / close a named bash REPL. |
POST /run_in_session | Run a command in a session (stateful). |
POST /execute | Run a command with no session (stateless subprocess). |
POST /read_file · /write_file · /upload | Structured file I/O and multipart upload. |
POST /stop | Kill the container and its Ray actor. |
POST /remove_image | docker rmi --force on the host to reclaim disk. |
POST /commit | Commit the running container to a new image tag. |
The GEM env API lives separately under /apis/v1/envs/gem/{make,step,reset,close,list}.
Environment variables that touch sandboxes
| Variable | Default | Read by | Effect |
|---|---|---|---|
STACX_SANDBOX_STARTUP_TIMEOUT | 300 | swe_sandbox.py | Start→alive ceiling passed to /start. |
STACX_KEEP_IMAGES | 0 | swe_agent.py | 1 skips post-trajectory image removal. |
STACX_DAGGER_ENV_CONCURRENCY | 4 | evaluator_config_dagger.py | Concurrent sandbox starts per env actor. |
SWE_ENTRYPOINT_LOCAL | (required) | swe_sandbox.py | Host path of the editor entrypoint, bind-mounted read-only. |
DOCKER_IMAGE_CACHE_DIR | unset | deployments/docker.py | Directory of <image>.tar files for docker load before pulling. |
SDK-side ROCK_* defaults — startup timeout (180 s), auto-clear lease (360 min), rocklet provisioning (ROCK_WORKER_ENV_TYPE, local/docker/uv/pip), and the optional ROCK_LOGGING_PATH log mount — live in rock/env_vars.py. | |||
Admin launch flags (rock/admin/main.py)
| Flag | Default | Effect |
|---|---|---|
--port | 8080 | Admin REST port (the getting-started guide's ROCK_PORT). |
--role | admin | admin serves the full API; proxy serves the routing-only proxy service. |
--env | local | local disables Redis (no expiry reaper; containers self-clear). |
See also Environment Engine (surrounding architecture), Task Scheduler (how sandboxes are scheduled onto nodes), and Rollout Architecture (the consuming agent loop).