feat: multiplex interception servers + tunnels across rollouts - #1605
Conversation
… cap Each prime rollout opens a tunnel; prime caps tunnel creation at 100/min per API token, so at high concurrency tunnel.start() returns 429 (per-token limit), raising ProgramError and killing the rollout (~61% failures at 256 concurrent rollouts). Add one process-wide AsyncLimiter shared across rollouts, wrapping tunnel.start(), paced to 100/min. Phrased as 1-every-0.6s (capacity 1) rather than (100, 60) so a full bucket can't fire 100 tunnels at once and re-trip the limit within the minute. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… prime) bench/run_bench.sh + bench/summarize.py drive the v1 eval CLI across runtimes and scales (gsm8k-v1, -c 512, retries off) and report e2e wall clock + per-rollout generation latency. bench/RESULTS.md records the ladder (32/256/512) and the prime tunnel-rate-limit fix (before/after). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an optional MAX_TOKENS knob to run_bench.sh (--sampling.max_tokens, label -t<n>), a plot_e2e.py that renders e2e by-runtime and by-batch-size charts, and a capped (max_tokens=2048) 32/64/128 two-pass section in RESULTS.md. Capping trims the ~1.5% long-generation straggler tail (p90=554), giving cleaner runtime-overhead numbers (e.g. docker-128 319 -> ~134s, prime-128 413 -> ~178s). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-token cap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the per-rollout InterceptionServer into a RolloutSession (one rollout's trace/limits/stops/user) + a shared, secret-routed InterceptionServer; add an eval-level InterceptionPool that brings up ceil(concurrency/multiplex) shared servers, each exposed once (one tunnel per server behind a remote runtime), and hands each rollout a session slot (shared endpoint + its own secret). Opt-in via --multiplex (EvalConfig.interception_multiplex; 0 = per-rollout, unchanged). The harness is untouched — it already authenticates with its per-rollout secret, which is what the server routes by. Drops remote tunnels from O(N) to O(N/multiplex). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n worktree Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t spot) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the env server too
Move multiplex from EvalConfig to EnvConfig (ge=1, default 32) so both the eval CLI and the env server (EnvServerConfig inherits it) multiplex — prime-rl, which drives the server, now benefits. Wire an elastic InterceptionPool into EnvServer: created once for the server's lifetime and grown on demand, so tunnels are reused across requests rather than re-created per call (v1 only; the legacy v0 bridge is skipped). The pool is now elastic (no upfront concurrency sizing) to fit the server's unbounded request load. Restructure interception.py + interception_pool.py into interception/{server,pool}.py with re-exporting __init__.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ption_pool() Both the eval runner and the env server built InterceptionPool directly from env.harness.config.runtime + multiplex — a duplicated reach-through. Add Environment.interception_pool() (the env owns multiplex + the harness runtime) and call it from both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…delCallError Those exceptions only existed to signal HTTP outcomes from an extracted RolloutSession.handle() back to the server. The server's handle_chat can run the loop directly on the routed session and return each response inline (as it did before multiplexing), so the extraction + both exceptions are unneeded. RolloutSession is now pure state + refused(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…with Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n -> plot.py) Rename run_bench.sh -> benchmark.sh: a first-class single-turn benchmark that iterates runtimes x batch sizes (default subprocess/docker/prime x 32/64/128, gsm8k-v1, max_tokens 1024, default multiplex) and writes bench/benchmark.json (metadata + per-run e2e + full per-rollout gen durations + reward/errors) via bench/aggregate.py. plot_e2e.py -> plot.py reads benchmark.json and renders p10/p50/p90 by runtime + by batch to bench/benchmark.png (gitignored). Drop the obsolete e2e_*.png; commit a prime-only benchmark.json. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nce + or-nullcontext) The env server's run() guarded pool creation with isinstance(self.env, Environment) and entered it via 'async with self.pool or nullcontext()'. Replace with a public interception_pool() hook (returns self.env.interception_pool()) that LegacyEnvServer overrides to a nullcontext — so run() is just 'async with self.interception_pool() as self.pool', no isinstance, no or-nullcontext. Also drop the 'for an eval or env server' phrase from the pool docstring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ale bench scripts - interception pool: rename _PooledServer -> PooledServer (exported); suppress per-entry teardown errors so one stuck tunnel can't leak the rest - bench: drop plot_mux.py (one-off A/B) and summarize.py (superseded by aggregate.py); keep only the benchmark.sh -> aggregate.py -> plot.py flow Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5513daf to
dd49188
Compare
register() under the lock before incrementing load, so a failed register can't leak a slot (the finally only runs once the slot is fully reserved). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1fc6bcb. Configure here.
| yield entry.endpoint, secret | ||
| finally: | ||
| entry.server.unregister(secret) | ||
| entry.load -= 1 |
There was a problem hiding this comment.
Pool load counter race
Medium Severity
In InterceptionPool.acquire, entry.load is incremented under _lock but decremented in finally without the lock. Concurrent rollouts finishing together can lose decrements, so load drifts below the number of active sessions and more than multiplex rollouts can register on one shared server.
Reviewed by Cursor Bugbot for commit 1fc6bcb. Configure here.
| multiplex: int = Field(32, ge=1) | ||
| """Rollouts that share one interception server (and, behind a remote runtime, one | ||
| tunnel). N concurrent rollouts use ~N/multiplex servers + tunnels instead of one each — | ||
| key past the per-token tunnel cap. 1 = a server (+ tunnel) per rollout.""" |
There was a problem hiding this comment.
Missing multiplex documentation
Low Severity
This PR adds first-class EnvConfig.multiplex (default 32) for eval and the env server, but no updates appear in docs/ (for example docs/evaluation.md or docs/reference.md) describing the flag or when to set multiplex=1.
Triggered by project rule: BugBot Instructions
Reviewed by Cursor Bugbot for commit 1fc6bcb. Configure here.
| multiplex: int = Field(32, ge=1) | ||
| """Rollouts that share one interception server (and, behind a remote runtime, one | ||
| tunnel). N concurrent rollouts use ~N/multiplex servers + tunnels instead of one each — | ||
| key past the per-token tunnel cap. 1 = a server (+ tunnel) per rollout.""" |
There was a problem hiding this comment.
Missing skills multiplex update
Low Severity
The new multiplex eval/env-server knob is not reflected in workflow skills such as skills/evaluate-environments/SKILL.md, which document eval and env-server configuration patterns.
Triggered by project rule: BugBot Instructions
Reviewed by Cursor Bugbot for commit 1fc6bcb. Configure here.
ApprovabilityVerdict: Needs human review This PR introduces a new multiplexing feature for interception servers with significant infrastructure changes affecting concurrent rollout behavior. An unresolved medium-severity review comment identifies a potential race condition in the pool's load counter that could allow over-subscription of shared servers. You can customize Macroscope's approvability policy. Learn more. |
…Intellect-ai#1605) * fix(v1): rate-limit prime tunnel creation to stay under the per-token cap Each prime rollout opens a tunnel; prime caps tunnel creation at 100/min per API token, so at high concurrency tunnel.start() returns 429 (per-token limit), raising ProgramError and killing the rollout (~61% failures at 256 concurrent rollouts). Add one process-wide AsyncLimiter shared across rollouts, wrapping tunnel.start(), paced to 100/min. Phrased as 1-every-0.6s (capacity 1) rather than (100, 60) so a full bucket can't fire 100 tunnels at once and re-trip the limit within the minute. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): runtime benchmark harness + results (subprocess vs docker vs prime) bench/run_bench.sh + bench/summarize.py drive the v1 eval CLI across runtimes and scales (gsm8k-v1, -c 512, retries off) and report e2e wall clock + per-rollout generation latency. bench/RESULTS.md records the ladder (32/256/512) and the prime tunnel-rate-limit fix (before/after). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): results report (min/p50/p90/max per run) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): cap generation length to isolate runtime overhead Add an optional MAX_TOKENS knob to run_bench.sh (--sampling.max_tokens, label -t<n>), a plot_e2e.py that renders e2e by-runtime and by-batch-size charts, and a capped (max_tokens=2048) 32/64/128 two-pass section in RESULTS.md. Capping trims the ~1.5% long-generation straggler tail (p90=554), giving cleaner runtime-overhead numbers (e.g. docker-128 319 -> ~134s, prime-128 413 -> ~178s). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(v1): bump prime tunnel limiter 100 -> 512 (Prime raised the per-token cap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(v1): multiplex interception servers + tunnels across rollouts Split the per-rollout InterceptionServer into a RolloutSession (one rollout's trace/limits/stops/user) + a shared, secret-routed InterceptionServer; add an eval-level InterceptionPool that brings up ceil(concurrency/multiplex) shared servers, each exposed once (one tunnel per server behind a remote runtime), and hands each rollout a session slot (shared endpoint + its own secret). Opt-in via --multiplex (EvalConfig.interception_multiplex; 0 = per-rollout, unchanged). The harness is untouched — it already authenticates with its per-rollout secret, which is what the server routes by. Drops remote tunnels from O(N) to O(N/multiplex). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): add MULTIPLEX knob to run_bench.sh; run from the script's own worktree Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): plot baseline vs multiplex gen-duration distribution Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): add multiplex=64 to the base-vs-mux plot (mux=32 is the sweet spot) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): plot p10/p50/p90 gen-duration band (baseline vs mux32 vs mux64) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(v1): make multiplex a first-class EnvConfig field; multiplex the env server too Move multiplex from EvalConfig to EnvConfig (ge=1, default 32) so both the eval CLI and the env server (EnvServerConfig inherits it) multiplex — prime-rl, which drives the server, now benefits. Wire an elastic InterceptionPool into EnvServer: created once for the server's lifetime and grown on demand, so tunnels are reused across requests rather than re-created per call (v1 only; the legacy v0 bridge is skipped). The pool is now elastic (no upfront concurrency sizing) to fit the server's unbounded request load. Restructure interception.py + interception_pool.py into interception/{server,pool}.py with re-exporting __init__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(v1): centralize the interception pool on Environment.interception_pool() Both the eval runner and the env server built InterceptionPool directly from env.harness.config.runtime + multiplex — a duplicated reach-through. Add Environment.interception_pool() (the env owns multiplex + the harness runtime) and call it from both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(v1): inline interception handle_chat; drop RolloutStopped/ModelCallError Those exceptions only existed to signal HTTP outcomes from an extracted RolloutSession.handle() back to the server. The server's handle_chat can run the loop directly on the routed session and return each response inline (as it did before multiplexing), so the extraction + both exceptions are unneeded. RolloutSession is now pure state + refused(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(v1): inline env.interception_pool() into the runner's async with Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): general single-turn benchmark (benchmark.sh -> benchmark.json -> plot.py) Rename run_bench.sh -> benchmark.sh: a first-class single-turn benchmark that iterates runtimes x batch sizes (default subprocess/docker/prime x 32/64/128, gsm8k-v1, max_tokens 1024, default multiplex) and writes bench/benchmark.json (metadata + per-run e2e + full per-rollout gen durations + reward/errors) via bench/aggregate.py. plot_e2e.py -> plot.py reads benchmark.json and renders p10/p50/p90 by runtime + by batch to bench/benchmark.png (gitignored). Drop the obsolete e2e_*.png; commit a prime-only benchmark.json. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(v1): public EnvServer.interception_pool() hook (drop isinstance + or-nullcontext) The env server's run() guarded pool creation with isinstance(self.env, Environment) and entered it via 'async with self.pool or nullcontext()'. Replace with a public interception_pool() hook (returns self.env.interception_pool()) that LegacyEnvServer overrides to a nullcontext — so run() is just 'async with self.interception_pool() as self.pool', no isinstance, no or-nullcontext. Also drop the 'for an eval or env server' phrase from the pool docstring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): drop committed mux_vs_base.png; gitignore all bench images Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(v1): public PooledServer, leak-proof pool teardown; prune stale bench scripts - interception pool: rename _PooledServer -> PooledServer (exported); suppress per-entry teardown errors so one stuck tunnel can't leak the rest - bench: drop plot_mux.py (one-off A/B) and summarize.py (superseded by aggregate.py); keep only the benchmark.sh -> aggregate.py -> plot.py flow Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(v1): reserve interception slot atomically with register register() under the lock before incrementing load, so a failed register can't leak a slot (the finally only runs once the slot is fully reserved). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * exp(v1): drop bench/RESULTS.md (keep only the benchmark scripts + data) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>


