hataa 2f8d1cfc21
fix(subagents): harden background-task registry and capacity snapshot edge cases (#5086)
* fix(subagents): harden background-task registry and capacity snapshot edge cases

- execute_async drops the just-registered background entry when submitting
  to the isolated loop fails. The caller sees the exception and never
  polls, and cleanup_background_task refuses non-terminal entries, so the
  entry would otherwise stay as a PENDING zombie forever.
- SubagentExecutionCapacity.snapshot derives queued from len(_waiters)
  instead of iterating it. snapshot is read from non-loop threads (e.g.
  configure_subagent_execution_capacity) while the loop thread mutates the
  deque, so iteration can raise 'deque mutated during iteration'. The raw
  length may count a waiter that just timed out but has not removed itself
  yet, which only makes the busy-check more conservative.

* fix(subagents): close failure-path gaps around background submit

Address both review findings on the background submission lifecycle:

- execute_async() copies the isolated-loop context before registering
  the _background_tasks entry, so a context-copy failure (callback-
  manager copy or loop-bound handler filtering) can no longer strand a
  permanent PENDING entry the caller will never poll.
- _submit_to_isolated_loop_in_context() resolves the loop before
  calling the coroutine factory. As direct run_coroutine_threadsafe
  arguments the coroutine was created first, so a loop-startup failure
  stranded a never-awaited coroutine (RuntimeWarning + retained
  captures until collection). Both call sites share the fix.

New tests verified red on the previous implementation, green after:
- context-copy failure leaves no registry residue
- the real submit helper (only the loop getter patched) never invokes
  the coroutine factory when loop startup fails

* fix(subagents): close the coroutine when scheduling rejects it

run_coroutine_threadsafe can itself raise once the coroutine exists (e.g.
the loop closes between the lookup and the internal call_soon_threadsafe).
Wrap the call, close the rejected coroutine, and re-raise; a focused test
patches only run_coroutine_threadsafe and asserts the created coroutine
reaches CORO_CLOSED.
2026-08-30 10:43:58 +08:00

167 lines
6.5 KiB
Python

"""Shared process-local admission control for native subagent execution."""
from __future__ import annotations
import asyncio
import threading
from collections import deque
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
class SubagentCapacityError(RuntimeError):
"""Base class for explicit admission failures."""
class SubagentCapacityRejected(SubagentCapacityError):
"""The process queue is full or configured to reject when saturated."""
class SubagentCapacityTimeout(SubagentCapacityError):
"""A queued execution did not receive a slot before its deadline."""
@dataclass(frozen=True)
class SubagentCapacitySnapshot:
max_running: int
running: int
max_queued: int
queued: int
admission_policy: str
class SubagentExecutionCapacity:
"""FIFO async capacity controller; queued work never owns a thread."""
def __init__(self, config: SubagentRuntimeConfig) -> None:
self._config = config
self._lock = asyncio.Lock()
self._running = 0
self._waiters: deque[asyncio.Future[None]] = deque()
def snapshot(self) -> SubagentCapacitySnapshot:
# snapshot() is read from non-loop threads (notably
# configure_subagent_execution_capacity), while the loop thread mutates
# the waiters deque under its own asyncio lock — iterating cross-thread
# can raise "deque mutated during iteration". ``len()`` reads the
# deque's size atomically instead. The raw length may count a waiter
# that just timed out but has not removed itself yet; that only makes
# the "capacity busy" answer more conservative, never less.
return SubagentCapacitySnapshot(
max_running=self._config.max_running,
running=self._running,
max_queued=self._config.max_queued,
queued=len(self._waiters),
admission_policy=self._config.admission_policy,
)
async def _acquire(self) -> None:
waiter: asyncio.Future[None] | None = None
async with self._lock:
if self._running < self._config.max_running:
self._running += 1
return
queued = sum(not candidate.done() for candidate in self._waiters)
if self._config.admission_policy == "reject" or queued >= self._config.max_queued:
raise SubagentCapacityRejected(f"Subagent execution capacity is full ({self._config.max_running} running, {queued} queued)")
waiter = asyncio.get_running_loop().create_future()
self._waiters.append(waiter)
try:
await asyncio.wait_for(
waiter,
timeout=self._config.queue_timeout_seconds,
)
except (TimeoutError, asyncio.CancelledError) as exc:
async with self._lock:
try:
self._waiters.remove(waiter)
except ValueError:
# A release transferred the slot just as the timeout fired.
# If the future completed, this caller owns that transfer and
# must release it before reporting the timeout.
if waiter.done() and not waiter.cancelled():
self._release_locked()
if isinstance(exc, asyncio.CancelledError):
raise
raise SubagentCapacityTimeout(f"Timed out after {self._config.queue_timeout_seconds}s waiting for a subagent execution slot") from exc
def _release_locked(self) -> None:
while self._waiters:
waiter = self._waiters.popleft()
if waiter.done():
continue
# Transfer the existing slot; _running intentionally stays flat.
waiter.set_result(None)
return
if self._running <= 0:
raise RuntimeError("Subagent execution capacity released without an owner")
self._running -= 1
async def _release(self) -> None:
async with self._lock:
self._release_locked()
@asynccontextmanager
async def slot(self) -> AsyncIterator[None]:
await self._acquire()
try:
yield
finally:
await self._release()
_config = SubagentRuntimeConfig()
_controller: SubagentExecutionCapacity | None = None
_controller_loop: asyncio.AbstractEventLoop | None = None
_state_lock = threading.Lock()
def configure_subagent_execution_capacity(config: SubagentRuntimeConfig) -> None:
"""Install the startup snapshot used by the lazily-created loop controller."""
global _config, _controller, _controller_loop
candidate = config.model_copy(deep=True)
with _state_lock:
# Gateway startup and embedded clients may initialize the same process.
# Treat installing the same frozen startup configuration as a no-op so
# those entry points cannot reset a live queue.
if _config == candidate:
return
if _controller is not None:
snapshot = _controller.snapshot()
if snapshot.running or snapshot.queued:
raise RuntimeError("Cannot reconfigure subagent capacity while executions are active")
_config = candidate
_controller = None
_controller_loop = None
def get_subagent_execution_capacity() -> SubagentExecutionCapacity:
"""Return the controller bound to the current execution loop."""
global _controller, _controller_loop
loop = asyncio.get_running_loop()
with _state_lock:
if _controller is None:
_controller = SubagentExecutionCapacity(_config)
_controller_loop = loop
elif _controller_loop is not loop:
snapshot = _controller.snapshot()
if snapshot.running or snapshot.queued:
raise RuntimeError("Native subagent capacity cannot move event loops while executions are active")
# Direct async consumers (notably embedded callers and tests) may
# legitimately use a new event loop after the previous idle loop
# has closed. Rebind only while completely idle; production sync
# and background paths still share the persistent isolated loop.
_controller = SubagentExecutionCapacity(_config)
_controller_loop = loop
return _controller
def configured_subagent_max_running() -> int:
"""Return the startup snapshot without requiring an event loop."""
with _state_lock:
return _config.max_running