Skip to content

Commit c377f04

Browse files
halleriteclaude
andcommitted
fix(v1): bound a user-sim respond attempt so its retry can actually fire
The intermittent test_user CI stall (and a 1-in-20 local repro): the drive loop's `session.user(...)` rode the MCP transport's 600s read timeout, so one wedged connection — accepted, then silent — silently absorbed the whole harness window (stop_condition=harness_timeout, errors=[], turn never arrives). The per-attempt retry on a fresh session, and the server's (seq, message) replay cache built exactly for retried turns, never got the chance to recover it. Each respond attempt (connect + initialize + call) is now bounded by UserConfig.timeout (default 60s, raisable for model-backed simulators whose turns legitimately run long); a timed-out attempt retries on a fresh session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 61d0dfe commit c377f04

3 files changed

Lines changed: 46 additions & 6 deletions

File tree

tests/v1/test_retries.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,35 @@ async def attempt() -> Episode:
7272
episode = await run_episode_with_retry(masked_then_good, retry)
7373
assert calls == 2
7474
assert episode.ok
75+
76+
77+
async def test_user_respond_attempt_is_time_bounded(monkeypatch):
78+
"""A wedged user-server connection (accepted, then silent) fails its attempt at
79+
the configured bound instead of sitting in the transport's read timeout, so the
80+
retry — on a fresh session — recovers the turn within the harness window."""
81+
import asyncio
82+
import contextlib
83+
import json
84+
from types import SimpleNamespace
85+
86+
from verifiers.v1.mcp import launch
87+
88+
attempts = 0
89+
90+
@contextlib.asynccontextmanager
91+
async def wedged_then_good(url):
92+
nonlocal attempts
93+
attempts += 1
94+
95+
async def call_tool(name, args):
96+
if attempts == 1:
97+
await asyncio.Event().wait() # the wedge: connected, never answers
98+
payload = json.dumps({"messages": [{"role": "user", "content": "next"}]})
99+
return SimpleNamespace(content=[SimpleNamespace(type="text", text=payload)])
100+
101+
yield SimpleNamespace(call_tool=call_tool)
102+
103+
monkeypatch.setattr(launch, "user_session", wedged_then_good)
104+
messages = await launch.user_respond("http://sim", "hi", 1, timeout=0.05)
105+
assert attempts == 2
106+
assert [m.content for m in messages] == ["next"]

verifiers/v1/mcp/launch.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -465,10 +465,12 @@ async def user_session(url: str) -> AsyncIterator[ClientSession]:
465465
await stack.aclose()
466466

467467

468-
async def user_respond(url: str, message: str, seq: int) -> Messages:
468+
async def user_respond(url: str, message: str, seq: int, *, timeout: float) -> Messages:
469469
"""One `respond` turn against the user server, on a fresh session per attempt. A retried turn
470470
whose response was lost would advance the simulator twice, so the server dedups on
471471
(`seq`, `message`) and replays the recorded turn — making the retry effectively exactly-once.
472+
`timeout` bounds each attempt (connect + initialize + call): a wedged connection must fail
473+
fast enough for a retry to land inside the harness window instead of silently absorbing it.
472474
The payload is parsed outside the retry so a parse failure fails once."""
473475
from verifiers.v1.dialects import parse_message
474476
from verifiers.v1.retries import retrying
@@ -481,10 +483,11 @@ async def user_respond(url: str, message: str, seq: int) -> Messages:
481483
label=f"user respond ({url})",
482484
):
483485
with attempt:
484-
async with user_session(url) as session:
485-
result = await session.call_tool(
486-
"respond", {"message": message, "seq": seq}
487-
)
486+
async with asyncio.timeout(timeout):
487+
async with user_session(url) as session:
488+
result = await session.call_tool(
489+
"respond", {"message": message, "seq": seq}
490+
)
488491
assert result is not None
489492
texts = [b.text for b in result.content if getattr(b, "type", None) == "text"]
490493
data = json.loads("\n".join(texts))
@@ -520,4 +523,4 @@ async def serve_user(
520523
state_secret=state_secret,
521524
state_base=state_base,
522525
) as url:
523-
yield partial(user_respond, url)
526+
yield partial(user_respond, url, timeout=user.config.timeout)

verifiers/v1/mcp/user.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ class UserConfig(BaseConfig):
3030

3131
colocated: bool = False
3232
runtime: RuntimeConfig = SubprocessConfig()
33+
timeout: float = 60.0
34+
"""Bound on one `respond()` attempt (connect + call). A wedged connection fails the attempt
35+
so the host's retry can recover it within the harness window; the turn's worst case is
36+
this times the retry budget. Raise it for simulators whose turns legitimately run long
37+
(e.g. model-backed users)."""
3338

3439

3540
ConfigT = TypeVar("ConfigT", bound=UserConfig)

0 commit comments

Comments
 (0)