Summary
Behind a remote runtime, each rollout's interception endpoint needs its own tunnel, and tunnel creation is rate-capped per API token — so one-tunnel-per-rollout caps remote fan-out, and at scale the per-rollout tunnel cost (creation pacing folds into each rollout's
generation.duration, plus tunnel-infra strain) inflates latency. This makes interception multiplexable: many rollouts share one server + one tunnel. Rebased directly ontofeat/nano-as-v1, this PR also folds in the prime tunnel rate-limiter and the v1 single-turn runtime benchmark that motivated the change (previously #1593, now superseded).Multiplexing
InterceptionServerinto a per-rolloutRolloutSession(its trace/limits/stops/user) + a shared, secret-routed server ({secret: session}). The harness is unchanged — it already authenticates with its per-rollout secret, which is exactly what the server routes by.InterceptionPool(mirrorsEnvironment.shared_tools): brings upceil(concurrency / multiplex)shared servers, exposes each once (one tunnel per server, via a host-side exposer runtime), and hands each rollout a session slot — shared endpoint + its own secret. The pool is elastic (servers grown on demand) and lives for the eval/server lifetime, so tunnels are reused across requests.EnvConfig.multiplex(ge=1, default 32) — inherited by both the eval CLI (--multiplex) and the env server, so prime-rl, which drives the server, benefits too.multiplex=1= a server+tunnel per rollout. Drops remote tunnels O(N) → O(N/multiplex). (interceptionsplit intointerception/{server,pool}.py;PooledServeris public.)Prime tunnels
modalstill has no tunnel limiter at all — separate fix.Benchmark (
bench/)benchmark.sh(providers × batch sizes) →aggregate.py→ committedbenchmark.json→plot.py(image gitignored). Compares per-rollout generation-duration p10/p50/p90 (e2e wall clock is gated by a single endpoint-queue straggler).Results — prime, gsm8k-v1 (capped
max_tokens=2048, 512 tunnel limiter)Per-rollout generation-duration p50/p90 (s) — baseline (tunnel/rollout) vs
--multiplex 32. (e2e wall clock is gated by a single endpoint-queue straggler, so the distribution is the robust comparator.)Tunnels at n=256: baseline 256, mux=32 8, mux=64 4. Multiplexing roughly halves p50/p90 from 128 up; multiplex=32 is the sweet spot — mux=64 ties it at 256 but is worse at 128 (p50 34 vs 23: 64 rollouts through 2 tunnels vs 32 through 4), so fewer-but-busier tunnels don't help. 0 errors across all runs.
Verification
Notes
multiplex=1reproduces the per-rollout baseline (the benchmark's "baseline" column).Note
Multiplex interception servers and tunnels across concurrent rollouts
InterceptionServerin server.py from single-rollout to multi-session, routing requests by per-rollout Bearer token secrets via a newRolloutSessiondataclass.InterceptionPoolin pool.py to share interception servers and tunnel endpoints across rollouts up to a configurablemultiplexcount (default 32), growing the pool on demand.Environment.interception_pool(),EnvServer,Episode.run,run_with_retry, andRollout.run; rollouts fall back to per-rollout servers when no pool is provided.bench/benchmark.sh,bench/aggregate.py,bench/plot.py) for measuring and visualizing rollout throughput across runtimes and batch sizes.Macroscope summarized 1fc6bcb.
Note
Medium Risk
Changes the default eval/env-server rollout path (multiplex=32) and shared-secret routing on the interception server; mis-pooling or auth bugs could cross-wire rollouts, while Prime tunnel pacing affects all concurrent remote exposes.
Overview
Adds shared interception so many concurrent rollouts reuse one localhost proxy (and, on Prime, one tunnel) instead of provisioning per rollout—addressing remote fan-out limits and straggler-heavy e2e latency at scale.
InterceptionServeris split into per-rolloutRolloutSessionstate and a server thatregisters sessions under bearer secrets and routes/v1/chat/completionsby token.InterceptionPoolgrowsceil(N / multiplex)servers on demand, exposes each once via a host-side exposer runtime, andacquirehands rollouts a shared endpoint plus their own secret.EnvConfig.multiplexdefaults to 32 (1restores one server/tunnel per rollout); the eval runner and env server enter the pool for the whole run and pass it through episode → retry → rollout (optional pool; no pool keeps the old per-rollout expose path).LegacyEnvServeroverrides the pool to a no-op.Prime tunnel creation is paced with a process-wide
AsyncLimiterat 512/min (capacity 1) so burst pool growth stays under the per-token cap.New
bench/tooling runs single-turn evals across runtimes × batch sizes, aggregates per-rolloutgeneration.duration(plus e2e, reward, errors) intobenchmark.json, and plots p10/p50/p90 withplot.py.Reviewed by Cursor Bugbot for commit 1fc6bcb. Bugbot is set up for automated code reviews on this repo. Configure here.