fix: keep blocking docker and subprocess calls off hypervisor-agent and agent-spawner event loops - #312
Conversation
Both services ran synchronous docker-py and subprocess work directly inside coroutines. Neither is deployed today, so neither has misbehaved, but agent-spawner carries the same pair of ingredients that took down the healer in #309: a blocked loop and a pub/sub connection. hypervisor-agent auto_scale_containers() and heal_back() called containers.list(), containers.get(), c.stop(timeout=15), c.start() and c.remove() on the loop. Extract the blocking bodies into _auto_scale_sync/_heal_back_sync and drive them with asyncio.to_thread, matching what _refresh_container_cache already does for _collect_container_metrics. DRY-RUN semantics, the ENFORCE_SCALING guards and the returned action strings are unchanged. It holds no pub/sub connection, so the symptom here was latency only. agent-spawner spawn_agent() ran subprocess.run(timeout=30) -- up to 30s of frozen loop -- and shutdown_agent()/spawn_listener() called containers.get() and container.stop(timeout=10). All now go through asyncio.to_thread. This one matters more than its dormancy suggests. spawn_listener holds a pubsub whose read carries a 5s socket_timeout, and check_idle_agents calls shutdown_agent concurrently. A 10s blocking stop() while that read is pending expires the timeout, spawn_listener raises, and since it re-raises into asyncio.gather the whole service dies rather than retrying. Exactly the healer's failure, minus the retry loop that turned the healer's into a leak. Verified by extracting the real functions with ast.get_source_segment and driving them against a fake slow docker/subprocess, with a 50ms ticker task measuring loop stall. Same harness against the pre-fix code as a control: control fixed auto_scale_containers 1982.1ms 17.2ms heal_back 1292.6ms 15.8ms spawn_agent 1999.4ms 16.9ms shutdown_agent 1692.7ms 28.8ms Behaviour assertions pass identically on both: DRY-RUN tags preserved, zombie and non-critical rules intact, critical containers untouched, stopped_by_agent bookkeeping correct, spawn lock released, activity tracking updated. Not verified at runtime: neither service has a container in the current stack. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 38 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Fourth of the sync-I/O-on-an-async-loop fixes (#309, #310, #311). Neither service is deployed, so neither has misbehaved — but
agent-spawnercarries the exact pair of ingredients that took the healer down in #309.hypervisor-agentauto_scale_containers()andheal_back()calledcontainers.list(),containers.get(),c.stop(timeout=15),c.start()andc.remove()— all synchronous docker-py — directly on the event loop.Fixed by extracting the blocking bodies into
_auto_scale_sync/_heal_back_syncand driving them withasyncio.to_thread, matching what_refresh_container_cachealready does for_collect_container_metrics. DRY-RUN semantics, theENFORCE_SCALINGguards, and the returned action strings are unchanged.It holds no pub/sub connection, so the symptom was latency only.
agent-spawner— the interesting onespawn_agent()ransubprocess.run(timeout=30)— up to 30 seconds of frozen loop — andshutdown_agent()/spawn_listener()calledcontainers.get()andcontainer.stop(timeout=10).This is dormant but not benign.
spawn_listenerholds a pubsub whose read carries a 5ssocket_timeout, andcheck_idle_agentscallsshutdown_agentconcurrently. A 10s blockingstop()while that read is pending expires the timeout,spawn_listenerraises, and because it re-raises intoasyncio.gatherthe whole service dies rather than retrying.That's the healer's failure exactly — minus the retry loop that turned the healer's into a connection leak. Here it would just crash the process.
Verification
Neither service has a container, so I extracted the real functions from source with
ast.get_source_segment, ran them against a fake slow docker/subprocess, and measured event-loop stall with a 50ms ticker task. The same harness was run against the pre-fix code frommainas a control:auto_scale_containersheal_backspawn_agentshutdown_agent(The fake docker sleeps ~2s; the real
subprocess.runceiling is 30s.)Behaviour assertions pass identically on control and fixed — DRY-RUN tags preserved, zombie and non-critical rules intact, critical containers untouched,
stopped_by_agentbookkeeping correct, spawn lock released, activity tracking updated. So the refactor changed timing, not semantics.Not verified
Neither service has a container in the current stack. This is tested at the function level against fakes, not end-to-end.
Found by fixing the tool, not by reading
The AST sweep used in #311 only matched
httpx/requests/docker.from_env/.containers.get|list. It missedsubprocess.runentirely, and missed everyc.stop()/c.start()/c.remove()because those are methods on container objects, not on.containers. Teaching it to track variables bound fromcontainers.get()/.list()and to flagsubprocess.*took it from 4 hits to 9 on these two files alone.Re-sweeping with the better rules turns up two more, both in running containers, deliberately left out of this PR:
agents/hyperhealth/main.py:111—subprocess.run()insideasync def(hyperhealth-apiis running)agents/broski-bot/cogs/status_cog.py:39—subprocess.run()insideasync def(broski-botis running)And
agents/test-agent/main.py:193callstime.sleep()in a coroutine (no container).The sweep's blind spot still stands: it only sees calls written lexically inside a coroutine, so it would not catch the
broski-pets-bridgeshape from #310, where the sync work sits in a helperdef.🤖 Generated with Claude Code