Skip to content

feat: multiplex interception servers + tunnels across rollouts - #1605

Merged
mikasenghaas merged 20 commits into
feat/nano-as-v1from
feat/interception-mux
Jun 10, 2026
Merged

feat: multiplex interception servers + tunnels across rollouts#1605
mikasenghaas merged 20 commits into
feat/nano-as-v1from
feat/interception-mux

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Jun 10, 2026

Copy link
Copy Markdown
Member

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 onto feat/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

  • Split InterceptionServer into a per-rollout RolloutSession (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.
  • New InterceptionPool (mirrors Environment.shared_tools): brings up ceil(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.
  • First-class 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). (interception split into interception/{server,pool}.py; PooledServer is public.)

Prime tunnels

  • Rate-limit prime tunnel creation to stay under the per-token cap, and bump the limiter 100 → 512 (Prime raised the cap). modal still has no tunnel limiter at all — separate fix.

Benchmark (bench/)

  • General single-turn runtime benchmark: benchmark.sh (providers × batch sizes) → aggregate.py → committed benchmark.jsonplot.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.)

n baseline mux=32 mux=64
32 22 / 25 19 / 25 20 / 26
64 25 / 36 20 / 29 20 / 26
128 47 / 67 23 / 36 34 / 41
256 67 / 117 38 / 62 38 / 59

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

  • Correctness (subprocess + prime): baseline path, pooled single-turn, pooled colocated-tool (2 turns), pooled multi-turn user-sim (3–5 turns) — all reward ~1.0, 0 errors. Prime smoke: 4 rollouts → 1 shared server, 1 tunnel.

Notes

Note

Multiplex interception servers and tunnels across concurrent rollouts

  • Refactors InterceptionServer in server.py from single-rollout to multi-session, routing requests by per-rollout Bearer token secrets via a new RolloutSession dataclass.
  • Adds InterceptionPool in pool.py to share interception servers and tunnel endpoints across rollouts up to a configurable multiplex count (default 32), growing the pool on demand.
  • Threads the pool through Environment.interception_pool(), EnvServer, Episode.run, run_with_retry, and Rollout.run; rollouts fall back to per-rollout servers when no pool is provided.
  • Adds a leaky-bucket rate limiter in prime.py to cap tunnel creation at 512/min per API token, preventing provider 429s under high concurrency.
  • Adds benchmarking scripts (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.

InterceptionServer is split into per-rollout RolloutSession state and a server that registers sessions under bearer secrets and routes /v1/chat/completions by token. InterceptionPool grows ceil(N / multiplex) servers on demand, exposes each once via a host-side exposer runtime, and acquire hands rollouts a shared endpoint plus their own secret. EnvConfig.multiplex defaults to 32 (1 restores 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). LegacyEnvServer overrides the pool to a no-op.

Prime tunnel creation is paced with a process-wide AsyncLimiter at 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-rollout generation.duration (plus e2e, reward, errors) into benchmark.json, and plots p10/p50/p90 with plot.py.

Reviewed by Cursor Bugbot for commit 1fc6bcb. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread verifiers/v1/interception/pool.py Outdated
Comment thread bench/plot.py
Comment thread verifiers/v1/interception/pool.py Outdated
Comment thread verifiers/v1/interception/pool.py
mikasenghaas and others added 18 commits June 10, 2026 19:40
… 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>
@mikasenghaas
mikasenghaas force-pushed the feat/interception-mux branch from 5513daf to dd49188 Compare June 10, 2026 19:42
@mikasenghaas
mikasenghaas changed the base branch from exp/v1-runtime-bench to feat/nano-as-v1 June 10, 2026 19:42
mikasenghaas and others added 2 commits June 10, 2026 19:44
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>
@mikasenghaas
mikasenghaas marked this pull request as ready for review June 10, 2026 19:50
@mikasenghaas
mikasenghaas merged commit 0b3555c into feat/nano-as-v1 Jun 10, 2026
3 checks passed

@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 3 potential issues.

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 1fc6bcb. Configure here.

yield entry.endpoint, secret
finally:
entry.server.unregister(secret)
entry.load -= 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1fc6bcb. Configure here.

Comment thread verifiers/v1/env.py
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."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Triggered by project rule: BugBot Instructions

Reviewed by Cursor Bugbot for commit 1fc6bcb. Configure here.

Comment thread verifiers/v1/env.py
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."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Triggered by project rule: BugBot Instructions

Reviewed by Cursor Bugbot for commit 1fc6bcb. Configure here.

@macroscopeapp

macroscopeapp Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Approvability

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

pull Bot pushed a commit to Stars1233/verifiers that referenced this pull request Jun 23, 2026
…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>
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