feat: self-healing prime tunnels and typed tunnel failures - #2158
feat: self-healing prime tunnels and typed tunnel failures#2158mikasenghaas wants to merge 5 commits into
Conversation
The interception tunnel was a start-time snapshot: a dead frpc process or a lapsed server-side registration poisoned every subsequent rollout on the server, each failing at its first model call as an untyped HarnessError carrying the gateway's 404 HTML page. Tunnel.expose() now yields an Endpoint handle instead of a URL string. PrimeTunnel's endpoint re-checks liveness per acquire (frpc process every call, registration status behind a 60s TTL) and re-mints a dead tunnel at a new URL before handing out the slot. A rollout whose harness fails while the tunnel is down gets a typed TunnelError (the HarnessError chained as cause) via a fresh health probe, so infra drops are retryable and distinguishable from agent failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR introduces significant new runtime behavior (self-healing tunnels with automatic re-minting and health probing) and has two unresolved review comments identifying a race condition in the You can customize Macroscope's approvability policy. Learn more. |
A bare liveness probe races concurrent healing: the tunnel dies mid-rollout, another rollout's acquire re-mints it, and the failed rollout's probe then sees the healthy replacement — misattributing the failure as HarnessError. A re-mint always changes the URL, so healthy(url) anchors the answer to the consumer's own tunnel: a stale slot URL proves that tunnel died even when a replacement is already up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ab92d9d to
925b435
Compare
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
client.start() could succeed and then a cancellation land on the limiter's __aexit__ — before self._client was assigned. The Exception handler doesn't catch it and close() only stops what's recorded, so the started frpc/registration was orphaned. Assign the client with no await in between; a cancellation inside start() itself is cleaned up by the SDK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| async def close(self) -> None: | ||
| """Stop the *current* frpc client (it changes across heals). Runs the synchronous | ||
| stop to completion even under cancellation (`run_shielded` re-raises the | ||
| cancellation after); tunnel-stop failures are best-effort.""" | ||
| client, self._client = self._client, None | ||
| if client is not None: | ||
| with contextlib.suppress(Exception): | ||
| await run_shielded(asyncio.to_thread(client.sync_stop)) |
There was a problem hiding this comment.
🟡 Medium tunnel/prime.py:136
close() does not take self._lock, so a concurrent url() call can mint a replacement tunnel after teardown has already stopped the old one. If url() is awaiting _alive() when close() runs, close() sets _client = None and stops the old client; then url() sees _client is None and calls _mint(), creating a new frpc process and public URL. Because close() only stopped the old client, this replacement tunnel is leaked beyond the context lifetime. Acquire self._lock in close() so it cannot run concurrently with url() or healthy().
| async def close(self) -> None: | |
| """Stop the *current* frpc client (it changes across heals). Runs the synchronous | |
| stop to completion even under cancellation (`run_shielded` re-raises the | |
| cancellation after); tunnel-stop failures are best-effort.""" | |
| client, self._client = self._client, None | |
| if client is not None: | |
| with contextlib.suppress(Exception): | |
| await run_shielded(asyncio.to_thread(client.sync_stop)) | |
| async def close(self) -> None: | |
| """Stop the *current* frpc client (it changes across heals). Runs the synchronous | |
| stop to completion even under cancellation (`run_shielded` re-raises the | |
| cancellation after); tunnel-stop failures are best-effort.""" | |
| async with self._lock: | |
| client, self._client = self._client, None | |
| if client is not None: | |
| with contextlib.suppress(Exception): | |
| await run_shielded(asyncio.to_thread(client.sync_stop)) |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/interception/tunnel/prime.py around lines 136-143:
`close()` does not take `self._lock`, so a concurrent `url()` call can mint a replacement tunnel *after* teardown has already stopped the old one. If `url()` is awaiting `_alive()` when `close()` runs, `close()` sets `_client = None` and stops the old client; then `url()` sees `_client is None` and calls `_mint()`, creating a new frpc process and public URL. Because `close()` only stopped the *old* client, this replacement tunnel is leaked beyond the context lifetime. Acquire `self._lock` in `close()` so it cannot run concurrently with `url()` or `healthy()`.
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 6fefddb. Configure here.
| await endpoint.url() # mint eagerly so a setup failure raises here, typed | ||
| yield endpoint | ||
| finally: | ||
| await endpoint.close() |
There was a problem hiding this comment.
Endpoint close races without lock
Medium Severity
expose's finally calls PrimeEndpoint.close() without taking _lock, while url and healthy mutate _client under that lock across awaits. Teardown can interleave with an in-flight probe or mint, orphaning a started tunnel or stopping a client another coroutine still uses.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6fefddb. Configure here.
The heal-vs-probe race rationale lives once, on the Endpoint.healthy contract; call sites keep one-liners. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>


Summary
Tunnel.expose()now yields anEndpointhandle (url()/healthy(url)) instead of a URL string — a tunnel's URL is not a constant, so the interception server asks per acquire instead of caching a start-time snapshot.PrimeTunnel's endpoint self-heals:url()verifies the frpc process on every call and the server-side registration behind a 60s TTL, and re-mints a dead tunnel (through the existing retry + rate-limiter path) at a new public URL before the next rollout gets its slot. The probe readsget_tunnel().statusrather thancheck_registered()— deletion is soft server-side (status="terminated", GET keeps returning 200), so existence alone reads a terminated tunnel as registered.TunnelError— theHarnessError(with the gateway's 404 body) chained as cause. Infra drops become retryable and distinguishable from agent failures instead of counting asHarnessError: bash exited 1: <404 HTML>.healthy(url)): a re-mint always changes the URL, so a stale slot URL proves that rollout's tunnel died even when a concurrent acquire has already healed past it — a bare liveness probe would race the heal and misattribute the failure.CustomTunnelyields aFixedEndpoint(always healthy — the operator owns that endpoint); no behavior change.Breaking
Tunnel.expose()yields anEndpointinstead ofstr— customTunnelimplementations must yield an object withasync url() -> str/async healthy(url: str) -> bool(or aFixedEndpoint).InterceptionServer.base_urlis removed; useawait server.url().Verification
Live evals (
echo-agentic-v1, bash harness, prime runtime,-c 1so rollouts run back-to-back on one shared elastic-pool tunnel), interrupting at a rollout's first intercepted model turn:HarnessErrorwith the 404 HTML body. After: rollout 2 fails typed (stop=TunnelError, trace recordstype: TunnelErrorwith the 404-HTMLHarnessErroras chained cause), the next acquire re-mints a new tunnel on the same local port, and the remaining rollouts all scorereward=1.000. Re-run after the anchored-probe fix with the same result.registration terminated server-side→ re-mint →reward=1.000.prime_tunnelSDK: the stale slot URL reads dead while the replacement reads healthy.uv run pytest tests/v1(64 passed, 63 key-gated skips),ruff check, andpre-commitgreen on touched files.🤖 Generated with Claude Code
Note
Add self-healing prime tunnels and typed
TunnelErrorfailures to rolloutEndpointprotocol in base.py with asyncurl()andhealthy()methods, replacing raw URL strings yielded byTunnel.expose().PrimeEndpointin prime.py that auto-heals dead tunnels by reminting a new frpc connection when the existing one is unhealthy, with rate limiting and registration health checks.InterceptionServernow stores anEndpointabstraction instead of a staticbase_url, and resolves the public URL dynamically at acquisition time.RolloutRunin rollout.py detects tunnel outages during harness failures via the new_tunnel_failure()method and surfaces them asTunnelErrorinstead of genericHarnessError.RolloutSessiongains aserverfield to hold a back-reference to itsInterceptionServer, enabling post-execution health checks.Macroscope summarized 34b1b64.
Note
Medium Risk
Changes remote-eval networking (tunnel lifecycle, error classification on harness failures) and breaks custom
Tunnel/ callers that cachedbase_url, though healing and attribution are scoped to prime tunnels and explicit health probes.Overview
Self-healing prime tunnels and typed infra failures replace cached tunnel URLs with per-acquire
Endpointhandles (url()/healthy(url)).PrimeEndpointre-mints when frpc dies or server-side registration is terminated (status probe, not bare existence), so later rollouts on a shared elastic pool get a fresh public URL without manual restart.Interception and rollouts resolve
await server.url()on each slot acquire instead of cachingbase_urlat server start. When a harness exits withHarnessErrorand no stashed model error, the rollout probeshealthy()against that rollout's slot URL (stale URL ⇒ dead tunnel even if another acquire already healed), and recordsTunnelErrorwith the harness error chained—so 404 HTML from a gone tunnel is retryable infra, not agent failure.Breaking:
Tunnel.expose()yieldsEndpoint(custom tunnels needurl()/healthy()orFixedEndpoint);InterceptionServer.base_urlis removed in favor ofawait server.url().Reviewed by Cursor Bugbot for commit 34b1b64. Bugbot is set up for automated code reviews on this repo. Configure here.