Repository navigation
Commit 1099366
fix(agents): reclaim zombie sandbox containers and create them lazily (#4513)
## What & why
Opening the desktop app could leave **15+ leftover `electric-sbx-*`
Docker containers running** that the user never started. They piled up
because, in several places, a sandbox container could outlive the work
it was created for. This PR closes each of those gaps so a container
only exists while something is actually using it.
## Why the containers piled up
Sandbox containers are meant to be short-lived: created for an agent's
work, then torn down. Three gaps let them survive instead:
1. **We created containers we didn't need.** Every time an agent woke up
— even for trivial bookkeeping (a scheduled tick with nothing to do, a
drained message queue) — it started a container *before* knowing whether
it would run anything. When the app reconnected and replayed a backlog
of these wakes, each one spun up its own container at once.
2. **We didn't clean up on quit.** Idle containers are torn down by a
short (~2-minute) delay timer. On quit, the app exits before those
timers fire — so every quit left a container behind.
3. **We didn't clean up leftovers at the next startup either.** The
startup cleanup deliberately skipped *running* containers (so it
wouldn't kill one another process is actively using). But a container
left by a crash or quit is still running — its main process is an idle
loop that never exits on its own — so the cleanup never touched the very
leftovers it was meant to reclaim.
Two smaller bugs made it worse: a container could be stranded if its
one-time setup step failed, and a Docker error code was occasionally
misread so we'd "reconnect" to a container that had already been
removed.
## How we fix it
The approach attacks all three gaps so a container can't outlive its
purpose:
1. **Create only when actually used (lazy creation).** A container is
now started the first time an agent really uses it — runs a command, or
reads/writes a file. Trivial wakes create nothing. (Edge cases
preserved: an agent finishing for good still cleans up any workspace
earlier runs created, and a child agent that "inherits" its parent's
sandbox still makes sure that sandbox exists first.)
2. **Clean up on quit.** On shutdown we now run the pending teardowns
immediately instead of letting the delay timers die with the process.
This covers every exit path (desktop quit, restarts, Ctrl-C / kill) and
is time-bounded so a stuck Docker daemon can't hang the quit.
3. **Reclaim true leftovers at startup.** Every container is tagged with
the process that created it. At startup we check whether that process is
still alive: if it's gone, the container is a genuine leftover and we
reclaim it — deleting throwaway ones and stopping reusable ones (so
their files survive for later reuse). Containers a live process is still
using are left untouched, so we never disturb a peer.
As a quality-of-life touch, every container is also labelled so Docker
Desktop groups them under one collapsible **`electric-sandboxes`** entry
you can stop or delete together (`docker compose -p electric-sandboxes
down`).
<details>
<summary>Implementation details (for reviewers)</summary>
### Zombie reclamation
- Containers carry a `com.electric.sandbox.owner-pid` label; the boot
sweep probes it (`kill(pid, 0)`) and reclaims running orphans whose
owner is dead — **remove** ephemeral, **stop** persistent (writable
layer survives for reattach by key). The sweep probes + reclaims
leftovers concurrently so boot latency stays flat as they accumulate.
- Labels are immutable, so reattaching a dead creator's container
records adoption in an in-container marker
(`/tmp/.electric-sbx-owner-pid`, tmpfs ⇒ wiped on stop); the sweep
probes it before reclaiming, so a live sibling's adopted container is
never swept. The sweep is now awaited at boot so it can't race the first
wake's reattach.
- New `shutdownAllDockerSandboxes()` flushes pending debounced teardowns
on runtime shutdown, wired through
`AgentHandlerResult.shutdownSandboxes` → `BuiltinAgentsServer.stop()`
(covers desktop quit, runtime restarts, CLI SIGINT/SIGTERM; bounded 5s).
Live-leased entries are left to their own dispose (a sibling runtime in
the same process may own them) — if the process dies first, the
pid-sweep reclaims them at next boot.
- Post-start init verifies the `mkdir` exit code and force-removes the
container on any failure; `isNameConflict()` now matches the daemon's
actual name-conflict message instead of bare 409.
### Lazy sandbox creation
- New `lazySandbox()` wrapper (`agents-runtime/sandbox/lazy.ts`) defers
the provider factory until the sandbox is actually **used**
(exec/fs/fetch). The bootstrap `docker` profile returns it, so trivial
wakes never create a container. Materialization is single-flight and
retried on failure.
- Terminal reclaim still works without use:
`reclaimDockerSandboxByKey()` wipes an earlier wake's persistent
workspace by key without creating a container just to delete it (owner
leases only; defers to live sibling leases — the last one draining wipes
it).
- Spawn-`inherit` force-materializes the owner's workspace
(`ensureSandboxMaterialized`) before spawning, so a child can attach
even when the parent never ran a tool.
- Concurrent container **creations** are capped at 4 process-wide
(`withCreationSlot`) — bursts queue against the daemon instead of
stampeding it; reattaches/execs are unlimited, total creations
unbounded.
### Grouping
All sandboxes carry `com.docker.compose.project=electric-sandboxes` (+
`com.docker.compose.service=<entity-type>`).
</details>
## Testing
- 13 new docker integration tests (running-orphan reclaim, adoption
sparing, legacy-label safety, init-failure cleanup, shutdown flush, lazy
composition, reclaim-by-key) and 13 unit tests for `lazySandbox` —
written first, confirmed red, then green.
- Full suites: agents-runtime 803 tests / 63 files, agents 55 / 11;
`tsc` and eslint clean.
## Out of scope (follow-ups)
- No cap on concurrent *wakes* (they're whole agent runs; capping would
queue user-visible messages behind backlog replay — needs a product
call).
- The `e2b` remote profile stays eager (working directory not statically
known at profile-build time); the same wrapper applies once it is.
- Server-side orphaned-claim recovery (`dispatchRecoveryIntervalMs` is
defined but unused) — expired runner leases still queue wakes forever.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>1 parent 2e4feee commit 1099366
16 files changed
Lines changed: 1214 additions & 161 deletions
File tree
- .changeset
- packages
- agents-runtime
- src
- sandbox
- docker
- test
- helpers
- agents/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
15 | 16 | | |
16 | 17 | | |
17 | 18 | | |
| |||
1322 | 1323 | | |
1323 | 1324 | | |
1324 | 1325 | | |
1325 | | - | |
| 1326 | + | |
1326 | 1327 | | |
1327 | 1328 | | |
1328 | 1329 | | |
| |||
1336 | 1337 | | |
1337 | 1338 | | |
1338 | 1339 | | |
| 1340 | + | |
| 1341 | + | |
| 1342 | + | |
| 1343 | + | |
| 1344 | + | |
| 1345 | + | |
1339 | 1346 | | |
1340 | 1347 | | |
1341 | 1348 | | |
| |||
1344 | 1351 | | |
1345 | 1352 | | |
1346 | 1353 | | |
1347 | | - | |
| 1354 | + | |
1348 | 1355 | | |
1349 | 1356 | | |
1350 | 1357 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| 12 | + | |
| 13 | + | |
12 | 14 | | |
13 | 15 | | |
14 | 16 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
| 12 | + | |
11 | 13 | | |
12 | 14 | | |
13 | 15 | | |
| |||
0 commit comments