Merge branch 'simp/r3-11-2d' into simp/integration3
This commit is contained in:
+130
-238
@@ -11,6 +11,7 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
import asyncio
|
||||
import contextlib
|
||||
from contextlib import suppress
|
||||
import functools
|
||||
import os
|
||||
import time
|
||||
@@ -38,22 +39,17 @@ class GatewayAdapterLifecycleMixin:
|
||||
|
||||
@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 to exit, so a connect()/close()
|
||||
that swallows ``CancelledError`` blocks recovery forever. Returns True if it finished in time.
|
||||
"""
|
||||
"""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)
|
||||
except asyncio.CancelledError:
|
||||
task.cancel()
|
||||
task.add_done_callback(consume_detached_task_result)
|
||||
raise
|
||||
if task in done:
|
||||
return True
|
||||
task.cancel()
|
||||
task.add_done_callback(consume_detached_task_result)
|
||||
return False
|
||||
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."""
|
||||
@@ -67,11 +63,8 @@ class GatewayAdapterLifecycleMixin:
|
||||
return True
|
||||
|
||||
async def _safe_adapter_disconnect(self, adapter, platform) -> None:
|
||||
"""Call adapter.disconnect() defensively (bounded, never raises).
|
||||
|
||||
After a failed connect() partial resources (ClientSession, poll tasks, subprocesses) would
|
||||
otherwise leak; must tolerate partial-init state.
|
||||
"""
|
||||
"""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):
|
||||
@@ -82,25 +75,24 @@ class GatewayAdapterLifecycleMixin:
|
||||
)
|
||||
|
||||
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 shutdown past systemd's ``TimeoutStopSec``; the
|
||||
SIGKILL skips ``atexit`` PID-file cleanup and the next start dies with "PID file race lost".
|
||||
"""
|
||||
"""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"."""
|
||||
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
|
||||
):
|
||||
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)
|
||||
try:
|
||||
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,
|
||||
@@ -110,11 +102,6 @@ class GatewayAdapterLifecycleMixin:
|
||||
"✗ %s disconnect timed out after %.1fs - forcing continue%s",
|
||||
platform.value, timeout, suffix,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"✗ %s disconnect error after %.2fs%s: %s",
|
||||
platform.value, time.monotonic() - started_at, suffix, e,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _env_timeout_override(name: str) -> Optional[float]:
|
||||
@@ -177,18 +164,17 @@ class GatewayAdapterLifecycleMixin:
|
||||
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).
|
||||
|
||||
Detached task: 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.
|
||||
"""
|
||||
"""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()
|
||||
task = self._track_task_in(tasks, asyncio.create_task(self._handle_adapter_fatal_error_detached(adapter)))
|
||||
# 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(task)
|
||||
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
|
||||
@@ -213,9 +199,8 @@ class GatewayAdapterLifecycleMixin:
|
||||
if not platform_config:
|
||||
return False
|
||||
if adapter.platform in self._failed_platforms:
|
||||
# Already queued is exactly when the watcher may have died (_spawn_supervised gives up
|
||||
# after _MAX_SUPERVISED_RESTARTS); without this backstop the platform is a silent
|
||||
# permanent outage — nothing retries and the stranded check treats "queued" as safe.
|
||||
# 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.
|
||||
self._ensure_reconnect_watcher_running()
|
||||
return False
|
||||
self._failed_platforms[adapter.platform] = self._reconnect_queue_entry(
|
||||
@@ -229,8 +214,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
"""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: a hang anywhere in the impl must not wedge this task — the
|
||||
# stranded check in ``finally`` only runs when we return.
|
||||
# Outer hard deadline: the stranded check in ``finally`` only runs when we return.
|
||||
timeout = self._adapter_disconnect_timeout_secs()
|
||||
if timeout <= 0:
|
||||
await self._handle_adapter_fatal_error_impl(adapter)
|
||||
@@ -270,16 +254,14 @@ class GatewayAdapterLifecycleMixin:
|
||||
await self.stop()
|
||||
|
||||
def _queue_retryable_best_effort(self, adapter: BasePlatformAdapter, why: str) -> None:
|
||||
try:
|
||||
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)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to queue %s after fatal-handler %s", adapter.platform.value, why, exc_info=True,
|
||||
)
|
||||
|
||||
async def _handle_adapter_fatal_error_impl(self, adapter: BasePlatformAdapter) -> None:
|
||||
# Snapshot the slot owner first: a stale notification must not overwrite a healthy
|
||||
# platform's runtime status or re-queue it.
|
||||
# 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(
|
||||
@@ -287,7 +269,6 @@ class GatewayAdapterLifecycleMixin:
|
||||
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",
|
||||
@@ -302,21 +283,15 @@ class GatewayAdapterLifecycleMixin:
|
||||
error_code=adapter.fatal_error_code,
|
||||
error_message=adapter.fatal_error_message,
|
||||
)
|
||||
|
||||
if existing is adapter:
|
||||
# Claim for teardown BEFORE awaiting disconnect(), else a concurrent second fatal
|
||||
# notification for the same adapter would disconnect() it twice.
|
||||
# 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 half-dead transport can wedge native close() (or
|
||||
# swallow CancelledError), and "disconnect then queue" left platforms permanently deaf.
|
||||
# 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.
|
||||
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:
|
||||
@@ -326,8 +301,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
logger.error("No connected messaging platforms remain. Shutting down gateway cleanly.")
|
||||
await self.stop()
|
||||
elif not self.adapters and self._failed_platforms:
|
||||
# Everything is down but queued: stay alive (cron still runs, watcher recovers) rather
|
||||
# than turn a transient outage into a state-killing systemd restart loop.
|
||||
# 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 "
|
||||
@@ -363,24 +337,17 @@ class GatewayAdapterLifecycleMixin:
|
||||
def _spawn_supervised(
|
||||
self, coro_factory, name, *, restart=True, _attempt=0, on_spawn=None, on_give_up=None
|
||||
):
|
||||
"""Launch a long-lived background task with task-level supervision.
|
||||
|
||||
Catches outer-loop/pre-try exceptions a bare ``create_task`` drops silently. Restarts with
|
||||
capped backoff up to ``_MAX_SUPERVISED_RESTARTS`` rapid failures; the counter resets after
|
||||
``_SUPERVISED_HEALTHY_SECS`` of healthy running. Each spawn uses a fresh ``Context`` (an
|
||||
inherited delegated-child marker would make the Kanban dispatcher reject its own writes).
|
||||
``on_spawn`` fires on EVERY spawn incl. respawns — callers tracking the handle elsewhere
|
||||
MUST pass it or a respawn leaves a stale handle and a SECOND watcher. ``on_give_up(name)``
|
||||
fires when the restart budget is spent.
|
||||
"""
|
||||
"""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."""
|
||||
# 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); running it from a fresh
|
||||
# Context gives the same isolation as create_task(..., context=Context()).
|
||||
# No create_task kwargs (test doubles mock a narrow signature); Context().run isolates instead.
|
||||
task = Context().run(lambda: asyncio.create_task(coro_factory()))
|
||||
# Marks a PERMANENT watcher so the scale-to-zero idle check ignores it (else the gateway
|
||||
# counts itself busy forever); transient _background_tasks stay counted.
|
||||
# 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:
|
||||
@@ -401,8 +368,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
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 so a
|
||||
# daemon crashing a few times over days is never abandoned.
|
||||
# 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:
|
||||
@@ -434,23 +400,18 @@ class GatewayAdapterLifecycleMixin:
|
||||
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 ``MessageEvent``, mark ``completed``/``failed``.
|
||||
"""
|
||||
"""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, _handoff_watch_scopes, _reclaim_stale
|
||||
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 keyed by session id: a handoff runs a FULL agent turn, so inline
|
||||
# processing would let one slow handoff time out every other profile's poll.
|
||||
# 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:
|
||||
@@ -459,8 +420,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
await self._process_handoff(*((row, profile_name) if _process_takes_profile else (row,)))
|
||||
await session_db.complete_handoff(session_id)
|
||||
except asyncio.CancelledError:
|
||||
# Gateway shutting down: leave the row 'running' so the next
|
||||
# start's reclaim marks it failed with a clear reason.
|
||||
# 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)
|
||||
@@ -470,12 +430,9 @@ class GatewayAdapterLifecycleMixin:
|
||||
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``.
|
||||
"""
|
||||
"""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
|
||||
@@ -487,15 +444,13 @@ class GatewayAdapterLifecycleMixin:
|
||||
if not await session_db.claim_handoff(session_id):
|
||||
# Another tick or another gateway already claimed it.
|
||||
continue
|
||||
# INVARIANT (do not weaken): this task is created inside _profile_runtime_scope but
|
||||
# typically RUNS after it exits; it sees the profile's home/secret scope only because
|
||||
# those seams are ContextVar-based and ensure_future copies the Context.
|
||||
# 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.
|
||||
inflight[session_id] = asyncio.ensure_future(
|
||||
_dispatch(row, session_id, session_db, profile_name)
|
||||
)
|
||||
|
||||
# A row still 'running' at startup belongs to a gateway that died mid-dispatch; it blocks
|
||||
# request_handoff until reclaimed.
|
||||
# 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._scope_or_null(_async_profile_runtime_scope, profile_home)
|
||||
|
||||
@@ -503,7 +458,6 @@ class GatewayAdapterLifecycleMixin:
|
||||
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:
|
||||
@@ -544,10 +498,9 @@ class GatewayAdapterLifecycleMixin:
|
||||
"""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),
|
||||
"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
|
||||
|
||||
@@ -561,9 +514,8 @@ class GatewayAdapterLifecycleMixin:
|
||||
):
|
||||
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,
|
||||
"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)
|
||||
@@ -627,18 +579,15 @@ class GatewayAdapterLifecycleMixin:
|
||||
return
|
||||
info["attention_flagged"] = True
|
||||
queued_for = now - info.get("queued_at", now)
|
||||
retrying_since_iso = (datetime.now(timezone.utc) - timedelta(seconds=queued_for)).isoformat()
|
||||
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`.", platform.value,
|
||||
queued_for / 3600.0, info.get("attempts", 0),
|
||||
"%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`.",
|
||||
platform.value, queued_for / 3600.0, info.get("attempts", 0),
|
||||
)
|
||||
self._update_platform_runtime_status(
|
||||
platform.value, platform_state="retrying", needs_attention=True,
|
||||
retrying_since=retrying_since_iso,
|
||||
retrying_since=(datetime.now(timezone.utc) - timedelta(seconds=queued_for)).isoformat(),
|
||||
)
|
||||
|
||||
def _mark_platform_fatal(self, status_key: str, adapter) -> None:
|
||||
@@ -671,7 +620,6 @@ class GatewayAdapterLifecycleMixin:
|
||||
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
|
||||
@@ -680,17 +628,14 @@ class GatewayAdapterLifecycleMixin:
|
||||
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
|
||||
|
||||
self._wire_adapter_handlers(adapter)
|
||||
# Reconnect after outage: keep the platform's server-side update queue so
|
||||
# messages sent while the bot was offline are delivered rather than dropped.
|
||||
# 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)
|
||||
@@ -700,13 +645,11 @@ class GatewayAdapterLifecycleMixin:
|
||||
"Reconnect %s: non-retryable error (%s), removing from retry queue",
|
||||
platform.value, adapter.fatal_error_message,
|
||||
)
|
||||
# Never installed on self.adapters, so nothing else will disconnect() it: dispose or
|
||||
# the resource owners built in __init__ leak ~2 fds each (fd limit in ~12h at the cap).
|
||||
# Never installed on self.adapters: dispose here or its __init__ resources leak ~2 fds each.
|
||||
await _dispose_unused_adapter(adapter)
|
||||
del self._failed_platforms[platform]
|
||||
else:
|
||||
# Retryable failures retry at the backoff cap forever; never auto-pause them — a
|
||||
# transient outage must not need `/platform resume`. Same fd-leak dispose as above.
|
||||
# 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",
|
||||
@@ -741,31 +684,21 @@ class GatewayAdapterLifecycleMixin:
|
||||
needs_attention=False, retrying_since=None,
|
||||
)
|
||||
logger.info("✓ %s reconnected successfully", platform.value)
|
||||
|
||||
# Final responses rejected while this adapter was down are still owned by this live
|
||||
# process, so startup recovery cannot claim them. Replay the transient subset now.
|
||||
try:
|
||||
# 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)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"failed-obligation redelivery after %s reconnect failed", platform.value, exc_info=True,
|
||||
)
|
||||
|
||||
# Rebuild channel directory with the new adapter
|
||||
try:
|
||||
with suppress(Exception):
|
||||
from gateway.channel_directory import build_channel_directory
|
||||
await build_channel_directory(self.adapters)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# A platform offline at gateway startup never got its restart-interrupted sessions
|
||||
# auto-resumed — the startup pass skips sessions whose adapter isn't connected yet.
|
||||
# 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
|
||||
)
|
||||
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
|
||||
@@ -794,21 +727,17 @@ class GatewayAdapterLifecycleMixin:
|
||||
|
||||
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.
|
||||
"""
|
||||
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, SecondaryPortBindingConfigError, _multiplex_profile_homes
|
||||
)
|
||||
if not getattr(self.config, "multiplex_profiles", False):
|
||||
if not self._multiplex_on():
|
||||
return 0
|
||||
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
active = get_active_profile_name() or "default"
|
||||
connected = 0
|
||||
claimed = self._primary_resource_claims(active)
|
||||
@@ -825,10 +754,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
except MultiplexConfigError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to start adapters for profile '%s': %s", profile_name, e, exc_info=True
|
||||
)
|
||||
|
||||
logger.error("Failed to start adapters for profile '%s': %s", profile_name, e, exc_info=True)
|
||||
self._record_served_profiles(active, profile_homes)
|
||||
return connected
|
||||
|
||||
@@ -863,33 +789,25 @@ class GatewayAdapterLifecycleMixin:
|
||||
write_runtime_status(served_profiles=served)
|
||||
|
||||
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) or ``SecondaryPortBindingConfigError``
|
||||
(the default profile owns the single shared HTTP listener).
|
||||
"""
|
||||
"""Hydrate + enter ``profile_home``'s scope once; return its gateway config. Raises
|
||||
``MultiplexConfigError`` (open dm/group policy) or ``SecondaryPortBindingConfigError`` (the
|
||||
default profile owns the single shared HTTP listener)."""
|
||||
from gateway.run import (
|
||||
MultiplexConfigError, SecondaryPortBindingConfigError, _load_gateway_runtime_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 (1Password/vault/...) off-loop ONCE: the sync hydration is
|
||||
# network-bound and would stall every other profile's heartbeat while this one boots.
|
||||
# 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_runtime_config()
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins()
|
||||
|
||||
# Register this profile's shell hooks / outbound webhooks: start() registers before any
|
||||
# profile scope exists, so a secondary profile's `hooks:` block would be silently inert.
|
||||
# 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)
|
||||
@@ -897,10 +815,8 @@ class GatewayAdapterLifecycleMixin:
|
||||
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'."
|
||||
"for that profile, or change dm_policy/group_policy away from 'open'."
|
||||
)
|
||||
|
||||
port_binding_platforms = sorted(
|
||||
platform.value
|
||||
for platform, platform_config in profile_cfg.platforms.items()
|
||||
@@ -955,38 +871,35 @@ class GatewayAdapterLifecycleMixin:
|
||||
"""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)
|
||||
multiplex = getattr(self.config, "multiplex_profiles", False)
|
||||
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
|
||||
# Enabled in the shared YAML but no credential in THIS profile's scope: building an
|
||||
# adapter anyway would fan one inbound message out across every credential-less profile.
|
||||
# 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 (the active profile's
|
||||
# one connection fans out by route); a secondary would only retry-loop and stall startup.
|
||||
# Relay/WhatsApp are shared process-level ingress under multiplex; a secondary would retry-loop.
|
||||
if multiplex and platform in (Platform.RELAY, Platform.WHATSAPP):
|
||||
continue
|
||||
try:
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[MULTIPLEX] Profile '%s': _create_adapter('%s') raised %s", profile_name,
|
||||
platform.value, e, exc_info=True,
|
||||
)
|
||||
continue
|
||||
if not adapter:
|
||||
logger.warning(
|
||||
"[MULTIPLEX] Profile '%s': skipping platform '%s' - adapter creation returned None",
|
||||
profile_name, platform.value,
|
||||
)
|
||||
if not adapter:
|
||||
logger.warning(
|
||||
"[MULTIPLEX] Profile '%s': skipping platform '%s' - adapter creation returned None",
|
||||
profile_name, platform.value,
|
||||
)
|
||||
continue
|
||||
# Same-token / same-listener conflict detection — refuse a duplicate poll or bind.
|
||||
credential_claim = self._adapter_credential_claim(platform, adapter)
|
||||
@@ -1043,12 +956,10 @@ class GatewayAdapterLifecycleMixin:
|
||||
self, adapter: BasePlatformAdapter, profile_name: str, platform: Platform
|
||||
) -> None:
|
||||
"""Install the profile-scoped handlers shared by startup and reconnect."""
|
||||
# Runtime status is process-scoped while message/config work is profile-scoped. Keep both
|
||||
# dimensions in the key so dashboard/NAS health aggregation sees which secondary failed.
|
||||
# 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 — without this every secondary bot
|
||||
# would key into the default profile's `agent:main:` lane.
|
||||
# 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)
|
||||
@@ -1066,19 +977,15 @@ class GatewayAdapterLifecycleMixin:
|
||||
else self._busy_text_mode
|
||||
),
|
||||
)
|
||||
# Voice transcripts from this bot's channels dispatch through THIS adapter (primary
|
||||
# wiring lives at connect time).
|
||||
# Voice transcripts from this bot's channels dispatch through THIS adapter.
|
||||
self._bind_voice_input_callback(adapter)
|
||||
# Secondary adapters always carry the profile they serve so prune paths namespace topic
|
||||
# bindings correctly under multiplex.
|
||||
# Secondary adapters carry their profile so prune paths namespace topic bindings correctly.
|
||||
adapter._hermes_profile_name = profile_name
|
||||
|
||||
async def _secondary_reconnect_attempt(self, profile_name: str, platform: Platform):
|
||||
"""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.
|
||||
"""
|
||||
"""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
|
||||
@@ -1136,34 +1043,29 @@ class GatewayAdapterLifecycleMixin:
|
||||
platform, profile=profile_name
|
||||
)
|
||||
return
|
||||
# Not installed: a newer reconnect won the slot mid-connect (never replace it),
|
||||
# shutdown began mid-connect (never republish after the registry drained), or
|
||||
# connect failed. Release partial resources; stop only for a non-retryable fatal.
|
||||
# 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 asyncio.CancelledError:
|
||||
if adapter is not None:
|
||||
await self._safe_adapter_disconnect(adapter, platform)
|
||||
raise
|
||||
except Exception:
|
||||
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
|
||||
backoff = _reconnect_backoff(attempts)
|
||||
logger.info(
|
||||
"Secondary %s reconnect retry in %ds (profile: %s)", platform.value, backoff,
|
||||
profile_name,
|
||||
"Secondary %s reconnect retry in %ds (profile: %s)", platform.value, backoff, profile_name
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
finally:
|
||||
@@ -1185,8 +1087,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
if not getattr(adapter, "fatal_error_retryable", True):
|
||||
return
|
||||
if is_global_startup_conflict(getattr(adapter, "fatal_error_code", None)):
|
||||
# A live foreign holder of this profile's token is an ownership conflict, not a blip:
|
||||
# park it fatal (like ``duplicate_credential``) instead of retry-storming.
|
||||
# A live foreign token holder is an ownership conflict, not a blip: park it fatal.
|
||||
logger.error(
|
||||
"[MULTIPLEX] Profile '%s': %s credential is held by another "
|
||||
"gateway (%s) — parked, not retried. %s", profile_name, platform.value,
|
||||
@@ -1199,15 +1100,14 @@ class GatewayAdapterLifecycleMixin:
|
||||
try:
|
||||
self._schedule_secondary_profile_reconnect(profile_name, platform, adapter)
|
||||
except Exception:
|
||||
# The handoff touches live registries; if it raises, the parked task dies as an
|
||||
# unretrieved-task exception logged only at GC. Surface it where operators look.
|
||||
# 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:
|
||||
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.
|
||||
@@ -1281,11 +1181,9 @@ class GatewayAdapterLifecycleMixin:
|
||||
@staticmethod
|
||||
def _stamp_event_profile(event, profile_name: str) -> None:
|
||||
"""Best-effort: stamp ``source.profile`` on an inbound event that has none yet."""
|
||||
try:
|
||||
with suppress(Exception):
|
||||
if getattr(event, "source", None) is not None and not event.source.profile:
|
||||
event.source.profile = profile_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _make_profile_message_handler(self, profile_name: str):
|
||||
"""Message handler that stamps source.profile, then delegates under the profile scope
|
||||
@@ -1304,8 +1202,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
"""Stamp an owning adapter's profile before resolving busy policy."""
|
||||
async def _handler(event, _session_key):
|
||||
self._stamp_event_profile(event, profile_name)
|
||||
routed_session_key = self._session_key_for_source(event.source)
|
||||
return await self._handle_active_session_busy_message(event, routed_session_key)
|
||||
return await self._handle_active_session_busy_message(event, self._session_key_for_source(event.source))
|
||||
|
||||
return _handler
|
||||
|
||||
@@ -1346,19 +1243,18 @@ class GatewayAdapterLifecycleMixin:
|
||||
|
||||
def _primary_message_handler(self):
|
||||
"""Return the correctly scoped handler for a primary adapter."""
|
||||
if getattr(self.config, "multiplex_profiles", False):
|
||||
return self._make_default_profile_message_handler()
|
||||
return self._handle_message
|
||||
return self._make_default_profile_message_handler() if self._multiplex_on() else self._handle_message
|
||||
|
||||
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."""
|
||||
try:
|
||||
# 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)
|
||||
except Exception:
|
||||
# Observer failures must never break the adapter's update loop.
|
||||
logger.debug("gateway_platform_event hook dispatch failed", exc_info=True)
|
||||
|
||||
def _make_profile_platform_event_handler(self, profile_name: str):
|
||||
"""Bind platform-event auth and hook dispatch to one multiplex profile."""
|
||||
@@ -1386,7 +1282,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
return _handler
|
||||
|
||||
def _primary_platform_event_handler(self):
|
||||
if getattr(self.config, "multiplex_profiles", False):
|
||||
if self._multiplex_on():
|
||||
return self._make_default_profile_platform_event_handler()
|
||||
return self._handle_gateway_platform_event
|
||||
|
||||
@@ -1414,8 +1310,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
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 (e.g. Discord) keep the token on `config`; without that fallback the
|
||||
# same-token check is silently skipped and every profile polls the same bot.
|
||||
# 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",
|
||||
@@ -1447,7 +1342,6 @@ class GatewayAdapterLifecycleMixin:
|
||||
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):
|
||||
@@ -1468,8 +1362,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
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."""
|
||||
from gateway.run import get_hermes_home
|
||||
multiplex = bool(getattr(self.config, "multiplex_profiles", False))
|
||||
transport_home = (Path(get_hermes_home()) if multiplex and profile_name is None else None)
|
||||
transport_home = Path(get_hermes_home()) if self._multiplex_on() and profile_name is None else None
|
||||
|
||||
def check(
|
||||
user_id: str, chat_type: Optional[str] = None, chat_id: Optional[str] = None, *,
|
||||
@@ -1481,8 +1374,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
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 ``build_source`` retains, so adapter-level policy reads
|
||||
# resolve the receiving adapter even once the routed profile is stamped below.
|
||||
# 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)
|
||||
|
||||
+97
-238
@@ -39,8 +39,7 @@ _DURABLE_CLAIM_OPS = {
|
||||
class GatewayNotificationsMixin:
|
||||
"""Process/completion/update notifications, media delivery and async-delegation delivery for GatewayRunner."""
|
||||
|
||||
# Routing-complete coalescing keys: process completions (short-window fan-in) and async
|
||||
# delegations (originating session + parent session + route).
|
||||
# Coalescing keys: process completions (short-window fan-in) and async delegations (+ parent session).
|
||||
_COMPLETION_BATCH_KEY_FIELDS = ("session_key", "platform", "chat_type", "chat_id", "thread_id", "user_id")
|
||||
_ASYNC_GROUP_KEY_FIELDS = ("session_key", "parent_session_id", *_COMPLETION_BATCH_KEY_FIELDS[1:])
|
||||
|
||||
@@ -94,7 +93,6 @@ class GatewayNotificationsMixin:
|
||||
adapter = self._adapter_for_source(source)
|
||||
if not adapter:
|
||||
return
|
||||
|
||||
config = getattr(self, "config", None)
|
||||
chat_id = getattr(source, "chat_id", None)
|
||||
if config and getattr(source, "platform", None) == Platform.SLACK and _is_slack_ignored_channel(config, chat_id):
|
||||
@@ -106,16 +104,13 @@ class GatewayNotificationsMixin:
|
||||
)
|
||||
metadata = self._thread_metadata_for_source(source)
|
||||
if notice_delivery == "private" and getattr(source, "user_id", None):
|
||||
try:
|
||||
with _log_suppressed(
|
||||
logging.DEBUG, "[%s] send_private_notice failed, falling back to public",
|
||||
getattr(source, "platform", "?"), exc_info=True,
|
||||
):
|
||||
result = await adapter.send_private_notice(source.chat_id, source.user_id, content, metadata=metadata)
|
||||
if getattr(result, "success", False):
|
||||
return
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[%s] send_private_notice failed, falling back to public",
|
||||
getattr(source, "platform", "?"), exc_info=True,
|
||||
)
|
||||
|
||||
await adapter.send(source.chat_id, content, metadata=metadata)
|
||||
|
||||
async def _resolve_compression_lineage_target(
|
||||
@@ -127,14 +122,12 @@ class GatewayNotificationsMixin:
|
||||
except Exception:
|
||||
logger.debug("Async-delegation compression-tip lookup failed for %s", pinned_session_id, exc_info=True)
|
||||
target_session_id = None
|
||||
|
||||
if not target_session_id or target_session_id == pinned_session_id:
|
||||
logger.warning(
|
||||
"Async-delegation completion pinned to compressed session %s "
|
||||
"without a continuation; dropping injection.", pinned_session_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
tip_row = await session_db.get_session(target_session_id)
|
||||
except Exception:
|
||||
@@ -145,11 +138,9 @@ class GatewayNotificationsMixin:
|
||||
target_session_id, "unknown" if tip_row is None else "ended",
|
||||
)
|
||||
return None
|
||||
|
||||
route_owns_lineage = session_entry.session_id in {pinned_session_id, target_session_id}
|
||||
if not route_owns_lineage:
|
||||
# A delegation may survive several compression rotations: accept an intermediate stale
|
||||
# route only when its own verified compression tip is the same live target.
|
||||
# Across several rotations, accept a stale route only when its own tip is the same live target.
|
||||
try:
|
||||
route_row = await session_db.get_session(session_entry.session_id)
|
||||
route_tip = (
|
||||
@@ -162,7 +153,6 @@ class GatewayNotificationsMixin:
|
||||
except Exception:
|
||||
route_tip = None
|
||||
route_owns_lineage = route_tip == target_session_id
|
||||
|
||||
if not route_owns_lineage:
|
||||
logger.warning(
|
||||
"Async-delegation completion for compression lineage %s -> %s "
|
||||
@@ -189,20 +179,17 @@ class GatewayNotificationsMixin:
|
||||
"dropping injection (#55578 fail-closed)."
|
||||
)
|
||||
return None
|
||||
|
||||
pinned_row = None
|
||||
try:
|
||||
pinned_row = await session_db.get_session(pinned_session_id)
|
||||
except Exception:
|
||||
logger.debug("Async-delegation parent lookup failed for %s", pinned_session_id, exc_info=True)
|
||||
|
||||
if pinned_row is None:
|
||||
logger.warning(
|
||||
"Async-delegation completion has unknown spawning session %s; "
|
||||
"dropping injection (#55578 fail-closed).", pinned_session_id,
|
||||
)
|
||||
return None
|
||||
|
||||
target_session_id = pinned_session_id
|
||||
follows_compression = False
|
||||
if pinned_row.get("ended_at"):
|
||||
@@ -215,26 +202,22 @@ class GatewayNotificationsMixin:
|
||||
)
|
||||
return None
|
||||
if _end_reason != "compression":
|
||||
# Idle/timeout/lifecycle end (scale-to-zero norm): the chat route is still valid and
|
||||
# ``session_entry`` is its current session, so deliver here rather than drop — otherwise
|
||||
# the row is acked at adapter acceptance then silently lost.
|
||||
# Idle/timeout end (scale-to-zero norm): the chat route is still valid, so deliver to its
|
||||
# current session rather than drop (the row would be acked then silently lost).
|
||||
logger.info(
|
||||
"Async-delegation completion pinned to %s-ended session %s; "
|
||||
"retargeting to the chat's current session %s.",
|
||||
_end_reason or "idle", pinned_session_id, session_entry.session_id,
|
||||
)
|
||||
return session_entry
|
||||
|
||||
follows_compression = True
|
||||
target_session_id = await self._resolve_compression_lineage_target(
|
||||
session_db, session_entry, pinned_session_id,
|
||||
)
|
||||
if target_session_id is None:
|
||||
return None
|
||||
|
||||
if target_session_id == session_entry.session_id:
|
||||
return session_entry
|
||||
|
||||
prior_session_id = session_entry.session_id
|
||||
if follows_compression:
|
||||
switched = await self.async_session_store.advance_compression_session(
|
||||
@@ -245,50 +228,38 @@ class GatewayNotificationsMixin:
|
||||
if switched is None:
|
||||
logger.warning(
|
||||
"Async-delegation completion could not bind routing key %s to "
|
||||
"owning session %s; dropping injection.",
|
||||
session_entry.session_key, target_session_id,
|
||||
"owning session %s; dropping injection.", session_entry.session_key, target_session_id,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Pinned async-delegation completion to owning session %s "
|
||||
"(was %s) for routing key %s (#57498)",
|
||||
"Pinned async-delegation completion to owning session %s (was %s) for routing key %s (#57498)",
|
||||
target_session_id, prior_session_id, session_entry.session_key,
|
||||
)
|
||||
return switched
|
||||
|
||||
async def _deliver_media_from_response(
|
||||
self, response: str, event: MessageEvent, adapter,
|
||||
thread_metadata: Optional[Dict[str, Any]] = None,
|
||||
self, response: str, event: MessageEvent, adapter, thread_metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Extract explicit MEDIA: tags from an already-streamed response and deliver them.
|
||||
|
||||
The text is already delivered; this only handles file attachments. Unlike the non-streaming
|
||||
path in ``gateway/platforms/base.py`` this rescan is EXPLICIT-ONLY: a bare local path in a
|
||||
streamed reply was either shown as text or is stale inspected content, and promoting it sent
|
||||
files the model never asked to deliver. MEDIA tags are NOT deduped against prior turns: a
|
||||
MEDIA: directive in the final reply is a deliberate attach (incl. user-requested resends);
|
||||
stale auto-appended tags are deduped upstream (_collect_auto_append_media_tags).
|
||||
"""
|
||||
"""Deliver explicit MEDIA: tags from an already-streamed response (text already delivered).
|
||||
EXPLICIT-ONLY, unlike the non-streaming path in ``gateway/platforms/base.py``: a bare local
|
||||
path in a streamed reply is shown text or stale inspected content, and promoting it sent
|
||||
files the model never asked for. MEDIA tags are NOT deduped against prior turns (a final-reply
|
||||
directive is a deliberate attach); stale auto-appended tags are deduped upstream."""
|
||||
from urllib.parse import quote as _quote
|
||||
with _log_suppressed(logging.WARNING, "Post-stream media extraction failed: %s"):
|
||||
# Capture [[as_document]] before extract_media strips it: image files then go through
|
||||
# send_document (preserving bytes), not send_multiple_images (Telegram recompresses).
|
||||
# Capture [[as_document]] before extract_media strips it: images then go via send_document.
|
||||
force_document_attachments = "[[as_document]]" in response
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
|
||||
media_files, cleaned = adapter.extract_media(response)
|
||||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||
# Strip image URLs for parity with the non-streaming chain; no extract_local_files here.
|
||||
# Strip image URLs (parity with the non-streaming chain); no extract_local_files here.
|
||||
adapter.extract_images(cleaned)
|
||||
|
||||
_thread_meta = (
|
||||
dict(thread_metadata)
|
||||
if thread_metadata is not None
|
||||
else self._thread_metadata_for_source(event.source, self._reply_anchor_for_event(event))
|
||||
)
|
||||
chat_id = event.source.chat_id
|
||||
|
||||
# Images go out as one batch (e.g. Signal's multi-attachment RPC) unless [[as_document]].
|
||||
def _is_photo(media_path: str, is_voice: bool) -> bool:
|
||||
ext = Path(media_path).suffix.lower()
|
||||
@@ -296,14 +267,12 @@ class GatewayNotificationsMixin:
|
||||
|
||||
image_paths = [p for p, v in media_files if _is_photo(p, v)]
|
||||
non_image_media = [(p, v) for p, v in media_files if not _is_photo(p, v)]
|
||||
|
||||
if image_paths:
|
||||
try:
|
||||
images = [(f"file://{_quote(p)}", "") for p in image_paths]
|
||||
await adapter.send_multiple_images(chat_id=chat_id, images=images, metadata=_thread_meta)
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Post-stream image batch delivery failed: %s", adapter.name, e)
|
||||
|
||||
for media_path, is_voice in non_image_media:
|
||||
try:
|
||||
ext = Path(media_path).suffix.lower()
|
||||
@@ -329,9 +298,8 @@ class GatewayNotificationsMixin:
|
||||
if not text_already_delivered:
|
||||
text_content = _strip_response_attachments_for_direct_send(response, adapter)
|
||||
if text_content:
|
||||
# Reconcile-by-edit first: a stream-sealed message whose recorded payload didn't confirm
|
||||
# the final (post-stream mutation) already carries most of the answer; a plain send here
|
||||
# would duplicate it.
|
||||
# Reconcile-by-edit first: a stream-sealed message already carries most of the answer;
|
||||
# a plain send here would duplicate it.
|
||||
_reconciled = False
|
||||
_sc_msg_id = getattr(stream_consumer, "message_id", None)
|
||||
if (
|
||||
@@ -353,21 +321,20 @@ class GatewayNotificationsMixin:
|
||||
logger.debug("Queued-lane reconcile edit failed (%s); falling back to send.", _qe)
|
||||
if not _reconciled:
|
||||
await adapter.send(source.chat_id, text_content, metadata=metadata)
|
||||
|
||||
# Failed turns deliver their (normalized failure) text but must not upload attachments as if
|
||||
# they succeeded — mirrors the ``not agent_result.get("failed")`` completed-turn guard.
|
||||
if not deliver_media:
|
||||
return
|
||||
|
||||
synthetic_event = MessageEvent(text="", source=source, message_id=event_message_id)
|
||||
await self._deliver_media_from_response(response, synthetic_event, adapter, thread_metadata=metadata)
|
||||
await self._deliver_media_from_response(
|
||||
response, MessageEvent(text="", source=source, message_id=event_message_id), adapter,
|
||||
thread_metadata=metadata,
|
||||
)
|
||||
|
||||
def _schedule_update_notification_watch(self) -> None:
|
||||
"""Ensure a background task is watching for update completion."""
|
||||
existing_task = getattr(self, "_update_notification_task", None)
|
||||
if existing_task and not existing_task.done():
|
||||
return
|
||||
|
||||
try:
|
||||
self._update_notification_task = asyncio.create_task(self._watch_update_progress())
|
||||
except RuntimeError:
|
||||
@@ -378,8 +345,7 @@ class GatewayNotificationsMixin:
|
||||
from gateway.run import _hermes_home
|
||||
return cls._UpdatePaths(
|
||||
pending=_hermes_home / ".update_pending.json",
|
||||
claimed=_hermes_home / ".update_pending.claimed.json",
|
||||
output=_hermes_home / ".update_output.txt",
|
||||
claimed=_hermes_home / ".update_pending.claimed.json", output=_hermes_home / ".update_output.txt",
|
||||
exit_code=_hermes_home / ".update_exit_code",
|
||||
prompt=_hermes_home / ".update_prompt.json", response=_hermes_home / ".update_response",
|
||||
)
|
||||
@@ -389,7 +355,7 @@ class GatewayNotificationsMixin:
|
||||
for path in (paths.claimed, paths.pending):
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
with suppress(Exception):
|
||||
pending = json.loads(path.read_text(encoding="utf-8"))
|
||||
platform_str = pending.get("platform")
|
||||
chat_id = pending.get("chat_id")
|
||||
@@ -405,8 +371,6 @@ class GatewayNotificationsMixin:
|
||||
return self._UpdateTarget(
|
||||
adapter, chat_id, session_key or f"{platform_str}:{chat_id}", metadata, platform,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _pending_marker_metadata(self, platform, chat_id, data: dict, adapter):
|
||||
@@ -419,8 +383,7 @@ class GatewayNotificationsMixin:
|
||||
async def _watch_update_completion_only(self, paths: "_UpdatePaths", deadline: float, poll_interval: float) -> None:
|
||||
"""Fallback when no adapter/chat can be resolved: wait for the exit code, then notify."""
|
||||
logger.warning("Update watcher: cannot resolve adapter/chat_id, falling back to completion-only")
|
||||
# Keep polling until _send_update_notification actually delivers (True) — it re-resolves the
|
||||
# adapter each call and returns False (markers kept) while the platform is still reconnecting.
|
||||
# Poll until _send_update_notification delivers (it returns False while the platform reconnects).
|
||||
loop = asyncio.get_running_loop()
|
||||
while paths.any_pending() and loop.time() < deadline:
|
||||
if paths.exit_code.exists() and await self._send_update_notification():
|
||||
@@ -474,8 +437,7 @@ class GatewayNotificationsMixin:
|
||||
f"⚕ **Update needs your input:**\n\n{prompt_text}{default_hint}\n\n"
|
||||
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), or type your answer directly."
|
||||
)
|
||||
# Keep the prompt marker on disk until the user answers so a watcher after a mid-prompt
|
||||
# gateway restart can recover by re-forwarding it.
|
||||
# Keep the prompt marker on disk until answered so a restarted watcher can re-forward it.
|
||||
self._session_state(target.session_key).persistent.update_prompt_pending = True
|
||||
logger.info("Forwarded update prompt to %s: %s", target.session_key, prompt_text[:80])
|
||||
|
||||
@@ -496,13 +458,11 @@ class GatewayNotificationsMixin:
|
||||
paths = self._update_paths()
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
|
||||
target = self._resolve_update_target(paths)
|
||||
if target is None:
|
||||
await self._watch_update_completion_only(paths, deadline, poll_interval)
|
||||
return
|
||||
session_key = target.session_key
|
||||
|
||||
bytes_sent = 0
|
||||
last_stream_time = loop.time()
|
||||
buffer = ""
|
||||
@@ -517,11 +477,9 @@ class GatewayNotificationsMixin:
|
||||
def _read_new_output() -> None:
|
||||
nonlocal buffer, bytes_sent
|
||||
if paths.output.exists():
|
||||
try:
|
||||
with suppress(OSError):
|
||||
chunk, bytes_sent = self._read_update_output_since(paths.output, bytes_sent)
|
||||
buffer += chunk
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
while loop.time() < deadline:
|
||||
if paths.exit_code.exists():
|
||||
@@ -536,17 +494,13 @@ class GatewayNotificationsMixin:
|
||||
logger.info("Update finished (exit=%s), notified %s", exit_code, session_key)
|
||||
self._clear_update_markers(paths, session_key)
|
||||
return
|
||||
|
||||
_read_new_output()
|
||||
if buffer.strip() and (loop.time() - last_stream_time) >= stream_interval:
|
||||
await _flush_buffer()
|
||||
|
||||
# Forward a prompt only when none is still awaiting a response; otherwise the watcher
|
||||
# would re-read the same .update_prompt.json every poll and spam duplicate prompts.
|
||||
# Forward a prompt only when none is pending, else every poll re-forwards the same prompt.
|
||||
_pending_state = self._peek_session_state(session_key) if session_key else None
|
||||
if (
|
||||
paths.prompt.exists() and session_key
|
||||
and not (_pending_state is not None and _pending_state.persistent.update_prompt_pending)
|
||||
if paths.prompt.exists() and session_key and not getattr(
|
||||
getattr(_pending_state, "persistent", None), "update_prompt_pending", False
|
||||
):
|
||||
try:
|
||||
prompt_data = json.loads(paths.prompt.read_text(encoding="utf-8"))
|
||||
@@ -556,9 +510,7 @@ class GatewayNotificationsMixin:
|
||||
await self._forward_update_prompt(target, prompt_text, prompt_data.get("default", ""))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.debug("Failed to read update prompt: %s", e)
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
if not paths.exit_code.exists():
|
||||
logger.warning("Update watcher timed out after %.0fs", timeout)
|
||||
paths.exit_code.write_text("124", encoding="utf-8")
|
||||
@@ -576,7 +528,6 @@ class GatewayNotificationsMixin:
|
||||
paths = self._update_paths()
|
||||
if not paths.any_pending():
|
||||
return False
|
||||
|
||||
cleanup = True
|
||||
active_pending_path = paths.claimed
|
||||
|
||||
@@ -597,23 +548,18 @@ class GatewayNotificationsMixin:
|
||||
return True
|
||||
elif not paths.claimed.exists():
|
||||
return True
|
||||
|
||||
pending = json.loads(paths.claimed.read_text(encoding="utf-8"))
|
||||
platform_str = pending.get("platform")
|
||||
chat_id = pending.get("chat_id")
|
||||
|
||||
if not paths.exit_code.exists():
|
||||
return _defer("Update notification deferred: update still running")
|
||||
|
||||
exit_code = self._update_exit_code(paths)
|
||||
output = paths.output.read_bytes().decode("utf-8", errors="replace") if paths.output.exists() else ""
|
||||
|
||||
platform = Platform(platform_str)
|
||||
adapter = self.adapters.get(platform)
|
||||
if chat_id and not adapter:
|
||||
# The update finished, but the target platform has not reconnected yet (common right
|
||||
# after the restart that `hermes update` triggers). A definitive skip would delete the
|
||||
# markers and silently lose the notification; preserve them for a later retry.
|
||||
# Target platform not reconnected yet (common right after the update's restart): keep the
|
||||
# markers for a later retry instead of silently losing the notification.
|
||||
return _defer("Update notification deferred: %s adapter not connected yet", platform_str)
|
||||
if chat_id:
|
||||
metadata = self._pending_marker_metadata(platform, chat_id, pending, adapter)
|
||||
@@ -624,10 +570,11 @@ class GatewayNotificationsMixin:
|
||||
output = "…" + output[-3500:]
|
||||
status = "✅ Hermes update finished." if exit_code == 0 else "❌ Hermes update failed."
|
||||
msg = f"{status}\n\n```\n{output}\n```"
|
||||
elif exit_code == 0:
|
||||
msg = "✅ Hermes update finished successfully."
|
||||
else:
|
||||
msg = "❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details."
|
||||
msg = (
|
||||
"✅ Hermes update finished successfully." if exit_code == 0 else
|
||||
"❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details."
|
||||
)
|
||||
await adapter.send(chat_id, msg, metadata=_non_conversational_metadata(metadata, platform=platform))
|
||||
logger.info("Sent post-update notification to %s:%s (exit=%s)", platform_str, chat_id, exit_code)
|
||||
except Exception as e:
|
||||
@@ -636,7 +583,6 @@ class GatewayNotificationsMixin:
|
||||
if cleanup:
|
||||
for p in (active_pending_path, paths.claimed, paths.output, paths.exit_code):
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
return True
|
||||
|
||||
async def _send_restart_notification(self) -> Optional[tuple[str, str, Optional[str]]]:
|
||||
@@ -645,30 +591,24 @@ class GatewayNotificationsMixin:
|
||||
notify_path = _hermes_home / ".restart_notify.json"
|
||||
if not notify_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(notify_path.read_text(encoding="utf-8"))
|
||||
platform_str = data.get("platform")
|
||||
chat_id = data.get("chat_id")
|
||||
thread_id = data.get("thread_id")
|
||||
|
||||
if not platform_str or not chat_id:
|
||||
return None
|
||||
|
||||
platform = Platform(platform_str)
|
||||
transport = resolve_delivery_transport(platform, self.config, self.adapters)
|
||||
if transport is None:
|
||||
logger.debug("Restart notification skipped: no live transport for %s", platform_str)
|
||||
return None
|
||||
|
||||
platform_cfg = self.config.platforms.get(platform)
|
||||
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
|
||||
logger.info(
|
||||
"Restart notification suppressed: %s has gateway_restart_notification=false",
|
||||
platform_str,
|
||||
"Restart notification suppressed: %s has gateway_restart_notification=false", platform_str
|
||||
)
|
||||
return None
|
||||
|
||||
metadata = self._pending_marker_metadata(platform, chat_id, data, transport.adapter)
|
||||
if data.get("delivered_via_upstream_relay") is True:
|
||||
metadata = dict(metadata or {})
|
||||
@@ -686,7 +626,6 @@ class GatewayNotificationsMixin:
|
||||
"Restart notification to %s:%s was not delivered: %s", platform_str, chat_id, _send_error(result),
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("Sent restart notification to %s:%s", platform_str, chat_id)
|
||||
return str(platform_str), str(chat_id), str(thread_id) if thread_id else None
|
||||
except Exception as e:
|
||||
@@ -742,7 +681,6 @@ class GatewayNotificationsMixin:
|
||||
delivered: set[tuple[str, str, Optional[str]]] = set()
|
||||
skipped = skip_targets or set()
|
||||
message = "♻️ Gateway online — Hermes is back and ready."
|
||||
|
||||
for platform, platform_cfg, home, transport in self._home_channel_transports():
|
||||
if not platform_cfg.gateway_restart_notification:
|
||||
logger.info(
|
||||
@@ -750,17 +688,14 @@ class GatewayNotificationsMixin:
|
||||
platform.value,
|
||||
)
|
||||
continue
|
||||
|
||||
target = _notice_target_key(platform.value, home.chat_id, home.thread_id)
|
||||
if target in skipped or target in delivered:
|
||||
continue
|
||||
|
||||
if await self._send_home_channel_message(
|
||||
platform, home, transport, message, "Home-channel startup notification failed for %s:%s: %s",
|
||||
):
|
||||
delivered.add(target)
|
||||
logger.info("Sent home-channel startup notification to %s:%s", platform.value, home.chat_id)
|
||||
|
||||
return delivered
|
||||
|
||||
async def _send_session_db_warning_notifications(self) -> None:
|
||||
@@ -773,24 +708,20 @@ class GatewayNotificationsMixin:
|
||||
error = getattr(self, "_session_db_init_error", None)
|
||||
if not error:
|
||||
return
|
||||
|
||||
from hermes_state import classify_persistence_error, format_session_db_unavailable
|
||||
if classify_persistence_error(error) == "corrupt":
|
||||
message = (
|
||||
"⚠️ Session database corruption detected. Messages may not be "
|
||||
"persisted. Recovery options:\n1. Run `hermes doctor --fix`\n"
|
||||
"2. Salvage with: sqlite3 ~/.hermes/state.db \".recover\" (then replace state.db)\n"
|
||||
"3. Restore from a backup in ~/.hermes/backups/\n"
|
||||
"Run `hermes doctor` for sanitized diagnostics."
|
||||
"⚠️ Session database corruption detected. Messages may not be persisted. Recovery "
|
||||
"options:\n1. Run `hermes doctor --fix`\n2. Salvage with: sqlite3 ~/.hermes/state.db "
|
||||
"\".recover\" (then replace state.db)\n3. Restore from a backup in ~/.hermes/backups/\nRun "
|
||||
"`hermes doctor` for sanitized diagnostics."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"⚠️ Session database unavailable — messages may not be persisted. "
|
||||
f"{format_session_db_unavailable()}\nRun `hermes doctor` for diagnostics."
|
||||
)
|
||||
|
||||
logger.warning("Broadcasting state.db failure warning to home channels: %s", error)
|
||||
|
||||
for platform, _platform_cfg, home, transport in self._home_channel_transports():
|
||||
await self._send_home_channel_message(
|
||||
platform, home, transport, message, "state.db warning notification failed for %s:%s: %s",
|
||||
@@ -804,7 +735,6 @@ class GatewayNotificationsMixin:
|
||||
from gateway.run import _parse_session_key
|
||||
session_key = str(evt.get("session_key") or "").strip()
|
||||
derived = {}
|
||||
|
||||
if session_key:
|
||||
try:
|
||||
self.session_store._ensure_loaded()
|
||||
@@ -813,13 +743,10 @@ class GatewayNotificationsMixin:
|
||||
return entry.origin
|
||||
except Exception as exc:
|
||||
logger.debug("Synthetic process-event session-store lookup failed for %s: %s", session_key, exc)
|
||||
|
||||
cached_source = self._get_cached_session_source(session_key)
|
||||
if cached_source is not None:
|
||||
return cached_source
|
||||
|
||||
derived = _parse_session_key(session_key) or {}
|
||||
|
||||
platform_name = str(evt.get("platform") or derived.get("platform") or "").strip().lower()
|
||||
chat_type = str(evt.get("chat_type") or derived.get("chat_type") or "").strip().lower()
|
||||
chat_id = str(evt.get("chat_id") or derived.get("chat_id") or "").strip()
|
||||
@@ -830,11 +757,9 @@ class GatewayNotificationsMixin:
|
||||
session_key, platform_name, chat_type, chat_id, evt.get("type", "?"),
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
platform = Platform(platform_name)
|
||||
# Reject arbitrary strings (dynamic pseudo-members): built-ins are always valid, plugin
|
||||
# platforms must be registered in the platform registry.
|
||||
# Reject dynamic pseudo-members: plugin platforms must be registered.
|
||||
if platform.value not in _BUILTIN_PLATFORM_VALUES:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
@@ -851,15 +776,12 @@ class GatewayNotificationsMixin:
|
||||
|
||||
scope_id = _opt("scope_id")
|
||||
if scope_id is None and chat_type not in ("dm", "thread"):
|
||||
# Reconstructed (non-persisted) source for a scoped chat with no scope discriminator: a
|
||||
# relay connector's fail-closed tenant guard may decline the reply unless user_id resolves
|
||||
# it. Don't fail — DMs/author-bound chats still route and native adapters need no
|
||||
# scope_id — but warn so a post-restart egress decline isn't silent.
|
||||
# Reconstructed scoped-chat source without scope_id: a relay connector's tenant guard may
|
||||
# decline the reply. Warn, don't fail (native adapters need no scope_id).
|
||||
logger.warning(
|
||||
"Synthetic event source for %s chat=%s (%s) reconstructed "
|
||||
"without scope_id; scoped relay egress may be declined by "
|
||||
"the connector's tenant guard (user_id fallback only).",
|
||||
platform_name, chat_id, chat_type,
|
||||
"the connector's tenant guard (user_id fallback only).", platform_name, chat_id, chat_type,
|
||||
)
|
||||
return SessionSource(
|
||||
platform=platform, chat_id=chat_id, chat_type=chat_type, thread_id=_opt("thread_id"),
|
||||
@@ -876,7 +798,6 @@ class GatewayNotificationsMixin:
|
||||
watch_events = _drain_gateway_watch_events(completion_queue)
|
||||
if self._load_background_notifications_mode() == "off":
|
||||
return
|
||||
|
||||
for evt in watch_events:
|
||||
synth_text = _format_gateway_process_notification(evt)
|
||||
if not synth_text:
|
||||
@@ -916,15 +837,9 @@ class GatewayNotificationsMixin:
|
||||
return False
|
||||
|
||||
def _resolve_injection_adapter(self, platform_name: str):
|
||||
"""Adapter for a synthetic-event platform: transport resolver first, literal scan as fallback.
|
||||
|
||||
Alias-aware resolution (relay-plane): one adapter under Platform.RELAY fronts N logical
|
||||
platforms, so a literal ``p.value == platform_name`` scan misses "slack" and drops the
|
||||
completion as "no gateway route". Native adapter wins; relay is eligible only when it
|
||||
advertises fronting the logical platform. The legacy literal scan is still correct for
|
||||
native adapters and keeps minimal runner stubs (tests) / exotic platform strings working
|
||||
when the resolver can't run.
|
||||
"""
|
||||
"""Adapter for a synthetic-event platform: alias-aware transport resolver first (one
|
||||
Platform.RELAY adapter fronts N logical platforms; native wins), literal ``p.value`` scan as
|
||||
fallback for minimal runner stubs / exotic platform strings when the resolver can't run."""
|
||||
from gateway.run import resolve_delivery_transport
|
||||
try:
|
||||
_transport = resolve_delivery_transport(Platform(platform_name), self.config, self.adapters)
|
||||
@@ -945,8 +860,7 @@ class GatewayNotificationsMixin:
|
||||
from gateway.wake import adapter_supports_push
|
||||
source = await asyncio.to_thread(self._build_process_event_source, evt)
|
||||
if not source:
|
||||
# API-server sessions bind the RAW X-Hermes-Session-Id key (_bind_api_server_session), not a
|
||||
# structured ``agent:main:...`` key, so _build_process_event_source returned None above.
|
||||
# API-server sessions bind the RAW X-Hermes-Session-Id key, not a structured ``agent:...`` key.
|
||||
raw_sid = str(evt.get("origin_session_id") or "").strip()
|
||||
_sk = str(evt.get("session_key") or "").strip()
|
||||
if not raw_sid and _sk and _parse_session_key(_sk) is None:
|
||||
@@ -970,9 +884,8 @@ class GatewayNotificationsMixin:
|
||||
if not adapter:
|
||||
return None
|
||||
if not adapter_supports_push(adapter):
|
||||
# Non-push adapter (api_server) resolved WITH routing metadata: its chat_id is the raw session
|
||||
# id (_bind_api_server_session binds chat_id = session_id), so handle_message would run the
|
||||
# wake under a build_session_key() key that never matches the raw session — self-post.
|
||||
# Non-push adapter (api_server): its chat_id IS the raw session id, so handle_message would
|
||||
# key the wake under a build_session_key() that never matches — self-post instead.
|
||||
raw_sid = str(evt.get("origin_session_id") or "").strip() or str(source.chat_id or "")
|
||||
return await self._self_post_api_server(adapter, synth_text, raw_sid, evt)
|
||||
try:
|
||||
@@ -988,9 +901,8 @@ class GatewayNotificationsMixin:
|
||||
"Watch pattern notification — injecting for %s chat=%s thread=%s",
|
||||
platform_name, source.chat_id, source.thread_id,
|
||||
)
|
||||
# Relay-plane egress priming: a synthetic turn injected right after a restart reaches a relay
|
||||
# adapter whose per-chat routing caches are cold (they warm only on inbound), so its replies
|
||||
# egress without tenant discriminators and the connector's fail-closed guard declines them.
|
||||
# Relay egress priming: post-restart routing caches are cold (they warm only on inbound), so
|
||||
# replies would egress without tenant discriminators and be declined by the connector.
|
||||
_prime = getattr(adapter, "prime_routing_cache", None)
|
||||
if callable(_prime):
|
||||
_prime(synth_event)
|
||||
@@ -1042,15 +954,10 @@ class GatewayNotificationsMixin:
|
||||
return seen
|
||||
|
||||
async def _classify_completion_target(self, parent_session_id: str) -> str:
|
||||
"""Classify an async-completion delivery target before adapter acceptance.
|
||||
|
||||
- ``"deliver"``: spawning session live (or compression-rotated with a live continuation);
|
||||
proves deliverability only, the resolver still retargets.
|
||||
- ``"terminal"``: parent gone for good (unknown / explicit user boundary like /new); drop
|
||||
the durable row rather than falsely ack or replay forever.
|
||||
- ``"retry"``: transient uncertainty (DB unavailable, rotation mid-flight); release the
|
||||
claim for a later consumer; the attempt cap bounds churn.
|
||||
"""
|
||||
"""Classify an async-completion target before adapter acceptance: ``"deliver"`` (spawning
|
||||
session live or compression-rotated with a live continuation; the resolver still retargets),
|
||||
``"terminal"`` (parent gone for good — unknown / user boundary like /new; drop the durable row
|
||||
rather than falsely ack), ``"retry"`` (DB unavailable / rotation mid-flight; release the claim)."""
|
||||
from gateway.run import _USER_BOUNDARY_END_REASONS
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
if session_db is None:
|
||||
@@ -1066,15 +973,13 @@ class GatewayNotificationsMixin:
|
||||
return "deliver"
|
||||
end_reason = str(parent.get("end_reason") or "")
|
||||
if end_reason != "compression":
|
||||
# Only a USER-closed thread of work (/new, user_exit, session_switch) is unreachable;
|
||||
# idle/timeout ends are normal on scale-to-zero relays — the chat stays routable and the
|
||||
# resolver retargets. Boundary set shared with the resolver: no drift.
|
||||
# Only a USER-closed session (/new, user_exit, session_switch) is unreachable; idle/timeout
|
||||
# ends stay routable and the resolver retargets. Boundary set shared with the resolver.
|
||||
return "terminal" if end_reason in _USER_BOUNDARY_END_REASONS else "deliver"
|
||||
try:
|
||||
tip_session_id = await session_db.get_compression_tip(parent_session_id)
|
||||
if not tip_session_id or tip_session_id == parent_session_id:
|
||||
# Rotation caught mid-flight: parent is compression-ended but its continuation isn't
|
||||
# visible yet. Retry, don't drop.
|
||||
# Rotation mid-flight: continuation not visible yet. Retry, don't drop.
|
||||
return "retry"
|
||||
tip = await session_db.get_session(tip_session_id)
|
||||
except Exception:
|
||||
@@ -1118,10 +1023,8 @@ class GatewayNotificationsMixin:
|
||||
return claim
|
||||
elif evt_type != "completion":
|
||||
return claim
|
||||
|
||||
# Background completions carry only session_key, so after /new the OLD session's notification
|
||||
# would land in the chat's NEW session. Stamped events get the same pre-flight as async
|
||||
# delegations; unstamped ones deliver.
|
||||
# would land in the NEW one. Stamped events get the async-delegation pre-flight; unstamped deliver.
|
||||
parent_session_id = str(evt.get("parent_session_id") or "").strip()
|
||||
if not parent_session_id:
|
||||
return claim
|
||||
@@ -1139,14 +1042,12 @@ class GatewayNotificationsMixin:
|
||||
logger.warning(
|
||||
"Background process %s completion targets "
|
||||
"permanently-gone session %s (user boundary such as "
|
||||
"/new); dropping notification (output remains "
|
||||
"available via process(action='log')).",
|
||||
"/new); dropping notification (output remains available via process(action='log')).",
|
||||
evt.get("session_id") or "<unknown>", parent_session_id,
|
||||
)
|
||||
claim.proceed = False
|
||||
elif verdict == "retry":
|
||||
# Transient uncertainty (session DB down / compression rotation mid-flight): tell the
|
||||
# watcher to re-poll and retry rather than drop or misroute the result.
|
||||
# Transient uncertainty: tell the watcher to re-poll rather than drop or misroute.
|
||||
if claim.claim_id:
|
||||
self._settle_durable_claim("release", claim.delegation_id, claim.claim_id)
|
||||
claim.proceed, claim.early_result = False, False
|
||||
@@ -1164,20 +1065,16 @@ class GatewayNotificationsMixin:
|
||||
return claim.early_result
|
||||
if identity is not None and self._completion_identity_seen(identity, claim=True):
|
||||
return None
|
||||
|
||||
accepted = False
|
||||
try:
|
||||
injection_result = await self._inject_watch_notification(synth_text, evt)
|
||||
if injection_result is not True:
|
||||
return injection_result
|
||||
accepted = True
|
||||
|
||||
if identity is not None:
|
||||
with self._completion_delivery_lock:
|
||||
self._mark_completions_delivered_locked((identity,))
|
||||
|
||||
# The durable async-delegation row is the authoritative replay state — ack it after
|
||||
# adapter acceptance; no parallel ledger here.
|
||||
# The durable row is the authoritative replay state — ack it after adapter acceptance.
|
||||
if claim.claim_id:
|
||||
try:
|
||||
from tools.async_delegation import complete_completion_delivery
|
||||
@@ -1210,8 +1107,7 @@ class GatewayNotificationsMixin:
|
||||
session_id = str(evt.get("session_id") or "unknown")
|
||||
exit_code = evt.get("exit_code")
|
||||
reason = str(evt.get("completion_reason") or "exited")
|
||||
# Completion output normally passes the terminal redactor at the producer seam, but that is
|
||||
# configurable and this is user-facing, so keep the unconditional gateway floor. Redact
|
||||
# Unconditional gateway redaction floor (the producer-seam redactor is configurable). Redact
|
||||
# BEFORE slicing: truncating first can leave a credential fragment the patterns miss.
|
||||
output = _redact_gateway_user_facing_secrets(str(evt.get("output") or "")).strip()
|
||||
if len(output) > 800:
|
||||
@@ -1242,16 +1138,14 @@ class GatewayNotificationsMixin:
|
||||
try:
|
||||
await asyncio.sleep(self._completion_notification_batch_window)
|
||||
entries = self._completion_notification_batches.pop(key, [])
|
||||
# Detach before adapter delivery: a completion arriving while this batch is in flight
|
||||
# must be able to schedule the next flush.
|
||||
# Detach before delivery so a completion arriving mid-flight can schedule the next flush.
|
||||
if self._completion_notification_batch_tasks.get(key) is current_task:
|
||||
self._completion_notification_batch_tasks.pop(key, None)
|
||||
if not entries:
|
||||
return
|
||||
synth_text = entries[0][0] if len(entries) == 1 else self._format_coalesced_process_completions(entries)
|
||||
|
||||
# A duplicate primary can legitimately return None from the lifecycle dedupe seam; try the
|
||||
# next batch identity so a fresh sibling is never discarded with that duplicate.
|
||||
# A duplicate primary returns None from the dedupe seam; try the next identity so a fresh
|
||||
# sibling is never discarded with it.
|
||||
delivered = None
|
||||
for _text, candidate_evt, _future in entries:
|
||||
delivered = await self._deliver_completion_notification(synth_text, candidate_evt)
|
||||
@@ -1260,8 +1154,7 @@ class GatewayNotificationsMixin:
|
||||
if delivered is True and len(entries) > 1:
|
||||
self._record_coalesced_completion_siblings([evt for _text, evt, _future in entries])
|
||||
except asyncio.CancelledError:
|
||||
# Shutdown may cancel us mid fan-in or while adapter delivery is blocked: recover entries not
|
||||
# yet detached and resolve every waiter as retryable before adapters are torn down.
|
||||
# Shutdown cancellation: recover undetached entries and resolve every waiter as retryable.
|
||||
delivered = False
|
||||
if not entries:
|
||||
entries = self._completion_notification_batches.pop(key, [])
|
||||
@@ -1270,8 +1163,7 @@ class GatewayNotificationsMixin:
|
||||
logger.exception("Coalesced process completion delivery failed")
|
||||
delivered = False
|
||||
finally:
|
||||
# Never strand watcher futures when formatting, delivery, or cancellation interrupts a batch:
|
||||
# False follows the existing watcher retry path; None remains the ordinary dedupe result.
|
||||
# Never strand watcher futures: False = watcher retry path; None = ordinary dedupe result.
|
||||
self._settle_batch_waiters(entries, delivered)
|
||||
# Do not remove a newer flush task that reused the same route key.
|
||||
if self._completion_notification_batch_tasks.get(key) is current_task:
|
||||
@@ -1295,7 +1187,6 @@ class GatewayNotificationsMixin:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Defensive cleanup for an orphaned queue with no live flush task.
|
||||
batches = getattr(self, "_completion_notification_batches", {})
|
||||
for entries in batches.values():
|
||||
@@ -1306,22 +1197,17 @@ class GatewayNotificationsMixin:
|
||||
|
||||
async def _enqueue_process_completion_notification(self, synth_text: str, evt: dict) -> Optional[bool]:
|
||||
"""Fan in concurrent process completions that share one conversation."""
|
||||
# Some unit tests construct GatewayRunner with object.__new__. Keep the
|
||||
# batching seam lazy so those focused lifecycle tests remain valid.
|
||||
# Lazy defaults: lifecycle tests build GatewayRunner via object.__new__.
|
||||
for attr, default in (
|
||||
("_completion_notification_batches", dict),
|
||||
("_completion_notification_batch_tasks", dict),
|
||||
("_completion_notification_batches", dict), ("_completion_notification_batch_tasks", dict),
|
||||
("_completion_notification_batch_flush_tasks", set),
|
||||
("_completion_notification_batch_window", lambda: 0.1),
|
||||
("_completion_notification_batches_stopping", lambda: False),
|
||||
("_background_tasks", set),
|
||||
("_completion_notification_batches_stopping", lambda: False), ("_background_tasks", set),
|
||||
):
|
||||
if not hasattr(self, attr):
|
||||
setattr(self, attr, default())
|
||||
|
||||
if self._completion_notification_batches_stopping:
|
||||
return False
|
||||
|
||||
key = self._event_route_key(evt, self._COMPLETION_BATCH_KEY_FIELDS)
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
self._completion_notification_batches.setdefault(key, []).append((synth_text, evt, future))
|
||||
@@ -1360,14 +1246,10 @@ class GatewayNotificationsMixin:
|
||||
logger.debug(fail_msg, exc_info=True)
|
||||
|
||||
async def _deliver_async_delegation_group(self, group: list[dict]) -> Optional[bool]:
|
||||
"""Deliver a same-session batch of async completions as ONE turn.
|
||||
|
||||
Single-event groups ride the per-event path. Multi-event groups deliver the primary via
|
||||
``_deliver_completion_notification`` with consolidated text of every sibling THIS runner
|
||||
claimed; sibling claims are acked only after adapter acceptance, and siblings claimed by
|
||||
another consumer are excluded (no double delivery). Returns True after acceptance, False
|
||||
to requeue the group, None when nothing is deliverable here (retry siblings requeued).
|
||||
"""
|
||||
"""Deliver a same-session batch of async completions as ONE turn: the primary carries the
|
||||
consolidated text of every sibling THIS runner claimed (siblings owned elsewhere are excluded;
|
||||
their claims are acked only after adapter acceptance). True after acceptance, False to requeue
|
||||
the group, None when nothing is deliverable here (retry siblings requeued)."""
|
||||
from gateway.run import _format_gateway_process_notification
|
||||
from tools.process_registry import process_registry as _pr
|
||||
deliverable: list[tuple[dict, str]] = []
|
||||
@@ -1379,13 +1261,11 @@ class GatewayNotificationsMixin:
|
||||
if identity is not None and self._completion_identity_seen(identity):
|
||||
continue
|
||||
deliverable.append((evt, synth_text))
|
||||
|
||||
if not deliverable:
|
||||
return None
|
||||
if len(deliverable) == 1:
|
||||
evt, synth_text = deliverable[0]
|
||||
return await self._deliver_completion_notification(synth_text, evt)
|
||||
|
||||
from tools.async_delegation import claim_event_delivery, complete_event_delivery, release_event_delivery
|
||||
primary_evt, primary_text = deliverable[0]
|
||||
blocks = [primary_text]
|
||||
@@ -1393,15 +1273,12 @@ class GatewayNotificationsMixin:
|
||||
for evt, synth_text in deliverable[1:]:
|
||||
claim_id = claim_event_delivery(evt, f"gateway-batch:{id(self)}")
|
||||
if claim_id is None:
|
||||
# Another consumer owns this row's delivery; keep its result out of our
|
||||
# consolidated text so it is never double-injected.
|
||||
# Another consumer owns this row: keep it out of our text so it is never double-injected.
|
||||
continue
|
||||
siblings.append((evt, claim_id))
|
||||
blocks.append(synth_text)
|
||||
|
||||
if not siblings:
|
||||
return await self._deliver_completion_notification(primary_text, primary_evt)
|
||||
|
||||
header = (
|
||||
f"[IMPORTANT: {len(blocks)} background subagent delegations "
|
||||
"completed for this session. Treat these results as one "
|
||||
@@ -1419,12 +1296,10 @@ class GatewayNotificationsMixin:
|
||||
)
|
||||
self._record_coalesced_completion_siblings([evt for evt, _claim_id in siblings])
|
||||
else:
|
||||
# Not delivered — release every sibling claim so a retry or another consumer can claim
|
||||
# it, honestly leaving the durable rows pending.
|
||||
# Not delivered: release every sibling claim so a retry or another consumer can take it.
|
||||
self._settle_sibling_claims(siblings, release_event_delivery, "Could not release coalesced durable claim")
|
||||
if delivered is None:
|
||||
# The primary was dropped/owned elsewhere but the siblings still need delivery —
|
||||
# requeue just them for the next tick.
|
||||
# Primary dropped/owned elsewhere: requeue just the siblings for the next tick.
|
||||
for evt, _claim_id in siblings:
|
||||
_pr.completion_queue.put(evt)
|
||||
return delivered
|
||||
@@ -1439,8 +1314,7 @@ class GatewayNotificationsMixin:
|
||||
from tools.process_registry import process_registry as _pr
|
||||
while self._running:
|
||||
with _log_suppressed(logging.DEBUG, "Async delegation watcher error: %s"):
|
||||
# Peek for async-delegation events only; watch/completion events belong to other drains,
|
||||
# so requeue anything that isn't ours.
|
||||
# Take async-delegation events only; requeue watch/completion events for their own drains.
|
||||
requeue = []
|
||||
async_events = []
|
||||
while not _pr.completion_queue.empty():
|
||||
@@ -1451,9 +1325,8 @@ class GatewayNotificationsMixin:
|
||||
(async_events if evt.get("type") == "async_delegation" else requeue).append(evt)
|
||||
for evt in requeue:
|
||||
_pr.completion_queue.put(evt)
|
||||
# A same-tick drain often carries several completions for the SAME session (a fan-out
|
||||
# finishing together); delivering each individually floods it with N synthetic turns.
|
||||
# Group by full gateway route + parent session: one consolidated turn per group.
|
||||
# A fan-out finishing together yields N completions for one session; group by full route +
|
||||
# parent session so each group becomes ONE consolidated turn.
|
||||
groups: dict[tuple[str, ...], list[dict]] = {}
|
||||
for evt in async_events:
|
||||
self._enrich_async_delegation_routing(evt)
|
||||
@@ -1478,9 +1351,8 @@ class GatewayNotificationsMixin:
|
||||
if new_output:
|
||||
from agent.redact import redact_terminal_output
|
||||
new_output = redact_terminal_output(new_output, getattr(session, "command", "") or "")
|
||||
# redact_terminal_output() is unforced, so it returns raw text when security.redact_secrets
|
||||
# is off. This goes straight to the platform adapter, so it needs the same unconditional
|
||||
# floor as agent-notify.
|
||||
# redact_terminal_output() is unforced (raw when security.redact_secrets is off); this goes
|
||||
# straight to the adapter, so apply the same unconditional floor as agent-notify.
|
||||
new_output = _redact_gateway_user_facing_secrets(new_output)
|
||||
return new_output
|
||||
|
||||
@@ -1503,8 +1375,7 @@ class GatewayNotificationsMixin:
|
||||
_command = getattr(session, "command", "") or ""
|
||||
_raw = strip_ansi(session.output_buffer) if session.output_buffer else ""
|
||||
_raw = redact_terminal_output(_raw, _command)
|
||||
# Truncate on line boundaries (never start mid-line): keep the last ~2000 chars snapped to
|
||||
# the preceding newline, prepending a marker when output was cut.
|
||||
# Keep the last ~2000 chars snapped to a line boundary, with a marker when cut.
|
||||
_LIMIT = 2000
|
||||
if len(_raw) > _LIMIT:
|
||||
_tail = _raw[-_LIMIT:]
|
||||
@@ -1524,8 +1395,7 @@ class GatewayNotificationsMixin:
|
||||
"completion_reason": getattr(session, "completion_reason", "exited"),
|
||||
"termination_source": getattr(session, "termination_source", ""),
|
||||
"output": _redact_gateway_user_facing_secrets(_out),
|
||||
# Spawning conversation's session-db id (stamped in terminal_tool); lets delivery
|
||||
# pre-flight drop this completion if the user closed that session (/new) first.
|
||||
# Spawning session-db id: lets pre-flight drop this completion if the user /new'd first.
|
||||
"parent_session_id": (
|
||||
watcher.get("parent_session_id") or getattr(session, "parent_session_id", "") or ""
|
||||
),
|
||||
@@ -1547,13 +1417,10 @@ class GatewayNotificationsMixin:
|
||||
)
|
||||
|
||||
async def _run_process_watcher(self, watcher: dict) -> None:
|
||||
"""Periodically check a background process and push updates to the user.
|
||||
|
||||
Runs as an asyncio task. Stays silent when nothing changed. Auto-removes when the process
|
||||
exits or is killed. Notification mode (``display.background_process_notifications``):
|
||||
concise (default, one-line; failures append output tail) / all (running updates + final
|
||||
raw output) / result (final raw only) / error (final raw only if exit != 0) / off.
|
||||
"""
|
||||
"""Poll a background process and push updates until it exits. Mode
|
||||
(``display.background_process_notifications``): concise (default one-liner; failures append
|
||||
the output tail) / all (running updates + final raw) / result (final raw) / error (final raw
|
||||
if exit != 0) / off."""
|
||||
from tools.process_registry import format_process_notification, process_registry
|
||||
session_id = watcher["session_id"]
|
||||
interval = watcher["check_interval"]
|
||||
@@ -1562,10 +1429,8 @@ class GatewayNotificationsMixin:
|
||||
thread_id = watcher.get("thread_id", "")
|
||||
agent_notify = watcher.get("notify_on_complete", False)
|
||||
notify_mode = self._load_background_notifications_mode()
|
||||
|
||||
logger.debug("Process watcher started: %s (every %ss, notify=%s, agent_notify=%s)",
|
||||
session_id, interval, notify_mode, agent_notify)
|
||||
|
||||
silent = notify_mode == "off" and not agent_notify
|
||||
last_output_len = 0
|
||||
while True:
|
||||
@@ -1581,11 +1446,9 @@ class GatewayNotificationsMixin:
|
||||
current_output_len = len(session.output_buffer)
|
||||
has_new_output = current_output_len > last_output_len
|
||||
last_output_len = current_output_len
|
||||
|
||||
if session.exited:
|
||||
# Agent-triggered completion: inject a synthetic message unless the agent already consumed
|
||||
# the result via wait/log. poll() is read-only and deliberately does NOT mark consumed —
|
||||
# a status check must not suppress this delivery turn.
|
||||
# Agent-notify: inject a synthetic message unless the agent already consumed the result via
|
||||
# wait/log (poll() is read-only and deliberately does NOT mark consumed).
|
||||
if agent_notify and not process_registry.is_completion_consumed(session_id):
|
||||
completion_evt = self._build_process_completion_event(watcher, session, session_id)
|
||||
synth_text = format_process_notification(completion_evt)
|
||||
@@ -1597,10 +1460,8 @@ class GatewayNotificationsMixin:
|
||||
# of suppressing the result.
|
||||
continue
|
||||
break
|
||||
|
||||
# Normal text-only notification. Skip when the agent already consumed this completion via
|
||||
# wait/log (output returned inline) — the raw "finished" message would be a duplicate.
|
||||
# The agent_notify skip FALLS THROUGH here, hence this check. poll() is read-only.
|
||||
# Text-only notification; skip when already consumed via wait/log (the agent_notify branch
|
||||
# FALLS THROUGH here, hence the re-check).
|
||||
if process_registry.is_completion_consumed(session_id):
|
||||
logger.debug(
|
||||
"Process watcher: completion for %s already consumed "
|
||||
@@ -1613,7 +1474,6 @@ class GatewayNotificationsMixin:
|
||||
message_text = self._format_process_final_message(session_id, session, notify_mode)
|
||||
await self._send_watcher_message(platform_name, chat_id, thread_id, message_text)
|
||||
break
|
||||
|
||||
elif has_new_output and notify_mode == "all" and not agent_notify:
|
||||
# New output — deliver a status update (only in "all" mode; agent_notify watchers
|
||||
# only care about completion).
|
||||
@@ -1622,5 +1482,4 @@ class GatewayNotificationsMixin:
|
||||
platform_name, chat_id, thread_id,
|
||||
f"[Background process {session_id} is still running~ New output:\n{new_output}]",
|
||||
)
|
||||
|
||||
logger.debug("Process watcher ended%s: %s", " (silent)" if silent else "", session_id)
|
||||
|
||||
+174
-243
@@ -138,11 +138,15 @@ class GatewayShutdownMixin:
|
||||
+ self._active_deferred_agent_worker_count()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _running_cron_job_count() -> int:
|
||||
from cron.scheduler import get_running_job_ids
|
||||
return len(get_running_job_ids())
|
||||
|
||||
def _active_cron_job_count(self) -> int:
|
||||
"""Cron jobs currently executing — they run outside ``_running_agents``; 0 if cron can't import."""
|
||||
try:
|
||||
from cron.scheduler import get_running_job_ids
|
||||
return len(get_running_job_ids())
|
||||
return self._running_cron_job_count()
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
@@ -151,8 +155,7 @@ class GatewayShutdownMixin:
|
||||
|
||||
Only the primary API server owns the HTTP listener, so only it is a source of this work.
|
||||
"""
|
||||
adapter = getattr(self, "adapters", {}).get(Platform.API_SERVER)
|
||||
helper = getattr(adapter, name, None)
|
||||
helper = getattr(getattr(self, "adapters", {}).get(Platform.API_SERVER), name, None)
|
||||
return max(0, int(helper(*args))) if callable(helper) else 0
|
||||
|
||||
def _active_api_run_count(self) -> int:
|
||||
@@ -225,18 +228,18 @@ class GatewayShutdownMixin:
|
||||
for t in self._background_tasks
|
||||
):
|
||||
return True
|
||||
try:
|
||||
def _delegations_active() -> bool:
|
||||
from tools.async_delegation import active_count
|
||||
if active_count() > 0:
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - never let the idle check raise
|
||||
logger.debug("scale-to-zero async-delegation check failed", exc_info=True)
|
||||
try:
|
||||
return active_count() > 0
|
||||
|
||||
def _processes_active() -> bool:
|
||||
from tools.process_registry import process_registry
|
||||
if process_registry.has_any_active() or process_registry.pending_watchers:
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - never let the idle check raise
|
||||
logger.debug("scale-to-zero bg-work check failed", exc_info=True)
|
||||
return bool(process_registry.has_any_active() or process_registry.pending_watchers)
|
||||
|
||||
for label, probe in (("async-delegation", _delegations_active), ("bg-work", _processes_active)):
|
||||
with _log_suppressed(logging.DEBUG, f"scale-to-zero {label} check failed", exc_info=True):
|
||||
if probe():
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@@ -319,9 +322,8 @@ class GatewayShutdownMixin:
|
||||
return # not opted in — normal, stay quiet
|
||||
active = [getattr(p, "value", p) for p in self._scale_to_zero_active_messaging_platforms()]
|
||||
logger.info(
|
||||
"scale-to-zero: NOT armed despite opt-in — "
|
||||
"relay_only_or_absent=%s (enabled platforms=%s), wake_url=%s. "
|
||||
"Need relay-only messaging + a registered wake URL.",
|
||||
"scale-to-zero: NOT armed despite opt-in — relay_only_or_absent=%s (enabled platforms=%s), "
|
||||
"wake_url=%s. Need relay-only messaging + a registered wake URL.",
|
||||
messaging_is_relay_only_or_absent(active), active or "none",
|
||||
"set" if self._relay_wake_url_or_none() else "MISSING",
|
||||
)
|
||||
@@ -330,9 +332,8 @@ class GatewayShutdownMixin:
|
||||
|
||||
def _scale_to_zero_is_idle(self) -> bool:
|
||||
from gateway.scale_to_zero import is_idle
|
||||
# FULL work aggregate (cron + API runs live outside _running_agents) with fail-AWAKE reads:
|
||||
# the drain counters swallow errors to 0, which for a suspend predicate would look idle, so
|
||||
# an unreadable source counts as work here.
|
||||
# FULL work aggregate with fail-AWAKE reads: the drain counters swallow errors to 0, which a
|
||||
# suspend predicate would read as idle, so an unreadable source counts as work here.
|
||||
|
||||
def _read_or_awake(label: str, fn: Callable[[], Any], busy_sentinel: Any) -> Any:
|
||||
try:
|
||||
@@ -341,15 +342,10 @@ class GatewayShutdownMixin:
|
||||
logger.debug("scale-to-zero: %s unreadable — staying awake", label, exc_info=True)
|
||||
return busy_sentinel
|
||||
|
||||
def _cron_count() -> int:
|
||||
from cron.scheduler import get_running_job_ids
|
||||
return len(get_running_job_ids())
|
||||
|
||||
cron_count = _read_or_awake("cron work count", _cron_count, 1)
|
||||
cron_count = _read_or_awake("cron work count", self._running_cron_job_count, 1)
|
||||
api_count = _read_or_awake("api work count", lambda: self._api_server_hook("active_agent_work_count"), 1)
|
||||
# An attached dashboard/desktop/TUI client (heartbeat file mtime from the dashboard process) is
|
||||
# inbound activity too — folded into the inbound clock, not a conjunct, so disconnect gets the
|
||||
# same idle_timeout grace as a message and a lingering marker cannot pin the box.
|
||||
# An attached dashboard/desktop/TUI client (heartbeat mtime) is inbound activity — folded into
|
||||
# the inbound clock, not a conjunct, so a lingering marker cannot pin the box.
|
||||
last_inbound = self._last_inbound_at
|
||||
from gateway.scale_to_zero import dashboard_client_last_seen
|
||||
seen = _read_or_awake("dashboard heartbeat", dashboard_client_last_seen, time.time())
|
||||
@@ -385,13 +381,10 @@ class GatewayShutdownMixin:
|
||||
return self.adapters.get(Platform.RELAY)
|
||||
|
||||
async def _scale_to_zero_watcher(self, interval: float = 30.0) -> None:
|
||||
"""Watch for idle, drive the relay dormant, then self-suspend the machine.
|
||||
|
||||
On sustained idle: status `draining` (NOT _running=False), relay go_dormant() (socket close,
|
||||
NOT disconnect()), no mark_resume_pending (suspend preserves RAM), THEN suspend via the flaps
|
||||
socket. The gateway owns the suspend because Fly autostop sees only INBOUND connections and
|
||||
would freeze mid-job or before the relay flip. Off-Fly the watcher does not quiesce at all.
|
||||
"""
|
||||
"""Watch for idle, drive the relay dormant, then self-suspend. On sustained idle: status
|
||||
`draining` (NOT _running=False), relay go_dormant() (socket close, NOT disconnect()), no
|
||||
mark_resume_pending (suspend preserves RAM), THEN suspend via the flaps socket — Fly autostop
|
||||
sees only INBOUND connections and would freeze mid-job. Off-Fly: no quiesce at all."""
|
||||
await asyncio.sleep(min(interval, 30.0)) # let startup settle
|
||||
while self._running:
|
||||
try:
|
||||
@@ -410,9 +403,8 @@ class GatewayShutdownMixin:
|
||||
if not self._scale_to_zero_no_suspend_logged:
|
||||
self._scale_to_zero_no_suspend_logged = True
|
||||
logger.info(
|
||||
"scale-to-zero: idle, but this platform suspends on "
|
||||
"its own timer (no in-machine suspend API); staying "
|
||||
"connected rather than quiescing"
|
||||
"scale-to-zero: idle, but this platform suspends on its own timer (no "
|
||||
"in-machine suspend API); staying connected rather than quiescing"
|
||||
)
|
||||
continue
|
||||
logger.info(
|
||||
@@ -432,9 +424,8 @@ class GatewayShutdownMixin:
|
||||
# After a wake the drained inbound updates _last_inbound_at; give it a window so we
|
||||
# don't immediately re-go-dormant on the same idle reading before traffic lands.
|
||||
self._scale_to_zero_cooldown_until = time.time() + max(interval, 60.0)
|
||||
# Self-suspend ONLY after a clean quiesce: the relay flip (buffered delivery + wake
|
||||
# poke armed) must be set before the freeze, or inbound black-holes while we sleep.
|
||||
# Re-check idle one last time — inbound may have landed during the quiesce await.
|
||||
# Self-suspend ONLY after a clean quiesce (else inbound black-holes while we sleep), and
|
||||
# re-check idle — inbound may have landed during the quiesce await.
|
||||
if not dormant_ok:
|
||||
continue
|
||||
if not self._scale_to_zero_is_idle():
|
||||
@@ -463,9 +454,8 @@ class GatewayShutdownMixin:
|
||||
except Exception: # noqa: BLE001 - suspend is best-effort, never crash
|
||||
logger.debug("scale-to-zero: self-suspend failed", exc_info=True)
|
||||
|
||||
# External drain control: the dashboard's begin/cancel-drain endpoint writes/removes the
|
||||
# ``.drain_request.json`` marker (gateway/drain_control.py); the watcher flips the gateway
|
||||
# between accepting and refusing NEW turns WITHOUT exiting (reversible on marker removal).
|
||||
# External drain control: the dashboard writes/removes ``.drain_request.json`` (gateway/drain_control.py);
|
||||
# the watcher flips between accepting and refusing NEW turns WITHOUT exiting (reversible).
|
||||
def _enter_external_drain(self) -> None:
|
||||
"""Begin external drain: refuse NEW turns (in-flight ones are NOT interrupted). Idempotent."""
|
||||
if self._external_drain_active:
|
||||
@@ -473,11 +463,9 @@ class GatewayShutdownMixin:
|
||||
self._external_drain_active = True
|
||||
logger.info(
|
||||
"External drain ENGAGED (.drain_request.json present) — refusing "
|
||||
"new turns; %d in-flight turn(s) will finish. Process stays up.",
|
||||
self._active_work_count(),
|
||||
"new turns; %d in-flight turn(s) will finish. Process stays up.", self._active_work_count(),
|
||||
)
|
||||
# Flip persisted lifecycle state so /api/status.gateway_busy / gateway_drainable track the
|
||||
# drain; active_agents is preserved (read-merge keeps the live count), only state changes.
|
||||
# Persist "draining" so /api/status tracks it; active_agents is read-merged, only state changes.
|
||||
self._update_runtime_status("draining")
|
||||
|
||||
def _exit_external_drain(self) -> None:
|
||||
@@ -502,9 +490,8 @@ class GatewayShutdownMixin:
|
||||
from gateway.drain_control import drain_requested
|
||||
while self._running:
|
||||
try:
|
||||
# drain_requested() does a synchronous read_text() on the marker file: at 1s cadence
|
||||
# that is a blocking disk read on the event loop ~86k times/day, and under host I/O
|
||||
# pressure one read can stall 30s+ and take every platform heartbeat down. Off-thread it.
|
||||
# Off-thread: a synchronous marker read at 1s cadence can stall 30s+ under host I/O
|
||||
# pressure and take every platform heartbeat down.
|
||||
if await asyncio.to_thread(drain_requested):
|
||||
self._enter_external_drain()
|
||||
# API and cron work live outside messaging's _running_agents map; refresh the
|
||||
@@ -523,17 +510,16 @@ class GatewayShutdownMixin:
|
||||
error_code: Optional[str] = None, error_message: Optional[str] = None,
|
||||
needs_attention: Optional[bool] = None, retrying_since: Any = _UNSET,
|
||||
) -> None:
|
||||
with suppress(Exception):
|
||||
from gateway.status import write_runtime_status
|
||||
extra: Dict[str, Any] = {}
|
||||
if needs_attention is not None:
|
||||
extra["needs_attention"] = needs_attention
|
||||
if retrying_since is not _UNSET:
|
||||
extra["retrying_since"] = retrying_since
|
||||
write_runtime_status(
|
||||
platform=platform, platform_state=platform_state, error_code=error_code,
|
||||
error_message=error_message, **extra,
|
||||
)
|
||||
from gateway.run import _write_runtime_status_quiet
|
||||
extra: Dict[str, Any] = {}
|
||||
if needs_attention is not None:
|
||||
extra["needs_attention"] = needs_attention
|
||||
if retrying_since is not _UNSET:
|
||||
extra["retrying_since"] = retrying_since
|
||||
_write_runtime_status_quiet(
|
||||
platform=platform, platform_state=platform_state, error_code=error_code,
|
||||
error_message=error_message, **extra,
|
||||
)
|
||||
|
||||
# Per-platform circuit breaker (pause/resume): reconnect watcher + /platform pause|resume.
|
||||
def _pause_failed_platform(self, platform, *, reason: str = "") -> None:
|
||||
@@ -543,15 +529,14 @@ class GatewayShutdownMixin:
|
||||
return
|
||||
info["paused"] = True
|
||||
info["pause_reason"] = reason or "auto-paused after repeated failures"
|
||||
# Push next_retry far enough out that a stale code path missing "paused" still never fires.
|
||||
# next_retry=inf: a stale code path missing "paused" still never fires.
|
||||
info["next_retry"] = float("inf")
|
||||
self._update_platform_runtime_status(
|
||||
platform.value, platform_state="paused", error_code=None, error_message=info["pause_reason"],
|
||||
)
|
||||
logger.warning(
|
||||
"%s paused after %d consecutive failures (%s) — "
|
||||
"fix the underlying issue then run `/platform resume %s` "
|
||||
"to retry, or `hermes gateway restart` to restart the gateway.",
|
||||
"%s paused after %d consecutive failures (%s) — fix the underlying issue then run `/platform "
|
||||
"resume %s` to retry, or `hermes gateway restart` to restart the gateway.",
|
||||
platform.value, info.get("attempts", 0), info["pause_reason"], platform.value,
|
||||
)
|
||||
|
||||
@@ -592,14 +577,12 @@ class GatewayShutdownMixin:
|
||||
self._update_runtime_status("draining")
|
||||
last_counts, last_status_at = counts, now
|
||||
|
||||
# Cron/API-server/deferred work lives outside ``self._running_agents`` — fold it into this
|
||||
# wait, or a cron job's tool work is killed without warning once it's the only thing running.
|
||||
# Cron/API/deferred work lives outside ``_running_agents``; fold it in or it is killed unwarned.
|
||||
_cron0, _api0, _deferred0 = last_counts[1:]
|
||||
_maybe_update_status(force=True)
|
||||
if not self._running_agents and not (_cron0 or _api0 or _deferred0):
|
||||
return snapshot, False
|
||||
# Cron has its own deadline: ``restart_drain_timeout`` defaults to 0 (an interrupted chat turn
|
||||
# is announced and resumable) but a cron run killed mid-flight is a permanent failure.
|
||||
# Cron has its own deadline: a chat turn is announced+resumable; a killed cron run is a permanent failure.
|
||||
started = loop.time()
|
||||
deadline = started + timeout
|
||||
cron_deadline = started + (timeout if cron_timeout is None else cron_timeout)
|
||||
@@ -609,8 +592,7 @@ class GatewayShutdownMixin:
|
||||
agents, cron, api, deferred = self._drain_work_counts()
|
||||
return bool(((agents or api or deferred) and now < deadline) or (cron and now < cron_deadline))
|
||||
|
||||
# Both budgets at 0 leave this loop unentered ("interrupt immediately") as an expired deadline,
|
||||
# not a special case, so timed_out below is always computed from real state.
|
||||
# Both budgets at 0 = an expired deadline (loop unentered), so timed_out still comes from real state.
|
||||
while _still_draining():
|
||||
_maybe_update_status()
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -628,12 +610,12 @@ class GatewayShutdownMixin:
|
||||
logger.debug("Interrupted running agent for session %s during shutdown", session_key)
|
||||
# API-server / desk turns are adapter-owned and never enter _running_agents, so the loop above
|
||||
# cannot see them even though _drain_active_agents() waited for them.
|
||||
interrupted_api = self._interrupt_api_server_runs(reason)
|
||||
if interrupted_api:
|
||||
logger.debug("Interrupted %d api_server run(s) during shutdown", interrupted_api)
|
||||
interrupted_deferred = self._interrupt_deferred_agent_workers(reason)
|
||||
if interrupted_deferred:
|
||||
logger.debug("Interrupted %d deferred agent worker(s) during shutdown", interrupted_deferred)
|
||||
for count, what in (
|
||||
(self._interrupt_api_server_runs(reason), "api_server run(s)"),
|
||||
(self._interrupt_deferred_agent_workers(reason), "deferred agent worker(s)"),
|
||||
):
|
||||
if count:
|
||||
logger.debug("Interrupted %d %s during shutdown", count, what)
|
||||
|
||||
def _shutdown_interrupt_reason(self) -> str:
|
||||
from gateway.run import _INTERRUPT_REASON_GATEWAY_RESTART, _INTERRUPT_REASON_GATEWAY_SHUTDOWN
|
||||
@@ -687,9 +669,8 @@ class GatewayShutdownMixin:
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
continue
|
||||
# deliver=local jobs, and deliver=origin jobs with no resolvable origin, resolve to zero
|
||||
# targets and must stay silent rather than fall back to a home channel. Interrupted
|
||||
# notices are failure-category engine status, so they honor failure_deliver.
|
||||
# deliver=local / unresolvable-origin jobs resolve to zero targets and stay silent (no home-
|
||||
# channel fallback). Interrupted notices are failure-category status: honor failure_deliver.
|
||||
targets = _resolve_delivery_targets(job, for_failure=True)
|
||||
except Exception as e:
|
||||
logger.debug("Cron interrupt targets unresolved for %s: %s", job_id, e)
|
||||
@@ -712,17 +693,13 @@ class GatewayShutdownMixin:
|
||||
dedup_key = (job_id, *_notice_target_key(platform.value, chat_id, thread_id))
|
||||
if dedup_key in notified:
|
||||
continue
|
||||
try:
|
||||
with _log_suppressed(logging.DEBUG, "Cron interrupt notice to %s:%s raised: %s", platform.value, chat_id):
|
||||
metadata = self._thread_metadata_for_target(platform, chat_id, thread_id, adapter=adapter)
|
||||
result = await adapter.send(chat_id, msg, metadata=metadata)
|
||||
if _send_failed(result):
|
||||
logger.debug(
|
||||
"Cron interrupt notice to %s:%s failed: %s", platform.value, chat_id, _send_error(result),
|
||||
)
|
||||
continue
|
||||
notified.add(dedup_key)
|
||||
except Exception as e:
|
||||
logger.debug("Cron interrupt notice to %s:%s raised: %s", platform.value, chat_id, e)
|
||||
if await self._send_notice_logged(
|
||||
adapter, chat_id, msg, platform.value, "Cron interrupt notice to %s:%s failed: %s",
|
||||
"Cron interrupt notice to %s:%s raised: %s", metadata=metadata,
|
||||
):
|
||||
notified.add(dedup_key)
|
||||
if notified:
|
||||
logger.info("Shutdown: delivered %d interrupted-cron-job notice(s)", len(notified))
|
||||
return len(notified)
|
||||
@@ -752,19 +729,27 @@ class GatewayShutdownMixin:
|
||||
) -> bool:
|
||||
"""Send one shutdown notice; True when delivered. Failures are debug-logged, never raised."""
|
||||
where = "home channel " if kind == "home channel" else ""
|
||||
try:
|
||||
result = await adapter.send(chat_id, msg, **send_kwargs)
|
||||
if _send_failed(result):
|
||||
logger.debug(
|
||||
"Failed to send shutdown notification to %s%s:%s: %s", where, platform_str, chat_id,
|
||||
_send_error(result),
|
||||
)
|
||||
return False
|
||||
logger.info("Sent shutdown notification to %s %s:%s", kind, platform_str, chat_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send shutdown notification to %s%s:%s: %s", where, platform_str, chat_id, e)
|
||||
fail_fmt = f"Failed to send shutdown notification to {where}%s:%s: %s"
|
||||
if not await self._send_notice_logged(adapter, chat_id, msg, platform_str, fail_fmt, **send_kwargs):
|
||||
return False
|
||||
logger.info("Sent shutdown notification to %s %s:%s", kind, platform_str, chat_id)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def _send_notice_logged(
|
||||
adapter, chat_id: str, msg: str, platform_str: str, fail_fmt: str, raise_fmt: Optional[str] = None, **kw
|
||||
) -> bool:
|
||||
"""``adapter.send`` whose failure is debug-logged as ``fmt % (platform, chat, error)`` — ``fail_fmt``
|
||||
for success=False, ``raise_fmt`` (default ``fail_fmt``) for a raise; True only on a delivered send."""
|
||||
try:
|
||||
result = await adapter.send(chat_id, msg, **kw)
|
||||
except Exception as e:
|
||||
logger.debug(raise_fmt or fail_fmt, platform_str, chat_id, e)
|
||||
return False
|
||||
if _send_failed(result):
|
||||
logger.debug(fail_fmt, platform_str, chat_id, _send_error(result))
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _notify_active_sessions_of_shutdown(self) -> None:
|
||||
"""Send shutdown/restart notifications to active chats and home channels.
|
||||
@@ -772,14 +757,12 @@ class GatewayShutdownMixin:
|
||||
Called at the start of stop() while adapters are connected; send failures never block shutdown.
|
||||
"""
|
||||
restart_source = self._restart_command_source if self._restart_requested else None
|
||||
action = "restarting" if self._restart_requested else "shutting down"
|
||||
hint = (
|
||||
"Your current task will be interrupted. "
|
||||
"Send any message after restart and I'll try to resume where you left off."
|
||||
if self._restart_requested
|
||||
else "Your current task will be interrupted."
|
||||
)
|
||||
msg = f"⚠️ Gateway {action} — {hint}"
|
||||
msg = "⚠️ Gateway shutting down — Your current task will be interrupted."
|
||||
if self._restart_requested:
|
||||
msg = (
|
||||
"⚠️ Gateway restarting — Your current task will be interrupted. "
|
||||
"Send any message after restart and I'll try to resume where you left off."
|
||||
)
|
||||
restart_key = None
|
||||
if restart_source is not None:
|
||||
with suppress(Exception):
|
||||
@@ -817,19 +800,15 @@ class GatewayShutdownMixin:
|
||||
if self._restart_requested and restart_source is not None:
|
||||
logger.debug("Skipping home-channel shutdown notifications for in-chat restart")
|
||||
return
|
||||
# Suppress ONLY the home-channel broadcast when the drain asked to be quiet (routine fleet
|
||||
# auto-update); per-session pings above stay (empty on a drained shutdown, useful on a
|
||||
# force-interrupt). Current-epoch marker only, so an orphaned marker can't silence a fresh gateway.
|
||||
try:
|
||||
# A quiet drain (routine fleet auto-update) suppresses ONLY the home-channel broadcast; per-session
|
||||
# pings above stay. Current-epoch marker only; a failing check fails toward the louder behaviour.
|
||||
with _log_suppressed(logging.DEBUG, "drain_notification_suppressed check failed: %s"):
|
||||
from gateway.drain_control import drain_notification_suppressed
|
||||
if drain_notification_suppressed():
|
||||
logger.info(
|
||||
"Home-channel shutdown broadcast suppressed by drain marker (suppress_notification=true)"
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
# Never let the suppression check block the broadcast — fail toward the louder behaviour.
|
||||
logger.debug("drain_notification_suppressed check failed: %s", e)
|
||||
# Snapshot adapters: adapter.send() can hit a fatal path (_handle_fatal) that pops the adapter
|
||||
# from self.adapters -> ``RuntimeError: dictionary changed size during iteration``.
|
||||
for platform, adapter in list(self.adapters.items()):
|
||||
@@ -849,9 +828,9 @@ class GatewayShutdownMixin:
|
||||
)
|
||||
continue
|
||||
# Home channels omit ``metadata=`` when empty (adapter doubles may not accept the kwarg).
|
||||
send_kwargs = {"metadata": metadata} if metadata else {}
|
||||
if await self._send_shutdown_notice(
|
||||
adapter, str(home.chat_id), msg, "home channel", platform.value, **send_kwargs
|
||||
adapter, str(home.chat_id), msg, "home channel", platform.value,
|
||||
**({"metadata": metadata} if metadata else {}),
|
||||
):
|
||||
notified.add(dedup_key)
|
||||
|
||||
@@ -976,9 +955,8 @@ class GatewayShutdownMixin:
|
||||
return
|
||||
with suppress(Exception):
|
||||
if hasattr(agent, "shutdown_memory_provider"):
|
||||
# Drain queued memory writes BEFORE teardown: shutdown_all() gives the memory worker
|
||||
# only ~5s, so a /reset or rotation could drop handed-off writes (stale memory next
|
||||
# session). Bounded, mirrors CLI exit; a failure never blocks teardown.
|
||||
# Drain queued memory writes BEFORE teardown (shutdown_all() gives the worker only ~5s, so a
|
||||
# /reset or rotation could drop them). Bounded; a failure never blocks teardown.
|
||||
_mm = getattr(agent, "_memory_manager", None)
|
||||
if _mm is not None and hasattr(_mm, "flush_pending"):
|
||||
with suppress(Exception):
|
||||
@@ -1007,7 +985,7 @@ class GatewayShutdownMixin:
|
||||
|
||||
@staticmethod
|
||||
def _read_json_counts(path: Path) -> Optional[dict]:
|
||||
"""Parsed counter dict, or None when the file is missing/unreadable."""
|
||||
"""Parsed counter dict, or None when the file is missing/unreadable (no exists() pre-check needed)."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
@@ -1017,10 +995,9 @@ class GatewayShutdownMixin:
|
||||
"""Increment persisted restart-failure counters for active sessions; drop the rest (loop broken)."""
|
||||
from gateway.run import atomic_json_write
|
||||
path = self._stuck_loop_counts_path()
|
||||
counts = (self._read_json_counts(path) if path.exists() else None) or {}
|
||||
new_counts = {key: counts.get(key, 0) + 1 for key in active_session_keys}
|
||||
counts = self._read_json_counts(path) or {}
|
||||
with suppress(Exception):
|
||||
atomic_json_write(path, new_counts, indent=None)
|
||||
atomic_json_write(path, {key: counts.get(key, 0) + 1 for key in active_session_keys}, indent=None)
|
||||
|
||||
def _suspend_stuck_loop_sessions(self) -> int:
|
||||
"""Suspend sessions active across too many restarts (startup, AFTER suspend_recently_active())."""
|
||||
@@ -1032,7 +1009,7 @@ class GatewayShutdownMixin:
|
||||
return 0
|
||||
suspended = 0
|
||||
for session_key in [k for k, v in counts.items() if v >= self._STUCK_LOOP_THRESHOLD]:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
entry = self.session_store._entries.get(session_key)
|
||||
if entry and not entry.suspended:
|
||||
entry.suspended = True
|
||||
@@ -1041,8 +1018,6 @@ class GatewayShutdownMixin:
|
||||
"Auto-suspended stuck session %s (active across %d consecutive restarts — likely a stuck loop)",
|
||||
session_key, counts[session_key],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if suspended:
|
||||
with suppress(Exception):
|
||||
self.session_store._save()
|
||||
@@ -1085,9 +1060,8 @@ class GatewayShutdownMixin:
|
||||
)
|
||||
watcher_env = GatewayShutdownMixin._restart_watcher_env()
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
# Console python under CREATE_NO_WINDOW owns one hidden console inherited by the restart
|
||||
# child, so nothing flashes. Do NOT swap in pythonw.exe — a console-less watcher forces
|
||||
# every console-subsystem descendant to allocate a visible conhost.
|
||||
# Console python under CREATE_NO_WINDOW: nothing flashes. NOT pythonw.exe — a console-less
|
||||
# watcher makes every console-subsystem descendant allocate a visible conhost.
|
||||
watcher_python = sys.executable
|
||||
venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv")
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
@@ -1102,10 +1076,8 @@ class GatewayShutdownMixin:
|
||||
str(current_pid), str(restart_after_s), *hermes_cmd, "gateway", "restart",
|
||||
]
|
||||
popen_kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=watcher_env)
|
||||
# The watcher must break away from the parent CLI's job object (Desktop wrappers, Windows
|
||||
# Terminal, schtasks) or it is reaped when the CLI exits. A job without
|
||||
# JOB_OBJECT_LIMIT_BREAKAWAY_OK rejects CREATE_BREAKAWAY_FROM_JOB (OSError); retry once
|
||||
# without the bit, preserving argv and the scrubbed env.
|
||||
# Break away from the parent CLI's job object or be reaped when the CLI exits; a job without
|
||||
# BREAKAWAY_OK rejects CREATE_BREAKAWAY_FROM_JOB (OSError) — retry once without the bit.
|
||||
try:
|
||||
subprocess.Popen(watcher_argv, **popen_kwargs, **windows_detach_popen_kwargs())
|
||||
except OSError:
|
||||
@@ -1252,22 +1224,19 @@ class GatewayShutdownMixin:
|
||||
self._restart_detached = detached
|
||||
self._restart_via_service = via_service
|
||||
self._restart_task_started = True
|
||||
# Refuse new turns while in-flight work finishes. Keep ``_running`` True so adapters stay
|
||||
# connected and the active turn can still deliver its final response.
|
||||
# Refuse new turns; keep ``_running`` True so the active turn can still deliver its final response.
|
||||
self._draining = True
|
||||
|
||||
async def _run_restart() -> None:
|
||||
await self._await_active_work_before_restart()
|
||||
# Launch the detached helper only AFTER the after-turn wait: its drain_timeout+5 deadline
|
||||
# covers stop() teardown; earlier it would fire the restart mid-turn.
|
||||
# Detached helper only AFTER the after-turn wait, or its drain_timeout+5 deadline fires mid-turn.
|
||||
if detached:
|
||||
with _log_suppressed(logging.ERROR, "Failed to launch detached gateway restart helper: %s"):
|
||||
await self._launch_detached_restart_command()
|
||||
await asyncio.sleep(0.05)
|
||||
await self.stop(restart=True, detached_restart=detached, service_restart=via_service)
|
||||
|
||||
# NOT in _background_tasks: _stop_impl cancels every entry there, which would propagate
|
||||
# CancelledError into _stop_impl and skip _shutdown_event.set() / _exit_code = 75.
|
||||
# NOT in _background_tasks: _stop_impl cancels those, which would skip _shutdown_event.set() / exit 75.
|
||||
self._restart_task = asyncio.create_task(_run_restart())
|
||||
return True
|
||||
|
||||
@@ -1315,16 +1284,18 @@ class GatewayShutdownMixin:
|
||||
def _step(label: str, fn: Callable[[], Any]) -> Any:
|
||||
return GatewayShutdownMixin._quiet_step(f"{label} ({phase}) error", fn)
|
||||
|
||||
def _count_step(fmt: str, fn: Callable[[], int]) -> None:
|
||||
n = fn()
|
||||
if n:
|
||||
logger.info(fmt, phase, n)
|
||||
|
||||
def _kill_processes() -> None:
|
||||
from tools.process_registry import process_registry
|
||||
_killed = process_registry.kill_all()
|
||||
if _killed:
|
||||
logger.info("Shutdown (%s): killed %d tool subprocess(es)", phase, _killed)
|
||||
_count_step("Shutdown (%s): killed %d tool subprocess(es)", process_registry.kill_all)
|
||||
|
||||
def _mark_cron_interrupted() -> list:
|
||||
# kill_all() is a global sweep, so any cron job dispatched right now lost its tool
|
||||
# subprocess; its agent thread may still emit a plausible response from truncated
|
||||
# output. Mark the run interrupted so it can never be reported as success.
|
||||
# kill_all() is global: a cron job mid-dispatch lost its tool subprocess and its agent thread may
|
||||
# still emit a plausible response from truncated output — mark it interrupted, never success.
|
||||
from cron.scheduler import mark_running_jobs_interrupted
|
||||
_interrupted = mark_running_jobs_interrupted(
|
||||
f"Gateway shutdown ({phase}) killed the job's tool subprocess before the run finished."
|
||||
@@ -1338,9 +1309,10 @@ class GatewayShutdownMixin:
|
||||
|
||||
def _interrupt_delegations() -> None:
|
||||
from tools.async_delegation import interrupt_all as _interrupt_async
|
||||
_async_n = _interrupt_async(reason=f"gateway shutdown ({phase})")
|
||||
if _async_n:
|
||||
logger.info("Shutdown (%s): interrupted %d background delegation(s)", phase, _async_n)
|
||||
_count_step(
|
||||
"Shutdown (%s): interrupted %d background delegation(s)",
|
||||
lambda: _interrupt_async(reason=f"gateway shutdown ({phase})"),
|
||||
)
|
||||
|
||||
_step("process_registry.kill_all", _kill_processes)
|
||||
_marked_cron_jobs = _step("mark_running_jobs_interrupted", _mark_cron_interrupted) or []
|
||||
@@ -1388,16 +1360,14 @@ class GatewayShutdownMixin:
|
||||
async def _stop_drain_active_work(self, timeout: float, ctx: "GatewayShutdownMixin._StopContext") -> None:
|
||||
"""Pre-mark resume_pending, drain agents/cron/API work into ``ctx``."""
|
||||
from gateway.run import GatewayRunner
|
||||
# Pre-mark resume_pending BEFORE the drain: if the service manager kills us mid-drain, the
|
||||
# durable marker already lets the next boot recover the sessions.
|
||||
# Pre-mark resume_pending BEFORE the drain so a mid-drain SIGKILL still leaves a durable marker.
|
||||
_pre_drain_keys = await GatewayRunner._mark_running_sessions_resume_pending(
|
||||
self, "pre-drain mark_resume_pending"
|
||||
)
|
||||
_cron_at_start = self._active_cron_job_count()
|
||||
_api_at_start = self._active_api_run_count()
|
||||
_deferred_at_start = ctx.deferred_count()
|
||||
# Cron floor clamped to the watchdog leash so it never costs the post-drain cleanup window.
|
||||
# getattr-guard: shutdown-path tests drive the phases from bare doubles lacking the class default.
|
||||
# Cron floor clamped to the watchdog leash; getattr-guard for bare shutdown-path doubles.
|
||||
_cron_drain_cfg = getattr(self, "_cron_drain_timeout", DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT)
|
||||
_cron_timeout = resolve_cron_drain_budget(
|
||||
timeout, _cron_drain_cfg, watchdog_delay=resolve_shutdown_watchdog_delay(timeout),
|
||||
@@ -1406,18 +1376,15 @@ class GatewayShutdownMixin:
|
||||
if _cron_at_start and _cron_timeout > timeout:
|
||||
logger.info(
|
||||
"Shutdown drain: %d in-flight cron job(s) — waiting up to "
|
||||
"%.0fs for them (cron_drain_timeout=%.0fs, "
|
||||
"restart_drain_timeout=%.0fs)",
|
||||
"%.0fs for them (cron_drain_timeout=%.0fs, restart_drain_timeout=%.0fs)",
|
||||
_cron_at_start, _cron_timeout, _cron_drain_cfg, timeout,
|
||||
)
|
||||
_drain_started_at = time.monotonic()
|
||||
ctx.active_agents, ctx.timed_out = await self._drain_active_agents(timeout, _cron_timeout)
|
||||
ctx.drain_elapsed = time.monotonic() - _drain_started_at
|
||||
logger.info(
|
||||
"Shutdown phase: drain done at +%.2fs (drain took %.2fs, "
|
||||
"timed_out=%s, active_at_start=%d, active_now=%d, "
|
||||
"cron_at_start=%d, cron_now=%d, "
|
||||
"api_at_start=%d, api_now=%d, "
|
||||
"Shutdown phase: drain done at +%.2fs (drain took %.2fs, timed_out=%s, active_at_start=%d, "
|
||||
"active_now=%d, cron_at_start=%d, cron_now=%d, api_at_start=%d, api_now=%d, "
|
||||
"deferred_at_start=%d, deferred_now=%d)", ctx.elapsed(), ctx.drain_elapsed,
|
||||
ctx.timed_out, len(ctx.active_agents), self._running_agent_count(), _cron_at_start,
|
||||
self._active_cron_job_count(), _api_at_start, self._active_api_run_count(),
|
||||
@@ -1439,15 +1406,12 @@ class GatewayShutdownMixin:
|
||||
from gateway.run import GatewayRunner
|
||||
logger.warning(
|
||||
"Gateway drain timed out after %.1fs with %d active agent(s), "
|
||||
"%d in-flight cron job(s), %d api_server run(s), and "
|
||||
"%d deferred agent worker(s); "
|
||||
"%d in-flight cron job(s), %d api_server run(s), and %d deferred agent worker(s); "
|
||||
"interrupting remaining work.", ctx.drain_elapsed, self._running_agent_count(),
|
||||
self._active_cron_job_count(), self._active_api_run_count(), ctx.deferred_count(),
|
||||
)
|
||||
# Mark forcibly-interrupted sessions resume_pending BEFORE interrupting so the next message
|
||||
# auto-resumes instead of becoming a fresh session via suspend_recently_active(); stuck
|
||||
# sessions still escalate via ``.restart_failure_counts`` (suspended=True wins). Uses the
|
||||
# CURRENT _running_agents, not the drain-start snapshot, so cleanly finished sessions get no note.
|
||||
# Mark resume_pending BEFORE interrupting so the next message auto-resumes (stuck sessions
|
||||
# still escalate via .restart_failure_counts). CURRENT _running_agents, not the drain snapshot.
|
||||
await GatewayRunner._mark_running_sessions_resume_pending(self, "mark_resume_pending")
|
||||
reason = GatewayRunner._shutdown_interrupt_reason(self)
|
||||
self._interrupt_running_agents(reason)
|
||||
@@ -1459,23 +1423,19 @@ class GatewayShutdownMixin:
|
||||
def _work_live() -> bool:
|
||||
return bool(self._running_agents or self._active_api_run_count() or ctx.deferred_count())
|
||||
|
||||
# Wait on API-server work too: the interrupt is cooperative; closing the settle window as soon
|
||||
# as _running_agents is empty would kill an API turn's tool subprocesses before it unwinds.
|
||||
# Wait on API-server work too, or an API turn's tool subprocesses are killed before it unwinds.
|
||||
while _work_live() and loop.time() < interrupt_deadline:
|
||||
self._update_runtime_status("draining")
|
||||
await asyncio.sleep(0.1)
|
||||
# Work can materialize AFTER the one-shot interrupt (a /v1/runs task registers only when
|
||||
# _create_agent returns; a pending sentinel is promoted by track_agent() later). Re-signal so
|
||||
# it gets a cooperative interrupt instead of a bare tool-subprocess kill.
|
||||
# Work can materialize AFTER the one-shot interrupt (/v1/runs registers on _create_agent return;
|
||||
# pending sentinels promote later). Re-signal for a cooperative interrupt, not a bare kill.
|
||||
if _work_live():
|
||||
self._interrupt_running_agents(reason)
|
||||
logger.debug("Re-signaled interrupt for work still live at settle-window exit")
|
||||
# Kill lingering tool subprocesses NOW, before adapter disconnect / DB close: under systemd
|
||||
# (TimeoutStopSec ≈ drain_timeout + headroom) deferring risks the cgroup SIGKILL reaping them.
|
||||
# Kill tool subprocesses NOW: deferring past adapter/DB teardown risks the systemd cgroup SIGKILL.
|
||||
_interrupted_cron_jobs = GatewayRunner._stop_kill_tool_subprocesses("post-interrupt")
|
||||
logger.info("Shutdown phase: post-interrupt tool kill done at +%.2fs", ctx.elapsed())
|
||||
# Last window with the transport up: the cron worker's own "interrupted" notice arrives
|
||||
# after adapter teardown and is lost.
|
||||
# Last window with the transport up (the cron worker's own notice arrives after teardown).
|
||||
with _log_suppressed(logging.DEBUG, "Cron interrupt notification failed: %s"):
|
||||
await self._notify_interrupted_cron_jobs(_interrupted_cron_jobs)
|
||||
logger.info("Shutdown phase: cron interrupt notices done at +%.2fs", ctx.elapsed())
|
||||
@@ -1486,8 +1446,7 @@ class GatewayShutdownMixin:
|
||||
with _log_suppressed(logging.ERROR, "Failed to launch detached gateway restart: %s"):
|
||||
await self._launch_detached_restart_command()
|
||||
await self._finalize_shutdown_agents(ctx.active_agents)
|
||||
# Idle cached agents too: _finalize_shutdown_agents only covers agents mid-turn at drain
|
||||
# time, but _agent_cache may hold agents whose MemoryProviders never got on_session_end().
|
||||
# Idle cached agents too: their MemoryProviders may never have seen on_session_end().
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
_cache = getattr(self, "_agent_cache", None)
|
||||
if _cache_lock is not None and _cache is not None:
|
||||
@@ -1495,13 +1454,11 @@ class GatewayShutdownMixin:
|
||||
_idle_agents = list(_cache.values())
|
||||
_cache.clear()
|
||||
for _entry in _idle_agents:
|
||||
# Bounded + off-loop so a wedged memory provider can't hang shutdown forever
|
||||
# (this path is why SIGTERM once failed to kill the process).
|
||||
# Bounded + off-loop: a wedged memory provider here once made SIGTERM hang forever.
|
||||
await self._cleanup_agent_resources_off_loop(
|
||||
_entry[0] if isinstance(_entry, tuple) else _entry, context="shutdown idle-cache"
|
||||
)
|
||||
# Cancel + await completion flush tasks (fan-in sleep / adapter delivery) while adapters are
|
||||
# still alive so every watcher gets a retryable result before platform teardown.
|
||||
# Settle completion flush tasks while adapters are alive so every watcher gets a retryable result.
|
||||
cancel_completion_batches = getattr(self, "_cancel_process_completion_batch_tasks", None)
|
||||
if cancel_completion_batches is not None:
|
||||
await cancel_completion_batches()
|
||||
@@ -1520,8 +1477,7 @@ class GatewayShutdownMixin:
|
||||
"""Cancel background tasks, flush pending messages, clear per-session state, final tool kill."""
|
||||
from gateway.run import GatewayRunner
|
||||
for _task in list(self._background_tasks):
|
||||
# _restart_task is awaiting _stop_task right now; cancelling it would propagate
|
||||
# CancelledError into _stop_impl and skip _shutdown_event.set(). It self-terminates anyway.
|
||||
# _restart_task awaits _stop_task: cancelling it would tunnel into _stop_impl and skip _shutdown_event.set().
|
||||
if _task is self._stop_task or _task is self._restart_task:
|
||||
continue
|
||||
_task.cancel()
|
||||
@@ -1529,36 +1485,30 @@ class GatewayShutdownMixin:
|
||||
self.adapters.clear()
|
||||
for _session_key in list(self._running_agents):
|
||||
self._release_running_agent_state(_session_key)
|
||||
# Flush pending messages to disk before clearing: under FTS5 corruption the in-memory
|
||||
# pending text is the only surviving copy; clearing unflushed loses it permanently.
|
||||
# Flush pending messages before clearing: under FTS5 corruption they are the only surviving copy.
|
||||
with suppress(Exception):
|
||||
from gateway.shutdown_flush import flush_pending_to_file
|
||||
flush_pending_to_file(dict(self._pending_messages), reason="shutdown")
|
||||
# The FIFO tail lives in SessionState.conversation.queued_events, not the slot dict
|
||||
# above — flush it too or every follow-up parked in overflow at restart time is lost.
|
||||
# The overflow FIFO tail lives in SessionState.conversation.queued_events — flush it too.
|
||||
with suppress(Exception):
|
||||
from gateway.shutdown_flush import flush_overflow_to_file
|
||||
flush_overflow_to_file(
|
||||
{_k: list(_v) for _k, _v in dict(getattr(self, "_queued_events", None) or {}).items() if _v},
|
||||
reason="shutdown",
|
||||
)
|
||||
# On the real runner these are live SessionState views whose clear() resets one field per
|
||||
# session (never a wholesale dict swap that could lose a concurrent writer's entry).
|
||||
# Live SessionState views: clear() resets one field per session (never a wholesale dict swap).
|
||||
self._running_agents.clear()
|
||||
self._running_agents_ts.clear()
|
||||
if hasattr(self, "_active_session_leases"):
|
||||
self._active_session_leases.clear()
|
||||
self._pending_messages.clear()
|
||||
self._pending_approvals.clear()
|
||||
if hasattr(self, "_busy_ack_ts"):
|
||||
self._busy_ack_ts.clear()
|
||||
for _attr in ("_active_session_leases", "_busy_ack_ts"): # absent on bare shutdown-path doubles
|
||||
if hasattr(self, _attr):
|
||||
getattr(self, _attr).clear()
|
||||
self._shutdown_event.set()
|
||||
# Global catch-all subprocess kill (safe to repeat): covers the graceful path and
|
||||
# anything respawned since the drain-timeout path's post-interrupt kill.
|
||||
# Global catch-all subprocess kill (safe to repeat) for the graceful path and late respawns.
|
||||
GatewayRunner._stop_kill_tool_subprocesses("final-cleanup")
|
||||
logger.info("Shutdown phase: final-cleanup tool kill done at +%.2fs", ctx.elapsed())
|
||||
# Reap the process-global auxiliary-client cache: per-turn cleanup misses clients bound to
|
||||
# worker-thread loops that died with their executor (cron ticks) → httpx transports leak to EMFILE.
|
||||
# Reap the auxiliary-client cache: clients bound to dead worker-thread loops leak httpx transports.
|
||||
def _reap_aux_clients() -> None:
|
||||
from agent.auxiliary_client import shutdown_cached_clients
|
||||
shutdown_cached_clients()
|
||||
@@ -1568,30 +1518,25 @@ class GatewayShutdownMixin:
|
||||
def _stop_quiesce_and_close_session_dbs(self, timeout: float, ctx: "GatewayShutdownMixin._StopContext") -> None:
|
||||
"""Quiesce the executor, then close SessionDB handles only if no worker is still live."""
|
||||
from gateway.run import GatewayRunner, _EXECUTOR_QUIESCE_TIMEOUT
|
||||
# Quiesce the thread pool BEFORE closing session DBs: otherwise a coroutine can mint a fresh
|
||||
# pool (``_executor_closing`` still False) or an already-started run_in_executor future keeps
|
||||
# writing after ``SessionDB.close()`` checkpointed the WAL — the late write reopens the handle
|
||||
# and mints a split WAL generation (close-time corruption). Bounded and clamped to what is
|
||||
# left of the watchdog leash minus a second for the close itself.
|
||||
# Quiesce the thread pool BEFORE closing session DBs: a late executor write after
|
||||
# SessionDB.close() checkpointed the WAL reopens the handle and splits the WAL generation
|
||||
# (close-time corruption). Clamped to the remaining watchdog leash minus 1s for the close.
|
||||
_exec_quiesce_budget = max(
|
||||
0.0, min(_EXECUTOR_QUIESCE_TIMEOUT, resolve_shutdown_watchdog_delay(timeout) - ctx.elapsed() - 1.0),
|
||||
)
|
||||
_exec_live = GatewayRunner._shutdown_executor(self, drain_timeout=_exec_quiesce_budget)
|
||||
if _exec_live:
|
||||
# A live worker may be mid-write: closing now is the exact #101093 corruption sequence,
|
||||
# so skip the close and let SQLite recover from its WAL on next open (a transient
|
||||
# "database is locked" on an immediate --replace at worst, not a corrupt file).
|
||||
# A live worker may be mid-write (the #101093 corruption sequence): skip the close and let
|
||||
# SQLite recover from its WAL on next open (at worst a transient "database is locked").
|
||||
logger.warning(
|
||||
"Shutdown phase: %d executor worker(s) still running after "
|
||||
"a %.2fs quiesce — skipping the SessionDB close/checkpoint "
|
||||
"to avoid racing a live write (#101093); handles are left "
|
||||
"Shutdown phase: %d executor worker(s) still running after a %.2fs quiesce — skipping the "
|
||||
"SessionDB close/checkpoint to avoid racing a live write (#101093); handles are left "
|
||||
"open for SQLite to recover on next open", _exec_live, _exec_quiesce_budget,
|
||||
)
|
||||
return
|
||||
logger.info("Shutdown phase: executor quiesced at +%.2fs", ctx.elapsed())
|
||||
_step = GatewayShutdownMixin._quiet_step
|
||||
# Close SQLite session DBs so the WAL lock is released; otherwise --replace leaves the old
|
||||
# connection holding it until exit and the new gateway gets 'database is locked'.
|
||||
# Close SQLite session DBs so --replace's new gateway does not hit 'database is locked'.
|
||||
# ``_session_db`` is an AsyncSessionDB facade — unwrap; ``session_store`` holds ``_db``.
|
||||
_self_db = getattr(self, "_session_db", None)
|
||||
_self_db = getattr(_self_db, "_db", _self_db)
|
||||
@@ -1599,18 +1544,15 @@ class GatewayShutdownMixin:
|
||||
for _db in (_self_db, getattr(store, "_db", None)):
|
||||
if _db is not None and hasattr(_db, "close"):
|
||||
_step("SessionDB close error", _db.close)
|
||||
# A multiplexed session_store caches one SessionDB per profile; ``_db`` above only covered
|
||||
# the root scope. Sweep the rest so secondary WAL locks are released before --replace.
|
||||
# Multiplexed session_store caches one SessionDB per profile; sweep the secondary WAL locks too.
|
||||
_sweep = getattr(store, "close_all_db_handles", None)
|
||||
if _sweep is not None:
|
||||
_step("SessionDB handle sweep error", _sweep)
|
||||
# Same sweep for the runner's own per-profile session_search handles (slash commands
|
||||
# resolve them under profile scopes).
|
||||
# Same sweep for the runner's own per-profile session_search handles.
|
||||
_step("Runner SessionDB handle sweep error", lambda: GatewayRunner.close_all_session_db_handles(self))
|
||||
|
||||
def _close_shared() -> None:
|
||||
# Shared SessionDB instances still held by the process-wide registry (tools, cron,
|
||||
# mirror, etc. opened via get_shared_session_db but not released above).
|
||||
# Shared SessionDB instances still held by the process-wide registry (tools, cron, mirror).
|
||||
from hermes_state import close_shared_session_dbs
|
||||
closed = close_shared_session_dbs()
|
||||
if closed:
|
||||
@@ -1628,20 +1570,17 @@ class GatewayShutdownMixin:
|
||||
from gateway.status import remove_pid_file, release_gateway_runtime_lock
|
||||
remove_pid_file()
|
||||
release_gateway_runtime_lock()
|
||||
# Clean-shutdown marker: suspend_recently_active() need only run after unexpected exits.
|
||||
# If the drain timed out and agents were force-interrupted, sessions may be half-finished
|
||||
# — skip the marker so the next startup suspends them.
|
||||
# Clean-shutdown marker skips suspend_recently_active() next boot; a timed-out drain left
|
||||
# half-finished sessions, so no marker — the next startup suspends them.
|
||||
if not ctx.timed_out:
|
||||
with suppress(Exception):
|
||||
(_hermes_home / ".clean_shutdown").touch()
|
||||
else:
|
||||
logger.info(
|
||||
"Skipping .clean_shutdown marker — drain timed out with "
|
||||
"interrupted agents; next startup will suspend recently "
|
||||
"active sessions."
|
||||
"interrupted agents; next startup will suspend recently active sessions."
|
||||
)
|
||||
# Stuck-loop detection: the counter increments for sessions active at each restart; at
|
||||
# the threshold (3 consecutive) the next startup auto-suspends the session.
|
||||
# Stuck-loop counter: sessions active across 3 consecutive restarts are auto-suspended next boot.
|
||||
if ctx.active_agents:
|
||||
self._increment_restart_failure_counts(set(ctx.active_agents.keys()))
|
||||
if self._restart_requested and self._restart_command_source is None:
|
||||
@@ -1656,19 +1595,16 @@ class GatewayShutdownMixin:
|
||||
indent=None,
|
||||
)
|
||||
if self._restart_requested and self._restart_via_service:
|
||||
# Service manager owns restarts: exit 75 + ``RestartForceExitStatus=75`` has systemd
|
||||
# replace this process without a second helper racing the unit's stop/start job.
|
||||
# Exit 75 + ``RestartForceExitStatus=75``: systemd replaces us without a racing helper.
|
||||
self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE
|
||||
self._exit_reason = self._exit_reason or "Gateway restart requested"
|
||||
self._draining = False
|
||||
# Terminal gateway_state: "stopped" by default, "running" on an UNEXPECTED signal (s6 SIGTERM
|
||||
# on docker restart, OOM-kill) — container_boot.py only auto-starts gateways last seen
|
||||
# "running". Operator stops write a planned-stop marker BEFORE signalling; restarts persist "stopped".
|
||||
# Terminal gateway_state: "stopped", or "running" on an UNEXPECTED signal (docker restart,
|
||||
# OOM) — container_boot.py only auto-starts gateways last seen "running".
|
||||
if getattr(self, "_signal_initiated_shutdown", False) and not self._restart_requested:
|
||||
logger.info(
|
||||
"Gateway stopped by an unexpected signal — persisting "
|
||||
"gateway_state=running so container_boot auto-starts on "
|
||||
"the next boot (issue #42675)"
|
||||
"gateway_state=running so container_boot auto-starts on the next boot (issue #42675)"
|
||||
)
|
||||
self._update_runtime_status("running", self._exit_reason)
|
||||
else:
|
||||
@@ -1694,22 +1630,18 @@ class GatewayShutdownMixin:
|
||||
async def _stop_impl(self) -> None:
|
||||
"""Run every ``_stop_*`` phase under the thread-based shutdown watchdog."""
|
||||
from gateway.run import GatewayRunner
|
||||
# Thread-based watchdog (asyncio timeouts cannot recover a frozen loop): if teardown never
|
||||
# finishes within drain+grace it dumps faulthandler stacks and os._exit so KeepAlive/systemd
|
||||
# can revive. Skipped under pytest (no delayed hard-exit in the worker).
|
||||
# Thread-based watchdog (asyncio timeouts cannot recover a frozen loop): dumps stacks and
|
||||
# os._exit past drain+grace so the service manager revives us. Skipped under pytest.
|
||||
_watchdog_done = threading.Event()
|
||||
self._shutdown_watchdog_done = _watchdog_done
|
||||
# Shutdown-path tests and third-party runner doubles may only implement the older
|
||||
# drain-count surface (no deferred-worker counter).
|
||||
# Shutdown-path doubles may lack the deferred-worker counter.
|
||||
ctx = GatewayShutdownMixin._StopContext(
|
||||
deferred_count=getattr(self, "_active_deferred_agent_worker_count", lambda: 0)
|
||||
)
|
||||
if not os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
arm_shutdown_watchdog(
|
||||
resolve_shutdown_watchdog_delay(self._restart_drain_timeout),
|
||||
done_event=_watchdog_done,
|
||||
snapshot_fn=lambda: GatewayRunner._shutdown_watchdog_snapshot(self, ctx),
|
||||
exit_code=1,
|
||||
resolve_shutdown_watchdog_delay(self._restart_drain_timeout), done_event=_watchdog_done,
|
||||
snapshot_fn=lambda: GatewayRunner._shutdown_watchdog_snapshot(self, ctx), exit_code=1,
|
||||
)
|
||||
try:
|
||||
await GatewayRunner._stop_begin_teardown(self, ctx)
|
||||
@@ -1725,8 +1657,7 @@ class GatewayShutdownMixin:
|
||||
_watchdog_done.set()
|
||||
|
||||
async def stop(
|
||||
self, *, restart: bool = False, detached_restart: bool = False,
|
||||
service_restart: bool = False,
|
||||
self, *, restart: bool = False, detached_restart: bool = False, service_restart: bool = False
|
||||
) -> None:
|
||||
"""Stop the gateway and disconnect all adapters."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
+172
-360
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user