feat(v1): keep boxes between rollouts with Agent.provision(task, reuse=key) - #2581
feat(v1): keep boxes between rollouts with Agent.provision(task, reuse=key)#2581faresobeid wants to merge 4 commits into
Conversation
…e=key) A `RuntimePool` (runtimes/pool.py) holds live boxes between `provision` contexts: one box per key, handed back while its resolved config matches, it is alive and its idle time is under `ttl`; `max_idle` caps the idle set, a sweeper stops the expired, and every box is torn down with the pool (or by the atexit backstop). `Agent(config, runtimes=pool)` injects it like `interception`; `Env.serving()` owns one when `EnvConfig.runtimes` is set (`--env.runtimes.ttl`), reachable as `Env.runtimes` for `discard(key)`. Default unchanged: `reuse=None` / no pool provisions and tears down as before.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR introduces an opt-in runtime pool that keeps sandboxes alive across episodes, adding substantial keyed leasing, concurrency, TTL, liveness, and teardown behavior. Unresolved findings identify shutdown-race and cross-rollout environment-isolation risks, so the lifecycle and isolation design needs human review. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
…per-key gates are dropped - `_take` stops the popped box on any BaseException from the reusability check, so a lease cancelled mid-`alive()` no longer orphans a live box. - The new lease's env is set before the probe (a `PATH=""` left by the last lease no longer fails the `true` probe and cold-provisions a healthy box). - `_locks` holds a refcounted `_Gate` per key, deleted once no lease holds or waits on it, so a long-lived worker no longer accumulates one lock per reuse key.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1ccd16a. Configure here.
…losed
`RuntimePool.lease` checked `_closed` only after the yield, so a lease waiting
on a key's gate when `stop()` ran went on to `_take()` or start a fresh box and
run its caller after the pool — and `Env.serving()` — had wound down; that box
was never in `_idle`, so `stop` could not see it. The gate now rejects the
lease with RuntimeError("runtime pool is closed") the moment it is acquired
after close (a lease already live still stops its box on release).
…v is its per-exec overlay `lease` set `runtime.env` before `start()`, and docker (`--env` on `docker run`), prime (`environment_vars`) and modal (`env=`) bake `runtime.env` into the box at creation: the first lease's env, its task's `runtime_env()` secrets included, stayed a container default for every later lease on the reused box. Now a fresh pooled box is started from `config` alone, the lease's env is set after `start()` and rides each exec (every runtime overlays `runtime.env` via `process_env`), and a parked box carries no lease's env. `provision_runtime` (single-use boxes) is unchanged.
|
Closing for now: this change moves to our pipeline-specific verifiers branch (kept there together with the other data-flywheel runtime changes). It may return as an upstream PR later in a shape independent of the pipeline. |
|
Branch name for the record: flywheel (the backticks were eaten by the shell in the comment above). |
…med) `Env.runtimes`, `EnvConfig.runtimes` (`--env.runtimes`), the `_EpisodeAgent` wiring, the `reuse_v1` fixture and its e2e go: the pipeline builds the `RuntimePool` itself and hands it to `Agent(runtimes=)`. `RuntimePoolConfig` is a frozen dataclass of one field, `ttl` (`max_idle` and the trim go: leased boxes are bounded by the caller's concurrency).

What
Agent.provision(task, *, reuse: str | None = None)over an env-ownedRuntimePool:verifiers/v1/runtimes/pool.py(new):RuntimePoolConfig(ttl=600, max_idle=16)andRuntimePool— live boxes kept betweenprovisioncontexts, one box per key.lease(key, config, env)(the seamprovisioncalls) hands the idle box back when its resolvedRuntimeConfigequals, it is not stopped, its idle time is underttlandawait runtime.alive(); otherwise it stops the stale one and doesmake_runtime(config)+start()asprovision_runtimedoes. A normal exit parks the box idle (the oldest pastmax_idleis stopped); an exception (incl.CancelledError), a box the caller alreadystop()ped, or a closed pool stops it — the outcomeprovision_runtime'sfinally: stop()gives today. Keys are exclusive: a second lease of a live key waits on a per-keyasyncio.Lock(FIFO), so a box never hosts two rollouts at once — re-entering a key inside its own context therefore deadlocks (documented on the class and onprovision).discard(key)stops the box now (after its live lease, if any), idempotent.async with pool:runs a TTL sweeper (min(ttl / 4, 30)s, theElasticInterceptionPool._warm_taskshape) and stops every idle box on exit; idle boxes are held strongly, so they stay in_LIVEandcleanup_at_exitfrees them on a hard exit.Agent(config, *, interception=None, runtimes=None)injects a pool the wayinterceptionis injected (a pool belongs to what spans agents);provision(task, reuse=key)ridesself.runtimes.lease(...)when both are set, else the existingprovision_runtimepath._EpisodeAgentpasses it through.EnvConfig.runtimes: RuntimePoolConfig | None = None(--env.runtimes.ttl 900 --env.runtimes.max-idle 32);Env.serving()enters aRuntimePoolbeside the interception when set, exposes it asEnv.runtimes(live only insideserving(), sorun()candiscard(key)), and closes it on exit. Exported asvf.RuntimePool/vf.RuntimePoolConfig.A run placed into the box is a borrowed-box rollout as before:
trace.agent.runtime.borrowedisTrueandruntime.idis equal across the reusing traces; theRuntimeobject is the samePrimeRuntime/DockerRuntime/ … — no wrapper type, no newRuntimeInfofield.Why
A data-flywheel seat turn is one rollout, but the seat's box must outlive it: it carries the prelude, the checked-out repo and the seat's working state, a prime VM cold boot costs 30–60 s per turn while turns arrive every few minutes, and the judge runs K per-solution rollouts on one task's box. Today the env does this by hand —
seat._Instance.provision/release/_leave,CLOSE_GRACE/REOPEN_BACKOFF, aPlaceprotocol andplatform_outage_s/placement_wait_s/provision_wait_sknobs: ~150 lines re-implementing the stopped-check, shielded stop, atexit and TTL that verifiers already owns for its own rollouts. Once the seat becomesEnv.run(task, agents), its agents are minted per episode ("no state spans concurrent episodes") and a rollout stops the runtime it provisioned at close, so nothing in an env can hold a box across two episodes without env-owned plumbing — which is a keyed runtime cache. Borrowing is already complete in verifiers (run(runtime=box)never starts or stops the box, re-runstask.setup/harness.setupper rollout,prepare_setupre-opens egress "when reusing a restricted runtime"); what was missing is the keyed lookup, a lifetime beyond oneprovisioncontext, an explicit release, and the env-level owner.What is preserved
reuse=None, or an agent without a pool (runtimes=None,EnvConfig.runtimes=None— the default):provisiontakes the untouchedprovision_runtimepath;Rolloutis unchanged.runtimesunset.--no-serve).docs/untouched (provision/runtime=are not described there).Tests
tests/v1/test_runtimes.py(new, deterministic, subprocess boxes, no model): same key → same object /info.idand the newenv; drifted config → fresh box, old stopped; idle pastttl→ replaced;alive()False → replaced; the sweeper stops an expired idle box; exception through the context → stopped, not kept; callerstop()inside → not kept;discardstops and is idempotent; pool close stops idle boxes and a release after close stops;max_idle=1stops the oldest; two leases of one key serialise; an idle box stays in_LIVEaftergc.collect()andcleanup_at_exit()frees it;Agent.provision(task, reuse=key)with a pool reuses,provision(task)beside it still tears down, and without a poolreuse=provisions and tears down as before.tests/v1/test_e2e.py::test_runtime_pool_keeps_the_box_across_episodesover a new fixture envreuse-v1(tests/v1/fixtures/reuse_v1.py,run()=provision(task, reuse="seat")+run(task, runtime=box)):-r 2underenv.runtimes.ttl=60→ both tracesagent.runtime.borrowedwith oneruntime.id; the same eval withoutruntimes→ two ids. Rows:null-harness-in-subprocess(run here, passes) andbash-harness-in-restricted-docker(ablockpolicy, so the second episode's setup takes the reused-restrictedprepare_setuppath) — not run here (no docker on this machine); CI's docker job covers it.uv run pytest tests/v1 -m "not e2e" -n auto: 94 passed.uv run ruff check,uv run ruff format --check,uv run pre-commit run --all-files,uv run --python 3.13 ty check verifiers: clean.Note
Add
RuntimePoolandAgent.provision(reuse=key)to keep boxes across rolloutsRuntimePoolandRuntimePoolConfigin pool.py to manage idle runtimes by key, with background expiration sweeping and capacity limits (default TTL 600s, max 16 idle)Agent.provisionin agent.py to lease a runtime byreusekey when a pool is available; provisioning without a pool or key falls back to single-context teardownruntimesfield toEnvConfigin env.py and starts or stops the pool during theEnv.servinglifecycleAgent.provisionandEnvnow retain and reuse runtimes whenruntimesis configured, changing teardown timing for pooled runtimesMacroscope summarized b04e190.
Note
Medium Risk
Opt-in sandbox reuse changes runtime lifecycle and isolation boundaries; defaults are unchanged but bugs could leak state between rollouts on the same key.
Overview
Adds an env-owned
RuntimePoolso agents canprovision(task, reuse=key)and park the same sandbox between contexts instead of tearing it down every time.EnvConfig.runtimes(ttl,max_idle) turns the pool on forEnv.serving(); episode agents receive the pool, anddiscard(key)can drop a cached box early.Agent.provisionnow acceptsreuse=: with a pool it goes throughRuntimePool.lease(config match, liveness under the new lease env, TTL, per-key exclusivity); without a pool or key, behavior staysprovision_runtime. New unit tests cover pool semantics; e2ereuse-v1asserts oneruntime.idacross two episodes when pooled vs one box per episode when not.Reviewed by Cursor Bugbot for commit 7030a48. Bugbot is set up for automated code reviews on this repo. Configure here.