Skip to content

fix(local-sandbox): hold each container a local run starts to the sandbox's cpu and memory - #109

Open
earakely-scale wants to merge 4 commits into
mainfrom
edgararakelyan/local-sandbox-resource-limits
Open

earakely-scale wants to merge 4 commits into
mainfrom
edgararakelyan/local-sandbox-resource-limits

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

The local sandbox provider accepted cpu and memory but applied neither, so every container a local run started was unlimited. One memory hog or busy loop could take the host and every run on it.

Now each container a local sandbox starts is held to the sandbox's cpu and memory with Docker's --cpus and --memory. Swap is capped at the same value, so memory can't spill into swap. That covers:

  • a container-mode sandbox's own container (an agent's sandbox);
  • an agent placed on a VM-mode sandbox, and its Docker-in-Docker sidecar;
  • every service in an env's compose stack (cpus, mem_limit and memswap_limit);
  • run_docker_container's containers.

The limits are recorded in the sandbox's work dir, so a handle rebuilt by a later step (get_sandbox) still applies them. A sandbox created before this change has no record and runs unlimited, as before.

Semantics

  • Hard caps, like the VM providers (E2B, Sail, Scale sandbox), which size the VM itself. LocalSandbox is a VmSandbox, and locally there is no VM to bound its containers, so Docker does it.

  • Per container, not per stack. Each container on a VM-mode sandbox gets the sandbox's whole budget. That stops any single runaway, but a five-service env stack at 1 CPU / 8 GiB can still use 5 CPUs / 40 GiB in total. A combined cap would need a shared parent cgroup (Linux only); that's left for later.

  • CPU above the engine's count is lowered to it, with a warning. Docker refuses --cpus above the engine's CPU count (docker run exits 125, and compose fails the same way), so a request like deploy_sandbox's default 2.0 on a 1-CPU engine would otherwise fail. Memory above the host's total is accepted by Docker and passed through unchanged.

  • Behaviour change: what's requested is now enforced. With today's defaults:

    • local agents get 1 CPU / 8 GiB, the same as on E2B, Sail or Scale sandbox;
    • unsized env services get 1 CPU / 8 GiB each;
    • deploy_sandbox containers get 2 CPUs / 8 GiB.

    Tasks that need more set cpu / memory_mb on the step.

  • Remote providers are unchanged. Their sandboxes record no limits, and a test checks that their docker commands and compose output are byte-identical.

Testing

Unit tier: 6434 passed. New tests cover:

  • the Docker flags and compose keys;
  • the record surviving a rebuilt handle;
  • an old sandbox with no record, an unreadable record, and a record that can't be written;
  • the CPU clamp, and asking the engine each time;
  • each place a container starts.

All 11 mutants of the new code are killed.

Integration tiers on Linux (Ubuntu 24.04, Docker 29.1, cgroup v2, 8 CPUs), run as CI runs them:

  • Fast tier: all 158 pass, including the new test_container_limits_local.py. It caught one test regression, fixed in the second commit: test_verify_sandbox_local asserted that an agent's work dir holds only its container-mode marker, and it now also allows the limits record.
  • Slow tier: 27 passed. Its 35 skips are all allowed capability gaps (no model endpoint or remote sandbox). Every one of the 136 containers agent-env started in it was held to its sandbox's limits.

Chaos on the same Linux host:

Scenario Result
Memory hog on a 1 CPU / 256 MiB sandbox, beside a sibling container Killed by the kernel at its cap (OOMKilled, exit 137, after holding 224 MiB). The sibling kept running; host memory available barely moved (14622 → 14462 MiB). Teardown removed both containers.
Four busy processes on a 0.5-CPU sandbox cpu.max = 50000 100000; measured 0.500 cores over 10 s. The same load with no limit took 3.96 cores.
64 CPUs asked on an 8-CPU engine Container runs at 8 CPUs, with the warning.
Sandbox with no limits record (from before this change) Container runs unlimited, as before.
Agent placed with enable_docker on a 1 CPU / 1 GiB sandbox Agent and its Docker sidecar each held to 1 CPU / 1 GiB. A memory hog started inside the agent's own Docker was killed inside the sidecar's cap; the agent container was untouched and kept serving its card.
Real model run: the claude-code agent bundle, 2 tasks (one with an MCP env behind the gateway compose stack) Both passed. All 7 containers were held to 1 CPU / 8 GiB: both agents, plus gateway, items, servicedb, pgweb and db-mcp. Teardown left nothing behind.
The same real run while the memory hog and the CPU spinners ran on other sandboxes Both tasks passed, at their solo timings (42.3 s vs 40.5 s). The hog was killed two seconds before the env's gateway came up.
agent-env run hello Passed.

