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:

ComponentWhere it runsSourceRole
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.

control plane (admin + Ray) data plane (in container) terminal / abnormal exit per turn: run_in_session CREATE admin picks node, spawns Ray actor STARTING image, docker run rocklet boots READY is_alive=true, session created EXECUTING bash + editor, state persists STOPPED docker kill, actor killed start wait alive 1st action stop() < 1 s pull: minutes warm: 10–25 s < 1 s per turn ≤ 120 s ~ 1 s startup timeout · node dead · auto-clear lease expiry → teardown (termination "aborted") What "start" actually does (rock/deployments/docker.py · DockerDeployment.start) 1. resolve image name → if pull policy allows, load from DOCKER_IMAGE_CACHE_DIR tar, else docker pull from the registry 2. map host ports (rocklet :8000, server :8080, ssh :22) → docker run --privileged --cpus --memory, command = rocklet 3. RemoteSandboxRuntime polls until is_alive (bounded by startup_timeout); manager writes {image, expire_time} to Redis On stop(): docker kill → ray.kill(actor) → ports released → Redis cleared. The image survives unless removed (§6).

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.

FieldDefaultMeaning
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).
cpus2CPU quota → --cpus. The SWE lane uses 4.
auto_clear_seconds14400Idle lease (4 h). Refreshed on every API call; a container idle past it is reaped.
startup_timeoutROCK_SANDBOX_STARTUP_TIMEOUT_SECONDS (180)Ceiling on the create→alive poll loop. The SWE client passes 300 (see STACX_SANDBOX_STARTUP_TIMEOUT).
route_keyNone → random UUIDSticky routing header so repeated calls reach the same sandbox.
cluster"zb"Cluster selector sent as the X-Cluster header.
user_id / experiment_idNoneOptional 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):

TypeKey fieldsPurpose
CreateBashSessionRequestsession, startup_source[], env_enable, envOpen a named REPL; optionally source files and inherit / inject env.
BashActioncommand, session, timeout, checkRun one command. checksilent / raise / ignore controls exit-code handling.
Commandcommand: str | list[str]Stateless execute payload (no session).
ReadFileRequest / WriteFileRequestpath, content, encodingStructured file I/O without going through the shell.
BashObservationoutput, exit_code, failure_reasonThe result of a session command (aliased Observation).
SandboxStatusResponseis_alive, host_ip, port_mapping, imageHealth + 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:

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:

  1. Placement. node_ip = context.placement["node_ip"], chosen by the Task Scheduler; the handler addresses http://{node_ip}:8080.
  2. Start. RockSandbox.start(image, cpus=4, memory="16g", docker_args=[…]), image from get_swe_bench_image(instance_id), up to 10 retries.
  3. Repo setup. The SWE-Bench repo is already at /testbed, so setup_repo_in_sandbox just git checkouts the base commit; init_session activates the conda testbed env and cds in.
  4. Turns. Each action becomes an execute_swe_tool call: execute_bashrun_in_session; str_replace_editor → the in-container entrypoint.
  5. Teardown. The handler stops the sandbox and, unless STACX_KEEP_IMAGES=1, removes the per-instance image (§6).
client control plane :8080 data plane :8000 (container) scheduler token PlacementToken (node_ip) Agent turn dagger_agent.py tool: execute_bash RockSandbox sandbox/rock.py execute_in_session() rock admin SandboxManager :8080 · Ray lookup SandboxActor RemoteSandboxRuntime rocklet :8000 BashSession pexpect /bin/bash PS1 sentinel + $? POST /apis/envs/sandbox/v1/run_in_session   { sandbox_id, session, command, check:"silent" } Observation { output, exit_code } — cleaned of PS1 / prompt noise, appended as the next turn Resource token lifecycle (owned by the Task Scheduler, not ROCK) acquire ONE PlacementToken at trajectory start → carries node_ip → the same sandbox is reused for every turn above release in a finally block after run + eval complete — the token gates node capacity; the per-turn calls do not re-acquire.

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_imagedocker 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

SymptomCause & 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)

EndpointPurpose
POST /start · /start_asyncCreate a sandbox (sync waits for alive; async returns the id immediately).
GET /get_status · /is_aliveHealth, ports, image, host.
POST /create_session · /close_sessionOpen / close a named bash REPL.
POST /run_in_sessionRun a command in a session (stateful).
POST /executeRun a command with no session (stateless subprocess).
POST /read_file · /write_file · /uploadStructured file I/O and multipart upload.
POST /stopKill the container and its Ray actor.
POST /remove_imagedocker rmi --force on the host to reclaim disk.
POST /commitCommit 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

VariableDefaultRead byEffect
STACX_SANDBOX_STARTUP_TIMEOUT300swe_sandbox.pyStart→alive ceiling passed to /start.
STACX_KEEP_IMAGES0swe_agent.py1 skips post-trajectory image removal.
STACX_DAGGER_ENV_CONCURRENCY4evaluator_config_dagger.pyConcurrent sandbox starts per env actor.
SWE_ENTRYPOINT_LOCAL(required)swe_sandbox.pyHost path of the editor entrypoint, bind-mounted read-only.
DOCKER_IMAGE_CACHE_DIRunsetdeployments/docker.pyDirectory 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)

FlagDefaultEffect
--port8080Admin REST port (the getting-started guide's ROCK_PORT).
--roleadminadmin serves the full API; proxy serves the routing-only proxy service.
--envlocallocal 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).