diff --git a/gateway/run_adapters.py b/gateway/run_adapters.py index acab8f0318..6f2a9d435b 100644 --- a/gateway/run_adapters.py +++ b/gateway/run_adapters.py @@ -538,10 +538,16 @@ class GatewayAdapterLifecycleMixin: with _log_suppressed(logging.DEBUG, "Stale-handoff reclaim failed", exc_info=True): async with _scope(_phome): await _reclaim_stale(self) + offload = getattr(self, "_run_in_executor_with_context", None) try: while self._running: try: - for profile_name, profile_home in _handoff_watch_scopes(self): + # Resolve watch scopes OFF the loop (the profiles_to_serve() filesystem walk can + # stall the loop past the liveness probe on a thrashing host — see the sibling + # fix in run_goals._loop_wakeup_watcher). Stand-ins keep the on-loop resolve. + scopes = (await offload(_handoff_watch_scopes, self) if callable(offload) + else _handoff_watch_scopes(self)) + for profile_name, profile_home in scopes: # Idle gate (run_idle_gates): skip the scope entry when the profile's store # holds no pending handoff. The root poll (None) is unscoped and stays cheap. if profile_home is not None and not await off_loop_gate( diff --git a/gateway/run_goals.py b/gateway/run_goals.py index 31412fd5d7..9f1d7dfb1b 100644 --- a/gateway/run_goals.py +++ b/gateway/run_goals.py @@ -485,9 +485,18 @@ class GatewayGoalsMixin: for sid, state in active_loops: await self._loop_wakeup_fire_one(sid, state, now, warned_no_route, profile_name) + offload = getattr(self, "_run_in_executor_with_context", None) while self._running: try: - for profile_name, profile_home in _handoff_watch_scopes(self): + # Resolve watch scopes OFF the loop: _handoff_watch_scopes -> profiles_to_serve() + # walks the filesystem (realpath chains + profile-dir scans) every pass; on a + # thrashing host that stalls the loop past the liveness watchdog's 10s probe and + # the gateway self-exits 75 (mini 27/9 wedges: [hermes] caught in posixpath.realpath). + # Stand-in runners without the executor hop keep the historical on-loop resolve + # (same defensive idiom as run_idle_gates.off_loop_gate). + scopes = (await offload(_handoff_watch_scopes, self) if callable(offload) + else _handoff_watch_scopes(self)) + for profile_name, profile_home in scopes: # Idle gate (run_idle_gates): skip the scope entry when the profile's store holds # no active loop. The root scan (None) is unscoped and stays cheap. if profile_home is not None and not await self._run_in_executor_with_context(