macOS (Rancher Desktop, 4 CPUs): a container-mode sandbox at 0.5 CPU / 256 MiB got exactly that limit, and 64 CPUs were held to 4 with the warning. The memory-hog, clamp and old-sandbox scenarios above pass there too (8/8).

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the latest cleanup change leaves no outstanding finding.

Fix All in CursorFindings

  1. P2 Failed deploy leaves a directory ▶
Fix with agent prompt
### Issue 1
src/agent_env/providers/sandbox_providers/local_sandbox.py:undefined-185
If saving `.container-limits.json` fails, `LocalSandbox` raises after creating its work directory. No sandbox handle is returned, so cleanup cannot find that directory. Repeated failed attempts leave more directories behind. Remove the new directory when the write fails.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Local sandboxes now apply requested CPU and memory limits to the containers they start, keeping each container within its own budget. The limits follow rebuilt sandbox handles and cover environment compose services as well as individual containers.

  • Local containers stay within their sandbox’s CPU and memory budget.
  • Every local environment service gets its sandbox’s CPU and memory limits.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Create local sandbox] --> B[Save limits in work directory]
  B -->|Saved| C[Start containers with caps]
  B -->|Write fails| D[Remove new directory and raise]
  B --> E[Rebuild handle later]
  E --> C
Loading

Reviews (3) · Last reviewed commit: "fix(local-sandbox): a sandbox whose limi..." · Reviewed by Greptile

earakely-scale and others added 2 commits October 7, 2026 16:46
…dbox's cpu and memory

The local provider accepted cpu and memory and applied neither, so one runaway agent or env
container could take the host, and every run on it. A local sandbox now records what it was
created with, and Docker holds each container it starts to that: a container-mode sandbox's own
container, an agent placed on a VM-mode sandbox and its Docker-in-Docker sidecar, every service of
an env's compose stack, and run_docker_container's containers. The record is kept in the
sandbox's work dir, so a handle rebuilt for a later step still applies it.

A local VM-mode sandbox has no VM bounding everything it runs, so each container on it is held to
the sandbox's budget. Docker refuses --cpus beyond its engine's CPU count, so cpu is held to that,
with a warning. Remote providers are unchanged: a VM's size bounds its containers, and their
commands and compose output stay as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eside its container-mode marker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner October 8, 2026 00:30
Comment thread src/agent_env/providers/sandbox_providers/local_sandbox.py Outdated
Comment thread src/agent_env/providers/sandbox_providers/local_sandbox.py Outdated
… make a sandbox whose limits can't be recorded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
if container_limits is not None:
self.container_limits = container_limits
# Unrecorded, a handle a later step rebuilds would start containers unheld, so a failed write fails here.
(self._work_dir / _LIMITS_FILE).write_text(json.dumps(container_limits.to_dict()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed deploy leaves a directory

If saving .container-limits.json fails, LocalSandbox raises after creating its work directory. No sandbox handle is returned, so cleanup cannot find that directory. Repeated failed attempts leave more directories behind. Remove the new directory when the write fails.

Knowledge Base Used: Provider integrations and sandbox deployment

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/providers/sandbox_providers/local_sandbox.py
Line: 185

Comment:
**Failed deploy leaves a directory**

If saving `.container-limits.json` fails, `LocalSandbox` raises after creating its work directory. No sandbox handle is returned, so cleanup cannot find that directory. Repeated failed attempts leave more directories behind. Remove the new directory when the write fails.

**Knowledge Base Used:** [Provider integrations and sandbox deployment](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/provider-integrations.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 636ebe6: a folder the constructor made is removed before the error propagates.

…s new folder with it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant