fix: mcp robustness fixes under high-concurrency#2062
Merged
Conversation
…rn doesn't fail rollouts
Under high rollout concurrency (e.g. 512 on the subprocess runtime) the user-sim
and tool MCP connections drop (httpx.ReadError -> anyio.ClosedResourceError) at
rollout-start churn. Previously the host-side user path held one connection open
for the whole rollout and retried only the initial connect, and the harness tool
path held persistent sessions with no retry — so a single transient drop 500'd
the interception opening turn (crashing the null harness) or crashed the harness
mid-loop, failing the rollout.
The MCP servers are stateless-HTTP (setup runs once at startup; state lives on the
shared-state channel), so they hold nothing per connection. Reconnect on a fresh
session per call and retry transient transport drops with backoff:
- launch.py: connect_user yields a respond that opens/closes a session per turn
(_user_respond) with bounded retry; the opening respond("") is idempotent.
- null/default program.py: connect_mcp enumerates tools without holding sessions;
call_mcp opens a fresh session per call and retries transient drops. main() no
longer holds an AsyncExitStack of live connections.
Each session is opened and closed within the caller's task, keeping AnyIO cancel
scopes nested. Validated e2e at 512 concurrency / 1 step on the subprocess runtime:
alphabet-sort (user) 15.1% -> 0.0% rollout error, gsm8k (vanilla) 0.0%, wiki-search
(tool) 0.0%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…; drop leading _ from MCP constants The host-side user-sim path now drives its per-call reconnect through the existing verifiers.v1.retries.retrying() policy (tenacity: exponential backoff + jitter + logging) — the same one open_tunnel uses for "targeted retries where there's no SDK underneath" — instead of a hand-rolled loop. TRANSIENT_ERRORS lists the transport faults to retry (incl. the task group's ExceptionGroup, which at this boundary only wraps a transport failure); non-transport body errors are not retried. The harness program.py scripts can't import verifiers and keep deps minimal (openai/mcp), so they keep a small inline retry with the same semantics. Also renames the module-level MCP tunables to public (no leading underscore): MCP_CALL_RETRIES / MCP_TIMEOUT / TRANSIENT_ERRORS (launch) and MCP_CALL_ATTEMPTS / MCP_CALL_BACKOFF / MCP_CALL_MAX_BACKOFF / MCP_TIMEOUT (programs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drops the leading underscore (parity with the now-public MCP_* / TRANSIENT_ERRORS constants). is_transient stays a small inline helper in each standalone program.py (they can't import verifiers). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… whitelist Drops is_transient / TRANSIENT_ERRORS. Retrying any Exception is sound at this boundary: a tool that fails for real returns an error in its result rather than raising, and cancellation (CancelledError, or an ExceptionGroup carrying one, which promotes to BaseExceptionGroup) is a BaseException — so tenacity's default on=Exception (and the harness's bare 'except Exception') never retry it. The host path keeps give_up=RolloutError so our own deterministic boundary errors aren't retried. Removes the now-unused anyio imports. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mikasenghaas
marked this pull request as ready for review
July 18, 2026 06:31
ApprovabilityVerdict: Needs human review This PR makes significant changes to MCP session management and retry logic. An unresolved P1 review comment identifies a race condition bug in the replay cache logic where the cache can be visible before state commits, contradicting the PR's concurrency robustness goals. You can customize Macroscope's approvability policy. Learn more. |
The retry scope wrapped call_tool AND the client-side parsing (json.loads / parse_message on the user path, mcp_content_to_chat_content on the tool path). If call_tool already committed on the server, a parse failure re-invoked the whole call — advancing the user simulator or repeating tool side effects. Retry only the call; parse the returned result outside the retry, so a parse failure fails once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the review findings on the retry scope: - retrying call_tool is at-least-once delivery: a connection lost after the server executed the call but before the response arrived would replay it, double-advancing the user simulator or repeating tool side effects. - an exception during session TEARDOWN (after a successful call) escaped the old retry scope's `async with` and also triggered a replay. The session context manager now owns resilience: setup (open transport + initialize) is retried freely since nothing has reached the tool yet; the body runs at most once (a mid-call drop fails the rollout instead of risking a duplicate side effect); a teardown failure after a completed body is swallowed since the result is already in hand. Call sites collapse to a plain `async with mcp_session(...)`. launch.py drives the setup retry through the shared retrying() policy; the standalone harness programs keep an inline loop. Also declares the null program's direct httpx import in its script dependencies. Verified against stubbed transports: setup retried to success, mid-call drop raises once (call count 1), teardown-after-body swallowed, setup exhaustion raises, cancellation propagates unretried; plus real-server round-trip and parse-failure-fails-once tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At-most-once (retry only session setup, never the call) measurably regressed:
at 512 concurrency, transport drops hit the respond round-trip itself, failing
0.4-4.2% of rollouts per step. And the double-advance hazard already existed a
layer up: when the opening respond's response was lost, the harness SDK retried
the 500 and the interception re-invoked session.user("") against a simulator
that had already advanced - producing corrupt turns (empty messages upstream).
Retry the call freely and make the replay safe instead:
- User.respond (wire tool) gains a `seq` conversation position; the per-rollout
user server keeps its last (seq, message) -> payload and replays a retried
turn instead of advancing the simulator twice. This dedups the host client's
retries AND the interception-level re-invocations (both pass the same seq -
the interception derives it from the prompt position, which is byte-identical
across SDK retries).
- launch.py: _user_session is a plain fresh-session CM that suppresses teardown
failures after the body (the result is in hand); _user_respond retries the
full call via the shared retrying() policy with a stable seq; the payload is
parsed outside the retry so a parse failure fails once.
- null/bash program.py: same plain session CM + a single with_retry helper for
tool enumeration and calls. Task tool calls are documented at-least-once (MCP
has no idempotency key); a failed tool reports through its result, not an
exception, so retried exceptions are transport faults.
Verified: 12 stubbed-transport semantics tests (setup churn retried, mid-call
drop replayed with the same seq, teardown-after-body swallowed, exhaustion
raises, cancellation unretried, replay cache dedups retried turns and advances
on new ones) plus real-server round-trip and parse-fails-once tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The replay cache is written after the simulator runs, so a retry racing a slow first execution (churn-delayed server) missed it - each duplicate advanced the simulator, and a burst could exhaust a queued-turns sim entirely (observed as empty openings -> "No messages provided" upstream, ~0.2%/step at 512). A lock around the wrapper serializes duplicates: they wait, re-check the cache, and join the recorded turn instead of re-executing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The opening-injection guard keyed on `trace.num_turns == 0`, which mutates across attempts: when the opening request's final response is lost after the simulated conversation already committed turns, the SDK's byte-identical retry arrived with num_turns > 0, skipped the injection, and forwarded its empty message list upstream — the renderer's "No messages provided" 502, repeated deterministically until the harness died (the residual ~0.2-0.4%/step at 512 concurrency). Recognize the opening request by its own shape instead — no assistant turn in the request — which is stable across retries; later requests in tool loops carry assistant history and are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 eaa53e4. Configure here.
The user-turn position was taken before the assistant message joined the prompt, so when a simulator opened with zero messages and the model's first non-tool reply was empty, the first post-model turn shared the opening's (seq, message) key and replayed the opening payload instead of advancing the simulator. Append the assistant turn to the prompt before taking the position, making every mid-conversation seq strictly greater than the opening's. Also documents that the respond replay cache spans exactly one conversation - the user server process is launched per rollout (serve_user), so the cache cannot leak across rollouts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n plumbing - MCP_TIMEOUT mirrors the OpenAI SDK client defaults (connect=5s, everything else 600s): a connect failure under churn surfaces in seconds and hits the retry machinery instead of hanging for a minute, and one familiar default replaces a bespoke pair. - Inline the connect_user wrapper (it existed to hold the old persistent session); serve_user yields partial(user_respond, url) directly. - user_respond/user_session lose their leading underscore; the replay cache unpacks into (last_turn, last_payload) instead of a 3-tuple. - Trim comments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
xeophon
reviewed
Jul 18, 2026
The lock + replay cache lived inside `_with_state(respond)`, so `_push_state` ran in the wrapper after `respond` returned — outside the lock. A racing retry could take the lock, hit the cache, and return the recorded payload before the original attempt's state commit landed: the interception could then evaluate `@stop` against stale state (or see a success whose state never committed). Invert the nesting: `_with_state` now wraps an inner `advance`, and the lock + cache wrap that. The whole turn — pull, advance, commit — runs under the lock, and the cache is published only after it commits, so a racing retry either drives a fresh turn or joins the fully-committed one. Reported by xeophon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hand-rolled retry loop in the null/bash harness programs with tenacity's AsyncRetrying (exponential backoff + jitter), matching the shared `retrying()` policy the host side already uses. Roughly LOC-neutral, but a battle-tested backoff instead of a bespoke one. Adds tenacity (pure-python, zero-dep) to each program's PEP-723 dependencies; drops the now-unused MCP_CALL_BACKOFF / MCP_CALL_MAX_BACKOFF constants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
xeophon
approved these changes
Jul 18, 2026
mikasenghaas
added a commit
to PrimeIntellect-ai/prime-rl
that referenced
this pull request
Jul 18, 2026
* fix: re-pin verifiers for MCP transport resilience under high concurrency Bumps deps/verifiers to include the per-call reconnect + retry fix for the v1 user-sim and tool MCP client paths. Under high rollout concurrency on the subprocess/docker runtimes (e.g. 512 inflight), transient MCP transport drops (httpx.ReadError -> anyio.ClosedResourceError) at rollout-start churn previously failed rollouts (surfaced as "ExceptionGroup: unhandled errors in a TaskGroup" -> HarnessError). The stateless-HTTP MCP servers are now reconnected per call and transient drops retried, so the churn is invisible to rollout success. Verified e2e at 512 concurrency / 1 step on the subprocess runtime: - alphabet-sort-v1 (user sim): 15.1% -> 0.0% rollout error - gsm8k-v1 (vanilla): 0.0% - wiki-search-v1 (tool): 0.0% See PrimeIntellect-ai/verifiers#2062. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin to include retrying() refactor Follow-up to the MCP transport-resilience re-pin: bumps deps/verifiers to the revision where the host-side user MCP path reuses the shared retrying() policy (no functional change vs the prior pin — same per-call reconnect semantics). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (public is_transient) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (drop redundant comment) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (retry any Exception, drop transport whitelist) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (drop MCP constant comments) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (merge main: default harness renamed to bash) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (parse outside MCP retry scope) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: update example SWE configs for the default->bash harness rename verifiers#2063 (pulled in by the pin bump) renamed the built-in 'default' harness to 'bash'. Switch the example configs that set harness id 'default' accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (at-most-once MCP calls, setup-only retries) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (exactly-once user turns via replay cache) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (serialize user respond) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (retried-opening injection fix) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (distinct mid-conversation seq) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (OpenAI SDK timeouts, trimmed user-turn plumbing) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump verifiers pin (replay-cache commit ordering + tenacity harness retries) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: re-pin verifiers to merged main (MCP transport resilience) verifiers#2062 merged as 4a3c1eb4c; pin deps/verifiers at verifiers main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Companion re-pin: PrimeIntellect-ai/prime-rl#3084
Summary
Make the v1 MCP client paths resilient to connection churn under high rollout concurrency, so per-rollout transport blips no longer fail rollouts — without replay-corrupting the user simulator.
Under high concurrency (e.g. 512 rollouts on the subprocess runtime), the user-sim and tool MCP connections drop at rollout-start churn (
httpx.ReadError→anyio.ClosedResourceError). Previously:connect_user) held one connection open for the whole rollout and retried only the initial connect — a mid-sessionrespond()drop propagated. The openingrespond("")(interceptionhandle_request) was unwrapped, so a drop there became a500to the harness, which exited non-zero →HarnessError→ failed rollout. Worse, the harness SDK's retry of that 500 made the interception re-invokerespond("")against a simulator that had already advanced for the lost turn — silently corrupting the trajectory (observed as empty/garbage turns upstream).null/bashprogram.py) held persistent MCP sessions with no retry, so a mid-loopcall_tooltransport drop crashed the harness → failed rollout.Design
Every call now runs on a fresh session (the servers are stateless-HTTP:
setup()/setup_task()run once at startup, per-rollout state lives on the shared-state channel), and retries are made safe rather than avoided:respondtool gains aseqconversation position (derived by the interception from the prompt position — byte-identical across SDK retries). The per-rollout user server keeps its last(seq, message) → payloadand replays a retried turn instead of advancing the simulator twice; a lock serializes duplicate in-flight attempts (a retry racing a slow first execution) so they join the recorded turn. This dedups the host client's retries and the interception-level re-invocations. The host retries the full call through the existing sharedretrying()policy (verifiers.v1.retries).trace.num_turns == 0, which mutates across attempts: an SDK retry of the opening request after the simulated conversation had committed turns (final response lost) skipped injection and forwarded its empty message list upstream (the renderer's deterministicNo messages provided502). The opening request is now recognized by its own shape — no assistant turn in the request — which is stable across retries.with_retryin the harness programs replays a call whose response was lost; a tool that fails for real reports through its result, not an exception, so a retried exception is a transport fault. (At-most-once was tried and measurably regressed: 0.4–4.2% of rollouts per step failed at 512 concurrency because drops hit the round-trip itself, not just setup.)Each session is opened and closed within the caller's task, keeping AnyIO cancellation scopes correctly nested; cancellation is never retried or swallowed. The
program.pyscripts (depsopenai/mcp/httpx, can't importverifiers) carry a small inlinewith_retry; the null program now declares its directhttpxdependency.Verification
Semantics (real code, stubbed transports + a real stateless
FastMCPserver):seq; the user server's replay cache returns the recorded turn (simulator advances once)(seq, message)→ cached payload, simulator invoked once; newseq→ advances;seq=-1→ no deduprespondpayload →UserErrorraised once,respondinvoked exactly onceEnd-to-end, 512 concurrency, subprocess runtime, one env per MCP shape:
alphabet-sort-v1gsm8k-v1wiki-search-v1alphabet-sort-v1is the reported regression: before the fix, the opening user-simrespond500'd under churn (ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)→HarnessError('harness 'null' exited 1: ... openai.InternalServerError: 500')). In the final 5-step run: 0 rollout failures, 0 empty-message upstream calls, and 20 respond retries — every one absorbed by the replay cache (the churn still happens; it's now handled). For calibration, the at-most-once alternative (retry setup only, never the call) was measured on the same setup and failed 0.4–4.2% of rollouts per step.Note
Medium Risk
Changes rollout-critical MCP and user-simulator semantics (retries, at-least-once tools, seq-based dedup); behavior is well-tested at 512 concurrency but any custom MCP tool that is not replay-safe could see duplicate side effects.
Overview
Hardens v1 MCP paths so transport churn at high rollout concurrency no longer fails harnesses or double-advances user simulators.
Harness programs (
null/bashprogram.py) drop long-lived MCP sessions:connect_mcponly enumerates tools and returns server specs; eachlist_tools/call_toolopens a fresh streamable-HTTP session withhttpxtimeouts, tenacity backoff (6 attempts), and teardown errors suppressed after a successful body. Tool calls are documented as at-least-once on replay.Host user path replaces
connect_userwithuser_respond+user_session(per-call session, sharedretrying()).serve_useryieldspartial(user_respond, url). The MCPrespondtool gains aseqargument; the user server caches(seq, message) → payloadunder anasyncio.Lockso retries replay instead of advancing twice.Interception passes
len(prompt)asseqfor opening and follow-up user turns. Opening injection no longer keys ontrace.num_turns == 0(unstable on SDK retries); it triggers when the request has no assistant messages. Prompt assembly after a model turn appends the assistant message before calling the user sim, then only the user messages (avoiding duplicate assistant inprompt).Reviewed by Cursor Bugbot for commit 7d9adb2. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Fix MCP robustness under high concurrency with per-call sessions, retries, and timeouts
with_retry(up to 6 attempts, exponential backoff) and explicithttpx.Timeout(60s connect / 300s read) to all MCP tool enumeration and invocation paths.seq) to the user simulator'srespondMCP tool, allowing the server to deduplicate retried requests via anasyncio.Lockand a(seq, message)replay cache.serve_userin launch.py to yield apartial(user_respond, url)instead of maintaining a persistent connection, aligning with the new per-call model.connect_mcpno longer accepts anAsyncExitStackand now returns(tool_schemas, dispatch, servers); callers must passserversanddispatchtocall_mcp.Changes since #2062 opened
verifiers.v1.mcp.user.User._registermethod to ensure atomic state synchronization and response generation under a single lock [7d9adb2]tenacitylibrary inwith_retryutility acrossverifiers.v1.harnesses.bash.programandverifiers.v1.harnesses.null.programmodules [7d9adb2]MCP_TIMEOUTconfiguration to usehttpx.Timeoutwith explicit connect timeout inverifiers.v1.harnesses.bash.programandverifiers.v1.harnesses.null.programmodules [7d9adb2]Macroscope summarized 9674b53.