fix(v1): detect env-server worker death instead of hanging#2131
fix(v1): detect env-server worker death instead of hanging#2131eligotts wants to merge 3 commits into
Conversation
An env server that died was invisible to its client. Three gaps, all of which turned a crash into an unbounded wait: - The broker reported its address as soon as the frontend socket was bound, before any worker had loaded the env. A spawner was told "ready" about a pool whose env was still loading — or already dead — so an env that fails to import surfaced much later as a rollout that never returns. - A dead worker was never reaped. Its `active` count never drained, so least-busy dispatch elected the corpse for every subsequent request, and its in-flight requests went unanswered forever (`EnvClient` awaits rollout replies untimed, by design). - `run_eval_server` read the address queue with a bare 600s timeout, so a server that died on startup was reported as a timeout rather than a crash. The broker now reports its address only once a worker answers `health`, polls worker exit codes on its serve loop, answers a dead worker's in-flight requests with an error the client raises on, and replaces the worker up to MAX_WORKER_RESTARTS before failing requests outright. `wait_for_address` is the shared queue reader that watches the process's exit code alongside the queue and quotes the tail of its log file — used here by `run_eval_server`, and by prime-rl's orchestrator for the servers it spawns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… reaping Two problems in the death handling itself, from review: - `_replace_worker` called `_spawn_worker` unguarded. A worker death caused by memory pressure is exactly when spawning its replacement can fail, and that exception escaped the serve loop — killing the broker and stranding every other client's in-flight requests. Mid-serve spawns now go through `_try_spawn_worker`, which logs and leaves the pool one worker short. Scale-up had the same latent bug and takes the same path. Startup spawns still propagate: a pool with no workers has nothing to serve. - Reaping closed a dead worker's socket after relaying only one reply, so a worker that answered several requests and then exited had the rest discarded and reported to clients as `worker died`. The reaper now drains the socket before retiring it. The serve loop still takes one reply per pass, so a chatty worker can't starve the frontend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ApprovabilityVerdict: Needs human review This PR introduces substantial new runtime behavior for worker death detection and replacement (~250 lines of new lifecycle management logic). Two unresolved review comments identify potential bugs: a high-severity issue where scale-up bypasses the restart budget, and a medium-severity readiness hang in the single-worker path. You can customize Macroscope's approvability policy. Learn more. |
A replacement that failed to spawn left the pool with no workers, and an empty pool has no dead worker left to drive another attempt — so `_reap_dead_workers` never tried again and every later request was refused with `no live workers`, with the restart budget still unspent. One transient failure to fork permanently disabled the pool. The reaper now retries while budget remains, bounded by its own interval and by MAX_WORKER_RESTARTS. Once the budget is spent the pool stays empty and fails requests fast, which is the honest end state: a host that cannot fork is not something the broker should paper over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 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 f0c87f2. Configure here.
| rollout_slots = max(1, request.n) | ||
| worker = min(self.workers, key=lambda w: w["active"]) | ||
| worker["active"] += rollout_slots | ||
| pending[request_id] = { |
There was a problem hiding this comment.
Scale-up bypasses restart budget
High Severity
_maybe_scale_up spawns workers without consulting MAX_WORKER_RESTARTS or _restarts. After the replacement budget is spent, sustained load can keep adding workers through scale-up, recreating the unbounded respawn loop that budget was meant to stop — especially with default elastic max_workers=None.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit f0c87f2. Configure here.
| child_conn.close() # the child holds its end; we keep parent_conn so our exit closes it | ||
| try: | ||
| address = await asyncio.to_thread(address_queue.get, timeout=600) | ||
| address = await wait_for_address(proc, address_queue, log_file=log_file) |
There was a problem hiding this comment.
Single-worker readiness hang returns
Medium Severity
Removing wait_for_server_startup leaves the max_workers=1 path without a readiness check. That path still publishes the address from run_server before run()/serving(), and wait_for_address returns as soon as the queue yields, so a death right after publish succeeds the wait and the client hangs on untimed requests.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit f0c87f2. Configure here.
An env server that died on startup left the orchestrator waiting: `_spawn` read the address queue with a bare 600s timeout, so a server that crashed (bad import, missing credentials, OOM) was reported as "did not report its address within 600s" — a timeout message for a crash whose real traceback sat unread in logs/envs/. Nothing watched the server after startup either, so a mid-run death silently stalled the pipeline: its in-flight requests never answer and the dispatcher awaits rollout replies untimed. - `_wait_for_address` polls the child's exit code alongside the queue and quotes the tail of its log file, where the child's redirected output landed. The 600s timeout now bounds only a genuinely slow start. - `Envs.check_alive`, called on each main-loop pass, raises if a spawned server has exited, so the run fails loudly instead of quietly draining. - Env servers get a death pipe, so one can no longer outlive the orchestrator (SIGKILL included) with its worker pool, sandboxes, and tunnels. - `Envs.start` registers its shutdown before the first spawn. A failure part-way through startup previously leaked the servers already up, and since they are non-daemonic, multiprocessing's implicit join at exit wedged the process. - Drop the unused `Env.shutdown` (`Envs.shutdown` is the only caller path, and it terminates with a join rather than a bare terminate). Deliberately self-contained: no deps/verifiers bump, so this needs no config migration and no relock. PrimeIntellect-ai/verifiers#2131 fixes the other half (a pooled server reporting its address before any worker has loaded the env, and a dead worker never being reaped), and prime-rl picks that up with the next routine verifiers bump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An env server that died on startup left the orchestrator waiting: `_spawn` read the address queue with a bare 600s timeout, so a server that crashed (bad import, missing credentials, OOM) was reported as "did not report its address within 600s" — a timeout message for a crash whose real traceback sat unread in logs/envs/. Nothing watched the server after startup either, so a mid-run death silently stalled the pipeline: its in-flight requests never answer and the dispatcher awaits rollout replies untimed. - `_spawn` reads the queue through verifiers' `wait_for_address`, which polls the child's exit code alongside the queue and quotes the tail of its log file. The 600s timeout now bounds only a genuinely slow start. - `Envs.check_alive`, called on each main-loop pass, raises if a spawned server has exited, so the run fails loudly instead of quietly draining. - Env servers get a death pipe, so one can no longer outlive the orchestrator (SIGKILL included) with its worker pool, sandboxes, and tunnels. - `Envs.start` registers its shutdown before the first spawn. A failure part-way through startup previously leaked the servers already up, and since they are non-daemonic, multiprocessing's implicit join at exit wedged the process. - Drop the unused `Env.shutdown` (`Envs.shutdown` is the only caller path, and it terminates with a join rather than a bare terminate). Bumps deps/verifiers for `wait_for_address` and the broker-side worker-death handling it pairs with (PrimeIntellect-ai/verifiers#2131), and relocks for the env package that bump adds. CI is red until this repo's configs are migrated for verifiers#2106, which moved `runtime` off the harness onto the agent — a separate chore this PR deliberately does not carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>


Problem
An env server that dies is invisible to its client — the failure shows up as a wait, not an error. Reported from prime-rl training: "environment processes dying on startup but the orchestrator continues to wait for them."
Three separate gaps, each of which converts a crash into an unbounded wait:
1. Readiness meant "socket bound", not "env loaded".
serve_envput the pool's address onaddress_queuebeforepool.run()spawned any worker:So a spawner was told "ready" about a pool whose env was still loading, or already dead. An env that fails to import (bad dependency, missing credentials) surfaced much later as a rollout that never returns. This is the default path for prime-rl, whose pool config defaults to
elastic.2. A dead worker was never reaped. Its
activecount never drained, so least-busy dispatch (min(self.workers, key=...active)) elected the corpse for every subsequent request — a black hole, not a single dropped request. Itspendingentries were never resolved, andEnvClientawaits rollout replies untimed by design, so the client waited forever. The module docstring acknowledged this as a TODO on the assumption that "worker death is rare".3.
run_eval_serverread the address queue blind.address_queue.get(timeout=600)with no liveness check: a server that died on startup was reported as a 600s timeout rather than a crash, with the real traceback nowhere in the error.Changes
EnvServerPooltakes theaddress_queueand reports its address only after a worker answershealth— a worker binds its socket before loading the env, so its first reply is what proves the env loaded. Untimed on purpose: a slow load (a legacy dataset pull) is the spawner's deadline to enforce; the broker only insists the process is still alive.exitcodeis a waitpid per worker and the loop turns once per request), retires the dead worker from dispatch, and answers its in-flight requests withBaseResponse(success=False, ...)so the client raises instead of hanging. Requests arriving with no live workers are failed immediately rather than queued into the void.MAX_WORKER_RESTARTS = 3: enough to ride out a one-off crash (an OOM, a segfaulting sandbox), few enough that an env dying on every load fails loudly instead of respawning forever.wait_for_address(process, address_queue, ...)is the shared queue reader: it polls the process's exit code alongside the queue and quotes the tail of the server's log file in the error. Used here byrun_eval_server; prime-rl's orchestrator uses it for the servers it spawns (docs(algo): describe shipped DPPO masking in place of stale importance-ratio clamp prime-rl#3135).run()'s body is split into_on_request/_on_reply/_reap_dead_workersso the loop stays readable;in_flightandpendingmoved onto the instance to avoid threading them through. The wire protocol is unchanged, soEnvClientneeds no changes.Verification
Existing suites pass (
tests/test_env_server.py,tests/v1/test_configs.py,tests/test_imports.py). Behaviour was checked with a throwaway script (not committed, per AGENTS.md) covering four cases against a real broker and real worker processes:run())wait_for_address)The last case suspends the worker so the request is genuinely in flight, then
SIGKILLs it — the black-hole scenario from gap 2.Review follow-ups (25bd48d)
Two bugs in the death handling itself, both found by review and both real:
_replace_workercalled_spawn_workerunguarded. A worker death caused by memory pressure is exactly when spawning its replacement can fail, and that exception escaped the serve loop — killing the broker and stranding every other client's in-flight requests. Mid-serve spawns now go through_try_spawn_worker, which logs and leaves the pool one worker short._maybe_scale_uphad the same latent bug (pre-existing) and takes the same path. Startup spawns still propagate: a pool with no workers has nothing to serve.worker died— silently relabelling successful rollouts as failures._reap_dead_workersnow drains the socket before retiring it. The serve loop still takes one reply per pass so a chatty worker can't starve the frontend.Two things to look at in review:
run_eval_serverno longer callsclient.wait_for_server_startup()after reading the address. It is now redundant — the address is the readiness signal — and it never meant anything for a pooled server anyway, since the broker answershealthinline whatever its workers are doing. But the--servereval path needs live models, so I could not run it locally; only the pool-level behaviour above was exercised.MAX_WORKER_RESTARTSis a per-pool budget, not per-worker, so a large pool losing one worker each to OOM degrades after three. That seemed the right default over per-worker budgets (which never stop respawning a systematically broken env), but it's a judgement call.🤖 Generated with Claude Code
Note
Medium Risk
Changes core broker dispatch, readiness, and failure paths for training/eval orchestration; wire protocol unchanged but rollout failures may surface as explicit errors where clients previously hung.
Overview
Fixes env-server crashes being reported as indefinite waits by tightening readiness, startup waiting, and worker lifecycle in the v1 serve pool.
Readiness no longer means the broker socket is bound.
EnvServerPoolputs its address onaddress_queueonly after a worker responds to a startuphealthprobe (env actually loaded).serve_envpasses the queue into the pool instead of reporting immediately after bind.Startup uses new
wait_for_address, which polls the spawned process exit code while waiting on the queue and includes a log tail on failure.run_eval_serveradopts this and drops the redundantEnvClient.wait_for_server_startup()call.Runtime adds periodic reaping of dead workers: drain queued replies, fail orphaned in-flight requests with
BaseResponse(success=False), retire the worker, and spawn replacements up toMAX_WORKER_RESTARTS. Mid-serve spawns go through_try_spawn_workerso spawn failures do not kill the broker. Requests with no live workers fail immediately. The broker loop is refactored into_on_request/_on_reply/_reap_dead_workerswith instance-levelpendingandin_flight.Reviewed by Cursor Bugbot for commit f0c87f2. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Detect env-server worker death during startup instead of hanging indefinitely
address_queue.get+wait_for_server_startupcalls inrunner.pywithwait_for_address, which polls the child process exit code and raises immediately if it dies.wait_for_addressandENV_SERVER_SPAWN_TIMEOUTtoserve/pool.py; both are now publicly exported fromverifiers.v1.serve.MAX_WORKER_RESTARTS.Macroscope summarized f0c87f2.