Files
hermes-agent/gateway/run_adapters.py
kshitijk4poor 40be7008e0 fix(gateway): skip the executor hop for single-profile watcher scope resolves
The off-loop scope resolve hopped to the executor on every handoff (2s)
and loop-wakeup (15s) tick, even in the default single-profile mode where
_handoff_watch_scopes does no I/O and returns [(None, None)]. The executor
is unbounded (one thread per work item), so that spawned ~34 OS threads a
minute for no work. One helper next to _handoff_watch_scopes now returns
the root poll directly when multiplex is off and only hops for the
multiplex filesystem walk; both watchers use it, replacing the local
_resolve_scopes closure. Config-less test stand-ins still resolve via the
patched resolver.

Also give the run_goals half teeth: the loop watcher's profile-gate test
patched the resolver with a lambda that recorded nothing, so reverting
run_goals stayed green. It now runs with multiplex on (required by the
short-circuit), records the calling thread and asserts off-loop; red on
the pre-fix run_goals.py.

Co-authored-by: Emir Saffar <emir.saffar@uropenn.se>
2026-09-27 18:30:25 +05:30

1837 lines
104 KiB
Python

"""Adapter connect/disconnect, fatal-error recovery, reconnect watcher and multiplex profile
adapters for GatewayRunner (mixin bound via the MRO).
``gateway.run`` internals are imported lazily inside method bodies (import cycle), so
``patch("gateway.run.X")`` keeps intercepting them at call time.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import asyncio
import contextlib
from contextlib import suppress
import functools
import os
import time
import weakref as _weakref
from agent.async_utils import consume_detached_task_result
from contextvars import Context
from datetime import datetime, timedelta, timezone
from gateway.config import SHARED_LISTENER_MIRROR_PLATFORMS, Platform, platform_binds_port as _platform_binds_port
from gateway.platforms.base import BasePlatformAdapter
from gateway.platforms.helpers import carry_inbound_dedup, inbound_dedup_caches
from gateway.restart import is_global_startup_conflict
from gateway.run_shutdown import _log_suppressed
from gateway.session import SessionSource
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, Optional
if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle)
from gateway.run import GatewayRunner # noqa: F401
from gateway.run_turn_runner import TurnRunner # noqa: F401
# Log-record parity with the origin module.
logger = logging.getLogger("gateway.run")
_UNSET = object() # "no per-profile human_delay snapshot": fall back to the primary's value
class _UnresolvedProfileHome:
"""A NAMED routed profile whose home does not resolve — never the same thing as ``None``
("this body is the launch profile's own work"). Overloading ``None`` for both let an inbound
message on a secondary's bot run with the LAUNCH profile's ``.env`` and frozen env."""
__slots__ = ()
def __repr__(self) -> str: # log/diagnostic readability
return "<unresolved profile home>"
UNRESOLVED_PROFILE_HOME = _UnresolvedProfileHome()
class GatewayAdapterLifecycleMixin:
"""Adapter lifecycle: connect/teardown, fatal recovery, reconnect watcher, multiplex profiles."""
@staticmethod
async def _wait_or_detach(task: "asyncio.Future", timeout: float) -> bool:
"""Wait up to ``timeout`` for ``task``; on deadline (or our own cancellation) detach it. Not
``asyncio.wait_for``: that WAITS for the cancelled child, so a connect()/close() swallowing
``CancelledError`` blocks recovery forever. True if it finished in time."""
done: set = set()
try:
done, _pending = await asyncio.wait({task}, timeout=timeout)
finally:
if task not in done: # timed out, or our own cancellation
task.cancel()
task.add_done_callback(consume_detached_task_result)
return task in done
async def _await_adapter_cleanup_with_timeout(self, awaitable: Awaitable[Any], timeout: float) -> bool:
"""Await adapter cleanup with a detach-on-deadline bound; True when it completed."""
if timeout <= 0:
await awaitable
return True
task = asyncio.ensure_future(awaitable)
if not await self._wait_or_detach(task, timeout):
return False
await task
return True
async def _safe_adapter_disconnect(self, adapter, platform) -> None:
"""Call adapter.disconnect() defensively (bounded, never raises, tolerates partial-init state):
after a failed connect() partial resources (ClientSession, poll tasks, subprocesses) leak."""
timeout = self._adapter_disconnect_timeout_secs()
label = platform.value if platform is not None else "adapter"
with _log_suppressed(logging.DEBUG, "Defensive %s disconnect after failed connect raised: %s", label):
if not await self._await_adapter_cleanup_with_timeout(adapter.disconnect(), timeout):
logger.warning(
"Timed out after %.1fs while disconnecting %s adapter; continuing shutdown",
timeout, label,
)
async def _bounded_adapter_teardown(self, adapter, platform, *, profile: Optional[str] = None) -> None:
"""Tear down one adapter on the shutdown path with bounded awaits (never raises). Unbounded,
a half-dead transport stalls past systemd's ``TimeoutStopSec``; the SIGKILL skips ``atexit``
PID-file cleanup and the next start dies with "PID file race lost".
Both ``cancel_background_tasks()`` and ``disconnect()`` can block indefinitely when a platform's
network state is half-dead (e.g. a wedged Feishu/Lark WebSocket thread waiting on I/O). See #14128.
"""
timeout = self._adapter_disconnect_timeout_secs()
suffix = f" (profile: {profile})" if profile else ""
started_at = time.monotonic()
try:
if not await self._await_adapter_cleanup_with_timeout(adapter.cancel_background_tasks(), timeout):
logger.warning(
"✗ %s background-task cancel timed out after %.1fs - forcing continue%s",
platform.value, timeout, suffix,
)
except Exception as e:
logger.debug("✗ %s background-task cancel error%s: %s", platform.value, suffix, e)
with _log_suppressed(
logging.ERROR, "✗ %s disconnect error after %.2fs%s: %s",
platform.value, time.monotonic() - started_at, suffix,
):
if await self._await_adapter_cleanup_with_timeout(adapter.disconnect(), timeout):
logger.info(
"✓ %s disconnected (%.2fs)%s", platform.value, time.monotonic() - started_at, suffix,
)
else:
logger.warning(
"✗ %s disconnect timed out after %.1fs - forcing continue%s",
platform.value, timeout, suffix,
)
@staticmethod
def _env_timeout_override(name: str) -> Optional[float]:
"""Non-negative float from env var ``name``; None when unset or unparseable (warned)."""
raw = os.getenv(name, "").strip()
if not raw:
return None
try:
return max(0.0, float(raw))
except ValueError:
logger.warning("Ignoring invalid %s=%r", name, raw)
return None
def _adapter_disconnect_timeout_secs(self) -> float:
"""Return the per-adapter disconnect timeout used during shutdown."""
from gateway.run import _ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT
override = self._env_timeout_override("HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT")
return _ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT if override is None else override
def _platform_connect_timeout_secs(self, platform=None, *, initial: bool = False) -> float:
"""Per-platform connect timeout. Telegram's full 180s is NOT spent at cold start (it would
hold the gateway out of ``running``); the watcher retries with the full budget.
``initial=True`` marks the cold-start connect awaited before the gateway reaches ``running``. The
cold-start wait is capped and the platform is handed to the reconnect watcher, which retries with
the full budget (and ``is_reconnect=True``, preserving the offline update queue — #46621).
"""
from gateway.run import (
_PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT, _TELEGRAM_CONNECT_TIMEOUT_SECS_DEFAULT,
_TELEGRAM_INITIAL_CONNECT_TIMEOUT_SECS_DEFAULT,
)
override = self._env_timeout_override("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT")
if override is not None:
return override
if platform != Platform.TELEGRAM:
return _PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT
return _TELEGRAM_INITIAL_CONNECT_TIMEOUT_SECS_DEFAULT if initial else _TELEGRAM_CONNECT_TIMEOUT_SECS_DEFAULT
async def _connect_adapter_with_timeout(
self, adapter, platform, *, is_reconnect: bool = False, initial: bool = False
) -> bool:
"""Connect with a bound so one platform can't block others. ``is_reconnect``: cold boot
drops the stale server-side queue, a reconnect keeps it. ``initial``: capped budget.
``is_reconnect`` is forwarded to ``adapter.connect()`` so platform adapters can distinguish a cold
first boot (drop any stale server-side queue) from a watcher reconnect after a prolonged outage
(preserve the queue so messages sent during the outage are delivered rather than silently dropped —
#46621).
``initial`` selects the capped cold-start budget for platforms whose full connect budget is too long
to spend before the gateway reaches ``running`` (#85993 — Telegram's 180s).
"""
timeout = self._platform_connect_timeout_secs(platform, initial=initial)
if timeout <= 0:
return await adapter.connect(is_reconnect=is_reconnect)
task = asyncio.ensure_future(adapter.connect(is_reconnect=is_reconnect))
if await self._wait_or_detach(task, timeout):
return bool(await task)
raise TimeoutError(f"{platform.value} connect timed out after {timeout:g}s")
async def _connect_initial_adapter_with_timeout(self, adapter, platform) -> bool:
"""Cold-start connect with replace intent visible ONLY during this await, so a later
network recovery can never evict a healthy token holder."""
adapter._platform_lock_takeover_allowed = bool(self._platform_lock_takeover_on_start)
try:
return await self._connect_adapter_with_timeout(adapter, platform, initial=True)
finally:
adapter._platform_lock_takeover_allowed = False
async def _handle_reaction_event(self, ctx: Dict[str, Any]) -> None:
"""Fan a normalised reaction event out to the HookRegistry; errors never block the adapter."""
event_name = str(ctx.get("event_name") or "reaction:added")
with _log_suppressed(logging.DEBUG, "[Gateway] reaction hook emit failed", exc_info=True):
await self.hooks.emit(event_name, ctx)
async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:
"""React to an adapter failure after startup (retryable → background reconnect queue). Runs
detached: the notification arrives on the failing adapter's own polling task, which the
handler's disconnect can cancel mid-flight, stranding the platform half-handled."""
tasks = getattr(self, "_fatal_handler_tasks", None)
if tasks is None:
tasks = self._fatal_handler_tasks = set()
# shield(): a plain `await task` would tunnel the caller's cancellation into the detached
# task; with shield the caller sees CancelledError and the handler runs to completion.
await asyncio.shield(
self._track_task_in(tasks, asyncio.create_task(self._handle_adapter_fatal_error_detached(adapter)))
)
def _reconnect_queue_entry(
self, platform, adapter, platform_config, *, attempts: int, delay: float, queued: bool = True
) -> dict:
"""Build a ``_failed_platforms`` entry (startup failures and runtime fatals share the shape)."""
now = time.monotonic()
return {
"config": platform_config, "attempts": attempts, "next_retry": now + delay,
**({"queued_at": now} if queued else {}),
"credential_claim": self._adapter_credential_claim(platform, adapter),
"listener_claim": self._adapter_listener_claim(platform, adapter),
"inbound_dedup": inbound_dedup_caches(adapter),
}
def _queue_retryable_fatal_platform(self, adapter: BasePlatformAdapter) -> bool:
"""Queue a retryable fatal adapter for background reconnection (True when newly queued).
Must not await: callers run this BEFORE any disconnect so a wedged close can't strand it.
Idempotent if already queued. See #80598.
"""
if not adapter.fatal_error_retryable:
return False
platform_config = self.config.platforms.get(adapter.platform)
if not platform_config:
return False
if adapter.platform in self._failed_platforms:
# Already queued is exactly when the watcher may have died (supervision gave up); without
# this backstop nothing retries and the stranded check treats "queued" as safe.
# Nothing to enqueue -- but "already queued" is precisely the state in which the watcher has had
# time to die, and the enqueue branch below holds the ONLY call to
# _ensure_reconnect_watcher_running(). _spawn_supervised auto-restarts the watcher after a crash
# (#71758), but only _MAX_SUPERVISED_RESTARTS times in rapid succession; past that it logs
# "giving up restarts" and the watcher stays dead forever. _ensure_reconnect_watcher_running is
# the documented backstop for exactly that budget exhaustion (#70344) -- and it was unreachable
# for a platform already in the queue, which is the only kind of platform the watcher can have
# been retrying long enough to exhaust it on. The result is a silent permanent outage: nothing
# retries, and the stranded check in _handle_adapter_fatal_error_detached deliberately treats a
# queued platform as safe, so the process never restarts either (#90386).
self._ensure_reconnect_watcher_running()
return False
self._failed_platforms[adapter.platform] = self._reconnect_queue_entry(
adapter.platform, adapter, platform_config, attempts=0, delay=0.0,
)
logger.info("%s queued for background reconnection", adapter.platform.value)
# Ensure the reconnect watcher is alive — if it died (e.g. from exhausting its restart budget),
# respawn it so queued platforms are not permanently stranded (#70344).
self._ensure_reconnect_watcher_running()
return True
async def _handle_adapter_fatal_error_detached(self, adapter: BasePlatformAdapter) -> None:
"""Run the fatal handler; a platform left stranded (not reconnected, not queued, not
intentionally disabled) exits the gateway with failure so the service manager restarts it."""
try:
# Outer hard deadline: the stranded check in ``finally`` only runs when we return.
timeout = self._adapter_disconnect_timeout_secs()
if timeout <= 0:
# Outer hard deadline (#80598): even with queue-before-disconnect, a hang anywhere in the
# impl (status write side effects, detach races, etc.) must not leave this task wedged
# forever — the stranded check in ``finally`` only runs when we return.
await self._handle_adapter_fatal_error_impl(adapter)
else:
# Disconnect budget + proportional bookkeeping overhead (tests shrink the timeout).
outer = timeout + min(2.0, max(0.05, timeout))
if not await self._await_adapter_cleanup_with_timeout(
self._handle_adapter_fatal_error_impl(adapter), outer
):
logger.error(
"Fatal-error handling for %s timed out after %.1fs; "
"ensuring reconnect queue is populated", adapter.platform.value, outer,
)
# Best-effort queue before re-raising: a cancelled fatal handler must not strand a
# retryable platform (#80598).
# Best-effort queue so an unexpected raise mid-handler cannot leave a retryable platform
# permanently deaf (#80598).
self._queue_retryable_fatal_platform(adapter)
except asyncio.CancelledError:
# A cancelled or raising fatal handler must not strand a retryable platform.
self._queue_retryable_best_effort(adapter, "cancellation")
raise
except Exception:
logger.exception("Fatal-error handling for %s raised unexpectedly", adapter.platform.value)
self._queue_retryable_best_effort(adapter, "exception")
finally:
platform = adapter.platform
shutdown_event = getattr(self, "_shutdown_event", None)
if (
adapter.fatal_error_retryable
and platform not in self.adapters
and platform not in getattr(self, "_failed_platforms", {})
and not (shutdown_event is not None and shutdown_event.is_set())
):
logger.error(
"%s adapter was lost without entering the reconnection "
"queue; exiting gateway so the service manager restarts it.", platform.value,
)
self._exit_reason = f"{platform.value} adapter lost without reconnection queue"
self._exit_with_failure = True
await self.stop()
def _queue_retryable_best_effort(self, adapter: BasePlatformAdapter, why: str) -> None:
with _log_suppressed(
logging.DEBUG, "Failed to queue %s after fatal-handler %s",
adapter.platform.value, why, exc_info=True,
):
self._queue_retryable_fatal_platform(adapter)
async def _handle_adapter_fatal_error_impl(self, adapter: BasePlatformAdapter) -> None:
# Snapshot the slot owner first: a stale notification must not touch a healthy platform.
existing = self.adapters.get(adapter.platform)
if existing is not None and existing is not adapter:
logger.debug(
"Ignoring stale fatal error from a superseded %s adapter instance: %s",
adapter.platform.value, adapter.fatal_error_code or "unknown",
)
return
logger.error(
"Fatal %s adapter error (%s): %s", adapter.platform.value,
adapter.fatal_error_code or "unknown", adapter.fatal_error_message or "unknown error",
)
# relay_disabled (credential revoked by opt-out) renders "disabled", not red fatal/retrying.
self._update_platform_runtime_status(
adapter.platform.value,
platform_state=(
"disabled" if adapter.fatal_error_code == "relay_disabled"
else "retrying" if adapter.fatal_error_retryable else "fatal"
),
error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message,
)
if existing is adapter:
# Claim for teardown BEFORE awaiting disconnect(), else a second fatal disconnects it twice.
self.adapters.pop(adapter.platform, None)
self.delivery_router.adapters = self.adapters
# Queue BEFORE any disconnect await: a wedged close() once left platforms permanently deaf.
self._queue_retryable_fatal_platform(adapter)
if existing is adapter:
# Bounded by the shutdown-path timeout so this always returns to the stranded check.
# Queue retryable failures BEFORE any disconnect await (#80598). A half-dead transport can wedge
# native close() (or swallow CancelledError inside it) so the previous "disconnect then queue"
# order left platforms permanently deaf inside a live process even after the network recovered.
# Populate the queue first so the reconnect watcher always has work; teardown is best-effort
# after.
await self._safe_adapter_disconnect(adapter, adapter.platform)
if not self.adapters and not self._failed_platforms:
self._exit_reason = adapter.fatal_error_message or "All messaging adapters disconnected"
if adapter.fatal_error_retryable:
self._exit_with_failure = True
logger.error("No connected messaging platforms remain. Shutting down gateway for service restart.")
else:
logger.error("No connected messaging platforms remain. Shutting down gateway cleanly.")
await self.stop()
elif not self.adapters and self._failed_platforms:
# All down but queued: stay alive (cron runs, watcher recovers) rather than restart-loop.
logger.warning(
"No connected messaging platforms remain, but %d platform(s) "
"queued for reconnection — gateway staying alive, watcher will "
"retry in background.", len(self._failed_platforms),
)
def _retain_background_task(self, task: "asyncio.Task") -> "asyncio.Task":
"""Register ``task`` in ``_background_tasks`` (created lazily for bare test runners)."""
tasks = getattr(self, "_background_tasks", None)
if not isinstance(tasks, set):
tasks = self._background_tasks = set()
tasks.add(task)
task.add_done_callback(tasks.discard)
return task
@staticmethod
def _track_task_in(tasks: set, task: "asyncio.Task") -> "asyncio.Task":
"""Register ``task`` in an arbitrary lifecycle set with self-removal on completion."""
tasks.add(task)
task.add_done_callback(tasks.discard)
return task
def _request_clean_exit(self, reason: str) -> None:
self._exit_cleanly = True
self._exit_reason = reason
self._shutdown_event.set()
@staticmethod
def _supervised_backoff(attempt: int) -> float:
"""Capped exponential respawn delay (a method so tests can collapse the schedule)."""
return min(60, 2 ** min(attempt, 6))
def _spawn_supervised(
self, coro_factory, name, *, restart=True, _attempt=0, on_spawn=None, on_give_up=None
):
"""Launch a long-lived supervised background task: exceptions a bare ``create_task`` drops
are logged, and it respawns with capped backoff up to ``_MAX_SUPERVISED_RESTARTS`` rapid
failures (counter resets after ``_SUPERVISED_HEALTHY_SECS`` healthy). Fresh ``Context`` per
spawn (an inherited delegated-child marker would make the Kanban dispatcher reject its own
writes). ``on_spawn`` fires on EVERY spawn incl. respawns — handle trackers MUST pass it or a
respawn leaves a stale handle and a SECOND watcher; ``on_give_up(name)`` fires at budget end.
``on_give_up`` (optional) is invoked with ``name`` when supervision is abandoned — the restart
budget is spent and this task will never be respawned by the supervisor again. Supervision being
finite is correct; having no owner of the invariant afterwards is not. A task that still has queued
work depending on it needs somewhere to hand that fact to, and before this hook existed the only
thing standing between budget exhaustion and a permanent silent outage was a *later, unrelated
event* happening to call ``_ensure_...`` (#90386). This is the supervisor telling its caller "I am
done; the invariant is yours now", which is a thing only the supervisor knows.
"""
# Spawn timestamp lets ``_done`` tell a rapid crash-loop from a healthy-run-then-crash.
_started = time.monotonic()
# No create_task kwargs (test doubles mock a narrow signature); Context().run isolates instead.
task = Context().run(lambda: asyncio.create_task(coro_factory()))
# PERMANENT watcher: the scale-to-zero idle check ignores it (else busy forever).
task._hermes_supervised_watcher = True # type: ignore[attr-defined]
self._retain_background_task(task)
if on_spawn is not None:
# Record the live handle NOW so external trackers don't point at a dead prior task.
try:
on_spawn(task)
except Exception: # pragma: no cover - defensive; a tracker must never kill the spawn
logger.debug("on_spawn callback for %s raised", name, exc_info=True)
def _done(t):
self._background_tasks.discard(t)
if t.cancelled():
return
exc = t.exception()
if exc is None:
# Clean return = deliberate shutdown or a self-disabling watcher; NEVER respawn.
return
logger.error("Supervised task %s died: %r", name, exc, exc_info=exc)
if not (restart and self._running):
return
# A healthy run before the crash is a FRESH failure, not a crash-loop: reset the counter.
healthy = time.monotonic() - _started >= self._SUPERVISED_HEALTHY_SECS
effective_attempt = 0 if healthy else _attempt
if effective_attempt >= self._MAX_SUPERVISED_RESTARTS:
logger.error(
"Supervised task %s died %d times in rapid succession "
"(each within %ds of restart) — giving up restarts", name,
effective_attempt, self._SUPERVISED_HEALTHY_SECS,
)
if on_give_up is not None:
try:
on_give_up(name)
except Exception: # pragma: no cover - defensive
logger.debug("on_give_up callback for %s raised", name, exc_info=True)
return
backoff = self._supervised_backoff(effective_attempt)
async def _respawn():
await asyncio.sleep(backoff)
if self._running:
self._spawn_supervised(
coro_factory, name, restart=restart, _attempt=effective_attempt + 1,
on_spawn=on_spawn, on_give_up=on_give_up, # only the LAST give-up matters
)
# The done callback runs in its registration context; isolate the backoff task too.
self._retain_background_task(Context().run(lambda: asyncio.create_task(_respawn())))
task.add_done_callback(_done)
return task
async def _handoff_watcher(self, interval: float = 2.0, drain_timeout: float = 30.0) -> None:
"""Process pending CLI→gateway session handoffs from ``state.db``: claim atomically (pending
→ running), re-bind the home channel to the CLI session_id, dispatch a synthetic event, mark
``completed``/``failed``."""
from gateway.run import _async_profile_runtime_scope, _reclaim_stale, _resolve_handoff_watch_scopes
from gateway.run_idle_gates import off_loop_gate, profile_has_pending_handoff
await asyncio.sleep(5) # let platforms connect before dispatching through them
# Does _process_handoff accept the profile argument? Test stand-ins bind a one-arg callable.
try:
import inspect as _inspect
_process_takes_profile = len(_inspect.signature(self._process_handoff).parameters) >= 2
except Exception:
_process_takes_profile = False
# In-flight dispatches by session id: a handoff is a FULL agent turn, so never process inline.
inflight: Dict[str, "asyncio.Task"] = {}
async def _dispatch(row, session_id, session_db, profile_name) -> None:
"""Run one claimed handoff to a terminal state, off the poll path."""
try:
await self._process_handoff(*((row, profile_name) if _process_takes_profile else (row,)))
await session_db.complete_handoff(session_id)
except asyncio.CancelledError:
# Leave the row 'running' so the next start's reclaim marks it failed with a clear reason.
raise
except Exception as exc:
logger.warning("Handoff for session %s failed: %s", session_id, exc, exc_info=True)
with _log_suppressed(logging.DEBUG, "Could not record handoff failure", exc_info=True):
await session_db.fail_handoff(session_id, str(exc))
finally:
inflight.pop(session_id, None)
async def _tick(profile_name: Optional[str] = None) -> None:
"""One poll of the CURRENTLY-SCOPED store; ``profile_name`` (None = root) routes delivery
to that profile's OWN adapter. A closure, not a method: tests bind ``_handoff_watcher`` onto
a ``SimpleNamespace`` with only ``_session_db``/``_running``/``_process_handoff``."""
session_db = getattr(self, "_session_db", None)
if session_db is None:
return
pending = await session_db.list_pending_handoffs()
for row in pending:
session_id = row.get("id")
if not session_id or session_id in inflight:
continue
if not await session_db.claim_handoff(session_id):
# Another tick or another gateway already claimed it.
continue
# INVARIANT (do not weaken): created inside _profile_runtime_scope but RUNS after it
# exits; it sees the profile scope only because ensure_future copies the Context.
# Positional, not keyword: the watcher's existing unit tests bind a stand-in
# ``_process_handoff(row)`` with no second parameter, and a keyword call would TypeError
# into the failure branch — turning a passing suite into a silent no-op watcher. Arity is
# probed above. It still sees the profile's home and secret scope only because
# ``set_hermes_home_override`` and ``set_secret_scope`` are ContextVar-based — ensure_future
# copies the current Context into the Task. If either seam is ever migrated to a
# thread-local or module global, secondary- profile handoffs silently regress to
# primary-config delivery (the exact bug fixed in #91217) while still recording
# handoff_state='completed'.
inflight[session_id] = asyncio.ensure_future(
_dispatch(row, session_id, session_db, profile_name)
)
# A row still 'running' at startup died mid-dispatch and blocks request_handoff until reclaimed.
def _scope(profile_home): # local: tests bind this watcher onto bare SimpleNamespace runners
return GatewayAdapterLifecycleMixin._async_scope_or_null(_async_profile_runtime_scope, profile_home)
# Multiplex scope resolution walks the filesystem (profiles_to_serve) off the loop, so a
# stalled walk cannot trip the liveness probe — startup reclaim and every tick alike.
for _pname, _phome in await _resolve_handoff_watch_scopes(self):
with _log_suppressed(logging.DEBUG, "Stale-handoff reclaim failed", exc_info=True):
async with _scope(_phome):
await _reclaim_stale(self)
try:
while self._running:
try:
for profile_name, profile_home in await _resolve_handoff_watch_scopes(self):
# 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(
self, lambda home=profile_home: profile_has_pending_handoff(home)):
continue
async with _scope(profile_home):
await _tick(profile_name)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.debug("Handoff watcher tick error: %s", exc, exc_info=True)
await asyncio.sleep(interval)
finally:
# Bounded drain: cancelling would strand in-flight rows in 'running'.
pending_tasks = [t for t in inflight.values() if not t.done()]
if pending_tasks:
with _log_suppressed(logging.DEBUG, "Handoff drain raised", exc_info=True):
await asyncio.wait(pending_tasks, timeout=drain_timeout)
for task in pending_tasks:
if not task.done():
task.cancel()
def _on_reconnect_watcher_gave_up(self, name: str = "") -> None:
"""Own the reconnect invariant once supervision gives up: while running with queued
platforms, a watcher is live or a bounded respawn is scheduled (no later event can notice
a dead watcher). Slow-tier exhaustion logs loudly; deliberately NOT a process restart.
Before this, the only thing that noticed a dead watcher was a *later fatal error from some other
platform* reaching ``_queue_retryable_fatal_platform``. That is event-coupled recovery: it needs an
event that, by construction, may never come. #81036 moved queue publication ahead of disconnect and
drops the failed adapter from the live map, so once the watcher's budget is spent there may be no
adapter left that can emit the event recovery was waiting on. The platform stays queued, nothing
retries it, and the stranded check in ``_handle_adapter_fatal_error_detached`` treats a queued
platform as safe — so the process is never restarted either.
"""
if not getattr(self, "_running", False):
return
if getattr(self, "_failed_platforms", None):
self._schedule_slow_reconnect_watcher_respawn(attempt=0)
else:
# Nothing depends on the watcher; the enqueue path spawns a fresh one when needed.
logger.warning(
"Reconnect watcher supervision exhausted with an empty retry "
"queue — leaving it down until a platform is queued."
)
def _schedule_slow_reconnect_watcher_respawn(self, *, attempt: int) -> None:
"""Bounded slow-tier respawn of the reconnect watcher."""
if attempt >= self._MAX_SLOW_WATCHER_RESPAWNS:
logger.error(
"Reconnect watcher could not be kept alive after %d slow respawns; %d platform(s) remain "
"queued and unattended: %s. Manual intervention or a gateway restart is required.",
attempt, len(self._failed_platforms), ", ".join(str(p) for p in self._failed_platforms),
)
return
async def _slow_respawn() -> None:
await asyncio.sleep(self._RECONNECT_WATCHER_SLOW_RETRY_SECS)
task = getattr(self, "_reconnect_watcher_task", None)
if (
not getattr(self, "_running", False)
or not getattr(self, "_failed_platforms", None) # queue drained while waiting
or (task is not None and not task.done()) # a watcher came back; stand down
):
return
logger.warning(
"Reconnect watcher still down with %d platform(s) queued — slow respawn %d/%d",
len(self._failed_platforms), attempt + 1, self._MAX_SLOW_WATCHER_RESPAWNS,
)
self._spawn_reconnect_watcher(
on_give_up=lambda _name: self._schedule_slow_reconnect_watcher_respawn(attempt=attempt + 1)
)
self._retain_background_task(asyncio.create_task(_slow_respawn()))
def _spawn_reconnect_watcher(self, *, on_give_up=None):
"""Launch the reconnect watcher. ``on_spawn`` is load-bearing: without it a supervised
respawn leaves ``_reconnect_watcher_task`` dead and ``_ensure_...`` spawns a second one."""
self._reconnect_watcher_task = self._spawn_supervised(
self._platform_reconnect_watcher, "platform_reconnect_watcher",
on_spawn=lambda t: setattr(self, "_reconnect_watcher_task", t),
on_give_up=on_give_up or self._on_reconnect_watcher_gave_up,
)
return self._reconnect_watcher_task
def _ensure_reconnect_watcher_running(self) -> None:
"""Respawn a dead reconnect watcher (called on BOTH _queue_retryable_fatal_platform paths:
the re-fatal of an already-queued platform is the only case that exhausts the budget).
If the tracked reconnect watcher task has died (e.g. from exhausting its restart budget, or a
terminal exception that _spawn_supervised could not recover), respawns it so platforms queued for
reconnection are not permanently stranded. Called from _queue_retryable_fatal_platform on BOTH paths
(#70344, #90386): after a new enqueue, and after a re-fatal for a platform that is already queued --
the latter being the only case in which the watcher can have been retrying long enough to exhaust
its supervised restart budget.
"""
task = getattr(self, "_reconnect_watcher_task", None)
if not getattr(self, "_running", False) or (task is not None and not task.done()):
return # not running, or already alive
logger.warning(
"Reconnect watcher task is dead (done=%s) — respawning",
task.done() if task is not None else "N/A",
)
self._spawn_reconnect_watcher()
async def _platform_reconnect_watcher(self) -> None:
"""Periodically retry failed platforms: backoff 30s → 300s cap, retryable failures retry
forever (self-heal), non-retryable drop out. Pausing is manual only (``/platform pause``)."""
async def _idle(seconds: int, until_queued: bool = False) -> bool:
"""Sleep in 1s steps; False once the runner stops (or, if asked, once work is queued)."""
for _ in range(seconds):
if not self._running:
return False
if until_queued and self._failed_platforms:
break
await asyncio.sleep(1)
return True
await asyncio.sleep(10) # initial delay — let startup finish
while self._running:
if not self._failed_platforms:
if not await _idle(30, until_queued=True):
return
continue
now = time.monotonic()
for platform in list(self._failed_platforms.keys()):
if not self._running:
return
await self._reconnect_failed_platform(platform, now)
if not await _idle(10): # re-check every 10 seconds
return
def _flag_reconnect_needs_attention(
self, platform, info: dict, now: float, *, status_key: Optional[str] = None
) -> None:
"""Flag NEEDS_ATTENTION (once) past the threshold — a signal, NOT a circuit breaker. The threshold
is the bound profile's ``agent.reconnect_attention_after``: secondaries call this inside their
``_profile_runtime_scope`` with their ``<profile>:<platform>`` status key."""
from gateway.run import _reconnect_needs_attention
if info.get("attention_flagged") or not _reconnect_needs_attention(info, now):
return
info["attention_flagged"] = True
queued_for = now - info.get("queued_at", now)
logger.warning(
"%s has been failing/reconnecting continuously for %.1f hours (%d attempts) — flagging "
"NEEDS_ATTENTION. Retries continue, but this usually means a permanent problem (revoked "
"credentials, missing intents, broken sidecar). Check `hermes status` / `/platform list`.",
status_key or platform.value, queued_for / 3600.0, info.get("attempts", 0),
)
self._update_platform_runtime_status(
status_key or platform.value, platform_state="retrying", needs_attention=True,
retrying_since=(datetime.now(timezone.utc) - timedelta(seconds=queued_for)).isoformat(),
)
def _mark_platform_fatal(self, status_key: str, adapter) -> None:
"""Record an adapter's fatal error code/message as ``fatal`` runtime status."""
self._update_platform_runtime_status(
status_key, platform_state="fatal", error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message,
)
def _bump_reconnect_backoff(
self, platform, info: dict, attempt: int, error_code, error_message: str
) -> int:
"""Mark the platform retrying and record the failed attempt; returns the backoff applied."""
from gateway.run import _reconnect_backoff
self._update_platform_runtime_status(
platform.value, platform_state="retrying", error_code=error_code, error_message=error_message,
)
backoff = _reconnect_backoff(attempt)
info["attempts"] = attempt
info["next_retry"] = time.monotonic() + backoff
return backoff
async def _reconnect_failed_platform(self, platform, now: float) -> None:
"""One watcher pass for a queued platform: gate, attempt, and record the outcome."""
from gateway.run import _dispose_unused_adapter, _platform_has_bot_credential
info = self._failed_platforms.get(platform)
# None: removed concurrently since the caller's snapshot. Paused needs /platform resume.
if info is None or info.get("paused"):
return
self._flag_reconnect_needs_attention(platform, info, now)
if now < info["next_retry"]:
return # not time yet
platform_config = info["config"]
attempt = info["attempts"] + 1
# Empty-token primary configs can never reconnect; drop them so multiplex setups
# where a secondary profile owns the bot do not spin forever.
# See #64674.
if not _platform_has_bot_credential(platform, platform_config):
self._drop_from_reconnect_queue(platform, "no bot credential on queued config")
return
logger.info("Reconnecting %s (attempt %d)...", platform.value, attempt)
adapter = None
try:
adapter = self._create_adapter(platform, platform_config)
if not adapter:
self._drop_from_reconnect_queue(platform, "adapter creation returned None")
return
carry_inbound_dedup(info.get("inbound_dedup"), adapter)
self._wire_adapter_handlers(adapter)
# is_reconnect keeps the server-side update queue so offline-period messages are delivered.
success = await self._connect_adapter_with_timeout(adapter, platform, is_reconnect=True)
if success:
await self._install_reconnected_adapter(platform, adapter)
elif adapter.has_fatal_error and not adapter.fatal_error_retryable:
self._mark_platform_fatal(platform.value, adapter)
logger.warning(
"Reconnect %s: non-retryable error (%s), removing from retry queue",
platform.value, adapter.fatal_error_message,
)
# Never installed on self.adapters: dispose here or its __init__ resources leak ~2 fds each.
# The adapter is about to be dropped from the queue without ever being installed on
# self.adapters, so nothing else will call disconnect() on it. We must dispose it here,
# otherwise the resource owners it constructed in __init__ (ResponseStore for
# APIServerAdapter, etc.) leak 2 fds each. The gateway hits the 2560-fd limit after ~12h of
# failed reconnects at the 300s backoff cap (#37011).
await _dispose_unused_adapter(adapter)
del self._failed_platforms[platform]
else:
# Retryable failures retry at the cap forever (never auto-pause). Same fd-leak dispose.
backoff = self._bump_reconnect_backoff(
platform, info, attempt, adapter.fatal_error_code,
adapter.fatal_error_message or "failed to reconnect",
)
logger.info("Reconnect %s failed, next retry in %ds", platform.value, backoff)
# Same fd-leak concern as the non-retryable branch above: the adapter failed to connect and
# is being thrown away. Without an explicit dispose call, the resources it opened in
# __init__ stay open until the next GC pass — and aiohttp/SQLite handles don't get GC'd
# promptly, so 2 fds/retry leak at 300s backoff cap = ~12 fds/hour (#37011).
await _dispose_unused_adapter(adapter)
except Exception as e:
if adapter is not None:
# An exception escaping connect leaves the adapter in the same unowned state.
await _dispose_unused_adapter(adapter)
# A reconnect exception is transient; keep retrying at the cap rather than auto-pausing.
backoff = self._bump_reconnect_backoff(platform, info, attempt, None, str(e))
logger.warning("Reconnect %s error: %s, next retry in %ds", platform.value, e, backoff)
def _drop_from_reconnect_queue(self, platform, reason: str) -> None:
logger.warning("Reconnect %s: %s, removing from retry queue", platform.value, reason)
del self._failed_platforms[platform]
def _publish_primary_adapter(self, platform, adapter) -> None:
"""Register a connected primary adapter and wire voice mode/input (transcription without /voice join)."""
self.adapters[platform] = adapter
self._sync_voice_mode_state_to_adapter(adapter)
self._bind_voice_input_callback(adapter)
def _schedule_planned_restart_replay(self) -> None:
"""Replay the owed planned-restart notice after a reconnect, in the background: notification delivery
must not hold up adapter recovery or other platforms' reconnects."""
from gateway.run import _planned_restart_notification_pending
if _planned_restart_notification_pending():
task = self._retain_background_task(asyncio.create_task(
self._replay_pending_planned_restart_notification(),
))
task.add_done_callback(self._late_failure_callback("planned-restart notification replay failed"))
async def _install_reconnected_adapter(self, platform, adapter) -> None:
"""Publish a freshly reconnected primary adapter and replay what it missed while down."""
self._publish_primary_adapter(platform, adapter)
self.delivery_router.adapters = self.adapters
del self._failed_platforms[platform]
# connect() returning True does not mean the receive path is confirmed -- Telegram's degraded
# reconnect returns True so the gateway stays up while its own ladder retries. Stamping "connected"
# here would undo the adapter's accurate status.
_degraded = adapter.send_path_degraded
self._update_platform_runtime_status(
platform.value, platform_state="retrying" if _degraded else "connected", error_code=None,
error_message=adapter.DEGRADED_STATUS_MESSAGE if _degraded else None,
needs_attention=False, retrying_since=None,
)
if _degraded:
logger.info("⚠ %s reconnected in degraded mode (receive path not yet confirmed)", platform.value)
else:
logger.info("✓ %s reconnected successfully", platform.value)
self._schedule_planned_restart_replay()
# Responses rejected while down are owned by this live process (startup recovery cannot claim them).
with _log_suppressed(
logging.DEBUG, "failed-obligation redelivery after %s reconnect failed",
platform.value, exc_info=True,
):
await self._redeliver_failed_obligations_for_platform(platform)
# Rebuild channel directory with the new adapter
with suppress(Exception):
from gateway.channel_directory import build_channel_directory
await build_channel_directory(self.adapters)
# A platform offline at startup skipped its restart-interrupted sessions; resume them now.
try:
self._schedule_resume_pending_sessions(platform=platform)
except Exception:
logger.debug("resume-pending reschedule after %s reconnect failed", platform.value, exc_info=True)
async def _cancel_secondary_profile_reconnect_tasks(self) -> None:
"""Cancel profile-scoped reconnects before tearing down their registry, so a reconnect
mid-setup cannot republish an adapter after the registry drains (bounded wait)."""
pending = self._profile_failed_platforms
if not isinstance(pending, dict):
return
current = asyncio.current_task()
tasks = [
task
for profile_pending in pending.values()
if isinstance(profile_pending, dict)
for task in profile_pending.values()
if isinstance(task, asyncio.Task) and task is not current and not task.done()
]
for task in tasks:
task.cancel()
timeout = self._adapter_disconnect_timeout_secs()
if tasks and timeout > 0:
_done, unfinished = await asyncio.wait(tasks, timeout=timeout)
if unfinished:
logger.warning(
"Timed out waiting for %d secondary profile reconnect task(s) during shutdown", len(unfinished),
)
pending.clear()
async def _start_secondary_profile_adapters(self) -> int:
"""Bring up adapters for every non-active profile (multiplex only); returns connected count.
Each profile connects under its own HERMES_HOME + secret scope; credential/listener collisions
are refused here — the only point seeing every profile's credentials together."""
from gateway.run import MultiplexConfigError, _multiplex_profile_homes
from gateway.run_profile_reconcile import profile_serve_signature
if not self._multiplex_on():
# Runtime-status publication re-stamps the previous writer's record in place, so a multiplexer's
# ``served_profiles`` would outlive it into this single-profile run and `hermes -p X ...`
# would keep refusing (exit 78) / reporting "served" for profiles nobody serves.
with _log_suppressed(logging.DEBUG, "could not clear served_profiles", exc_info=True):
from gateway.status import publish_runtime_status
publish_runtime_status(served_profiles=[])
return 0
try:
from hermes_cli.profiles import get_active_profile_name, profiles_to_serve, profile_is_parked
except Exception:
return 0
if self._multiplex_on():
# Primary adapters belong to default. A named launcher is not skipped here —
# it still needs its own secondary adapter and credential ownership entry.
active = getattr(self, "_primary_profile_name", None) or "default"
else:
active = get_active_profile_name() or "default" # launch profile, pre-identity (adapter boot)
for name, home in profiles_to_serve(True, include_parked=True):
if name != "default" and profile_is_parked(home):
logger.info("profile '%s' is parked (gateway.parked); not served by this gateway", name)
connected = 0
claimed = self._primary_resource_claims(active)
profile_homes = _multiplex_profile_homes(self.config)
self._served_profile_signatures = {}
transient_failed = set()
for profile_name, profile_home in profile_homes:
if profile_name == active:
continue # handled by the primary startup loop
# Preserve changes made while the initial connection is awaiting I/O.
scan_signature = profile_serve_signature(profile_home)
try:
connected += await self._start_one_profile_adapters(profile_name, profile_home, claimed)
except MultiplexConfigError:
raise
except Exception as e:
logger.error("Failed to start adapters for profile '%s': %s", profile_name, e, exc_info=True)
# Not acknowledged: the reconcile watcher retries a transiently-failed profile.
transient_failed.add(profile_name)
else:
self._served_profile_signatures[profile_name] = scan_signature
self._record_served_profiles(active, profile_homes)
# ``_note_served_profiles`` fills a missing signature with the current one; that refill
# would park a transiently-failed profile before the first watcher tick can retry it.
for profile_name in transient_failed:
self._served_profile_signatures.pop(profile_name, None)
# Cached configs follow the served set: a profile that failed to start (or stopped being
# served) keeps no home channel in the host-wide notice fan-out, where it would be owed a
# notice no transport can deliver and ``.restart_pending.json`` would never be unlinked.
configs = getattr(self, "_profile_configs", None)
if configs is not None:
for profile_name in [p for p in configs if p not in self._served_profile_signatures]:
configs.pop(profile_name, None)
self._restore_secondary_completion_ledgers(profile_homes)
return connected
def _primary_resource_claims(self, active: str) -> Dict[tuple, str]:
"""Resource claim -> owning profile for every live or queued primary adapter (credential:
one account polled once; listener: one bind+port). A queued retryable primary owns both."""
claimed: Dict[tuple, str] = {}
for _plat, _ad in self.adapters.items():
fp = self._adapter_credential_fingerprint(_ad)
for claim in ((_plat, fp) if fp is not None else None, self._adapter_listener_claim(_plat, _ad)):
if claim is not None:
claimed[claim] = active
for retry_info in getattr(self, "_failed_platforms", {}).values():
for claim_name in ("credential_claim", "listener_claim"):
retry_claim = retry_info.get(claim_name)
if isinstance(retry_claim, tuple):
claimed[retry_claim] = active
return claimed
def _record_served_profiles(self, active: str, profile_homes) -> None:
"""Record the served set (eligible for routing/HTTP prefixes/cron/runtime scope — broader
than "has a connected adapter") for `hermes status`; seed per-profile PairingStores."""
with _log_suppressed(logging.DEBUG, "could not record served_profiles", exc_info=True):
from gateway.status import publish_runtime_status
from gateway.pairing import PairingStore
served = [active] + sorted(name for name, _home in profile_homes if name != active)
self._note_served_profiles(profile_homes)
for name in served:
if name and name not in self.pairing_stores:
self.pairing_stores[name] = (
self.pairing_store if name == active else PairingStore(profile=name)
)
publish_runtime_status(served_profiles=served)
# The host record is what a second `gateway run` reads to decide attach-vs-start; keep
# its served set in step with the live one (it is republished, never re-claimed).
from gateway.host_rendezvous import ROLE_GATEWAY, owns_host_lock, publish_record
if owns_host_lock(ROLE_GATEWAY):
from hermes_constants import get_hermes_home
publish_record(ROLE_GATEWAY, profiles=tuple(served), home=str(get_hermes_home()))
async def _load_secondary_profile_config(self, profile_name: str, profile_home: "Path"):
"""Hydrate + enter ``profile_home``'s scope once; return its gateway config. Raises
``MultiplexConfigError`` (open dm/group policy). Port-binding platforms are NOT refused: the
default profile owns the single shared listener and a secondary's port-binders are built in
shared-listener mode (``/p/<profile>/...``) by ``_start_one_profile_adapters``."""
from gateway.run import (
MultiplexConfigError, _load_gateway_config,
_own_policy_open_startup_violation, _profile_runtime_scope,
)
from gateway.config import load_gateway_config
from hermes_cli.env_loader import hydrate_profile_secret_sources
# Hydrate external secret sources off-loop ONCE: sync hydration would stall every heartbeat.
await asyncio.to_thread(hydrate_profile_secret_sources, profile_home)
with _profile_runtime_scope(profile_home, hydrate_secrets=False):
profile_runtime_cfg = _load_gateway_config()
from hermes_cli.plugins import discover_plugins, get_plugin_manager
discover_plugins()
self._subscribe_plugin_rewire(get_plugin_manager(), profile_name, profile_home)
# This profile's `hooks:` block: start() registered before any profile scope existed.
self._register_config_hooks(
"shell-hook/webhook registration failed for profile '%s'", profile_name, level=logging.WARNING,
)
profile_cfg = load_gateway_config()
violation = _own_policy_open_startup_violation(profile_cfg)
self._snapshot_profile_busy_modes(profile_name, profile_runtime_cfg)
if violation:
raise MultiplexConfigError(
f"Profile '{profile_name}' enables {violation}. "
"Enable GATEWAY_ALLOW_ALL_USERS or the platform allow-all flag "
"for that profile, or change dm_policy/group_policy away from 'open'."
)
return profile_cfg
@staticmethod
def _credential_claim_origin(profile_name: str, profile_home, platform: Platform, token: str) -> Optional[str]:
"""Where *token* was configured for *profile_name*: that profile's ``.env``, or ambient env.
``None`` when the value is not in either place (yaml-only, or unknown). An env label
is only returned when the profile's own ``.env`` does not contain the value — a shell
export that merely echoes the file is the file.
"""
from gateway.config import PLATFORM_TOKEN_ENV_NAMES
env_name = PLATFORM_TOKEN_ENV_NAMES.get(platform)
if not env_name or not isinstance(token, str) or not token.strip():
return None
token = token.strip()
in_dotenv = False
if profile_home:
try:
from agent.secret_scope import load_env_file
in_dotenv = (load_env_file(Path(profile_home) / ".env").get(env_name) or "").strip() == token
except Exception:
in_dotenv = False
if in_dotenv:
if profile_name and profile_name != "default":
return f"profiles/{profile_name}/.env"
return ".env"
if (os.environ.get(env_name) or "").strip() == token:
return f"env {env_name}"
return None
def _duplicate_credential_origins(
self, owner: str, profile_name: str, profile_home, platform: Platform, adapter: Any,
) -> tuple:
"""``(owner_origin, incoming_origin)`` for a same-credential refusal; either may be None."""
token = None
for obj, attr in (
(adapter, "token"), (adapter, "bot_token"),
(getattr(adapter, "config", None), "token"),
):
val = getattr(obj, attr, None) if obj is not None else None
if isinstance(val, str) and val.strip():
token = val.strip()
break
if not token:
return None, None
incoming = self._credential_claim_origin(profile_name, profile_home, platform, token)
owner_home = None
if owner == "default":
try:
from hermes_constants import get_default_hermes_root
owner_home = get_default_hermes_root()
except Exception:
owner_home = None
else:
try:
from hermes_cli.profiles import get_profile_dir
owner_home = get_profile_dir(owner)
except Exception:
owner_home = None
owner_origin = (
self._credential_claim_origin(owner, owner_home, platform, token) if owner_home else None
)
return owner_origin, incoming
def _refuse_duplicate_claim(
self, claim, claimed: Dict[tuple, str], profile_name: str, platform: Platform, kind: str,
*, owner_origin: Optional[str] = None, incoming_origin: Optional[str] = None,
) -> bool:
"""Log + park a secondary adapter whose credential/listener another profile owns (True when
refused). NOT disconnected: it never connected, and for a same-credential Photon adapter
disconnect() would shut down the primary profile's live sidecar."""
owner = claimed.get(claim) if claim is not None else None
if owner is None:
return False
pv = platform.value
env_derived = any(
isinstance(origin, str) and origin.startswith("env ")
for origin in (owner_origin, incoming_origin)
)
def _who(name: str, origin: Optional[str]) -> str:
return f"{name} ({origin})" if env_derived and origin else name
head = f"Profile '{_who(owner, owner_origin)}' and '{_who(profile_name, incoming_origin)}' both configure {pv} "
if kind == "credential":
message = head + f"with the same credential. Give each profile its own {pv} credential."
logger.error(
"Profile '%s' and '%s' both configure %s with the same credential — refusing to start the "
"duplicate (one credential cannot be consumed twice). Give each profile its own %s credential.",
_who(owner, owner_origin), _who(profile_name, incoming_origin), pv, pv,
)
else:
bind, port = claim[-2:]
message = head + f"sidecars on the same listener. Configure a distinct listener for profile '{profile_name}'."
logger.error(
"Profile '%s' and '%s' both configure %s sidecars on %s:%s — refusing to start the duplicate "
"listener. Set platforms.%s.extra.sidecar_port to a distinct port for profile '%s'.",
owner, profile_name, pv, bind, port, pv, profile_name,
)
self._update_platform_runtime_status(
f"{profile_name}:{platform.value}", platform_state="fatal",
error_code=f"duplicate_{kind}", error_message=message,
)
return True
def _note_unserved_secondary_platform(self, profile_name: str, platform: Platform) -> None:
"""A secondary enabled a shared-ingress platform (Relay, WhatsApp) the multiplexer only runs on
the default profile. Log the reason + remedy once per (profile, platform) and stamp a
``<profile>:<platform>`` status entry so ``hermes gateway status --profile X`` and the
dashboard show *why* the channel is dead instead of nothing at all."""
noted = getattr(self, "_unserved_secondary_platforms", None)
if noted is None:
noted = self._unserved_secondary_platforms = set()
if (profile_name, platform) in noted:
return
noted.add((profile_name, platform))
pv = platform.value
logger.info(
"[MULTIPLEX] Profile '%s': %s is enabled but not served — %s is process-level shared ingress "
"owned by the default profile under multiplex. Enable and configure %s on the default profile "
"(it serves every profile), or disable it in profile '%s'.",
profile_name, pv, pv, pv, profile_name,
)
self._update_platform_runtime_status(
f"{profile_name}:{pv}", platform_state="disabled", error_code="multiplex_shared_ingress",
error_message="not served under multiplex (shared ingress owned by default)",
)
def _unserved_shared_ingress_warnings(self) -> list:
"""Loud ``not being served`` lines for shared-ingress platforms secondaries enabled while
NO profile (default included) actually runs them; empty when the default serves the platform."""
noted = getattr(self, "_unserved_secondary_platforms", None) or ()
lines = []
for platform in sorted({p for _n, p in noted}, key=lambda p: p.value):
if platform in self.adapters or platform in (getattr(self, "_failed_platforms", None) or {}):
continue # the default owns it: secondaries ARE served through the shared adapter
profiles = sorted(n for n, p in noted if p is platform)
lines.append(
f"{platform.value} is enabled in profile(s) {', '.join(profiles)} but not on the default "
f"profile — the platform is not being served. Under multiplex {platform.value} is shared "
"ingress: enable and configure it on the default profile, or disable it in those profiles."
)
return lines
async def _start_one_profile_adapters(
self, profile_name: str, profile_home: "Path", claimed: Dict[tuple, str]
) -> int:
"""Create+connect one profile's adapters under its runtime scope."""
from gateway.run import _platform_has_bot_credential, _profile_runtime_scope
profile_cfg = await self._load_secondary_profile_config(profile_name, profile_home)
# Keep the served profile's config: host-wide passes (planned-restart notices) must reach
# every served profile's home channels, and this is the only place it is loaded.
configs = getattr(self, "_profile_configs", None)
if configs is None:
configs = self._profile_configs = {}
configs[profile_name] = profile_cfg
multiplex = self._multiplex_on()
profile_map = self._profile_adapters.setdefault(profile_name, {})
connected = 0
for platform, platform_config in profile_cfg.platforms.items():
if not platform_config.enabled:
continue
# Runtime re-scan of a served profile (config/.env changed): only platforms that are not
# already live or queued for reconnect are built — never a second poller on the same bot.
if platform in profile_map or platform in (
(getattr(self, "_profile_failed_platforms", None) or {}).get(profile_name) or {}):
continue
# No credential in THIS profile's scope: an adapter would fan inbound across every such profile.
if multiplex and not _platform_has_bot_credential(platform, platform_config):
logger.info(
"[MULTIPLEX] Profile '%s': skipping %s - no bot credential "
"in this profile's secrets", profile_name, platform.value,
)
continue
# Relay/WhatsApp are shared process-level ingress under multiplex; a secondary would retry-loop.
# Say so: four profiles with WHATSAPP_ENABLED=true and nothing in the log is a silent dead channel.
if multiplex and platform in (Platform.RELAY, Platform.WHATSAPP):
self._note_unserved_secondary_platform(profile_name, platform)
continue
# api_server / webhook: the default's listener already mirrors them at /p/<profile>/; a second
# instance here would fight the default for the port (#100397).
if multiplex and platform.value in SHARED_LISTENER_MIRROR_PLATFORMS:
logger.info(
"[MULTIPLEX] Profile '%s': %s is served by the default profile's listener at /p/%s/ — "
"not starting a second listener", profile_name, platform.value, profile_name,
)
continue
adapter = None
with _log_suppressed(
logging.ERROR, "[MULTIPLEX] Profile '%s': _create_adapter('%s') raised %s", profile_name,
platform.value, exc_info=True,
):
with _profile_runtime_scope(profile_home, hydrate_secrets=False):
adapter = self._create_adapter(platform, platform_config)
if not adapter:
logger.warning(
"[MULTIPLEX] Profile '%s': skipping platform '%s' - adapter creation returned None",
profile_name, platform.value,
)
if not adapter:
continue
# Same-token / same-listener conflict detection — refuse a duplicate poll or bind.
credential_claim = self._adapter_credential_claim(platform, adapter)
listener_claim = self._adapter_listener_claim(platform, adapter)
owner_name = claimed.get(credential_claim) if credential_claim is not None else None
owner_origin, incoming_origin = (None, None)
if owner_name:
owner_origin, incoming_origin = self._duplicate_credential_origins(
owner_name, profile_name, profile_home, platform, adapter,
)
if self._refuse_duplicate_claim(
credential_claim, claimed, profile_name, platform, "credential",
owner_origin=owner_origin, incoming_origin=incoming_origin,
) or self._refuse_duplicate_claim(listener_claim, claimed, profile_name, platform, "listener"):
continue
self._configure_profile_adapter(adapter, profile_name, platform)
try:
with _profile_runtime_scope(profile_home, hydrate_secrets=False):
success = await self._connect_initial_adapter_with_timeout(adapter, platform)
if not success:
logger.warning("✗ %s failed to connect (profile: %s)", platform.value, profile_name)
except Exception as e:
logger.error("✗ %s error (profile: %s): %s", platform.value, profile_name, e)
success = False
if not success:
await self._safe_adapter_disconnect(adapter, platform)
self._schedule_secondary_profile_startup_reconnect(profile_name, platform, adapter)
continue
profile_map[platform] = adapter
# Restore persisted /voice state for this bot (primary startup and reconnects do too).
# See #84872.
self._sync_voice_mode_state_to_adapter(adapter)
for claim in (credential_claim, listener_claim):
if claim is not None:
claimed[claim] = profile_name
connected += 1
logger.info("✓ %s connected (profile: %s)", platform.value, profile_name)
return connected
def _wire_adapter_handlers(
self, adapter: BasePlatformAdapter, *, message_handler=None, fatal_error_handler=None,
busy_session_handler=None, authorization_check=None, platform_event_handler=None,
busy_text_mode: Optional[str] = None, busy_text_timing: Optional[tuple[float, float]] = None,
human_delay: Optional[tuple[int, int]] | object = _UNSET,
) -> None:
"""Install the runner callbacks every adapter needs (defaults = primary handlers;
secondary wiring passes profile-scoped variants). ``set_reaction_handler`` is optional."""
adapter.set_message_handler(message_handler or self._primary_message_handler())
adapter.set_fatal_error_handler(fatal_error_handler or self._handle_adapter_fatal_error)
adapter.set_session_store(self.session_store)
adapter.set_busy_session_handler(busy_session_handler or self._primary_busy_session_handler())
_set_reaction = getattr(adapter, "set_reaction_handler", None)
if callable(_set_reaction):
_set_reaction(self._handle_reaction_event)
adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)
adapter.set_authorization_check(
authorization_check or self._make_adapter_auth_check(adapter.platform)
)
adapter.set_platform_event_handler(platform_event_handler or self._primary_platform_event_handler())
adapter._busy_text_mode = (self._busy_text_mode if busy_text_mode is None else busy_text_mode)
timing = busy_text_timing or getattr(self, "_busy_text_timing", None)
if timing:
adapter._busy_text_debounce_seconds, adapter._busy_text_hard_cap_seconds = timing
adapter._human_delay_range_ms = (
getattr(self, "_human_delay", None) if human_delay is _UNSET else human_delay)
def _configure_profile_adapter(
self, adapter: BasePlatformAdapter, profile_name: str, platform: Platform
) -> None:
"""Install the profile-scoped handlers shared by startup and reconnect."""
# Runtime status is process-scoped: key on profile:platform so health shows WHICH secondary failed.
adapter._runtime_status_platform_key = f"{profile_name}:{platform.value}"
# Declare ownership BEFORE any inbound event: adapter-level session keys are derived at ingress,
# before the handler stamps source.profile (else every secondary keys into `agent:main:`).
_set_owner = getattr(adapter, "set_owner_profile", None)
if callable(_set_owner):
_set_owner(profile_name)
# Voice transcripts from this bot's channels dispatch through THIS adapter (primary wiring lives at
# connect time; see #75198).
text_modes = getattr(self, "_busy_text_modes_by_profile", None)
timings = getattr(self, "_busy_text_timing_by_profile", None)
delays = getattr(self, "_human_delay_by_profile", None)
self._wire_adapter_handlers(
adapter,
message_handler=self._make_profile_message_handler(profile_name),
fatal_error_handler=self._make_profile_fatal_error_handler(profile_name, platform),
busy_session_handler=self._make_profile_busy_session_handler(profile_name),
authorization_check=self._make_adapter_auth_check(platform, profile_name=profile_name),
platform_event_handler=self._make_profile_platform_event_handler(profile_name),
busy_text_mode=(
text_modes.get(profile_name, self._busy_text_mode)
if isinstance(text_modes, dict)
else self._busy_text_mode
),
busy_text_timing=(timings.get(profile_name) if isinstance(timings, dict) else None),
human_delay=(delays.get(profile_name, _UNSET) if isinstance(delays, dict) else _UNSET),
)
# Voice transcripts from this bot's channels dispatch through THIS adapter.
self._bind_voice_input_callback(adapter)
# Secondary adapters carry their profile so prune paths namespace topic bindings correctly.
# See #76423.
adapter._hermes_profile_name = profile_name
# A secondary's port-binding adapter never binds: the default profile owns the one shared
# listener, which forwards /p/<profile>/<path> to this adapter's app (shared_ingress.py).
if self._multiplex_on() and platform.value not in SHARED_LISTENER_MIRROR_PLATFORMS \
and _platform_binds_port(platform.value, getattr(getattr(adapter, "config", None), "extra", None)):
adapter._shared_listener_profile = profile_name
async def _secondary_reconnect_attempt(self, profile_name: str, platform: Platform, inbound_dedup=None):
"""One scoped attempt to rebuild+connect a secondary adapter → ``(adapter, success)``;
``(None, None)`` = give up for good (disabled, credential removed, adapter unavailable). Caller
tears down a RETURNED adapter; one whose configure/connect raised is torn down here."""
from gateway.run import _platform_has_bot_credential, _profile_runtime_scope
# Lazy + per-attempt: keeps test monkeypatches on these modules live.
from hermes_cli.profiles import get_profile_dir
from hermes_cli.env_loader import hydrate_profile_secret_sources
from gateway.config import load_gateway_config
profile_home = get_profile_dir(profile_name)
# Hydrate external secret sources off-loop so they cannot starve heartbeats.
await asyncio.to_thread(hydrate_profile_secret_sources, profile_home)
with _profile_runtime_scope(profile_home, hydrate_secrets=False):
profile_config = load_gateway_config().platforms.get(platform)
if profile_config is None or not profile_config.enabled:
return None, None
# Startup credential gate mirror: a removed credential must not rebuild.
# Mirrors the startup credential gate (#84079): a credential removed from this profile's scope
# must not rebuild an adapter that would fan out turns.
if not _platform_has_bot_credential(platform, profile_config):
logger.info(
"Secondary %s reconnect skipped: no bot credential (profile: %s)",
platform.value, profile_name,
)
return None, None
adapter = self._create_adapter(platform, profile_config)
if adapter is None:
logger.warning(
"Secondary %s reconnect skipped: adapter unavailable (profile: %s)",
platform.value, profile_name,
)
return None, None
carry_inbound_dedup(inbound_dedup, adapter)
try:
self._configure_profile_adapter(adapter, profile_name, platform)
success = await self._connect_adapter_with_timeout(adapter, platform, is_reconnect=True)
except BaseException:
# Caller never sees this adapter; release its partial resources here.
await self._safe_adapter_disconnect(adapter, platform)
raise
return adapter, success
async def _run_secondary_profile_reconnect(
self, profile_name: str, platform: Platform, inbound_dedup=None
) -> None:
"""Reconnect a retryable secondary adapter under its own profile scope."""
from gateway.run import _profile_runtime_scope, _reconnect_backoff
attempts = 0
# Same escalation shape as the primary queue entry; ``queued_at`` is this task's start.
queue_info = {"queued_at": time.monotonic(), "attempts": 0}
current_task = asyncio.current_task()
try:
while self._running:
adapter = None
try:
adapter, success = await self._secondary_reconnect_attempt(
profile_name, platform, inbound_dedup
)
if adapter is None:
return
if success and self._running:
profile_map = self._profile_adapters.setdefault(profile_name, {})
if platform not in profile_map:
profile_map[platform] = adapter
self._sync_voice_mode_state_to_adapter(adapter)
logger.info("✓ %s reconnected (profile: %s)", platform.value, profile_name)
await self._redeliver_failed_obligations_for_platform(
platform, profile=profile_name
)
# What a primary reconnect replays too: the owed notice spans served profiles' home
# channels, and sessions boot skipped for this offline adapter wait for this call.
self._schedule_planned_restart_replay()
try:
self._schedule_resume_pending_sessions(platform=platform)
except Exception:
logger.debug("resume-pending reschedule after %s reconnect failed (profile: %s)",
platform.value, profile_name, exc_info=True)
return
# Not installed (newer reconnect won the slot, shutdown began, or connect failed):
# release partial resources; stop only for a non-retryable fatal.
await self._safe_adapter_disconnect(adapter, platform)
if success or (
getattr(adapter, "has_fatal_error", False)
and not getattr(adapter, "fatal_error_retryable", True)
):
return
except BaseException as exc:
if adapter is not None:
await self._safe_adapter_disconnect(adapter, platform)
if not isinstance(exc, Exception):
raise # CancelledError (and other BaseExceptions) propagate after release
logger.debug(
"Secondary %s reconnect attempt failed (profile: %s)", platform.value,
profile_name, exc_info=True,
)
if not self._running:
return
attempts += 1
queue_info["attempts"] = attempts
profile_home = self._routed_profile_home(profile_name)
# The attempt above already hydrated this profile's secret sources off-loop.
with self._scope_or_null(
functools.partial(_profile_runtime_scope, hydrate_secrets=False), profile_home):
self._flag_reconnect_needs_attention(
platform, queue_info, time.monotonic(), status_key=f"{profile_name}:{platform.value}")
backoff = _reconnect_backoff(attempts)
logger.info(
"Secondary %s reconnect retry in %ds (profile: %s)", platform.value, backoff, profile_name
)
await asyncio.sleep(backoff)
finally:
# Release our slot unless a newer task already owns it.
pending = self._profile_failed_platforms
profile_pending = pending.get(profile_name) if isinstance(pending, dict) else None
if isinstance(profile_pending, dict):
task = profile_pending.get(platform)
if not isinstance(task, asyncio.Task) or task is current_task:
profile_pending.pop(platform, None)
if not profile_pending:
pending.pop(profile_name, None)
def _schedule_secondary_profile_startup_reconnect(
self, profile_name: str, platform: Platform, adapter: BasePlatformAdapter
) -> None:
"""Queue a cold-start reconnect: startup failures happen BEFORE ``_running`` flips True (the
regular scheduler would drop them), so park a task and hand off once live."""
if not getattr(adapter, "fatal_error_retryable", True):
return
if is_global_startup_conflict(getattr(adapter, "fatal_error_code", None)):
# A live foreign token holder is an ownership conflict, not a blip: park it fatal.
logger.error(
# Park it fatal (like ``duplicate_credential``) instead of retry-storming the token every
# backoff (#83183).
"[MULTIPLEX] Profile '%s': %s credential is held by another "
"gateway (%s) — parked, not retried. %s", profile_name, platform.value,
adapter.fatal_error_code, adapter.fatal_error_message or "",
)
self._mark_platform_fatal(f"{profile_name}:{platform.value}", adapter)
return
def _handoff() -> None:
try:
self._schedule_secondary_profile_reconnect(profile_name, platform, adapter)
except Exception:
# A raise here would die as an unretrieved-task exception logged only at GC; surface it.
logger.exception(
"secondary-startup-reconnect handoff failed (profile=%s platform=%s)",
profile_name, platform.value,
)
async def _await_running_then_schedule() -> None:
if self._running: # fast path (also the only path for bare runners without _shutdown_event)
_handoff()
return
# Poll (startup completion has no event); bounded so a wedged startup cannot spin.
while not self._running and not self._shutdown_event.is_set():
await asyncio.sleep(0.1)
if self._running and not self._shutdown_event.is_set():
_handoff()
self._retain_background_task(asyncio.create_task(
_await_running_then_schedule(),
name=f"secondary-startup-reconnect:{profile_name}:{platform.value}",
))
def _schedule_secondary_profile_reconnect(
self, profile_name: str, platform: Platform, adapter: BasePlatformAdapter
) -> None:
"""Schedule one runner-owned reconnect without sharing primary secrets."""
if not self._running or not adapter.fatal_error_retryable:
return
pending = self._profile_failed_platforms
if not isinstance(pending, dict):
pending = self._profile_failed_platforms = {}
profile_pending = pending.setdefault(profile_name, {})
if platform in profile_pending:
return
profile_pending[platform] = self._retain_background_task(asyncio.create_task(
self._run_secondary_profile_reconnect(profile_name, platform, inbound_dedup_caches(adapter)),
name=f"secondary-reconnect:{profile_name}:{platform.value}",
))
def _make_profile_fatal_error_handler(
self, profile_name: str, platform: Platform
) -> Callable[[BasePlatformAdapter], Awaitable[None]]:
"""Route a secondary-profile fatal error to that profile's reconnect slot."""
return functools.partial(self._handle_profile_adapter_fatal_error, profile_name, platform)
async def _handle_profile_adapter_fatal_error(
self, profile_name: str, platform: Platform, adapter: BasePlatformAdapter
) -> None:
"""Remove a failed multiplexed adapter (the primary-only fatal handler ignores them)."""
profile_map = getattr(self, "_profile_adapters", {}).get(profile_name)
if not isinstance(profile_map, dict) or profile_map.get(platform) is not adapter:
logger.debug(
"Ignoring stale fatal error from secondary %s adapter (profile: %s)",
platform.value, profile_name,
)
return
profile_map.pop(platform, None)
await self._safe_adapter_disconnect(adapter, platform)
if not self._running:
return
self._schedule_secondary_profile_reconnect(profile_name, platform, adapter)
logger.error(
"Fatal %s adapter error for multiplexed profile %s (%s)", platform.value, profile_name,
adapter.fatal_error_code or "unknown",
)
@staticmethod
def _routed_profile_home(profile_name: str):
"""A named routed profile's home, or :data:`UNRESOLVED_PROFILE_HOME` when it does not
resolve (deleted or renamed mid-run, invalid name, transient OSError).
Never ``None``: ``None`` is reserved for "this body is the launch profile's own work", and
answering it for an unresolvable NAMED profile is what made a secondary's inbound message
run on the launch profile's credentials.
"""
from hermes_cli.profiles import get_profile_dir
try:
return get_profile_dir(profile_name)
except Exception:
logger.warning(
"Profile home for '%s' does not resolve; its handlers run with no profile scope "
"(credential reads fail closed instead of borrowing the launch profile's)",
profile_name, exc_info=True)
return UNRESOLVED_PROFILE_HOME
@staticmethod
def _scope_or_null(scope_factory, profile_home):
"""The runtime scope for one body, by what ``profile_home`` IS:
* a home — that profile's scope;
* ``None`` — no routed profile: the LAUNCH profile's own work, so bind the launch profile
explicitly once this process multiplexes. A bare ``nullcontext()`` made the launch
profile the one tenant running on ambient ``os.environ`` and the process home, which a
secondary's context may have poisoned; under the one-process-per-host ruling it is a
tenant like any other. Single-profile hosts are unchanged (no-op until activation);
* :data:`UNRESOLVED_PROFILE_HOME` — a NAMED profile whose home is gone: bind nothing, so an
unscoped ``get_secret`` raises ``UnscopedSecretError`` under multiplexing. A profile never
borrows another profile's value, and "we cannot tell whose this is" must fail closed.
"""
if profile_home is UNRESOLVED_PROFILE_HOME:
return contextlib.nullcontext()
if profile_home is not None:
return scope_factory(profile_home)
from tui_gateway.launch_profile_policy import launch_profile_scope_if_multiplexed
return launch_profile_scope_if_multiplexed()
@staticmethod
def _async_scope_or_null(scope_factory, profile_home):
"""``async with`` twin of :meth:`_scope_or_null` (for ``_async_profile_runtime_scope``)."""
if profile_home is UNRESOLVED_PROFILE_HOME:
return contextlib.nullcontext()
if profile_home is not None:
return scope_factory(profile_home)
from tui_gateway.launch_profile_policy import async_launch_profile_scope_if_multiplexed
return async_launch_profile_scope_if_multiplexed()
def _canonicalize(self, source, *, transport_profile: Optional[str] = None,
primary_home: Optional[Path] = None):
"""Runner-side identity seam: the pinned :class:`RoutingIdentity` of *source*, resolving it
once when absent. ``transport_profile`` names a secondary's own bot (its handlers know it by
construction); ``None`` = the primary/shared bot. ``None`` result = rejected route under
multiplexing (the caller drops; the ingress gate warns once) or a source that cannot resolve
(bare test rigs) — the legacy readers then stay in force."""
if source is None:
return None
from gateway.session_identity import canonical_identity
try:
identity = canonical_identity(
source, runner=self, transport_profile=transport_profile, primary_home=primary_home)
except Exception:
logger.debug("identity resolution failed; legacy readers stay in force", exc_info=True)
return None
if transport_profile and not getattr(source, "profile", None):
with suppress(Exception):
source.profile = transport_profile # a secondary's own event is at least its own
return identity
def _make_profile_message_handler(self, profile_name: str):
"""Message handler that canonicalizes the event's identity FIRST, then delegates under the
profile scope (auth runs BEFORE the agent-turn scope, so the profile's ``.env`` must be
visible here)."""
from gateway.run import _async_profile_runtime_scope
profile_home = self._routed_profile_home(profile_name)
async def _handler(event):
self._canonicalize(getattr(event, "source", None), transport_profile=profile_name)
async with self._async_scope_or_null(_async_profile_runtime_scope, profile_home):
return await self._handle_message(event)
return _handler
def _make_profile_busy_session_handler(self, profile_name: str):
"""Busy-path twin: canonicalize FIRST, then resolve busy policy under the profile scope
(auth runs against the profile's own allowlist, same as the cold-path message handler)."""
from gateway.run import _async_profile_runtime_scope
profile_home = self._routed_profile_home(profile_name)
async def _handler(event, _session_key):
self._canonicalize(event.source, transport_profile=profile_name)
async with self._async_scope_or_null(_async_profile_runtime_scope, profile_home):
return await self._handle_active_session_busy_message(event, self._session_key_for_source(event.source))
return _handler
def _make_default_profile_message_handler(self):
"""Scope primary-adapter messages to their routed multiplex profile. Authorization stays
with the transport profile (a routed profile may have no credential/allowlist)."""
from gateway.run import _async_profile_runtime_scope, get_hermes_home
default_home = Path(get_hermes_home())
async def _handler(event):
# A rejected route still enters ``_handle_message``, whose ingress gate drops it fail-closed.
profile_home = self._admit_primary_source(event.source, default_home) or default_home
async with _async_profile_runtime_scope(profile_home):
return await self._handle_message(event)
return _handler
def _make_default_profile_busy_session_handler(self):
"""Busy-path twin of ``_make_default_profile_message_handler``: busy callbacks bypass the message
handler, so the routed scope and transport-home authorization must be re-established here or the
follow-up is authorized in whatever scope is ambient (#103717)."""
from gateway.run import _async_profile_runtime_scope, get_hermes_home
default_home = Path(get_hermes_home())
async def _handler(event, _session_key):
source = event.source
profile_home = self._admit_primary_source(source, default_home)
if profile_home is None:
return True # rejected route: swallow, same disposition as the ingress gate
async with _async_profile_runtime_scope(profile_home):
return await self._handle_active_session_busy_message(
event, self._session_key_for_source(source)
)
return _handler
def _admit_primary_source(self, source, default_home: Path) -> Optional[Path]:
"""Canonicalize a primary-adapter source (transport home for authorization, routed profile
for the runtime) and return the runtime home to scope the turn under; ``None`` when the
route targets an unserved profile. Route ≠ admitting bot."""
identity = self._canonicalize(source, primary_home=default_home)
if identity is not None:
return identity.runtime_home
return None if getattr(source, "profile_route_rejected", False) is True else default_home
def _primary_message_handler(self):
"""Return the correctly scoped handler for a primary adapter."""
if self._multiplex_on():
return self._make_default_profile_message_handler()
return self._standalone_scoped(self._handle_message)
def _primary_busy_session_handler(self):
"""Return the correctly scoped busy-session handler for a primary adapter."""
if self._multiplex_on():
return self._make_default_profile_busy_session_handler()
return self._standalone_scoped(self._handle_active_session_busy_message)
def _standalone_scoped(self, handler):
"""Standalone twin of the ``_make_default_profile_*`` wrappers: run ``handler`` under
``_standalone_launch_scope`` so slash commands and turns keep resolving the launch profile's
credentials after a hosted room flipped the process-wide guard (#112878). Decided per event:
activation happens after the adapters were wired."""
async def _handler(*args):
with self._standalone_launch_scope():
return await handler(*args)
return _handler
def _multiplex_on(self) -> bool:
return bool(getattr(self.config, "multiplex_profiles", False))
async def _handle_gateway_platform_event(self, event: dict, source) -> None:
"""Authorize and publish one normalized adapter event to plugin hooks."""
# Observer failures must never break the adapter's update loop.
with _log_suppressed(logging.DEBUG, "gateway_platform_event hook dispatch failed", exc_info=True):
from hermes_cli.lifecycle import has_hook, invoke_hook
if has_hook("gateway_platform_event") and self._is_user_authorized_for_source(source):
invoke_hook("gateway_platform_event", **event)
def _make_profile_platform_event_handler(self, profile_name: str):
"""Bind platform-event auth and hook dispatch to one multiplex profile."""
from gateway.run import _profile_runtime_scope
profile_home = self._routed_profile_home(profile_name)
async def _handler(event, source):
self._canonicalize(source, transport_profile=profile_name)
with self._scope_or_null(_profile_runtime_scope, profile_home):
return await self._handle_gateway_platform_event(event, source)
return _handler
def _make_default_profile_platform_event_handler(self):
"""Scope primary-transport events to their routed multiplex profile."""
from gateway.run import _profile_runtime_scope, get_hermes_home
default_home = Path(get_hermes_home())
async def _handler(event, source):
profile_home = self._admit_primary_source(source, default_home)
if profile_home is None:
return None # rejected route: same disposition as the message ingress gate
with _profile_runtime_scope(profile_home):
return await self._handle_gateway_platform_event(event, source)
return _handler
def _primary_platform_event_handler(self):
if self._multiplex_on():
return self._make_default_profile_platform_event_handler()
return self._standalone_scoped(self._handle_gateway_platform_event)
@staticmethod
def _adapter_credential_claim(platform: Platform, adapter: Any) -> Optional[tuple]:
"""Return the exclusive credential resource claimed by an adapter."""
from gateway.run import GatewayRunner
fingerprint = GatewayRunner._adapter_credential_fingerprint(adapter)
return None if fingerprint is None else (platform, fingerprint)
@staticmethod
def _adapter_listener_claim(platform: Platform, adapter: Any) -> Optional[tuple]:
"""Exclusive listener claim (Photon sidecar bind+port): distinct credentials still cannot
share a port, so the later adapter is rejected before connect() disturbs the first."""
bind = getattr(adapter, "_sidecar_bind", None)
if getattr(platform, "value", None) != "photon" or not isinstance(bind, str) or not bind.strip():
return None
try:
port = int(getattr(adapter, "_sidecar_port", None))
except (TypeError, ValueError):
return None
return ("listener", "photon", bind.strip().lower(), port)
@staticmethod
def _adapter_credential_fingerprint(adapter: Any) -> Optional[str]:
"""Salted, log-safe hash of an adapter's credential; None when none is discoverable
(conflict detection is then skipped)."""
# Many adapters (Discord) keep the token on `config`; without that fallback the check is skipped.
candidates = [
(adapter, attr) for attr in (
"token", "bot_token", "_token", "api_token", "_bot_token",
"_project_secret", # Photon/Spectrum: project credentials, not a bot token
"_app_id", # Feishu/Lark app_id — stable, log-safe, already the _app_lock_identity
"_client_id", "_bot_id", # Teams / WeCom app-style id pairs
)
] + [(getattr(adapter, "config", None), attr) for attr in ("token", "bot_token")]
for obj, attr in candidates:
val = getattr(obj, attr, None)
if isinstance(val, str) and val.strip():
import hashlib
return hashlib.sha256(("hermes-mux:" + val.strip()).encode("utf-8")).hexdigest()[:16]
return None
def _create_adapter(self, platform: Platform, config: Any) -> Optional[BasePlatformAdapter]:
"""Create an adapter bound to this runner (every lifecycle path goes through here so
adapters can resolve inbound profile routes before handlers or connect())."""
adapter = self._instantiate_adapter(platform, config)
if adapter is not None:
adapter.gateway_runner = self
return adapter
def _instantiate_adapter(self, platform: Platform, config: Any) -> Optional[BasePlatformAdapter]:
"""Instantiate the adapter for a platform: plugin registry first, then built-ins."""
from gateway.run import _instantiate_builtin_adapter
if hasattr(config, "extra") and isinstance(config.extra, dict):
config.extra.setdefault("group_sessions_per_user", self.config.group_sessions_per_user)
config.extra.setdefault(
"thread_sessions_per_user", getattr(self.config, "thread_sessions_per_user", False)
)
with _log_suppressed(logging.DEBUG, "Platform registry lookup for '%s' failed: %s", platform.value):
from gateway.platform_registry import platform_registry
if platform_registry.is_registered(platform.value):
adapter = platform_registry.create_adapter(platform.value, config)
if adapter is None: # registered but failed — never fall through to built-ins
logger.error(
"Platform '%s' is registered but adapter creation failed "
"(check dependencies and config)", platform.value,
)
return adapter
return _instantiate_builtin_adapter(platform, config)
def _make_adapter_auth_check(
self, platform: Platform, profile_name: Optional[str] = None
) -> Callable[[str, Optional[str], Optional[str]], bool]:
"""Platform-bound auth callback for adapters (prompt-injection mitigation for fetched
context); delegates to :meth:`_is_user_authorized`. ``profile_name`` binds a secondary to
its scope; for the shared primary (None) the routed profile is stamped so its pairing store
is consulted while allowlist reads stay under the transport home.
Without this an inline-button caller approved only in the routed profile's pairing store was denied
(#86296), because the adapter's callback source was never route-stamped. A secondary-owned bot's
callback fires outside the profile runtime scope (straight off the adapter's event loop), so the
check re-enters the owning profile's scope per call — the gate's scoped env read otherwise falls
back to os.environ (the default profile's env) and denies the secondary's own allowlisted
callers (#120639).
"""
from gateway.run import get_hermes_home
transport_home = Path(get_hermes_home()) if self._multiplex_on() and profile_name is None else None
# Resolved once; the scope is entered per call so an ``.env`` allowlist edit reaches the next
# tap, matching the message path's per-message re-read.
profile_home = self._routed_profile_home(profile_name) if profile_name else None
def check(
user_id: str, chat_type: Optional[str] = None, chat_id: Optional[str] = None, *,
is_bot: bool = False, thread_id: Optional[str] = None,
) -> bool:
if not user_id:
return False
source = SessionSource(
platform=platform, chat_id=chat_id or "", chat_type=chat_type or "group",
user_id=user_id, thread_id=thread_id, is_bot=bool(is_bot), profile=profile_name,
)
# Same transport provenance as ``build_source``, so policy reads resolve the receiving adapter.
registry = (
(getattr(self, "_profile_adapters", None) or {}).get(profile_name)
if profile_name else getattr(self, "adapters", None)
) or {}
adapter = registry.get(platform)
if adapter is not None:
source._transport_adapter_ref = _weakref.ref(adapter)
if transport_home is None:
# Sync, on the adapter's event loop (per tap, per inline-query keystroke): never
# hydrate external secret sources here — that takes the process-global source lock
# (#99519). Startup and the message path hydrate off-loop; this reads their cache.
from gateway.run import _profile_runtime_scope
with self._scope_or_null(
functools.partial(_profile_runtime_scope, hydrate_secrets=False), profile_home):
return self._is_user_authorized(source)
# Canonicalize FIRST (callback sources never went through ``build_source``): the routed
# profile's pairing store is consulted, allowlists read under the transport home.
if self._canonicalize(source, primary_home=transport_home) is None:
return False # fail-closed, like the ``_handle_message`` ingress gate
return self._is_user_authorized_for_source(source)
return check