Skip to content

feat(v1): keep boxes between rollouts with Agent.provision(task, reuse=key) - #2581

Closed
faresobeid wants to merge 4 commits into
mainfrom
feat/runtime-pool
Closed

feat(v1): keep boxes between rollouts with Agent.provision(task, reuse=key)#2581
faresobeid wants to merge 4 commits into
mainfrom
feat/runtime-pool

Conversation

@faresobeid

@faresobeid faresobeid commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

What

Agent.provision(task, *, reuse: str | None = None) over an env-owned RuntimePool:

  • verifiers/v1/runtimes/pool.py (new): RuntimePoolConfig(ttl=600, max_idle=16) and RuntimePool — live boxes kept between provision contexts, one box per key. lease(key, config, env) (the seam provision calls) hands the idle box back when its resolved RuntimeConfig equals, it is not stopped, its idle time is under ttl and await runtime.alive(); otherwise it stops the stale one and does make_runtime(config) + start() as provision_runtime does. A normal exit parks the box idle (the oldest past max_idle is stopped); an exception (incl. CancelledError), a box the caller already stop()ped, or a closed pool stops it — the outcome provision_runtime's finally: stop() gives today. Keys are exclusive: a second lease of a live key waits on a per-key asyncio.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 on provision). 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, the ElasticInterceptionPool._warm_task shape) and stops every idle box on exit; idle boxes are held strongly, so they stay in _LIVE and cleanup_at_exit frees them on a hard exit.
  • Agent(config, *, interception=None, runtimes=None) injects a pool the way interception is injected (a pool belongs to what spans agents); provision(task, reuse=key) rides self.runtimes.lease(...) when both are set, else the existing provision_runtime path. _EpisodeAgent passes it through.
  • EnvConfig.runtimes: RuntimePoolConfig | None = None (--env.runtimes.ttl 900 --env.runtimes.max-idle 32); Env.serving() enters a RuntimePool beside the interception when set, exposes it as Env.runtimes (live only inside serving(), so run() can discard(key)), and closes it on exit. Exported as vf.RuntimePool / vf.RuntimePoolConfig.

A run placed into the box is a borrowed-box rollout as before: trace.agent.runtime.borrowed is True and runtime.id is equal across the reusing traces; the Runtime object is the same PrimeRuntime / DockerRuntime / … — no wrapper type, no new RuntimeInfo field.

class SeatEnv(vf.Env[SeatEnvConfig]):
    async def run(self, task, agents):
        key = f"{task.data.seat}:{task.data.key}"
        async with agents.seat.provision(task, reuse=key) as box:   # the cached box, or a fresh one
            await agents.seat.run(task, runtime=box)
        if task.data.leaves_box and self.runtimes is not None:
            await self.runtimes.discard(key)

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, a Place protocol and platform_outage_s/placement_wait_s/provision_wait_s knobs: ~150 lines re-implementing the stopped-check, shielded stop, atexit and TTL that verifiers already owns for its own rollouts. Once the seat becomes Env.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-runs task.setup/harness.setup per rollout, prepare_setup re-opens egress "when reusing a restricted runtime"); what was missing is the keyed lookup, a lifetime beyond one provision context, 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): provision takes the untouched provision_runtime path; Rollout is unchanged.
  • Reuse is a cache, never a correctness dependency: a miss provisions fresh; the caller's key asserts "same world" and config equality guards drift (a changed image/resources → fresh box). RL and eval leave runtimes unset.
  • Served path: each worker holds its own pool and dispatch does not route by key, so cross-episode reuse is best-effort there (documented, not solved; data-flywheel runs --no-serve).
  • No new dependencies; 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.id and the new env; drifted config → fresh box, old stopped; idle past ttl → replaced; alive() False → replaced; the sweeper stops an expired idle box; exception through the context → stopped, not kept; caller stop() inside → not kept; discard stops and is idempotent; pool close stops idle boxes and a release after close stops; max_idle=1 stops the oldest; two leases of one key serialise; an idle box stays in _LIVE after gc.collect() and cleanup_at_exit() frees it; Agent.provision(task, reuse=key) with a pool reuses, provision(task) beside it still tears down, and without a pool reuse= provisions and tears down as before.
  • tests/v1/test_e2e.py::test_runtime_pool_keeps_the_box_across_episodes over a new fixture env reuse-v1 (tests/v1/fixtures/reuse_v1.py, run() = provision(task, reuse="seat") + run(task, runtime=box)): -r 2 under env.runtimes.ttl=60 → both traces agent.runtime.borrowed with one runtime.id; the same eval without runtimes → two ids. Rows: null-harness-in-subprocess (run here, passes) and bash-harness-in-restricted-docker (a block policy, so the second episode's setup takes the reused-restricted prepare_setup path) — 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 RuntimePool and Agent.provision(reuse=key) to keep boxes across rollouts

  • Adds RuntimePool and RuntimePoolConfig in pool.py to manage idle runtimes by key, with background expiration sweeping and capacity limits (default TTL 600s, max 16 idle)
  • Updates Agent.provision in agent.py to lease a runtime by reuse key when a pool is available; provisioning without a pool or key falls back to single-context teardown
  • Adds the optional runtimes field to EnvConfig in env.py and starts or stops the pool during the Env.serving lifecycle
  • Behavioral Change: Agent.provision and Env now retain and reuse runtimes when runtimes is configured, changing teardown timing for pooled runtimes

Macroscope 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 RuntimePool so agents can provision(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 for Env.serving(); episode agents receive the pool, and discard(key) can drop a cached box early.

Agent.provision now accepts reuse=: with a pool it goes through RuntimePool.lease (config match, liveness under the new lease env, TTL, per-key exclusivity); without a pool or key, behavior stays provision_runtime. New unit tests cover pool semantics; e2e reuse-v1 asserts one runtime.id across 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.

…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.
@faresobeid
faresobeid marked this pull request as ready for review September 10, 2026 20:28
Comment thread verifiers/v1/runtimes/pool.py Outdated
Comment thread verifiers/v1/runtimes/pool.py Outdated
Comment thread verifiers/v1/runtimes/pool.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread verifiers/v1/runtimes/pool.py
@macroscopeapp

macroscopeapp Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

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.
Comment thread verifiers/v1/runtimes/pool.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread verifiers/v1/runtimes/pool.py Outdated
…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.
@faresobeid

Copy link
Copy Markdown
Collaborator Author

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.

@faresobeid faresobeid closed this Sep 12, 2026
@faresobeid

Copy link
Copy Markdown
Collaborator Author

Branch name for the record: flywheel (the backticks were eaten by the shell in the comment above).

faresobeid added a commit that referenced this pull request Sep 12, 2026
…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).
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