Merge branch 'simp/r3-10' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 21:57:15 -07:00
50 changed files with 5229 additions and 10785 deletions
+5 -23
View File
@@ -1,13 +1,5 @@
"""
Hermes Gateway - Multi-platform messaging integration.
This module provides a unified gateway for connecting the Hermes agent
to various messaging platforms (Telegram, Discord, WhatsApp, Weixin, and more) with:
- Session management (persistent conversations with reset policies)
- Dynamic context injection (agent knows where messages come from)
- Delivery routing (cron job outputs to appropriate channels)
- Platform-specific toolsets (different capabilities per platform)
"""
"""Hermes Gateway - multi-platform messaging integration (sessions, context
injection, delivery routing, platform-specific toolsets)."""
from .config import GatewayConfig, PlatformConfig, HomeChannel, load_gateway_config
from .session import (
@@ -19,17 +11,7 @@ from .session import (
from .delivery import DeliveryRouter, DeliveryTarget
__all__ = [
# Config
"GatewayConfig",
"PlatformConfig",
"HomeChannel",
"load_gateway_config",
# Session
"SessionContext",
"SessionStore",
"SessionResetPolicy",
"build_session_context_prompt",
# Delivery
"DeliveryRouter",
"DeliveryTarget",
"GatewayConfig", "PlatformConfig", "HomeChannel", "load_gateway_config",
"SessionContext", "SessionStore", "SessionResetPolicy", "build_session_context_prompt",
"DeliveryRouter", "DeliveryTarget",
]
+65 -141
View File
@@ -1,15 +1,11 @@
"""Memory-pressure bounds for the gateway's per-session AIAgent cache.
Each cached ``AIAgent`` pins ``_session_messages`` (the full live transcript,
tool outputs included — tens of MB on a tool-heavy session). The cache's LRU
cap counts entries, not bytes, and the idle TTL defers eviction for busy
sessions, so neither sees actual memory use. This module supplies that signal:
the process's own anonymous RSS against a budget derived from the cgroup limit.
``GatewayRunner._sweep_agent_cache_under_pressure`` uses it to shed LRU
transcripts via the soft-eviction path (rebuilt from the persisted session on
the next turn).
Everything here is pure or read-only. Config lives under ``agent.agent_cache``.
Each cached ``AIAgent`` pins its full live transcript (tens of MB on a tool-heavy
session); the LRU cap counts entries, not bytes, and the idle TTL defers eviction
for busy sessions, so neither sees actual memory use. This module supplies that
signal — own anonymous RSS against a budget derived from the cgroup limit — and
``GatewayRunner`` sheds LRU transcripts via soft eviction (rebuilt from the
persisted session next turn). Pure/read-only; config under ``agent.agent_cache``.
"""
from __future__ import annotations
@@ -20,29 +16,23 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterable, List, Optional, Tuple
# Shed well under the limit: once cgroup ``memory.high`` throttling kicks in
# (swap full), a SIGTERM flush cannot finish inside systemd's stop timeout.
# Shed well under the limit: once cgroup ``memory.high`` throttling kicks in (swap full),
# a SIGTERM flush cannot finish inside systemd's stop timeout.
_AUTO_BUDGET_FRACTION = 0.65
# Below this a budget is noise — small containers would evict every pass and
# never keep a warm prefix.
# Below this a budget is noise — small containers would evict every pass and never keep a warm prefix.
_AUTO_BUDGET_FLOOR_MB = 512
_DEFAULT_MAX_EVICTIONS_PER_PASS = 16
# Never shed the hottest sessions: their prompt cache is worth the most, and
# evicting them just moves the cost to the next turn.
# Never shed the hottest sessions: their prompt cache is worth the most; evicting them
# just moves the cost to the next turn.
_DEFAULT_PROTECT_RECENT = 8
_BYTES_PER_MB = 1024 * 1024
_OFF_WORDS = frozenset({"", "off", "none", "false", "disabled"})
@dataclass(frozen=True)
class AgentCacheBounds:
"""Operator-facing bounds for the per-session agent cache.
``max_size`` / ``idle_ttl_secs`` are ``None`` when unset so ``gateway/run.py``
keeps its module defaults; ``memory_high_mb`` is ``None`` when pressure
eviction is off.
"""
"""Operator-facing bounds. ``max_size``/``idle_ttl_secs`` are ``None`` when unset
so ``gateway/run.py`` keeps its defaults; ``memory_high_mb`` ``None`` = pressure eviction off."""
max_size: Optional[int] = None
idle_ttl_secs: Optional[float] = None
@@ -51,64 +41,43 @@ class AgentCacheBounds:
protect_recent: int = _DEFAULT_PROTECT_RECENT
def _positive(value: Any, cast: Callable[[Any], Any]) -> Any:
def _is_int(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _positive(value: Any, cast: Callable[[Any], Any] = int) -> Any:
"""``cast(value)`` if it is a positive number (bools rejected), else None."""
if isinstance(value, bool) or value is None:
return None
try:
parsed = cast(value)
parsed = None if isinstance(value, bool) or value is None else cast(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _positive_int(value: Any) -> Optional[int]:
return _positive(value, int)
def _positive_float(value: Any) -> Optional[float]:
return _positive(value, float)
return parsed if parsed is not None and parsed > 0 else None
def _cgroup_limit_bytes() -> Optional[int]:
"""Return the memory limit this process runs under, if cgroup-capped.
"""Memory limit this process runs under, if cgroup-capped.
Prefers cgroup v2 ``memory.high`` (the throttling point) over ``memory.max``,
then cgroup v1. Checks the process's *own* cgroup first (where a systemd
unit's ``MemoryHigh=``/``MemoryMax=`` lands — the root files read ``max``
there), then the root for container-style limits. ``max`` and the v1
near-2^63 sentinel mean unlimited.
Prefers v2 ``memory.high`` (the throttling point) over ``memory.max``, then v1.
Own cgroup first (where a systemd unit's ``MemoryHigh=``/``MemoryMax=`` lands —
root reads ``max`` there), then root for container-style limits. ``max`` and
the v1 near-2^63 sentinel mean unlimited.
"""
if sys.platform != "linux":
return None
candidates: list[str] = []
try:
from gateway.cgroup_cleanup import _own_cgroup_path
own = _own_cgroup_path()
except Exception:
own = None
if own and own != "/":
candidates += [f"/sys/fs/cgroup{own}/memory.high", f"/sys/fs/cgroup{own}/memory.max"]
candidates += [
"/sys/fs/cgroup/memory.high",
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
]
for candidate in candidates:
roots = ([f"/sys/fs/cgroup{own}"] if own and own != "/" else []) + ["/sys/fs/cgroup"]
for candidate in [f"{r}/memory.{f}" for r in roots for f in ("high", "max")] + ["/sys/fs/cgroup/memory/memory.limit_in_bytes"]:
try:
raw = Path(candidate).read_text(encoding="utf-8").strip()
except OSError:
limit = int(Path(candidate).read_text(encoding="utf-8").strip())
except (OSError, ValueError): # unreadable, empty, or "max"
continue
if not raw or raw == "max":
continue
try:
limit = int(raw)
except ValueError:
continue
if limit <= 0 or limit >= (1 << 62):
continue
return limit
if 0 < limit < (1 << 62):
return limit
return None
@@ -126,25 +95,16 @@ def _total_memory_bytes() -> Optional[int]:
def resolve_memory_high_mb(setting: Any) -> Optional[int]:
"""Resolve ``memory_high_mb`` into an absolute MB budget.
``"auto"`` derives it from the cgroup limit (or total RAM when uncapped);
a positive number is literal; anything falsy/off disables the pass.
"""
"""Absolute MB budget: ``"auto"`` derives from the cgroup limit (or total RAM when
uncapped); a positive number is literal; anything falsy/off disables the pass."""
if isinstance(setting, str):
normalized = setting.strip().lower()
if normalized != "auto":
return (
None
if normalized in ("", "off", "none", "false", "disabled")
else _positive_int(normalized)
)
elif isinstance(setting, bool):
if not setting:
return None
else:
return _positive_int(setting)
return None if normalized in _OFF_WORDS else _positive(normalized)
elif setting is False:
return None
elif setting is not True:
return _positive(setting)
limit = _cgroup_limit_bytes() or _total_memory_bytes()
if not limit:
return None
@@ -153,53 +113,29 @@ def resolve_memory_high_mb(setting: Any) -> Optional[int]:
def resolve_agent_cache_bounds(config: Any) -> AgentCacheBounds:
"""Read ``agent.agent_cache`` out of the *raw* config mapping.
The gateway's loader does not deep-merge ``DEFAULT_CONFIG``, so an absent
key stays absent and callers can tell "operator chose 128" from "unset".
"""
section: Any = None
if isinstance(config, dict):
agent_cfg = config.get("agent")
if isinstance(agent_cfg, dict):
section = agent_cfg.get("agent_cache")
"""Read ``agent.agent_cache`` from the *raw* config: the gateway loader does not
deep-merge ``DEFAULT_CONFIG``, so callers can tell "operator chose 128" from "unset"."""
section = (config.get("agent") or {}).get("agent_cache") if isinstance(config, dict) else None
if not isinstance(section, dict):
section = {}
max_evictions = _positive_int(section.get("max_evictions_per_pass"))
protect_recent = section.get("protect_recent")
protect_parsed = _positive_int(protect_recent)
# 0 means "shed anything" — distinct from unset. The bool guard keeps
# `protect_recent: false` (False == 0) on the default instead of silently
# disabling MRU protection.
if (
protect_parsed is None
and isinstance(protect_recent, int)
and not isinstance(protect_recent, bool)
and protect_recent == 0
):
protect_parsed = _positive(protect_recent)
# 0 means "shed anything" — distinct from unset. The bool guard keeps `protect_recent: false`
# (False == 0) on the default instead of silently disabling MRU protection.
if protect_parsed is None and _is_int(protect_recent) and protect_recent == 0:
protect_parsed = 0
return AgentCacheBounds(
max_size=_positive_int(section.get("max_size")),
idle_ttl_secs=_positive_float(section.get("idle_ttl_secs")),
max_size=_positive(section.get("max_size")),
idle_ttl_secs=_positive(section.get("idle_ttl_secs"), float),
memory_high_mb=resolve_memory_high_mb(section.get("memory_high_mb", "auto")),
max_evictions_per_pass=(
max_evictions if max_evictions is not None else _DEFAULT_MAX_EVICTIONS_PER_PASS
),
protect_recent=(
protect_parsed if protect_parsed is not None else _DEFAULT_PROTECT_RECENT
),
max_evictions_per_pass=_positive(section.get("max_evictions_per_pass")) or _DEFAULT_MAX_EVICTIONS_PER_PASS,
protect_recent=_DEFAULT_PROTECT_RECENT if protect_parsed is None else protect_parsed,
)
def read_anon_rss_mb() -> Optional[int]:
"""Return the process's anonymous resident memory in MB, or None.
Anonymous pages are where cached transcripts live; file-backed pages are
noise. ``collect_memory_snapshot`` reads ``/proc/self/status`` without a
dependency; psutil covers other platforms (total RSS only).
"""
"""Anonymous RSS in MB (where cached transcripts live; file-backed pages are noise),
or None. ``/proc/self/status`` first; psutil covers other platforms (total RSS only)."""
try:
from hermes_cli.mem_trim import collect_memory_snapshot
@@ -210,7 +146,6 @@ def read_anon_rss_mb() -> Optional[int]:
return kib // 1024
except Exception:
pass
try:
import psutil # type: ignore
@@ -220,37 +155,27 @@ def read_anon_rss_mb() -> Optional[int]:
def transcript_persistence_caught_up(agent: Any) -> bool:
"""True when the agent's live transcript is fully on disk.
"""True when the live transcript is fully on disk.
Soft eviction drops ``_session_messages`` and rebuilds from the persisted
session, so it is only safe once ``_last_flushed_db_idx`` (advanced to
``len(messages)`` by ``AIAgent._flush_messages_to_session_db`` only on a
fully successful write) has caught up. Unknown shapes are *not* caught up:
a skipped eviction costs memory, a wrong one costs the conversation.
Soft eviction rebuilds from the persisted session, so it is only safe once
``_last_flushed_db_idx`` (advanced only on a fully successful write) has caught
up. Unknown shapes are *not* caught up: a skipped eviction costs memory, a
wrong one costs the conversation.
"""
messages = getattr(agent, "_session_messages", None)
if not isinstance(messages, list):
return False
flushed = getattr(agent, "_last_flushed_db_idx", None)
if not isinstance(flushed, int) or isinstance(flushed, bool):
return False
return flushed >= len(messages)
messages, flushed = getattr(agent, "_session_messages", None), getattr(agent, "_last_flushed_db_idx", None)
return isinstance(messages, list) and _is_int(flushed) and flushed >= len(messages)
def plan_pressure_evictions(
ordered_entries: Iterable[Tuple[str, Any]],
*,
is_evictable: Callable[[str, Any], bool],
max_evictions: int,
protect_recent: int = 0,
ordered_entries: Iterable[Tuple[str, Any]], *, is_evictable: Callable[[str, Any], bool],
max_evictions: int, protect_recent: int = 0,
) -> List[Tuple[str, Any]]:
"""Choose which cached sessions to shed, least-recently-used first.
``ordered_entries`` must be LRU→MRU (the cache OrderedDict is kept that way
by ``move_to_end`` on every hit). The batch is capped so one pass cannot
stall the gateway. ``protect_recent`` is clamped to half the cache: a few
huge transcripts can exhaust the budget alone, and a fixed guard would then
protect the whole cache with nothing left to shed.
``ordered_entries`` must be LRU→MRU (the cache OrderedDict ``move_to_end``s on
every hit). The batch is capped so one pass cannot stall the gateway.
``protect_recent`` is clamped to half the cache: a few huge transcripts can
exhaust the budget alone, and a fixed guard would leave nothing to shed.
"""
entries = list(ordered_entries)
if max_evictions <= 0 or not entries:
@@ -258,7 +183,6 @@ def plan_pressure_evictions(
protect = min(max(protect_recent, 0), len(entries) // 2)
if protect:
entries = entries[:-protect]
plan: List[Tuple[str, Any]] = []
for key, agent in entries:
if len(plan) >= max_evictions:
+243 -384
View File
@@ -1,15 +1,13 @@
"""User-authorization methods for ``GatewayRunner``.
Inbound-message authorization cluster: whether a user/chat may talk to the
"""User-authorization mixin for ``GatewayRunner``: may this user/chat talk to the
agent, the per-adapter DM policy, and the unauthorized-DM behavior.
``self.*`` calls resolve via the MRO. ``gateway.run`` is never imported at
module import time (cycle); the one method that logs imports its ``logger``
lazily so records keep the ``"gateway.run"`` logger name.
``gateway.run`` is never imported at module import time (cycle); the one method
that logs imports its ``logger`` lazily so records keep the ``"gateway.run"`` name.
"""
from __future__ import annotations
import contextlib
import os
from typing import Optional
@@ -22,6 +20,7 @@ from gateway.whatsapp_identity import (
)
_GROUP_CHAT_TYPES = frozenset({"group", "forum", "channel"})
_GROUP_FORUM_TYPES = frozenset({"group", "forum"})
_TRUTHY = frozenset({"true", "1", "yes"})
# Platform -> ``<PLATFORM>_ALLOWED_USERS`` / ``<PLATFORM>_ALLOW_ALL_USERS``.
@@ -47,51 +46,39 @@ _ALLOW_BOTS_ENV = {
def _platform_gate_env(name: str, default: str = "") -> str:
"""Read an allow/deny gate env var with per-profile isolation.
When a profile secret scope is installed AND multiplexing is active, a
scoped miss returns ``default`` instead of falling through to
``os.environ``: the process env may hold ANOTHER profile's first-writer
bridged value, so a fallthrough would leak allowlists across profiles.
With a profile secret scope installed AND multiplexing active, a scoped miss
returns ``default`` instead of falling through to ``os.environ``, which may
hold ANOTHER profile's first-writer bridged value (allowlist leak).
Single-profile deployments behave exactly like ``os.getenv``.
"""
if not name:
return default
try:
with contextlib.suppress(Exception):
from agent.secret_scope import current_secret_scope, is_multiplex_active
scope = current_secret_scope()
if scope is not None and is_multiplex_active():
val = scope.get(name)
return default if val is None else str(val).strip()
except Exception:
pass
return (os.getenv(name) or default).strip()
_auth_env = _platform_gate_env
def _env_truthy(name: str) -> bool:
return _auth_env(name).lower() in _TRUTHY
def _registry_entry(platform):
"""Platform-registry entry for a (plugin) platform, or None."""
if platform is None:
return None
try:
with contextlib.suppress(Exception):
from gateway.platform_registry import platform_registry
return platform_registry.get(platform.value)
except Exception:
return None
def _platform_declares_allowed_users_env(platform) -> bool:
"""Whether a plugin platform's registry entry declares ``allowed_users_env``.
Such platforms (Buzz, DingTalk, ...) document ``PlatformConfig.extra
.allowed_users`` as the config-file spelling of that env allowlist, so the
live adapter's extra is a valid authorization source when the env var is
absent. Built-in platforms and unknown entries return False.
"""
entry = _registry_entry(platform)
return bool(entry and entry.allowed_users_env)
return None
def _coerce_allow_set(raw) -> set[str]:
@@ -107,11 +94,12 @@ def _allows(allowed: set[str], candidate: Optional[str]) -> bool:
return "*" in allowed or candidate in allowed
# ---------------------------------------------------------------------------
# Nostr npub -> hex (Buzz). ``BUZZ_ALLOWED_USERS`` accepts hex or ``npub1…``
# but inbound pubkeys are always hex, so npub entries must be decoded.
# Pure stdlib; mirrors plugins/platforms/buzz/adapter.py.
# ---------------------------------------------------------------------------
def _adapter_config_extra(adapter) -> dict:
return getattr(getattr(adapter, "config", None), "extra", None) or {}
# Nostr npub -> hex (Buzz): ``BUZZ_ALLOWED_USERS`` accepts hex or ``npub1…`` but
# inbound pubkeys are always hex. Pure stdlib; mirrors plugins/platforms/buzz/adapter.py.
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
@@ -180,25 +168,50 @@ def _normalize_nostr_allow_entries(entries: set) -> set:
return expanded
def _principal_matches_allowlist(source, user_id: str, allowed_ids: set) -> bool:
"""Whether *user_id* (under any platform-specific alias) is in *allowed_ids*."""
check_ids = {user_id}
if "@" in user_id:
check_ids.add(user_id.split("@")[0])
# WhatsApp (Baileys + Cloud): phone<->LID / JID aliases match the same principal.
if source.platform in {Platform.WHATSAPP, Platform.WHATSAPP_CLOUD}:
allowed_ids = set().union(*(_expand_whatsapp_auth_aliases(a) for a in allowed_ids)) or allowed_ids
check_ids.update(_expand_whatsapp_auth_aliases(user_id))
normalized_user_id = _normalize_whatsapp_identifier(user_id)
if normalized_user_id:
check_ids.add(normalized_user_id)
platform_value = source.platform.value if source.platform is not None else None
# SimpleX: user_id is the numeric contactId but the UI only shows display names.
if platform_value == "simplex" and source.user_name:
check_ids.add(source.user_name)
# Buzz: allowlist may hold npub or hex; inbound pubkeys are hex.
if platform_value == "buzz":
allowed_ids = _normalize_nostr_allow_entries(allowed_ids)
hex_user = _npub_to_hex(user_id) if user_id.startswith("npub") else None
if hex_user:
check_ids.add(hex_user)
return bool(check_ids & allowed_ids)
class GatewayAuthorizationMixin:
"""User/chat authorization methods for ``GatewayRunner``."""
# ``getattr(self, ...)`` throughout: test helpers build bare runners via
# ``object.__new__`` without ``adapters`` / ``config`` (pitfalls.md #17).
# ``object.__new__`` without ``adapters`` / ``config``.
def _authorization_adapter(
self,
platform: Optional[Platform],
profile: Optional[str] = None,
):
"""Resolve the live adapter whose intake policy should gate authorization.
def _primary_adapters(self) -> dict:
return getattr(self, "adapters", None) or {}
Secondary-profile adapters live in ``_profile_adapters[profile]``; the
launched (primary) profile owns ``self.adapters``. ``_profile_adapters``
is consulted BEFORE comparing against the active profile name: multiplex
turns override ``HERMES_HOME`` so ``_active_profile_name()`` reports the
secondary profile mid-turn, and treating it as primary would hand it the
default bot (or default-deny secondary-only platforms like A2A).
def _authorization_adapter(self, platform: Optional[Platform], profile: Optional[str] = None):
"""Live adapter whose intake policy gates authorization.
Secondary-profile adapters live in ``_profile_adapters[profile]``; the primary
profile owns ``self.adapters``. ``_profile_adapters`` is consulted BEFORE the
active profile name: multiplex turns override ``HERMES_HOME`` so
``_active_profile_name()`` reports the secondary profile mid-turn, and treating
it as primary would hand it the default bot.
"""
if not platform:
return None
@@ -207,23 +220,17 @@ class GatewayAuthorizationMixin:
profile_adapters = getattr(self, "_profile_adapters", None) or {}
if profile_name in profile_adapters:
return profile_adapters[profile_name].get(platform)
# Compare against the identity captured at construction, not the
# per-turn HERMES_HOME-derived name.
# Identity captured at construction, not the per-turn HERMES_HOME-derived name.
primary_profile = getattr(self, "_primary_profile_name", None)
if not primary_profile:
active_profile_fn = getattr(self, "_active_profile_name", None)
if callable(active_profile_fn):
try:
primary_profile = active_profile_fn()
except Exception:
primary_profile = None
with contextlib.suppress(Exception):
primary_profile = self._active_profile_name()
if profile_name == primary_profile:
return (getattr(self, "adapters", None) or {}).get(platform)
# Fail closed: a stamped secondary profile with no registry entry
# (adapter failed to connect) must NOT fall back to the default
# profile's adapter -- that sends replies out the wrong bot.
return self._primary_adapters().get(platform)
# Fail closed: a secondary profile whose adapter failed to connect must NOT
# fall back to the default profile's adapter (replies out the wrong bot).
return None
return (getattr(self, "adapters", None) or {}).get(platform)
return self._primary_adapters().get(platform)
def _adapter_for_source(self, source: Optional[SessionSource]):
"""Resolve the live adapter for an inbound ``SessionSource``."""
@@ -232,22 +239,17 @@ class GatewayAuthorizationMixin:
transport_adapter = self._registered_transport_adapter(source)
if transport_adapter is not None:
return transport_adapter
# Relay ingress keeps the underlying platform on the source (session
# keys / display policy), but delivery must use the one process-level
# RelayAdapter that owns the connector socket -- secondary profiles do
# not register their own, so a profile-aware lookup would silently
# disable streaming/typing/tool progress.
# Relay ingress keeps the underlying platform on the source, but delivery must
# use the one process-level RelayAdapter owning the connector socket; a
# profile-aware lookup would silently disable streaming/typing/tool progress.
if getattr(source, "delivered_via_upstream_relay", False) is True:
return (getattr(self, "adapters", None) or {}).get(Platform.RELAY)
return self._primary_adapters().get(Platform.RELAY)
# ``getattr``: test fixtures build bare SimpleNamespace sources without ``profile``.
return self._authorization_adapter(
getattr(source, "platform", None),
getattr(source, "profile", None),
)
return self._authorization_adapter(getattr(source, "platform", None), getattr(source, "profile", None))
def _owning_profile(self, adapter, platform):
"""Return (registered, profile) for a live adapter: profile is None for primary."""
if adapter is (getattr(self, "adapters", None) or {}).get(platform):
if adapter is self._primary_adapters().get(platform):
return True, None
for profile, profile_adapters in (getattr(self, "_profile_adapters", None) or {}).items():
if adapter is profile_adapters.get(platform):
@@ -255,13 +257,12 @@ class GatewayAuthorizationMixin:
return False, None
def _registered_transport_adapter(self, source: SessionSource):
"""Return the registered adapter that created *source*, if retained.
"""The registered adapter that created *source*, if retained.
``source.profile`` is the runtime/session namespace and may differ from
the adapter profile when one shared credential serves several routed
runtimes; ``build_source`` keeps the receiving adapter as in-process
provenance so replies and intake-policy checks stay on that transport.
Restored or hand-built sources fall back (fail-closed) to profile lookup.
``source.profile`` may differ from the adapter profile when one shared credential
serves several routed runtimes; ``build_source`` keeps the receiving adapter as
provenance so replies stay on that transport. Restored/hand-built sources fall
back (fail-closed) to profile lookup.
"""
adapter_ref = getattr(source, "_transport_adapter_ref", None)
adapter = adapter_ref() if callable(adapter_ref) else None
@@ -286,102 +287,57 @@ class GatewayAuthorizationMixin:
adapter = self._authorization_adapter(platform, profile)
return adapter is not None and bool(getattr(adapter, name, False))
def _adapter_authorization_is_upstream(
self,
platform: Optional[Platform],
*,
profile: Optional[str] = None,
) -> bool:
"""Whether the adapter delegates authz to a trusted authenticated upstream.
def _adapter_authorization_is_upstream(self, platform: Optional[Platform], *, profile: Optional[str] = None) -> bool:
"""Whether the adapter delegates authz to a trusted authenticated upstream (relay).
Mirrors ``BasePlatformAdapter.authorization_is_upstream`` (True for the
relay adapter: the connector authenticates the WS and resolves owner
bindings before delivering). Unlike ``_adapter_enforces_own_access_policy``
(a LOCAL policy only trusted when it is an allowlist) this UPSTREAM
decision is honored directly. False when the adapter is unknown.
Unlike ``_adapter_enforces_own_access_policy`` (a LOCAL policy only trusted when
it is an allowlist) this UPSTREAM decision is honored directly. False when unknown.
"""
return self._adapter_flag(platform, "authorization_is_upstream", profile)
def _adapter_enforces_own_access_policy(
self,
platform: Optional[Platform],
*,
profile: Optional[str] = None,
) -> bool:
"""Whether the adapter gates access at intake itself.
def _adapter_enforces_own_access_policy(self, platform: Optional[Platform], *, profile: Optional[str] = None) -> bool:
"""Whether the adapter gates access at intake itself (WeCom, Weixin, Yuanbao, QQBot, WhatsApp).
Mirrors ``BasePlatformAdapter.enforces_own_access_policy`` (WeCom, Weixin,
Yuanbao, QQBot, WhatsApp). The flag alone is NOT "already authorized":
these adapters default to ``open``, so ``_is_user_authorized`` only trusts
them when the effective policy is an actual ``allowlist``.
The flag alone is NOT "already authorized": these adapters default to ``open``,
so ``_is_user_authorized`` only trusts them under an actual ``allowlist`` policy.
"""
return self._adapter_flag(platform, "enforces_own_access_policy", profile)
def _config_extra(self, platform) -> dict:
"""``config.platforms[platform].extra`` as a dict ({} when absent)."""
config = getattr(self, "config", None)
platform_cfg = (
config.platforms.get(platform)
if config is not None and hasattr(config, "platforms")
else None
)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
platforms = getattr(getattr(self, "config", None), "platforms", None)
extra = getattr(platforms.get(platform), "extra", None) if platforms is not None else None
return extra if isinstance(extra, dict) else {}
def _adapter_setting(self, platform, attr: str, extra_key: str, profile):
"""Live adapter's resolved ``attr``, else ``config.extra[extra_key]``.
The adapter value is preferred because it already folds in the
``<PLATFORM>_*`` env override, which is not always bridged back into
``config.extra``; the extra fallback serves bare runners with no adapter.
"""
"""Live adapter's resolved ``attr`` (folds in the ``<PLATFORM>_*`` env override),
else ``config.extra[extra_key]`` for bare runners with no adapter."""
adapter = self._authorization_adapter(platform, profile)
value = getattr(adapter, attr, None) if adapter is not None else None
if value is None:
value = self._config_extra(platform).get(extra_key)
return value
def _adapter_dm_policy(
self,
platform: Optional[Platform],
*,
profile: Optional[str] = None,
) -> str:
"""Lowercased effective ``dm_policy`` (open/allowlist/disabled/pairing), ``""`` if unknown.
"Reached the gateway" only carries an authorization signal in the
``allowlist`` case; ``open`` forwards everyone and ``pairing`` forwards
unpaired DMs for the handshake.
"""
def _adapter_policy(self, platform, attr: str, extra_key: str, profile) -> str:
if not platform:
return ""
policy = self._adapter_setting(platform, "_dm_policy", "dm_policy", profile)
return str(policy or "").strip().lower()
return str(self._adapter_setting(platform, attr, extra_key, profile) or "").strip().lower()
def _adapter_group_policy(
self,
platform: Optional[Platform],
*,
profile: Optional[str] = None,
) -> str:
def _adapter_dm_policy(self, platform: Optional[Platform], *, profile: Optional[str] = None) -> str:
"""Lowercased effective ``dm_policy`` (open/allowlist/disabled/pairing), ``""`` if unknown."""
return self._adapter_policy(platform, "_dm_policy", "dm_policy", profile)
def _adapter_group_policy(self, platform: Optional[Platform], *, profile: Optional[str] = None) -> str:
"""Lowercased effective ``group_policy`` (open/allowlist/disabled), ``""`` if unknown."""
if not platform:
return ""
policy = self._adapter_setting(platform, "_group_policy", "group_policy", profile)
return str(policy or "").strip().lower()
return self._adapter_policy(platform, "_group_policy", "group_policy", profile)
def _adapter_group_has_sender_allowlist(
self,
platform: Optional[Platform],
chat_id: Optional[str],
*,
profile: Optional[str] = None,
self, platform: Optional[Platform], chat_id: Optional[str], *, profile: Optional[str] = None
) -> bool:
"""Whether a per-group sender allowlist (WeCom ``groups.<id>.allow_from``) gated this message.
A group may be open at the chat level while restricting senders; if such
a message reached the gateway the adapter already checked that sender
allowlist, so it is a trustworthy intake decision.
A group may be open at the chat level while restricting senders; reaching the
gateway then means the adapter already checked that list.
"""
if not platform or not chat_id:
return False
@@ -393,12 +349,10 @@ class GatewayAuthorizationMixin:
group_cfg = groups.get(chat_id_str)
if not isinstance(group_cfg, dict):
lowered = chat_id_str.lower()
for key, value in groups.items():
if isinstance(key, str) and key.lower() == lowered and isinstance(value, dict):
group_cfg = value
break
if not isinstance(group_cfg, dict):
group_cfg = groups.get("*")
group_cfg = next(
(v for k, v in groups.items() if isinstance(k, str) and k.lower() == lowered and isinstance(v, dict)),
groups.get("*"),
)
if not isinstance(group_cfg, dict):
return False
@@ -418,43 +372,100 @@ class GatewayAuthorizationMixin:
return getattr(self, "pairing_store", None)
def _adapter_extra_for_source(self, source) -> dict:
return _adapter_config_extra(self._adapter_for_source(source))
def _own_policy_authorizes(self, source, user_id, is_group, adapter_profile) -> Optional[bool]:
"""Own-policy adapter verdict when no env allowlist exists; None = no verdict.
Trusted only when the effective policy for THIS chat type is ``allowlist``:
``open`` forwards EVERY sender (the fail-open SECURITY.md §2.6 forbids),
``disabled`` never forwards, ``pairing`` forwards unpaired DMs for the handshake
(already denied by the pairing-store check). Anything else → default-deny.
"""
if is_group:
if self._adapter_group_has_sender_allowlist(source.platform, source.chat_id, profile=adapter_profile):
return True
effective_policy = self._adapter_group_policy(source.platform, profile=adapter_profile)
else:
effective_policy = self._adapter_dm_policy(source.platform, profile=adapter_profile)
if effective_policy != "allowlist":
return None
# Re-check DMs via the live adapter's ``_is_dm_allowed`` when present: pairing
# revoke can clear WHATSAPP_ALLOWED_USERS while a construction-time snapshot
# would keep authorizing until restart. Others keep the historical rubber-stamp.
if not is_group:
adapter = self._authorization_adapter(source.platform, profile=adapter_profile)
dm_check = getattr(adapter, "_is_dm_allowed", None) if adapter is not None else None
if callable(dm_check):
return bool(dm_check(user_id))
return True
def _adapter_extra_allowlist_authorizes(self, source, user_id, is_group) -> bool:
"""Adapters (e.g. Telegram) that gate via config.extra.allow_from / group_allow_from
without setting enforces_own_access_policy."""
adapter = self._adapter_for_source(source)
if adapter is None:
return {}
return getattr(getattr(adapter, "config", None), "extra", None) or {}
return False
extra = _adapter_config_extra(adapter)
adapter_allow = extra.get("group_allow_from" if is_group else "allow_from")
if not adapter_allow:
# Plugin platforms (Buzz, DingTalk) spell their env allowlist as
# ``extra.allowed_users``; under multiplex only the default profile's list
# reaches the env (first-writer-wins bridge), so read the live adapter's.
entry = _registry_entry(source.platform)
if entry and entry.allowed_users_env:
adapter_allow = extra.get("allowed_users")
if not adapter_allow:
return False
allowed = _coerce_allow_set(adapter_allow)
normalize = getattr(adapter, "normalize_user_id", None)
if callable(normalize):
# Ids and entries may spell the same principal differently (Buzz hex vs npub).
allowed = {normalize(entry) or entry for entry in allowed}
return _allows(allowed, user_id)
def _is_user_authorized(
self,
source: SessionSource,
*,
allow_adapter_delegation: bool = True,
) -> bool:
"""Check if a user is authorized to use the bot.
def _adapter_resolved_allowlist_ids(self, source) -> set[str]:
"""IDs an adapter resolved from username-shaped allowlist entries at connect time (Discord).
The per-turn .env hot-reload restores RAW usernames, so from the second turn on the
env allowlist holds usernames while user_id is numeric. Never a widening: the
empty-allowlist branch already returned and adapters only resolve operator-written
entries. Only called with a non-empty platform allowlist so group/global-only
configs never consult adapter memory; type-checked so mocks cannot auto-truthy in.
"""
adapter = resolved_ids = None
with contextlib.suppress(Exception):
adapter = self._adapter_for_source(source)
resolver = getattr(adapter, "resolved_allowlist_user_ids", None)
if callable(resolver):
with contextlib.suppress(Exception):
resolved_ids = resolver()
if not isinstance(resolved_ids, (set, frozenset, list, tuple)):
return set()
return {str(entry).strip() for entry in resolved_ids if isinstance(entry, (str, int)) and str(entry).strip()}
def _is_user_authorized(self, source: SessionSource, *, allow_adapter_delegation: bool = True) -> bool:
"""Whether a user may use the bot.
Order: trusted-upstream delegation, chat-scoped group allowlists,
``{PLATFORM}_ALLOW_BOTS``, per-platform allow-all, adapter role auth,
pairing store, env/config allowlists, ``GATEWAY_ALLOW_ALL_USERS``,
default deny.
``{PLATFORM}_ALLOW_BOTS``, per-platform allow-all, adapter role auth, pairing
store, env/config allowlists, ``GATEWAY_ALLOW_ALL_USERS``, default deny.
"""
from gateway.run import logger
# HA events are system-generated (HASS_TOKEN authenticates the
# connection); webhook events are HMAC-verified in the adapter.
# HA events are system-generated (HASS_TOKEN); webhook events are HMAC-verified.
if source.platform in {Platform.HOMEASSISTANT, Platform.WEBHOOK}:
return True
adapter_profile = self._adapter_profile_for_source(source)
is_group = source.chat_type in _GROUP_CHAT_TYPES
is_group_or_forum = source.chat_type in _GROUP_FORUM_TYPES
# Trusted-upstream delegation (relay): the Team Gateway connector
# authenticates this gateway's WS and resolves owner-only author
# bindings BEFORE delivering, so the event is already authorized and
# there is no local RELAY_ALLOWED_USERS to consult. Not a fail-open: it
# fires only for events actually delivered over the authenticated relay
# WS (transport stamps ``delivered_via_upstream_relay``) or whose
# adapter declares ``authorization_is_upstream=True``. The delivery
# marker is PRIMARY because a relayed message carries the UNDERLYING
# platform (discord/...), not ``Platform.RELAY``; the adapter-flag check
# covers events whose platform IS RELAY (interaction passthrough).
# Trusted-upstream delegation (relay): the connector authenticates this
# gateway's WS and resolves owner bindings BEFORE delivering, so there is no
# local RELAY_ALLOWED_USERS. Not a fail-open: fires only for events actually
# delivered over the relay WS (``delivered_via_upstream_relay``) or whose adapter
# declares ``authorization_is_upstream``. The delivery marker is PRIMARY because
# a relayed message carries the UNDERLYING platform, not ``Platform.RELAY``.
# ``is True``: a MagicMock stand-in must not auto-truthy into authz.
if allow_adapter_delegation and (
source.delivered_via_upstream_relay is True
@@ -464,33 +475,21 @@ class GatewayAuthorizationMixin:
user_id = source.user_id
# Chat-scoped group allowlists (TELEGRAM_GROUP_ALLOWED_CHATS /
# QQ_GROUP_ALLOWED_USERS) must work with ``user_id is None`` (anonymous
# admins, sender_chat posts, channel broadcasts), so they run before
# the no-user-id guard.
# Chat-scoped group allowlists must work with ``user_id is None`` (anonymous
# admins, sender_chat posts, channel broadcasts): run before the no-user-id guard.
if is_group and source.chat_id:
chat_allowlist_env = _GROUP_CHAT_ENV.get(source.platform, "")
if chat_allowlist_env and _allows(
_coerce_allow_set(_platform_gate_env(chat_allowlist_env)), source.chat_id
):
if chat_allowlist_env and _allows(_coerce_allow_set(_platform_gate_env(chat_allowlist_env)), source.chat_id):
return True
# config.yaml fallback (``extra.group_allowed_chats``): Telegram
# observe-unmentioned mode strips user_id from triggered group
# messages, so the env-only check above misses config allowlists.
try:
adapter_group_allowed = self._adapter_extra_for_source(source).get(
"group_allowed_chats"
)
if adapter_group_allowed and _allows(
_coerce_allow_set(adapter_group_allowed), source.chat_id
):
# config.yaml fallback (``extra.group_allowed_chats``): Telegram observe-
# unmentioned mode strips user_id, so the env-only check above misses it.
with contextlib.suppress(Exception):
adapter_group_allowed = self._adapter_extra_for_source(source).get("group_allowed_chats")
if adapter_group_allowed and _allows(_coerce_allow_set(adapter_group_allowed), source.chat_id):
return True
except Exception:
pass
# Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist.
# Also before the no-user-id guard: Slack Workflow Builder posts arrive
# as bot_message with user=None.
# Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist. Also before
# the no-user-id guard: Slack Workflow Builder posts arrive with user=None.
if getattr(source, "is_bot", False):
allow_bots_var = _ALLOW_BOTS_ENV.get(source.platform)
if allow_bots_var and _platform_gate_env(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
@@ -499,120 +498,60 @@ class GatewayAuthorizationMixin:
if not user_id:
return False
platform_env_map = dict(_ALLOWED_USERS_ENV)
platform_allow_all_map = dict(_ALLOW_ALL_ENV)
if source.platform not in platform_env_map:
try:
entry = _registry_entry(source.platform)
if entry:
if entry.allowed_users_env:
platform_env_map[source.platform] = entry.allowed_users_env
if entry.allow_all_env:
platform_allow_all_map[source.platform] = entry.allow_all_env
except Exception:
pass
platform_allow_env = _ALLOWED_USERS_ENV.get(source.platform, "")
platform_allow_all_var = _ALLOW_ALL_ENV.get(source.platform, "")
if source.platform not in _ALLOWED_USERS_ENV:
entry = _registry_entry(source.platform)
with contextlib.suppress(Exception):
platform_allow_env = getattr(entry, "allowed_users_env", "") or platform_allow_env
platform_allow_all_var = getattr(entry, "allow_all_env", "") or platform_allow_all_var
platform_allow_all_var = platform_allow_all_map.get(source.platform, "")
if platform_allow_all_var and _auth_env(platform_allow_all_var).lower() in _TRUTHY:
if platform_allow_all_var and _env_truthy(platform_allow_all_var):
return True
# Adapter-verified role auth (Discord confirmed DISCORD_ALLOWED_ROLES
# before dispatch). ``is True`` so a MagicMock source cannot pass.
# Adapter-verified role auth (Discord DISCORD_ALLOWED_ROLES). ``is True``: no MagicMock pass.
if allow_adapter_delegation and getattr(source, "role_authorized", False) is True:
return True
# Pairing store: a first-class grant created only by a trusted operator
# approving a code (inbound senders can never reach approve_code).
# Honored as a UNION with the allowlist; approval also mirrors the user
# into a configured allowlist (PairingStore._approve_user).
# Pairing store: a first-class grant created only by an operator approving a
# code. Honored as a UNION with the allowlist (approval also mirrors into it).
platform_name = source.platform.value if source.platform else ""
pairing_store = self._pairing_store_for(source)
if pairing_store is not None and pairing_store.is_approved(platform_name, user_id):
return True
platform_allowlist = _auth_env(platform_env_map.get(source.platform, ""))
platform_allowlist = _auth_env(platform_allow_env)
group_user_allowlist = ""
group_chat_allowlist = ""
if source.chat_type in {"group", "forum"}:
if is_group_or_forum:
group_user_allowlist = _auth_env(_GROUP_USER_ENV.get(source.platform, ""))
group_chat_allowlist = _auth_env(_GROUP_CHAT_ENV.get(source.platform, ""))
global_allowlist = _auth_env("GATEWAY_ALLOWED_USERS")
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
# No env allowlist. Own-policy adapters gate at intake, but their
# decision is only trusted when the effective policy for THIS chat
# type is ``allowlist``: ``open`` forwards EVERY sender (trusting it
# is the fail-open SECURITY.md §2.6 forbids), ``disabled`` never
# forwards, ``pairing`` forwards unpaired DMs for the handshake
# (already denied by the pairing-store check above). Anything else
# falls through to default-deny; GATEWAY_ALLOW_ALL_USERS, the
# per-platform ALLOW_ALL flag and pairing stay the explicit opt-ins.
if not (platform_allowlist or group_user_allowlist or group_chat_allowlist or global_allowlist):
# No env allowlist: own-policy adapters gate at intake (see _own_policy_authorizes).
if allow_adapter_delegation and self._adapter_enforces_own_access_policy(
source.platform, profile=adapter_profile
):
if is_group:
effective_policy = self._adapter_group_policy(source.platform, profile=adapter_profile)
if self._adapter_group_has_sender_allowlist(
source.platform, source.chat_id, profile=adapter_profile
):
return True
else:
effective_policy = self._adapter_dm_policy(source.platform, profile=adapter_profile)
if effective_policy == "allowlist":
# Re-check DMs against the live adapter when it exposes
# ``_is_dm_allowed``: pairing revoke can clear
# WHATSAPP_ALLOWED_USERS while a construction-time
# ``_allow_from`` snapshot would keep authorizing until
# restart. Adapters without the helper keep the historical
# "reached the gateway under allowlist policy" rubber-stamp.
if not is_group:
adapter = self._authorization_adapter(source.platform, profile=adapter_profile)
dm_check = getattr(adapter, "_is_dm_allowed", None) if adapter is not None else None
if callable(dm_check):
return bool(dm_check(user_id))
return True
# Adapters (e.g. Telegram) that gate via config.extra.allow_from /
# group_allow_from without setting enforces_own_access_policy.
adapter = self._adapter_for_source(source)
if adapter is not None:
extra = getattr(getattr(adapter, "config", None), "extra", None) or {}
adapter_allow = extra.get("group_allow_from" if is_group else "allow_from")
if not adapter_allow and _platform_declares_allowed_users_env(source.platform):
# Plugin platforms (Buzz) carry the env allowlist as
# ``extra.allowed_users``. Under multiplex the YAML->env
# bridge is first-writer-wins, so only the default profile's
# list reaches the env read above; consult the live
# profile-routed adapter's config instead.
adapter_allow = extra.get("allowed_users")
if adapter_allow:
allowed = _coerce_allow_set(adapter_allow)
normalize = getattr(adapter, "normalize_user_id", None)
if callable(normalize):
# Ids and entries may spell the same principal
# differently (Buzz hex vs npub).
allowed = {normalize(entry) or entry for entry in allowed}
if _allows(allowed, user_id):
return True
return _auth_env("GATEWAY_ALLOW_ALL_USERS").lower() in _TRUTHY
verdict = self._own_policy_authorizes(source, user_id, is_group, adapter_profile)
if verdict is not None:
return verdict
if self._adapter_extra_allowlist_authorizes(source, user_id, is_group):
return True
return _env_truthy("GATEWAY_ALLOW_ALL_USERS")
# Telegram group traffic authorized by chat ID (separate from
# TELEGRAM_GROUP_ALLOWED_USERS, which gates the sender).
if group_chat_allowlist and source.chat_type in {"group", "forum"} and source.chat_id:
if _allows(_coerce_allow_set(group_chat_allowlist), source.chat_id):
return True
# Backward-compat: TELEGRAM_GROUP_ALLOWED_USERS was once (mis)used as a
# chat-ID allowlist. "-"-prefixed values are chat IDs, so honor them as
# such and warn once; the correct var is TELEGRAM_GROUP_ALLOWED_CHATS.
if (
source.platform == Platform.TELEGRAM
and group_user_allowlist
and source.chat_type in {"group", "forum"}
and source.chat_id
group_chat_allowlist and is_group_or_forum and source.chat_id
and _allows(_coerce_allow_set(group_chat_allowlist), source.chat_id)
):
legacy_chat_ids = {
v.strip() for v in group_user_allowlist.split(",") if v.strip().startswith("-")
}
return True
# Backward-compat: TELEGRAM_GROUP_ALLOWED_USERS was once (mis)used as a chat-ID
# allowlist; "-"-prefixed values are chat IDs, honor them and warn once.
if source.platform == Platform.TELEGRAM and group_user_allowlist and is_group_or_forum and source.chat_id:
legacy_chat_ids = {v.strip() for v in group_user_allowlist.split(",") if v.strip().startswith("-")}
if legacy_chat_ids:
if not getattr(self, "_warned_telegram_group_users_legacy", False):
logger.warning(
@@ -626,113 +565,39 @@ class GatewayAuthorizationMixin:
if source.chat_id in legacy_chat_ids:
return True
# In group/forum chats TELEGRAM_GROUP_ALLOWED_USERS is the scoped
# allowlist and does not imply DM access; TELEGRAM_ALLOWED_USERS is
# platform-wide and works everywhere.
# TELEGRAM_GROUP_ALLOWED_USERS is group-scoped (no DM access); TELEGRAM_ALLOWED_USERS is platform-wide.
allowed_ids = (
_coerce_allow_set(platform_allowlist)
| _coerce_allow_set(group_user_allowlist)
| _coerce_allow_set(global_allowlist)
)
# Adapters that resolve username-shaped entries to numeric IDs at
# connect time (Discord) keep the resolved set in memory; the per-turn
# .env hot-reload restores the RAW usernames into the env, so from the
# second turn on ``platform_allowlist`` holds usernames while user_id is
# numeric. Union in the adapter's resolved IDs. Never a widening: the
# empty-allowlist branch already returned and adapters only resolve
# operator-written entries. Guarded on ``platform_allowlist`` so
# group/global-only configs never consult adapter memory; duck-typed +
# type-checked so mock adapters cannot auto-truthy in.
if platform_allowlist:
try:
adapter = self._adapter_for_source(source)
except Exception:
adapter = None
resolver = getattr(adapter, "resolved_allowlist_user_ids", None)
if callable(resolver):
try:
resolved_ids = resolver()
except Exception:
resolved_ids = None
if isinstance(resolved_ids, (set, frozenset, list, tuple)):
allowed_ids.update(
str(entry).strip()
for entry in resolved_ids
if isinstance(entry, (str, int)) and str(entry).strip()
)
allowed_ids |= self._adapter_resolved_allowlist_ids(source)
return "*" in allowed_ids or _principal_matches_allowlist(source, user_id, allowed_ids)
if "*" in allowed_ids:
return True
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform], *, profile: Optional[str] = None) -> str:
"""How unauthorized DMs are handled ("pair" / "ignore") for a platform.
check_ids = {user_id}
if "@" in user_id:
check_ids.add(user_id.split("@")[0])
# WhatsApp (Baileys + Cloud): resolve phone<->LID / JID aliases so
# device-suffix and bare-phone entries match the same principal.
if source.platform in {Platform.WHATSAPP, Platform.WHATSAPP_CLOUD}:
normalized_allowed_ids = set()
for allowed_id in allowed_ids:
normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))
if normalized_allowed_ids:
allowed_ids = normalized_allowed_ids
check_ids.update(_expand_whatsapp_auth_aliases(user_id))
normalized_user_id = _normalize_whatsapp_identifier(user_id)
if normalized_user_id:
check_ids.add(normalized_user_id)
platform_value = source.platform.value if source.platform is not None else None
# SimpleX: user_id is the numeric contactId (stable across renames) but
# the UI only surfaces display names, so match both. Plugin platform:
# compare by value.
if platform_value == "simplex" and source.user_name:
check_ids.add(source.user_name)
# Buzz: allowlist may hold npub or hex; inbound pubkeys are hex.
if platform_value == "buzz":
allowed_ids = _normalize_nostr_allow_entries(allowed_ids)
if user_id.startswith("npub"):
hex_user = _npub_to_hex(user_id)
if hex_user:
check_ids.add(hex_user)
return bool(check_ids & allowed_ids)
def _get_unauthorized_dm_behavior(
self,
platform: Optional[Platform],
*,
profile: Optional[str] = None,
) -> str:
"""Return how unauthorized DMs should be handled for a platform.
Resolution order:
1. Explicit per-platform ``unauthorized_dm_behavior`` in config.
2. Email -> ``"ignore"`` (inboxes hold arbitrary human mail; pairing
codes are not a safe default; matches GatewayConfig).
3. Explicit (non-default) global ``unauthorized_dm_behavior``.
4. Adapter dm_policy: ``pairing`` -> ``"pair"``; ``allowlist`` /
``disabled`` -> ``"ignore"``.
5. Any configured allowlist -> ``"ignore"`` (the operator restricted
access; spamming unknown contacts with codes is noisy and leaks).
6. Otherwise ``"pair"`` (open-gateway default).
Order: explicit per-platform config; Email → "ignore" (inboxes hold arbitrary
mail); explicit non-default global; adapter dm_policy (pairing → "pair",
allowlist/disabled → "ignore"); any configured allowlist → "ignore" (spamming
unknown contacts with codes is noisy and leaks); else "pair".
"""
config = getattr(self, "config", None)
if config and hasattr(config, "get_unauthorized_dm_behavior") and platform:
if "unauthorized_dm_behavior" in self._config_extra(platform):
return config.get_unauthorized_dm_behavior(platform)
if (
config and hasattr(config, "get_unauthorized_dm_behavior") and platform
and "unauthorized_dm_behavior" in self._config_extra(platform)
):
return config.get_unauthorized_dm_behavior(platform)
if platform == Platform.EMAIL:
return "ignore"
if config and hasattr(config, "unauthorized_dm_behavior"):
if config.unauthorized_dm_behavior != "pair":
return config.unauthorized_dm_behavior
if config and hasattr(config, "unauthorized_dm_behavior") and config.unauthorized_dm_behavior != "pair":
return config.unauthorized_dm_behavior
allowlist_keys = ["GATEWAY_ALLOWED_USERS"]
if platform:
dm_policy = self._adapter_dm_policy(platform, profile=profile)
if not dm_policy:
@@ -741,15 +606,9 @@ class GatewayAuthorizationMixin:
return "pair"
if dm_policy in {"allowlist", "disabled"}:
return "ignore"
# Historical: Yuanbao is absent from this allowlist-aware default.
env_key = "" if platform == Platform.YUANBAO else _ALLOWED_USERS_ENV.get(platform, "")
group_keys = (_GROUP_USER_ENV.get(platform), _GROUP_CHAT_ENV.get(platform))
for key in (env_key, *group_keys):
if key and _platform_gate_env(key).strip():
return "ignore"
if _platform_gate_env("GATEWAY_ALLOWED_USERS").strip():
allowlist_keys = [env_key, _GROUP_USER_ENV.get(platform), _GROUP_CHAT_ENV.get(platform), *allowlist_keys]
if any(key and _platform_gate_env(key).strip() for key in allowlist_keys):
return "ignore"
return "pair"
+58 -151
View File
@@ -1,27 +1,9 @@
"""One-shot artifact transport for browser control (Gateway side).
Transport-neutral store core for bounded browser-control artifacts
(screenshots, PDFs, uploads). It knows nothing about aiohttp: the routes in
:mod:`gateway.platforms.api_server` authenticate callers and rate-limit, then
hand bytes here. The controller WebSocket is a command channel, not a file
pipe — frames carry only ``artifact_id`` strings and the bytes live on disk
under a controlled root for a short TTL.
Contract (tests/gateway/test_browser_control_artifacts.py):
- Server-minted ``[0-9a-f]{32}`` ids resolved strictly inside the root;
client filenames are metadata only, never paths.
- Exact size and MIME caps enforced before any disk write.
- SHA-256 recorded in the receipt and re-verified by ``load``/``validate``.
- ``load`` requires the exact scope key and consumes atomically;
``validate`` checks existence/TTL/scope without consuming.
- No overwrite: an id collision is retried with a fresh id.
- ``prune_expired`` removes expired entries; the API server sweeps on demand.
Thread-safety: the in-memory index is lock-guarded; files are written to a
temp name and atomically renamed so a concurrent ``load`` never sees a
partial artifact.
"""
"""One-shot artifact transport for browser control (Gateway side); :mod:`gateway.platforms.api_server`
authenticates and rate-limits, then hands bytes here. Controller frames carry only server-minted
``[0-9a-f]{32}`` ids (client filenames are metadata, never paths); bytes live under a controlled root
for a short TTL. Size/MIME caps apply before any write; SHA-256 is re-verified on read; ``load`` needs
the exact scope key and consumes atomically. The index is lock-guarded and files are temp-written then
renamed so readers never see partials."""
from __future__ import annotations
@@ -43,10 +25,8 @@ DEFAULT_ARTIFACT_TTL_SECONDS = 300.0
DEFAULT_MAX_ARTIFACT_BYTES = 10 * 1024 * 1024
#: Exact allowlist — parameterized/unknown variants are rejected.
DEFAULT_ALLOWED_MIME_TYPES = frozenset({
"application/json", "application/pdf", "image/gif", "image/jpeg",
"image/png", "image/webp", "text/plain",
"application/json", "application/pdf", "image/gif", "image/jpeg", "image/png", "image/webp", "text/plain",
})
_ARTIFACT_ID_HEX = 32
_ARTIFACT_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_TEMP_SUFFIX = ".tmp"
@@ -86,7 +66,6 @@ class ArtifactTraversal(ArtifactError):
@dataclass(frozen=True)
class ArtifactReceipt:
"""Provenance record returned to the caller of ``store``."""
artifact_id: str
sha256: str
size_bytes: int
@@ -99,35 +78,22 @@ class ArtifactReceipt:
def to_dict(self, *, download_path: str = "") -> dict[str, Any]:
"""Serialize to the wire receipt (never contains file paths)."""
receipt = {
"artifact_id": self.artifact_id,
"sha256": self.sha256,
"size_bytes": self.size_bytes,
"content_type": self.content_type,
"filename": self.filename,
"created_at": self.created_at,
"expires_at": self.expires_at,
"ttl_seconds": self.ttl_seconds,
"one_shot": True,
return {
"artifact_id": self.artifact_id, "sha256": self.sha256, "size_bytes": self.size_bytes,
"content_type": self.content_type, "filename": self.filename, "created_at": self.created_at,
"expires_at": self.expires_at, "ttl_seconds": self.ttl_seconds, "one_shot": True,
**({"download_path": download_path} if download_path else {}),
}
if download_path:
receipt["download_path"] = download_path
return receipt
def artifact_scope_key(scope: Any) -> str:
"""Derive the stable scope key an artifact is bound to.
Only principal (mandatory) + transport family participate. ``session_id``
is deliberately EXCLUDED: HTTP artifact routes authenticate by API key and
can't resolve a session, while broker dispatch always carries one — hashing
it would make upload and dispatch never compose. Ids are unguessable and
downloads one-shot, so cross-session reuse within one principal is by
design. Capabilities/optional ids are excluded so a reconnect keeps its
artifacts.
"""
principal = ""
family = ""
Only principal (mandatory) + transport family participate. ``session_id`` is deliberately EXCLUDED: HTTP
artifact routes authenticate by API key and can't resolve a session while broker dispatch always carries
one, so hashing it would make upload and dispatch never compose (ids are unguessable and downloads
one-shot). Capabilities/optional ids are excluded so a reconnect keeps its artifacts."""
principal = family = ""
try:
principal = str(getattr(scope, "principal_id", "") or "")
family = str(getattr(scope, "transport_family", "") or "")
@@ -136,12 +102,7 @@ def artifact_scope_key(scope: Any) -> str:
if not principal:
# Fail closed: only an authenticated principal may mint artifacts.
raise ArtifactError("artifact scope must carry a resolved principal")
material = f"{principal}\x00{family}".encode("utf-8")
return hashlib.sha256(material).hexdigest()
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
return hashlib.sha256(f"{principal}\x00{family}".encode("utf-8")).hexdigest()
@dataclass
@@ -152,14 +113,8 @@ class _ArtifactEntry:
class ArtifactStore:
"""Thread-safe, TTL-bounded, scope-bound one-shot artifact store."""
def __init__(
self, root: Path, *,
ttl_seconds: float = DEFAULT_ARTIFACT_TTL_SECONDS,
max_bytes: int = DEFAULT_MAX_ARTIFACT_BYTES,
allowed_mime_types: frozenset = DEFAULT_ALLOWED_MIME_TYPES,
clock: Optional[Callable[[], float]] = None,
) -> None:
def __init__(self, root: Path, *, ttl_seconds: float = DEFAULT_ARTIFACT_TTL_SECONDS, max_bytes: int = DEFAULT_MAX_ARTIFACT_BYTES,
allowed_mime_types: frozenset = DEFAULT_ALLOWED_MIME_TYPES, clock: Optional[Callable[[], float]] = None) -> None:
self._root = Path(root)
self._root.mkdir(parents=True, exist_ok=True)
self._ttl_seconds = max(1.0, float(ttl_seconds))
@@ -173,11 +128,7 @@ class ArtifactStore:
self._sweep_orphan_files()
def _sweep_orphan_files(self) -> None:
"""Delete on-disk files with no live index entry.
Only names matching the minted 32-hex id shape or the ``*.tmp``
staging suffix are touched; anything else in the directory is left.
"""
"""Delete on-disk files with no live index entry; only minted-id-shaped and ``*.tmp`` names are touched."""
try:
candidates = list(self._root.iterdir())
except OSError:
@@ -185,18 +136,10 @@ class ArtifactStore:
with self._lock:
live = set(self._entries)
for path in candidates:
name = path.name
is_temp = name.endswith(_TEMP_SUFFIX)
if not path.is_file() or not (is_temp or _ARTIFACT_ID_RE.fullmatch(name)):
continue
if not is_temp and name in live:
continue
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
orphan = path.name.endswith(_TEMP_SUFFIX) or (_ARTIFACT_ID_RE.fullmatch(path.name) and path.name not in live)
if path.is_file() and orphan:
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
@property
def root(self) -> Path:
@@ -212,11 +155,7 @@ class ArtifactStore:
return self._allowed_mime_types
def store(self, data: bytes, *, filename: str, content_type: str, scope: Any) -> ArtifactReceipt:
"""Validate and store one artifact, returning its provenance receipt.
Raises :class:`ArtifactTooLarge` / :class:`ArtifactMimeRejected`
before any disk write; :class:`ArtifactError` on an unresolved scope.
"""
"""Validate and store one artifact, returning its receipt; size/MIME rejections fire before any disk write."""
size = len(data)
if size > self._max_bytes:
raise ArtifactTooLarge(f"artifact is {size} bytes; cap is {self._max_bytes}")
@@ -225,23 +164,19 @@ class ArtifactStore:
raise ArtifactMimeRejected(f"content type {content_type!r} is outside the exact allowlist")
scope_key = artifact_scope_key(scope)
now = self._clock()
# Mint a fresh id; retry on an astronomically unlikely collision.
while True:
artifact_id = secrets.token_hex(_ARTIFACT_ID_HEX // 2)
artifact_id = secrets.token_hex(16)
target = self._artifact_path(artifact_id)
with self._lock:
if artifact_id in self._entries or target.exists():
continue
receipt = ArtifactReceipt(
artifact_id=artifact_id, sha256=_sha256(data), size_bytes=size,
content_type=normalized_type, filename=_bounded_filename(filename),
created_at=now, expires_at=now + self._ttl_seconds,
ttl_seconds=self._ttl_seconds, scope_key=scope_key,
)
self._entries[artifact_id] = _ArtifactEntry(receipt=receipt, path=target)
break
if artifact_id not in self._entries and not target.exists():
receipt = ArtifactReceipt(
artifact_id=artifact_id, sha256=hashlib.sha256(data).hexdigest(), size_bytes=size,
content_type=normalized_type, filename=_bounded_filename(filename), created_at=now,
expires_at=now + self._ttl_seconds, ttl_seconds=self._ttl_seconds, scope_key=scope_key,
)
self._entries[artifact_id] = _ArtifactEntry(receipt=receipt, path=target)
break
# Temp + atomic rename so readers never observe a partial artifact.
temp = target.with_name(f"{target.name}{_TEMP_SUFFIX}")
try:
@@ -259,48 +194,39 @@ class ArtifactStore:
return receipt
def validate(self, artifact_id: str, *, scope: Any) -> ArtifactReceipt:
"""Return the receipt when the artifact is live for ``scope``
(existence, TTL, scope) without consuming it; raises otherwise."""
"""Receipt when the artifact is live for ``scope`` (existence, TTL, scope), without consuming."""
return self._entry_for(artifact_id, scope=scope).receipt
def load(self, artifact_id: str, *, scope: Any) -> tuple[bytes, ArtifactReceipt]:
"""One-shot download: verify, read, checksum, then consume.
A second ``load`` raises :class:`ArtifactNotFound`. A checksum
mismatch raises :class:`ArtifactChecksumMismatch` without consuming.
"""
"""One-shot download: verify, read, checksum, then consume (a checksum mismatch does not consume)."""
with self._lock:
entry = self._entry_for(artifact_id, scope=scope)
path = entry.path
if not path.exists():
if not entry.path.exists():
self._entries.pop(artifact_id, None)
raise ArtifactNotFound(f"artifact {artifact_id!r} is gone")
try:
data = path.read_bytes()
data = entry.path.read_bytes()
except OSError as exc:
raise ArtifactError(f"artifact read failed: {exc}") from exc
if _sha256(data) != entry.receipt.sha256:
if hashlib.sha256(data).hexdigest() != entry.receipt.sha256:
raise ArtifactChecksumMismatch(f"artifact {artifact_id!r} failed SHA-256 validation")
# Drop the index entry first so a concurrent load fails closed.
self._entries.pop(artifact_id, None)
try:
path.unlink(missing_ok=True)
entry.path.unlink(missing_ok=True)
except OSError:
logger.warning("artifact %s: file removal failed; TTL sweep will retry", artifact_id)
return data, entry.receipt
def prune_expired(self, now: Optional[float] = None) -> int:
"""Delete every artifact past its TTL (and stale temp files); return
the count removed. Idempotent."""
"""Delete every artifact past its TTL (and stale temp files); return the count removed."""
now = self._clock() if now is None else float(now)
with self._lock:
removed = self._prune_expired_locked(now)
for temp in self._root.glob(f"*{_TEMP_SUFFIX}"):
try:
with contextlib.suppress(OSError):
if temp.stat().st_mtime <= now - self._ttl_seconds:
temp.unlink(missing_ok=True)
except OSError:
continue
return removed
def count(self) -> int:
@@ -308,9 +234,11 @@ class ArtifactStore:
with self._lock:
return len(self._entries)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _discard_locked(self, artifact_id: str, path: Path) -> None:
"""Drop the index entry and best-effort unlink its file."""
self._entries.pop(artifact_id, None)
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
def _entry_for(self, artifact_id: str, *, scope: Any) -> _ArtifactEntry:
path = self._artifact_path(artifact_id)
@@ -326,23 +254,17 @@ class ArtifactStore:
if entry is None:
raise ArtifactNotFound(f"unknown artifact {artifact_id!r}")
if entry.receipt.expires_at <= now:
self._entries.pop(artifact_id, None)
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
self._discard_locked(artifact_id, path)
raise ArtifactExpired(f"artifact {artifact_id!r} expired")
if entry.receipt.scope_key != scope_key:
raise ArtifactScopeMismatch(f"artifact {artifact_id!r} is bound to a different scope")
return entry
def _prune_expired_locked(self, now: float) -> int:
removed = 0
for artifact_id, entry in list(self._entries.items()):
if entry.receipt.expires_at <= now:
self._entries.pop(artifact_id, None)
with contextlib.suppress(OSError):
entry.path.unlink(missing_ok=True)
removed += 1
return removed
expired = [(aid, e.path) for aid, e in self._entries.items() if e.receipt.expires_at <= now]
for artifact_id, path in expired:
self._discard_locked(artifact_id, path)
return len(expired)
def _artifact_path(self, artifact_id: str) -> Path:
"""Resolve a minted id strictly inside the controlled root."""
@@ -360,32 +282,18 @@ class ArtifactStore:
def _normalize_content_type(value: str) -> str:
"""Return the canonical MIME type, or ``""`` for malformed input."""
if not isinstance(value, str):
return ""
return value.strip().split(";", 1)[0].strip().lower()
return value.strip().split(";", 1)[0].strip().lower() if isinstance(value, str) else ""
def _bounded_filename(value: str, limit: int = 160) -> str:
"""Sanitize a display-only filename; never used as a filesystem path."""
if not isinstance(value, str):
return ""
cleaned = value.strip().replace("\\", "_").replace("/", "_")
cleaned = "".join(character for character in cleaned if ord(character) >= 32)
return cleaned[:limit]
# ----------------------------------------------------------------------
# Rate limiting (route-level, per principal)
# ----------------------------------------------------------------------
cleaned = value.strip().replace("\\", "_").replace("/", "_") if isinstance(value, str) else ""
return "".join(character for character in cleaned if ord(character) >= 32)[:limit]
class ArtifactRateLimiter:
"""Sliding-window per-key limiter; the API server keys it by principal."""
def __init__(
self, *, window_seconds: float = 60.0, max_requests: int = 30,
clock: Optional[Callable[[], float]] = None,
) -> None:
def __init__(self, *, window_seconds: float = 60.0, max_requests: int = 30, clock: Optional[Callable[[], float]] = None) -> None:
self._window_seconds = max(1.0, float(window_seconds))
self._max_requests = max(1, int(max_requests))
self._clock = clock if clock is not None else time.time
@@ -397,9 +305,8 @@ class ArtifactRateLimiter:
if not isinstance(key, str) or not key:
return False
now = self._clock()
window_start = now - self._window_seconds
with self._lock:
hits = [hit for hit in self._hits.get(key, []) if hit > window_start]
hits = [hit for hit in self._hits.get(key, []) if hit > now - self._window_seconds]
allowed = len(hits) < self._max_requests
if allowed:
hits.append(now)
+134 -448
View File
@@ -1,34 +1,10 @@
"""Transport-neutral browser-control broker core.
Binds an identity-scoped *controller* (the party driving a browser) to
*callers* (agents talking to it over any transport) without knowing HTTP,
WebSocket, or any wire format. The browser is a stateful single-owner
resource while the agent side is multi-tenant and multi-transport, so every
rule here makes a class of violation structurally impossible:
- Registration tickets are short-lived, single-use, identity-bound, and
``secrets``-random; ``consume_ticket`` exchanges one exactly once. The
ticket is the only cross-transport credential minted here; transports
decide how to carry it.
- ``select`` matches on *every* stable identity field (principal, profile,
session, controller id, browser profile id, transport family) plus the
controller's currently negotiated capability set; partial matches yield
``None``. A same-identity reconnect renegotiates capabilities in place.
- One pending command per command id; ``complete`` is single-shot (late
completion after cancel/detach returns ``False``).
- ``cancel`` aborts only the command matching scope + tool_call_id.
- ``detach`` fails closed: pending work of that scope raises
:class:`ControllerCancelled` instead of racing a late ``complete``.
- ``disconnect`` marks a transport offline without cancelling running work;
re-attaching the same identity refreshes the callback and may still
deliver the original result. Detach, cancel, identity replacement, and
timeout remain terminal.
Thread-safety: all state transitions happen under one reentrant lock; the
send callback is invoked *outside* it so a controller may synchronously
``complete`` from inside its own send, and waiters park on per-command
events, never on the broker lock.
"""
"""Transport-neutral browser-control broker core: binds an identity-scoped *controller*
(the party driving a browser) to *callers* on any transport. Tickets are short-lived,
single-use, identity-bound and consumed exactly once; ``select`` matches every stable
identity field plus the current capability set; ``complete`` is single-shot; ``detach``
fails pending work closed while ``disconnect`` only marks the transport offline. State
changes happen under one RLock; the send callback runs *outside* it so a controller may
``complete`` from inside its own send."""
from __future__ import annotations
@@ -48,39 +24,18 @@ DEFAULT_TICKET_TTL = 30.0
DEFAULT_COMMAND_TIMEOUT = 30.0
#: Maximum cancel frames retained while a same-identity controller is offline.
MAX_DEFERRED_CANCELS = 512
#: Current wire protocol version. Registration requires this exact integer;
#: booleans are rejected even though ``bool`` subclasses ``int``.
#: Current wire protocol version; registration requires this exact int (bools rejected).
BROWSER_CONTROL_PROTOCOL_VERSION = 1
#: Exact controller capability allowlist shared by every transport. Raw CDP,
#: script evaluation, console access, and other privileged surfaces stay out.
BROWSER_CONTROL_CAPABILITIES = frozenset(
{
"controller.noop",
"browser_back",
"browser_click",
"browser_navigate",
"browser_press",
"browser_screenshot",
"browser_scroll",
"browser_snapshot",
"browser_tab_activate",
"browser_tabs",
"browser_type",
}
)
#: Privileged capabilities (JS eval, raw CDP): fail-closed unless Developer
#: Mode (``browser.extension_control.developer_mode``) is on AND the
#: controller explicitly negotiated them.
#: Exact controller capability allowlist shared by every transport (raw CDP/eval/console stay out).
BROWSER_CONTROL_CAPABILITIES = frozenset({
"controller.noop", "browser_back", "browser_click", "browser_navigate", "browser_press", "browser_screenshot",
"browser_scroll", "browser_snapshot", "browser_tab_activate", "browser_tabs", "browser_type",
})
#: Privileged capabilities: fail-closed unless Developer Mode is on AND explicitly negotiated.
BROWSER_CONTROL_DEVELOPER_CAPABILITIES = frozenset({"browser_cdp", "browser_evaluate"})
#: Artifact transport capabilities. Non-developer because payloads never ride
#: in controller frames: only a store-validated ``artifact_id`` is dispatched.
BROWSER_CONTROL_ARTIFACT_CAPABILITIES = frozenset(
{"browser_artifact_download", "browser_artifact_upload"}
)
#: Artifact transport capabilities; non-developer because only a store-validated ``artifact_id`` travels.
BROWSER_CONTROL_ARTIFACT_CAPABILITIES = frozenset({"browser_artifact_download", "browser_artifact_upload"})
#: Wire method names for controller frames; transports carry them verbatim.
FRAME_COMMAND = "browser.controller.command"
@@ -96,22 +51,14 @@ def _extension_control_flag(config: Optional[dict], key: str) -> bool:
"""Read ``browser.extension_control.<key>`` as a literal ``True`` (default off)."""
if config is None:
try:
# Hot path (every browser tool call / check_fn): the read-only
# loader skips load_config()'s deepcopy; we never mutate.
# Hot path (every browser tool call): the read-only loader skips load_config()'s deepcopy.
from hermes_cli.config import load_config_readonly
config = load_config_readonly()
except Exception:
return False
if not isinstance(config, dict):
return False
browser = config.get("browser")
if not isinstance(browser, dict):
return False
extension_control = browser.get("extension_control")
if not isinstance(extension_control, dict):
return False
return extension_control.get(key, False) is True
browser = config.get("browser") if isinstance(config, dict) else None
extension_control = browser.get("extension_control") if isinstance(browser, dict) else None
return isinstance(extension_control, dict) and extension_control.get(key, False) is True
def browser_control_developer_mode(config: Optional[dict] = None) -> bool:
@@ -124,25 +71,13 @@ def browser_control_enabled(config: Optional[dict] = None) -> bool:
return _extension_control_flag(config, "enabled")
def filter_browser_control_capabilities(
value: Any,
*,
developer_mode: Optional[bool] = None,
) -> frozenset:
"""Return the permitted subset of a JSON/RPC capability list.
Non-list input has no capabilities; unknown/non-string entries are
dropped (registration rejects an empty returned set). Developer
capabilities pass only when Developer Mode is on (passed in, else read
from live config).
"""
def filter_browser_control_capabilities(value: Any, *, developer_mode: Optional[bool] = None) -> frozenset:
"""Permitted subset of a capability list (non-list -> empty); developer caps only in Developer Mode."""
if not isinstance(value, list):
return frozenset()
allowed = BROWSER_CONTROL_CAPABILITIES | BROWSER_CONTROL_ARTIFACT_CAPABILITIES
if developer_mode is None:
developer_mode = browser_control_developer_mode()
if developer_mode is True:
allowed = allowed | BROWSER_CONTROL_DEVELOPER_CAPABILITIES
if (browser_control_developer_mode() if developer_mode is None else developer_mode) is True:
allowed |= BROWSER_CONTROL_DEVELOPER_CAPABILITIES
return frozenset(c for c in value if isinstance(c, str) and c in allowed)
@@ -173,7 +108,6 @@ class ControllerRejected(BrowserControlError):
@dataclass(frozen=True)
class ControllerScope:
"""Exact controller identity plus capability set; equality is over all fields."""
principal_id: Optional[str] = None
profile_id: Optional[str] = None
session_id: Optional[str] = None
@@ -183,26 +117,17 @@ class ControllerScope:
capabilities: frozenset = frozenset()
def _scope_identity(scope: ControllerScope) -> tuple:
"""Stable identity fields, excluding negotiated capabilities."""
return (
scope.principal_id,
scope.profile_id,
scope.session_id,
scope.controller_id,
scope.browser_profile_id,
scope.transport_family,
)
#: Stable identity fields; negotiated ``capabilities`` are deliberately excluded.
_IDENTITY_FIELDS = ("principal_id", "profile_id", "session_id", "controller_id", "browser_profile_id", "transport_family")
def _same_scope_identity(first: ControllerScope, second: ControllerScope) -> bool:
return _scope_identity(first) == _scope_identity(second)
return all(getattr(first, name) == getattr(second, name) for name in _IDENTITY_FIELDS)
@dataclass(frozen=True)
class Ticket:
"""Opaque, single-use registration credential."""
value: str
expires_at: float
@@ -221,9 +146,7 @@ class _Controller:
owner: Any = None
connected: bool = True
deferred_cancels: list[dict] = field(default_factory=list)
# Serializes command/cancel writes with detach or replacement. Broker
# state is never held while waiting on it, so a transport callback may
# synchronously complete() without deadlocking.
# Serializes command/cancel writes with detach or replacement; never held with broker state.
send_lock: threading.Lock = field(default_factory=threading.Lock)
@@ -239,20 +162,14 @@ class _PendingCommand:
result: Any = None
def _cancel_frame(pending: _PendingCommand) -> dict:
return {"method": FRAME_CANCEL, "params": {"command_id": pending.command_id, "tool_call_id": pending.tool_call_id}}
class BrowserControlBroker:
"""Thread-safe broker core binding controllers to callers.
``clock`` is injectable (default ``time.monotonic``) so tests pin expiry.
"""
def __init__(
self,
*,
ticket_ttl: float = DEFAULT_TICKET_TTL,
command_timeout: float = DEFAULT_COMMAND_TIMEOUT,
clock: Optional[Callable[[], float]] = None,
developer_mode: Optional[bool] = None,
) -> None:
"""Thread-safe broker core; ``clock`` is injectable (default ``time.monotonic``)."""
def __init__(self, *, ticket_ttl: float = DEFAULT_TICKET_TTL, command_timeout: float = DEFAULT_COMMAND_TIMEOUT,
clock: Optional[Callable[[], float]] = None, developer_mode: Optional[bool] = None) -> None:
self._ticket_ttl = ticket_ttl
self._command_timeout = command_timeout
self._clock = clock if clock is not None else time.monotonic
@@ -260,19 +177,15 @@ class BrowserControlBroker:
self._tickets: Dict[str, _TicketRecord] = {}
self._controllers: Dict[ControllerScope, _Controller] = {}
self._pending: Dict[str, _PendingCommand] = {}
# None defers to live config on every selection so flipping
# developer_mode off REVOKES raw CDP/eval from attached controllers
# without restart; an explicit bool pins the gate (tests, multi-tenant).
self._developer_mode_pinned: Optional[bool] = (
None if developer_mode is None else developer_mode is True
)
# None defers to live config on every selection (so flipping developer_mode off REVOKES
# raw CDP/eval from attached controllers without restart); a bool pins the gate.
self._developer_mode_pinned: Optional[bool] = None if developer_mode is None else developer_mode is True
# Artifact stores keyed by profile id; ``None`` is the default slot.
# Per-profile stores keep multiplex profile A from pinning profile B
# to A's physical root.
self._artifact_stores: Dict[Optional[str], Any] = {}
def _developer_mode_now(self) -> bool:
"""Current Developer Mode authority (live config unless pinned)."""
@property
def developer_mode(self) -> bool:
"""Whether privileged capabilities may be selected/dispatched (live config unless pinned)."""
if self._developer_mode_pinned is not None:
return self._developer_mode_pinned
try:
@@ -280,55 +193,29 @@ class BrowserControlBroker:
except Exception:
return False
@property
def developer_mode(self) -> bool:
"""Whether privileged capabilities may be selected/dispatched."""
return self._developer_mode_now()
def attach_artifact_store(
self, store: Any, *, profile_id: Optional[str] = None
) -> None:
"""Attach a store exposing ``validate(artifact_id, *, scope) -> receipt``
(raising the artifacts module's :class:`ArtifactError` subclasses).
``profile_id`` scopes the store to one profile on multiplex hosts;
``None`` registers the default store. ``store=None`` clears the slot.
Artifact actions without a resolvable store fail closed.
"""
def attach_artifact_store(self, store: Any, *, profile_id: Optional[str] = None) -> None:
"""Attach a store exposing ``validate(artifact_id, *, scope) -> receipt`` for one profile
(``None`` = default slot); ``store=None`` clears the slot. Artifact actions fail closed without one."""
if store is None:
self._artifact_stores.pop(profile_id, None)
return
self._artifact_stores[profile_id] = store
else:
self._artifact_stores[profile_id] = store
def _artifact_store_for_scope(self, scope: "ControllerScope") -> Any:
"""Exact profile-scoped store, else the default (``None``) slot so
single-profile hosts keep the historical one-store behaviour."""
profile = getattr(scope, "profile_id", None) or None
store = self._artifact_stores.get(profile)
store = self._artifact_stores.get(getattr(scope, "profile_id", None) or None)
return store if store is not None else self._artifact_stores.get(None)
# ------------------------------------------------------------------
# Registration tickets
# ------------------------------------------------------------------
def mint_ticket(self, scope: ControllerScope) -> Ticket:
"""Mint a short-lived, single-use ticket bound to ``scope``."""
now = self._clock()
with self._lock:
for stale in [v for v, rec in self._tickets.items() if rec.expires_at <= now]:
del self._tickets[stale]
self._tickets = {v: rec for v, rec in self._tickets.items() if rec.expires_at > now}
value = secrets.token_urlsafe(32)
record = _TicketRecord(scope=scope, expires_at=now + self._ticket_ttl)
self._tickets[value] = record
self._tickets[value] = record = _TicketRecord(scope=scope, expires_at=now + self._ticket_ttl)
return Ticket(value=value, expires_at=record.expires_at)
def consume_ticket(self, value: str) -> ControllerScope:
"""Exchange a ticket for its scope exactly once.
Raises :class:`ControllerTicketInvalid` for unknown, already-consumed,
or expired tickets; expiry is checked against the live clock at
consume time.
"""
"""Exchange a ticket for its scope exactly once; unknown/consumed/expired -> ControllerTicketInvalid."""
now = self._clock()
with self._lock:
record = self._tickets.get(value)
@@ -341,52 +228,26 @@ class BrowserControlBroker:
record.consumed = True
return record.scope
# ------------------------------------------------------------------
# Controller registration / selection
# ------------------------------------------------------------------
def _controller_for_identity_locked(self, scope: ControllerScope) -> Optional[_Controller]:
"""Attached controller sharing ``scope``'s stable identity (any capabilities)."""
return next(
(c for c in self._controllers.values() if _same_scope_identity(c.scope, scope)),
None,
)
return next((c for c in self._controllers.values() if _same_scope_identity(c.scope, scope)), None)
def _lane_scopes_locked(self, session_id: str, principal_id: str, family: str) -> list[ControllerScope]:
"""Attached scopes bound to one session lane (session + principal + transport)."""
return [
scope
for scope in self._controllers
if scope.session_id == session_id
and scope.principal_id == principal_id
and scope.transport_family == family
]
def _live_controller(self, scope: ControllerScope) -> Optional[_Controller]:
with self._lock:
controller = self._controller_for_identity_locked(scope)
return controller if controller is not None and controller.connected else None
def attach(
self,
scope: ControllerScope,
send: Callable[[dict], None],
*,
owner: Any = None,
) -> None:
"""Attach or refresh the controller for one stable identity.
A same-identity reconnect refreshes the send callback and negotiated
capabilities without cancelling pending work. A different controller
or browser profile in the same authenticated session lane
hard-replaces the previous identity.
"""
def attach(self, scope: ControllerScope, send: Callable[[dict], None], *, owner: Any = None) -> None:
"""Attach or refresh the controller for one stable identity: a same-identity reconnect refreshes send
and capabilities without cancelling pending work; a different controller/browser profile in the same
authenticated session lane hard-replaces it."""
while True:
with self._lock:
existing = self._controller_for_identity_locked(scope)
lane_scopes = [
candidate
for candidate in self._controllers
if candidate.principal_id == scope.principal_id
and candidate.profile_id == scope.profile_id
and candidate.session_id == scope.session_id
and candidate.transport_family == scope.transport_family
and not _same_scope_identity(candidate, scope)
c for c in self._controllers if not _same_scope_identity(c, scope)
and (c.principal_id, c.profile_id, c.session_id, c.transport_family)
== (scope.principal_id, scope.profile_id, scope.session_id, scope.transport_family)
]
if existing is None and not lane_scopes:
self._controllers[scope] = _Controller(scope=scope, send=send, owner=owner)
@@ -404,69 +265,41 @@ class BrowserControlBroker:
if self._controllers.get(existing.scope) is not existing:
continue
self._controllers.pop(existing.scope, None)
existing.scope = scope
existing.send = send
existing.owner = owner
existing.connected = False
for pending in self._pending.values():
if _same_scope_identity(pending.scope, scope):
pending.scope = scope
deferred = list(existing.deferred_cancels)
existing.deferred_cancels.clear()
existing.scope, existing.send, existing.owner, existing.connected = scope, send, owner, False
for pending in self._pending_for_scope_locked(scope):
pending.scope = scope
deferred, existing.deferred_cancels = existing.deferred_cancels, []
self._controllers[scope] = existing
unsent: list[dict] = []
for index, frame in enumerate(deferred):
try:
send(frame)
continue
except Exception:
logger.exception("failed to flush deferred browser-controller cancel")
unsent = deferred[index:]
break
if unsent:
with self._lock:
if self._controllers.get(scope) is existing:
existing.deferred_cancels = unsent[-MAX_DEFERRED_CANCELS:]
raise ConnectionError(
"browser controller reconnect could not flush deferred cancels"
)
existing.deferred_cancels = deferred[index:][-MAX_DEFERRED_CANCELS:]
raise ConnectionError("browser controller reconnect could not flush deferred cancels")
with self._lock:
if self._controllers.get(scope) is existing:
existing.connected = True
return
def select(self, scope: ControllerScope, capability: str) -> Optional[_Controller]:
"""Return the connected controller matching identity and capability.
The caller's capabilities are not authoritative: identity is matched,
then the controller's *current* negotiated set is checked. Offline
controllers never accept new dispatches. Developer capabilities are
additionally gated on the LIVE Developer Mode flag (unless pinned).
"""
if capability in BROWSER_CONTROL_DEVELOPER_CAPABILITIES and not self._developer_mode_now():
"""Connected controller matching identity whose *current* negotiated set holds ``capability`` (the
caller's set is not authoritative); developer capabilities are also gated on LIVE Developer Mode."""
if capability in BROWSER_CONTROL_DEVELOPER_CAPABILITIES and not self.developer_mode:
return None
with self._lock:
controller = self._controller_for_identity_locked(scope)
if controller is None or not controller.connected:
return None
return controller if capability in controller.scope.capabilities else None
controller = self._live_controller(scope)
return controller if controller is not None and capability in controller.scope.capabilities else None
def is_owner(self, scope: ControllerScope, owner: Any) -> bool:
"""Whether ``owner`` is the exact live transport for ``scope``.
"""Whether ``owner`` is the exact live transport for ``scope`` (capability-independent)."""
controller = self._live_controller(scope)
return controller is not None and controller.owner is owner
Independent of capabilities, so a least-privilege controller need not
request ``controller.noop`` merely to heartbeat or complete an action.
"""
with self._lock:
controller = self._controller_for_identity_locked(scope)
return controller is not None and controller.connected and controller.owner is owner
def disconnect(
self,
scope: ControllerScope,
*,
owner: Any = _OWNER_UNSET,
) -> bool:
def disconnect(self, scope: ControllerScope, *, owner: Any = _OWNER_UNSET) -> bool:
"""Mark one exact controller transport offline without cancelling work."""
with self._lock:
controller = self._controller_for_identity_locked(scope)
@@ -474,37 +307,21 @@ class BrowserControlBroker:
return False
with controller.send_lock:
with self._lock:
if self._controllers.get(controller.scope) is not controller:
owned = owner is _OWNER_UNSET or controller.owner is owner
if self._controllers.get(controller.scope) is not controller or not owned:
return False
if owner is not _OWNER_UNSET and controller.owner is not owner:
return False
controller.connected = False
controller.owner = None
controller.connected, controller.owner = False, None
return True
def detach(
self,
scope: ControllerScope,
*,
owner: Any = _OWNER_UNSET,
notify_controller: bool = True,
) -> None:
"""Remove the controller for ``scope`` and fail its pending work closed.
Pending commands resolve cancelled (dispatchers raise
:class:`ControllerCancelled`); a late ``complete`` returns ``False``.
"""
def detach(self, scope: ControllerScope, *, owner: Any = _OWNER_UNSET, notify_controller: bool = True) -> None:
"""Remove the controller for ``scope`` and fail its pending work closed (ControllerCancelled)."""
with self._lock:
controller = self._controllers.get(scope)
if controller is None:
return
if owner is not _OWNER_UNSET and controller.owner != owner:
if controller is None or (owner is not _OWNER_UNSET and controller.owner != owner):
return
with controller.send_lock:
with self._lock:
if self._controllers.get(scope) is not controller:
return
if owner is not _OWNER_UNSET and controller.owner != owner:
if self._controllers.get(scope) is not controller or (owner is not _OWNER_UNSET and controller.owner != owner):
return
self._controllers.pop(scope, None)
pendings = self._pending_for_scope_locked(scope)
@@ -515,65 +332,31 @@ class BrowserControlBroker:
if notify_controller:
self._emit_cancel_frames(controller, pendings)
# ------------------------------------------------------------------
# Command lifecycle
# ------------------------------------------------------------------
def dispatch(
self,
scope: ControllerScope,
*,
action: str,
arguments: Optional[dict] = None,
tool_call_id: Optional[str] = None,
self, scope: ControllerScope, *, action: str, arguments: Optional[dict] = None, tool_call_id: Optional[str] = None,
) -> Any:
"""Send one controller command and block for its completion.
Raises :class:`ControllerUnavailable` (no exact match),
:class:`ControllerCancelled`, :class:`ControllerTimeout`, or
:class:`ControllerRejected` (``ok=False``). Artifact actions also
require an attached store and an approved ``artifact_id`` argument;
only the id travels in the frame, never the payload.
"""
"""Send one controller command and block for completion; raises ControllerUnavailable/Cancelled/Timeout/
Rejected. Artifact actions also need an attached store and an approved ``artifact_id`` (only the id travels)."""
controller = self.select(scope, action)
if controller is None:
raise ControllerUnavailable(
f"no controller for scope {scope!r} with capability {action!r}"
)
raise ControllerUnavailable(f"no controller for scope {scope!r} with capability {action!r}")
arguments = dict(arguments or {})
if action in BROWSER_CONTROL_ARTIFACT_CAPABILITIES:
self._validate_artifact_reference(scope, action, arguments)
command_id = secrets.token_hex(16)
frame = {
"method": FRAME_COMMAND,
"params": {
"command_id": command_id,
"action": action,
"arguments": arguments,
"controller_id": scope.controller_id,
"browser_profile_id": scope.browser_profile_id,
"tool_call_id": tool_call_id,
},
}
pending = _PendingCommand(
scope=controller.scope,
command_id=command_id,
tool_call_id=tool_call_id,
)
frame = {"method": FRAME_COMMAND, "params": {
"command_id": command_id, "action": action, "arguments": arguments, "controller_id": scope.controller_id,
"browser_profile_id": scope.browser_profile_id, "tool_call_id": tool_call_id,
}}
pending = _PendingCommand(scope=controller.scope, command_id=command_id, tool_call_id=tool_call_id)
with controller.send_lock:
with self._lock:
# select() ran outside the send lock; revalidate the live
# controller so disconnect/replacement can't strand a command.
attached = self._controller_for_identity_locked(scope)
if attached is not controller or not controller.connected:
raise ControllerUnavailable(
f"controller for scope {scope!r} detached before dispatch"
)
if self._controller_for_identity_locked(scope) is not controller or not controller.connected:
raise ControllerUnavailable(f"controller for scope {scope!r} detached before dispatch")
pending.scope = controller.scope
self._pending[command_id] = pending
try:
controller.send(frame)
except Exception:
@@ -583,14 +366,13 @@ class BrowserControlBroker:
raise
if not pending.event.wait(timeout=self._command_timeout):
timed_out = False
with self._lock:
# Event.wait() may return False at the exact boundary where a
# completion already won and removed the pending command.
if not pending.done and self._pending.get(command_id) is pending:
timed_out = not pending.done and self._pending.get(command_id) is pending
if timed_out:
pending.done = True
del self._pending[command_id]
timed_out = True
if timed_out:
with controller.send_lock:
with self._lock:
@@ -600,97 +382,48 @@ class BrowserControlBroker:
active = None
if active is not None:
self._emit_cancel_frames(active, [pending])
raise ControllerTimeout(
f"controller did not complete command {command_id!r} "
f"within {self._command_timeout}s"
)
raise ControllerTimeout(f"controller did not complete command {command_id!r} within {self._command_timeout}s")
if pending.cancelled:
raise ControllerCancelled(f"command {command_id!r} was cancelled")
if not pending.ok:
raise ControllerRejected(
f"controller rejected command {command_id!r}: {pending.result!r}"
)
raise ControllerRejected(f"controller rejected command {command_id!r}: {pending.result!r}")
return pending.result
def complete(
self,
command_id: str,
*,
scope: Optional[ControllerScope] = None,
ok: bool,
result: Any = None,
) -> bool:
"""Resolve a pending command by id; ``False`` when none is pending.
Safe from inside the controller's own ``send`` callback. Late
completions after ``cancel``/``detach`` are ignored (``False``).
"""
def complete(self, command_id: str, *, scope: Optional[ControllerScope] = None, ok: bool, result: Any = None) -> bool:
"""Resolve a pending command by id; ``False`` when none is pending. Safe from inside the send callback."""
with self._lock:
pending = self._pending.get(command_id)
if pending is None or pending.done:
if pending is None or pending.done or (scope is not None and pending.scope != scope):
return False
if scope is not None and pending.scope != scope:
return False
pending.done = True
pending.ok = ok is True
pending.result = result
pending.done, pending.ok, pending.result = True, ok is True, result
del self._pending[command_id]
pending.event.set()
return True
def cancel(self, scope: ControllerScope, *, tool_call_id: Optional[str]) -> bool:
"""Cancel exactly the pending command matching ``scope`` + tool_call_id.
Emits one cancel frame; returns ``False`` when nothing matched so
transports can answer idempotently.
"""
with self._lock:
controller = self._controller_for_identity_locked(scope)
if controller is None or not controller.connected:
"""Cancel the pending command matching ``scope`` + tool_call_id (one cancel frame); ``False`` if none."""
controller = self._live_controller(scope)
if controller is None:
return False
with controller.send_lock:
with self._lock:
attached = self._controller_for_identity_locked(scope)
if attached is not controller or not controller.connected:
if self._controller_for_identity_locked(scope) is not controller or not controller.connected:
return False
target = next(
(
p
for p in self._pending.values()
if _same_scope_identity(p.scope, scope)
and p.tool_call_id == tool_call_id
and not p.done
),
None,
)
target = next((p for p in self._pending_for_scope_locked(scope)
if p.tool_call_id == tool_call_id and not p.done), None)
if target is None:
return False
self._resolve_pending(target, cancelled=True)
self._emit_cancel_frames(controller, [target])
return True
# ------------------------------------------------------------------
# Internals (callers hold the lock unless noted)
# ------------------------------------------------------------------
def _resolve_pending(self, pending: _PendingCommand, *, cancelled: bool) -> None:
pending.cancelled = cancelled
pending.done = True
pending.cancelled, pending.done = cancelled, True
del self._pending[pending.command_id]
pending.event.set()
def _validate_artifact_reference(
self,
scope: ControllerScope,
action: str,
arguments: dict,
) -> None:
"""Fail closed unless ``arguments`` carries a store-approved artifact id.
Missing store/id, traversal, expiry, checksum, or scope mismatch all
surface as :class:`ControllerRejected` before any frame is emitted.
"""
def _validate_artifact_reference(self, scope: ControllerScope, action: str, arguments: dict) -> None:
"""Fail closed unless ``arguments`` carries a store-approved artifact id (failures -> ControllerRejected)."""
store = self._artifact_store_for_scope(scope)
if store is None:
raise ControllerRejected(f"{action} requires an attached artifact store")
@@ -702,22 +435,10 @@ class BrowserControlBroker:
except ControllerRejected:
raise
except Exception as exc:
raise ControllerRejected(
f"{action} rejected artifact reference {artifact_id!r}: {exc}"
) from exc
@staticmethod
def _cancel_frame(pending: _PendingCommand) -> dict:
return {
"method": FRAME_CANCEL,
"params": {
"command_id": pending.command_id,
"tool_call_id": pending.tool_call_id,
},
}
raise ControllerRejected(f"{action} rejected artifact reference {artifact_id!r}: {exc}") from exc
def _defer_cancel_locked(self, controller: _Controller, pending: _PendingCommand) -> None:
controller.deferred_cancels.append(self._cancel_frame(pending))
controller.deferred_cancels.append(_cancel_frame(pending))
if len(controller.deferred_cancels) > MAX_DEFERRED_CANCELS:
del controller.deferred_cancels[:-MAX_DEFERRED_CANCELS]
@@ -728,65 +449,30 @@ class BrowserControlBroker:
"""Send cancel frames (caller holds ``send_lock``, never the broker lock)."""
for pending in pendings:
try:
controller.send(self._cancel_frame(pending))
controller.send(_cancel_frame(pending))
except Exception:
logger.exception(
"failed to emit cancel frame for command %r", pending.command_id
)
logger.exception("failed to emit cancel frame for command %r", pending.command_id)
# ------------------------------------------------------------------
# Session-lane lookups / bulk teardown
# ------------------------------------------------------------------
@staticmethod
def _lane_key(session_id, task_id, principal_id, transport_family) -> Optional[tuple[str, str, str]]:
target = str(session_id or task_id or "").strip()
principal = str(principal_id or "").strip()
family = str(transport_family or "").strip()
if not target or not principal or not family:
return None
return target, principal, family
def scope_for_session(
self,
*,
session_id: Optional[str] = None,
task_id: Optional[str] = None,
principal_id: Optional[str] = None,
transport_family: Optional[str] = None,
) -> Optional[ControllerScope]:
"""Return one unambiguous attached scope for a server-owned session.
The public session id is only a hint; the caller must also supply its
server-derived principal and transport family. Missing identity, no
match, or multiple matches fail closed rather than picking by order.
"""
key = self._lane_key(session_id, task_id, principal_id, transport_family)
if key is None:
return None
def _lane_scopes(self, session_id, task_id, principal_id, transport_family) -> list[ControllerScope]:
"""Attached scopes bound to one session lane (session + principal + transport)."""
key = tuple(str(v or "").strip() for v in (session_id or task_id, principal_id, transport_family))
if not all(key):
return []
with self._lock:
matches = self._lane_scopes_locked(*key)
return [s for s in self._controllers if (s.session_id, s.principal_id, s.transport_family) == key]
def scope_for_session(self, *, session_id: Optional[str] = None, task_id: Optional[str] = None,
principal_id: Optional[str] = None, transport_family: Optional[str] = None) -> Optional[ControllerScope]:
"""One unambiguous attached scope for a server-owned session (session id is only a hint; the caller
supplies its server-derived principal + transport family). Missing/ambiguous identity fails closed."""
matches = self._lane_scopes(session_id, task_id, principal_id, transport_family)
return matches[0] if len(matches) == 1 else None
def lane_registered(
self,
*,
session_id: Optional[str] = None,
task_id: Optional[str] = None,
principal_id: Optional[str] = None,
transport_family: Optional[str] = None,
) -> bool:
"""Whether ANY controller (even offline) registered for this lane.
Distinguishes "lane bound but currently unavailable" (fail closed;
the extension lane stays authoritative) from "never registered"
(caller keeps the legacy backend). Ambiguous lanes report True.
"""
key = self._lane_key(session_id, task_id, principal_id, transport_family)
if key is None:
return False
with self._lock:
return bool(self._lane_scopes_locked(*key))
def lane_registered(self, *, session_id: Optional[str] = None, task_id: Optional[str] = None,
principal_id: Optional[str] = None, transport_family: Optional[str] = None) -> bool:
"""Whether ANY controller (even offline) registered for this lane: "bound but unavailable" fails closed
vs "never registered" (caller keeps the legacy backend). Ambiguous lanes report True."""
return bool(self._lane_scopes(session_id, task_id, principal_id, transport_family))
def disconnect_owner(self, owner: Any) -> int:
"""Mark every controller owned by one lost transport offline."""
+7 -12
View File
@@ -3,15 +3,14 @@
Runs as ``ExecStopPost=`` after the gateway's main process has exited: the
safety net for long-lived helpers the gateway doesn't track (``adb``, platform
bridges) that would otherwise be orphaned in the cgroup and block
``Restart=always``.
Per-PID SIGKILLs over ``cgroup.procs`` are used deliberately instead of writing
``1`` to ``cgroup.kill``: the kernel has returned ``EINVAL`` on the cgroup-wide
kill while per-PID signal delivery still works.
``Restart=always``. Per-PID SIGKILLs over ``cgroup.procs`` are used instead of
writing ``1`` to ``cgroup.kill``: the kernel has returned ``EINVAL`` on the
cgroup-wide kill while per-PID signal delivery still works.
"""
from __future__ import annotations
import contextlib
import os
import re
import signal
@@ -36,23 +35,19 @@ def _read_cgroup_pids(cgroup_path: str) -> list[int]:
return []
pids: list[int] = []
for line in raw.splitlines():
try:
with contextlib.suppress(ValueError):
pids.append(int(line.strip()))
except ValueError:
continue
return pids
def reap_cgroup(cgroup_path: str | None = None) -> int:
"""SIGKILL every PID in the cgroup other than the caller. Returns the count killed."""
if cgroup_path is None:
cgroup_path = _own_cgroup_path()
cgroup_path = _own_cgroup_path() if cgroup_path is None else cgroup_path
if not cgroup_path:
return 0
own = os.getpid()
killed = 0
for pid in _read_cgroup_pids(cgroup_path):
if pid == own:
if pid == os.getpid():
continue
try:
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — Linux-only (reads /proc, /sys/fs/cgroup; runs from a systemd unit)
+140 -170
View File
@@ -5,6 +5,7 @@ send_message reads it for action="list" and to resolve friendly channel names to
"""
import asyncio
import contextlib
import json
import logging
import time
@@ -75,12 +76,10 @@ def _apply_channel_aliases(platforms: Dict[str, Any]) -> None:
continue
chat_id = str(chat_id)
friendly = friendly.strip()
matched = False
for e in entries:
if isinstance(e, dict) and e.get("id") == chat_id:
e["name"] = friendly
matched = True
if not matched:
matches = [e for e in entries if isinstance(e, dict) and e.get("id") == chat_id]
for e in matches:
e["name"] = friendly
if not matches:
entries.append({
"id": chat_id, "name": friendly,
"type": "group" if chat_id.endswith("@g.us") else "dm", "thread_id": None,
@@ -92,7 +91,7 @@ def _normalize_channel_query(value: str) -> str:
def _channel_target_name(platform_name: str, channel: Dict[str, Any]) -> str:
"""Return the human-facing target label shown to users for a channel entry."""
"""Human-facing target label for a channel entry."""
name = channel["name"]
if platform_name == "discord" and channel.get("guild"):
return f"#{name}"
@@ -118,8 +117,11 @@ def _session_entry_name(origin: Dict[str, Any]) -> str:
return f"{base_name} / {topic_label}"
def _warn_slack_directory(team_id: str, detail: str) -> None:
"""Warn once per team/error per interval for recurring Slack refresh failures."""
def _report_slack_failure(team_id: str, error_code: Optional[str], detail: str) -> None:
"""missing_scope is expected (session-history fallback); anything else warns once per interval."""
if error_code == "missing_scope":
logger.debug("Channel directory: Slack team %s lacks channels:read; using session history only", team_id)
return
key = (str(team_id), str(detail))
now = time.monotonic()
last = _slack_directory_warning_last.get(key)
@@ -130,14 +132,6 @@ def _warn_slack_directory(team_id: str, detail: str) -> None:
logger.debug("Channel directory: suppressed repeated Slack channel list failure for team %s: %s", team_id, detail)
def _report_slack_failure(team_id: str, error_code: Optional[str], detail: str) -> None:
"""missing_scope is expected (session-history fallback); anything else warns."""
if error_code == "missing_scope":
logger.debug("Channel directory: Slack team %s lacks channels:read; using session history only", team_id)
else:
_warn_slack_directory(team_id, detail)
# --- Build / refresh -------------------------------------------------------
async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
@@ -178,12 +172,10 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
for plat in Platform:
await _discover(plat.value)
# Plugin platforms are dynamic enum members missing from Platform.__members__.
try:
with contextlib.suppress(Exception):
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
await _discover(entry.name)
except Exception:
pass
_apply_channel_aliases(platforms)
@@ -222,24 +214,18 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
def _slack_api_error_code(error: Exception) -> Optional[str]:
"""Return Slack Web API error code from SlackApiError-like exceptions."""
response = getattr(error, "response", None)
if response is None:
return None
try:
value = response.get("error")
except Exception:
return None
return str(value) if value else None
"""Slack Web API error code from SlackApiError-like exceptions."""
with contextlib.suppress(Exception):
value = error.response.get("error")
return str(value) if value else None
return None
def _normalize_adapter_channels(raw_channels: Any) -> List[Dict[str, Any]]:
"""Validate and dedupe entries returned by an adapter's ``list_channels()`` hook."""
channels: List[Dict[str, Any]] = []
seen_ids = set()
if not isinstance(raw_channels, list):
return channels
for raw in raw_channels:
for raw in raw_channels if isinstance(raw_channels, list) else ():
if not isinstance(raw, dict):
continue
channel_id = str(raw.get("id") or "").strip()
@@ -260,47 +246,86 @@ def _slack_base_id(entry_id: str) -> str:
return entry_id.split(":", 1)[0]
async def _build_slack(adapter) -> List[Dict[str, Any]]:
"""List Slack channels the bot has joined across all workspaces.
def _slack_has_raw_name(entry: Dict[str, Any]) -> bool:
return entry.get("name", "").startswith(_SLACK_RAW_ID_PREFIXES)
``users.conversations`` per workspace client (public + private member channels),
then merge DMs from session history. Missing channels:read falls back to
session history quietly rather than warning on every refresh.
"""
async def _slack_team_channels(team_id: str, client, seen_ids: set) -> List[Dict[str, Any]]:
"""``users.conversations`` for one workspace (public + private member channels), paginated."""
channels: List[Dict[str, Any]] = []
try:
cursor: Optional[str] = None
for _page in range(20): # safety cap on pagination
response = await client.users_conversations(
types="public_channel,private_channel",
exclude_archived=True,
limit=200,
cursor=cursor,
)
if not response.get("ok"):
error_code = response.get("error", "unknown")
_report_slack_failure(team_id, error_code, f"users.conversations not ok: {error_code}")
break
for ch in response.get("channels", []):
cid, name = ch.get("id"), ch.get("name")
if not cid or not name or cid in seen_ids:
continue
seen_ids.add(cid)
channels.append({"id": cid, "name": name, "type": "private" if ch.get("is_private") else "channel"})
cursor = (response.get("response_metadata") or {}).get("next_cursor")
if not cursor:
break
except Exception as e:
_report_slack_failure(team_id, _slack_api_error_code(e), str(e))
return channels
async def _slack_resolve_raw_names(client, channels: List[Dict[str, Any]]) -> None:
"""Name remaining raw-ID entries (DMs, channels outside bot scope) via
conversations.info + users.info once per base conversation, concurrently."""
unresolved_by_base: Dict[str, list] = {}
for entry in channels:
if _slack_has_raw_name(entry):
unresolved_by_base.setdefault(_slack_base_id(entry["id"]), []).append(entry)
if not unresolved_by_base:
return
async def _resolve_base(base_id: str, entries: list) -> None:
try:
resp = await client.conversations_info(channel=base_id)
if not resp.get("ok"):
return
ch_info = resp.get("channel", {})
resolved_name = resolved_type = None
if not ch_info.get("is_im"):
resolved_name = ch_info.get("name") or ch_info.get("name_normalized")
elif ch_info.get("user", ""):
user_resp = await client.users_info(user=ch_info["user"])
if user_resp.get("ok"):
u = user_resp["user"]
resolved_name = u.get("profile", {}).get("display_name") or u.get("real_name") or u.get("name")
resolved_type = "dm"
for entry in entries if resolved_name else ():
entry["name"] = resolved_name
if resolved_type:
entry["type"] = resolved_type
except Exception as e:
logger.debug("Channel directory: failed to resolve %s: %s", base_id, e)
await asyncio.gather(*[_resolve_base(bid, ents) for bid, ents in unresolved_by_base.items()])
async def _build_slack(adapter) -> List[Dict[str, Any]]:
"""List Slack channels the bot has joined across all workspaces, merged with
session-history DMs. Missing channels:read falls back to session history quietly."""
team_clients = getattr(adapter, "_team_clients", None) or {}
if not team_clients:
return await asyncio.to_thread(_build_from_sessions, "slack")
channels: List[Dict[str, Any]] = []
seen_ids: set = set()
for team_id, client in team_clients.items():
try:
cursor: Optional[str] = None
for _page in range(20): # safety cap on pagination
response = await client.users_conversations(
types="public_channel,private_channel",
exclude_archived=True,
limit=200,
cursor=cursor,
)
if not response.get("ok"):
error_code = response.get("error", "unknown")
_report_slack_failure(team_id, error_code, f"users.conversations not ok: {error_code}")
break
for ch in response.get("channels", []):
cid = ch.get("id")
name = ch.get("name")
if not cid or not name or cid in seen_ids:
continue
seen_ids.add(cid)
channels.append({"id": cid, "name": name, "type": "private" if ch.get("is_private") else "channel"})
cursor = (response.get("response_metadata") or {}).get("next_cursor")
if not cursor:
break
except Exception as e:
_report_slack_failure(team_id, _slack_api_error_code(e), str(e))
continue
channels.extend(await _slack_team_channels(team_id, client, seen_ids))
# Merge session-history DM/group entries, naming raw-ID entries from the
# API-discovered channels where the base conversation ID is known.
@@ -309,50 +334,12 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]:
eid = entry.get("id")
if not isinstance(eid, str) or eid in seen_ids:
continue
if entry.get("name", "").startswith(_SLACK_RAW_ID_PREFIXES):
base_id = _slack_base_id(eid)
if base_id in api_name_lookup:
entry["name"] = api_name_lookup[base_id]
if _slack_has_raw_name(entry) and _slack_base_id(eid) in api_name_lookup:
entry["name"] = api_name_lookup[_slack_base_id(eid)]
channels.append(entry)
seen_ids.add(eid)
# Resolve remaining raw-ID entries (DMs, channels outside bot scope) via
# conversations.info + users.info once per base conversation, concurrently.
unresolved = [ch for ch in channels if ch.get("name", "").startswith(_SLACK_RAW_ID_PREFIXES)]
if unresolved:
client = next(iter(team_clients.values()))
unresolved_by_base: Dict[str, list] = {}
for entry in unresolved:
unresolved_by_base.setdefault(_slack_base_id(entry["id"]), []).append(entry)
async def _resolve_base(base_id: str, entries: list) -> None:
try:
resp = await client.conversations_info(channel=base_id)
if not resp.get("ok"):
return
ch_info = resp.get("channel", {})
resolved_name = None
resolved_type = None
if ch_info.get("is_im"):
peer_user = ch_info.get("user", "")
if peer_user:
user_resp = await client.users_info(user=peer_user)
if user_resp.get("ok"):
u = user_resp["user"]
resolved_name = u.get("profile", {}).get("display_name") or u.get("real_name") or u.get("name")
resolved_type = "dm"
else:
resolved_name = ch_info.get("name") or ch_info.get("name_normalized")
if resolved_name:
for entry in entries:
entry["name"] = resolved_name
if resolved_type:
entry["type"] = resolved_type
except Exception as e:
logger.debug("Channel directory: failed to resolve %s: %s", base_id, e)
await asyncio.gather(*[_resolve_base(bid, ents) for bid, ents in unresolved_by_base.items()])
await _slack_resolve_raw_names(next(iter(team_clients.values())), channels)
return channels
@@ -365,51 +352,49 @@ def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
return _build_from_sessions_db(platform_name) or _build_from_sessions_json(platform_name)
def _extend_from_origins(entries: List[Dict[str, Any]], origins: Iterable[Tuple[Dict[str, Any], Any]]) -> None:
"""Append deduped directory entries for (origin, chat_type) pairs to *entries*.
def _entries_from_origins(platform_name: str, source: str, origins_fn) -> List[Dict[str, Any]]:
"""Deduped directory entries for the (origin, chat_type) pairs yielded by ``origins_fn()``.
Appends incrementally so a mid-iteration failure keeps the entries read so far.
"""
seen_ids = set()
for origin, chat_type in origins:
entry_id = _session_entry_id(origin)
if not entry_id or entry_id in seen_ids:
continue
seen_ids.add(entry_id)
entries.append({
"id": entry_id, "name": _session_entry_name(origin),
"type": chat_type, "thread_id": origin.get("thread_id"),
})
entries: List[Dict[str, Any]] = []
try:
seen_ids = set()
for origin, chat_type in origins_fn():
entry_id = _session_entry_id(origin)
if not entry_id or entry_id in seen_ids:
continue
seen_ids.add(entry_id)
entries.append({
"id": entry_id, "name": _session_entry_name(origin),
"type": chat_type, "thread_id": origin.get("thread_id"),
})
except Exception as e:
logger.debug("Channel directory: %s for %s: %s", source, platform_name, e)
return entries
def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]:
"""Pull channels/contacts from state.db gateway session rows."""
entries: List[Dict[str, Any]] = []
try:
def _origins() -> Iterable[Tuple[Dict[str, Any], Any]]:
from hermes_state import get_shared_session_db, release_or_close
db = get_shared_session_db()
try:
lister = getattr(db, "list_gateway_sessions", None)
if not callable(lister):
return []
return
rows = lister(platform=platform_name, active_only=False)
finally:
release_or_close(db)
for row in rows:
origin = None
with contextlib.suppress(TypeError, ValueError):
origin = json.loads(row["origin_json"]) if row.get("origin_json") else None
if not isinstance(origin, dict) or not origin:
origin = {"chat_id": row.get("chat_id"), "thread_id": row.get("thread_id"), "chat_name": row.get("display_name")}
yield origin, row.get("chat_type") or "dm"
def _origin(row) -> Dict[str, Any]:
if row.get("origin_json"):
try:
parsed = json.loads(row["origin_json"])
if isinstance(parsed, dict) and parsed:
return parsed
except (TypeError, ValueError):
pass
return {"chat_id": row.get("chat_id"), "thread_id": row.get("thread_id"), "chat_name": row.get("display_name")}
_extend_from_origins(entries, ((_origin(row), row.get("chat_type") or "dm") for row in rows))
except Exception as e:
logger.debug("Channel directory: state.db session read failed for %s: %s", platform_name, e)
return entries
return _entries_from_origins(platform_name, "state.db session read failed", _origins)
def _build_from_sessions_json(platform_name: str) -> List[Dict[str, str]]:
@@ -417,25 +402,19 @@ def _build_from_sessions_json(platform_name: str) -> List[Dict[str, str]]:
sessions_path = get_hermes_home() / "sessions" / "sessions.json"
if not sessions_path.exists():
return []
entries: List[Dict[str, Any]] = []
try:
def _origins() -> Iterable[Tuple[Dict[str, Any], Any]]:
with open(sessions_path, encoding="utf-8") as f:
data = json.load(f)
for _key, session in data.items():
# Keys starting with "_" (e.g. the gateway's "_README") are metadata sentinels.
if str(_key).startswith("_") or not isinstance(session, dict):
continue
origin = session.get("origin") or {}
if origin.get("platform") == platform_name:
yield origin, session.get("chat_type", "dm")
def _origins():
for _key, session in data.items():
# Keys starting with "_" (e.g. the gateway's "_README") are
# metadata sentinels, not sessions.
if str(_key).startswith("_") or not isinstance(session, dict):
continue
origin = session.get("origin") or {}
if origin.get("platform") == platform_name:
yield origin, session.get("chat_type", "dm")
_extend_from_origins(entries, _origins())
except Exception as e:
logger.debug("Channel directory: failed to read sessions for %s: %s", platform_name, e)
return entries
return _entries_from_origins(platform_name, "failed to read sessions", _origins)
# --- Read / resolve --------------------------------------------------------
@@ -444,21 +423,19 @@ def load_directory() -> Dict[str, Any]:
"""Load the cached directory from disk, with aliases re-applied on read."""
directory_path = _directory_path()
if directory_path.exists():
try:
with contextlib.suppress(Exception):
with open(directory_path, encoding="utf-8") as f:
data = json.load(f)
# Aliases apply on read too, so new names take effect between timed rebuilds.
_apply_channel_aliases(data.setdefault("platforms", {}))
return data
except Exception:
pass
base = {"updated_at": None, "platforms": {}}
_apply_channel_aliases(base["platforms"])
return base
def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]:
"""Return the channel ``type`` string (e.g. ``"channel"``, ``"forum"``) for *chat_id*, or *None* if unknown."""
"""Channel ``type`` string (e.g. ``"channel"``, ``"forum"``) for *chat_id*, or None if unknown."""
for ch in load_directory().get("platforms", {}).get(platform_name, []):
if ch.get("id") == chat_id:
return ch.get("type")
@@ -487,9 +464,7 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
# 1. Exact name match, including the display labels shown by send_message(action="list")
for ch in channels:
if _normalize_channel_query(ch["name"]) == query:
return ch["id"]
if _normalize_channel_query(_channel_target_name(platform_name, ch)) == query:
if query in (_normalize_channel_query(ch["name"]), _normalize_channel_query(_channel_target_name(platform_name, ch))):
return ch["id"]
# 2. Guild-qualified match for Discord ("GuildName/channel")
@@ -531,22 +506,17 @@ def format_directory_for_display(platforms: Optional[Dict[str, Any]] = None) ->
f"{plat_name}:<chat_id>, or bare '{plat_name}' for the home channel)"
)
elif plat_name == "discord":
# Group Discord channels by guild; DMs last.
# Group Discord channels by guild (sorted by name); DMs last, in discovery order.
guilds: Dict[str, List] = {}
dms: List = []
for ch in channels:
guild = ch.get("guild")
(guilds.setdefault(guild, []) if guild else dms).append(ch)
for guild_name, guild_channels in sorted(guilds.items()):
lines.append(f"Discord ({guild_name}):")
lines.extend(
f" discord:{_channel_target_name(plat_name, ch)}"
for ch in sorted(guild_channels, key=lambda c: c["name"])
)
(guilds.setdefault(ch["guild"], []) if ch.get("guild") else dms).append(ch)
groups = [(f"Discord ({g}):", sorted(chs, key=lambda c: c["name"])) for g, chs in sorted(guilds.items())]
if dms:
lines.append("Discord (DMs):")
lines.extend(f" discord:{_channel_target_name(plat_name, ch)}" for ch in dms)
groups.append(("Discord (DMs):", dms))
for header, group in groups:
lines.append(header)
lines.extend(f" discord:{_channel_target_name(plat_name, ch)}" for ch in group)
else:
lines.append(f"{plat_name.title()}:")
lines.extend(f" {plat_name}:{_channel_target_name(plat_name, ch)}" for ch in channels)
+7 -16
View File
@@ -1,15 +1,11 @@
"""Detect when the gateway is running stale code after a hot ``git pull``.
The gateway's ``sys.modules`` is frozen at boot. If the checkout is updated
underneath it (manual ``git pull``, or the window before ``hermes update``'s
graceful restart), a first-time lazy import can resolve a freshly-pulled module
against a stale cached dependency -> ImportError
(``tests/test_stale_utils_module_import.py``). We snapshot the revision at
underneath it, a first-time lazy import can resolve a freshly-pulled module
against a stale cached dependency -> ImportError. We snapshot the revision at
startup so risky callers (e.g. ``/model`` switching) can refuse with a clear
"restart the gateway" message instead.
If the revision can't be read (non-git install, IO error) the boot snapshot
stays ``None`` and detection no-ops — never a false positive.
"restart the gateway" message. If the revision can't be read (non-git install,
IO error) the boot snapshot stays ``None`` and detection no-ops — never a false positive.
"""
from __future__ import annotations
@@ -41,17 +37,12 @@ def record_boot_fingerprint() -> None:
def _short(fingerprint: str) -> str:
"""Render a ``git:<ref>:<sha>`` fingerprint as a compact label."""
sha = fingerprint.rsplit(":", 1)[-1]
if sha and sha != "unresolved" and len(sha) > 10:
return sha[:10]
return sha or fingerprint
return sha[:10] if sha and sha != "unresolved" and len(sha) > 10 else (sha or fingerprint)
def detect_code_skew() -> tuple[str, str] | None:
"""Return ``(boot_rev, disk_rev)`` short labels if the checkout drifted
since boot, else ``None``."""
if _boot_fingerprint is None:
return None
current = _fingerprint()
"""``(boot_rev, disk_rev)`` short labels if the checkout drifted since boot, else ``None``."""
current = _fingerprint() if _boot_fingerprint is not None else None
if current is None or current == _boot_fingerprint:
return None
return _short(_boot_fingerprint), _short(current)
+268 -695
View File
File diff suppressed because it is too large Load Diff
+174 -241
View File
@@ -2,9 +2,9 @@
Runs after ``GatewayConfig.from_dict`` so env always wins over config.yaml /
gateway.json. Most platforms follow one shape — "credentials present in env
⇒ enable the platform and copy the values into ``extra``" — and are declared
as ``_Cred`` rows in ``_ENV_STEPS`` (source order = application order).
Platforms with unique gating live as small functions in the same table.
⇒ enable the platform and copy the values into ``extra``" — declared as
``_Cred`` rows in ``_ENV_STEPS`` (source order = application order).
Platforms with unique gating are small functions in the same table.
"""
import contextlib
@@ -13,7 +13,7 @@ import logging
import os
from dataclasses import dataclass
from functools import partial
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, Optional
from gateway.config import (
GatewayConfig,
@@ -30,15 +30,13 @@ logger = logging.getLogger("gateway.config")
getenv = _getenv_str
# Platforms for which the "explicitly disabled in config.yaml, but credentials
# are present in the environment" WARNING has already been emitted in this
# process. Config reloads on every turn, so the notice is one-time per platform
# per process — loud once at startup, never a per-turn drumbeat.
# Platforms already warned about "explicitly disabled in config.yaml, but
# credentials present in env". Config reloads every turn, so the notice is
# one-time per platform per process.
_EXPLICIT_DISABLE_WARNED: set = set()
# Env var(s) whose presence drives each platform's env-enable branch, named in
# the explicit-disable WARNING below.
# the explicit-disable WARNING.
_ENV_ENABLE_CREDENTIALS: dict = {
Platform.TELEGRAM: ("TELEGRAM_BOT_TOKEN",),
Platform.DISCORD: ("DISCORD_BOT_TOKEN",),
@@ -63,13 +61,8 @@ _ENV_ENABLE_CREDENTIALS: dict = {
def _warn_explicit_disable_beats_env(platform: Platform) -> None:
"""One-time WARNING: ``platforms.<x>.enabled: false`` wins over env creds.
Until #48820 credential presence force-enabled twelve platforms regardless
of an explicit ``enabled: false``; users relying on "creds in .env =
platform on" need to be told why it went dark. Names the platform, the
winning config key, and the env var(s) that used to override it.
"""
"""One-time WARNING: ``platforms.<x>.enabled: false`` wins over env creds (which
used to force-enable), so users relying on "creds in .env = on" learn why it went dark."""
if platform in _EXPLICIT_DISABLE_WARNED:
return
_EXPLICIT_DISABLE_WARNED.add(platform)
@@ -88,12 +81,12 @@ def _warn_explicit_disable_beats_env(platform: Platform) -> None:
# --- small value parsers -----------------------------------------------------
def _csv_list(value: str) -> List[str]:
def _csv_list(value: str) -> list:
return [part.strip() for part in value.split(",") if part.strip()]
def _int_or(default: int) -> Callable[[str], int]:
"""``int(raw.strip(), 10)`` with *default* on malformed/blank input (``_getenv_int`` semantics)."""
"""``int(raw.strip(), 10)`` with *default* on malformed/blank input."""
def parse(raw: str) -> int:
try:
return int(str(raw).strip(), 10)
@@ -106,6 +99,10 @@ def _strip_slash(value: str) -> str:
return value.rstrip("/")
def _strip_lower(value: str) -> str:
return value.strip().lower()
def _truthy_token(value: str) -> bool:
return value.lower() in {"true", "1", "yes", "on"}
@@ -121,11 +118,7 @@ def _env_first(envs) -> str:
"""First truthy value among *envs* (a single name or a tuple of alternatives)."""
if isinstance(envs, str):
return getenv(envs)
for env in envs:
value = getenv(env)
if value:
return value
return ""
return next((v for v in map(getenv, envs) if v), "")
# --- reusable steps -----------------------------------------------------------
@@ -136,8 +129,8 @@ _INT = object() # spec marker: ``int(value)``, silently skipped when malformed
def _env_extras(extra: Dict[str, Any], spec, *, strip: bool = False) -> None:
"""``extra[key] = fn(value)`` for each ``(key, env[, fn])`` whose env value is truthy.
With ``strip=True`` the value is stripped BEFORE the truthiness check. ``fn=_INT`` parses an
int and silently ignores a non-integer value.
``strip=True`` strips BEFORE the truthiness check. ``fn=_INT`` parses an int
and silently ignores a non-integer value.
"""
for key, env, *fn in spec:
value = getenv(env)
@@ -159,10 +152,8 @@ def _env_home_channel(config: GatewayConfig, platform: Platform, env_base: str,
chat_id = chat_id.strip()
if chat_id and platform in config.platforms:
config.platforms[platform].home_channel = HomeChannel(
platform=platform,
chat_id=chat_id,
name=getenv(f"{env_base}_NAME", "Home"),
thread_id=getenv(f"{env_base}_THREAD_ID") or None,
platform=platform, chat_id=chat_id,
name=getenv(f"{env_base}_NAME", "Home"), thread_id=getenv(f"{env_base}_THREAD_ID") or None,
)
@@ -173,18 +164,14 @@ def _env_reply_mode(config: GatewayConfig, platform: Platform, env: str) -> None
def _enable_from_env(config: GatewayConfig, platform: Platform) -> PlatformConfig:
"""Enable *platform* because its env credentials are present — unless config.yaml explicitly disabled it.
READS (does not pop) the ``_enabled_explicit`` marker: the registry-driven plugin-enable pass later
also needs it. The marker is scrubbed for all platforms at the end of ``_apply_env_overrides``.
"""
"""Enable *platform* on env credentials unless config.yaml explicitly disabled it.
READS (does not pop) ``_enabled_explicit``: the plugin-enable pass also needs it."""
if platform not in config.platforms:
config.platforms[platform] = PlatformConfig(enabled=True)
return config.platforms[platform]
platform_config = config.platforms[platform]
if not platform_config.enabled:
if platform_config.extra.get("_enabled_explicit", False):
# Credentials are present (that is why we are here) but the user said no in config.yaml.
_warn_explicit_disable_beats_env(platform)
else:
platform_config.enabled = True
@@ -192,12 +179,11 @@ def _enable_from_env(config: GatewayConfig, platform: Platform) -> PlatformConfi
def _enable_port_bound_from_env(config: GatewayConfig, platform: Platform) -> PlatformConfig:
"""Enable a port-binding platform (api_server/webhook) unless config.yaml explicitly disabled it.
"""Enable a port-binding platform unless config.yaml explicitly disabled it.
In multiplex mode a secondary profile pins ``platforms.<x>.enabled: false`` so it shares the
default profile's listener instead of binding its own port, yet still inherits the process env;
without this guard the env presence would force-enable the listener and trip MultiplexConfigError.
POPs the ``_enabled_explicit`` marker: these branches are terminal (no later registry pass).
A multiplex secondary profile pins ``enabled: false`` to share the default profile's
listener yet inherits the process env; without this guard env presence would
force-enable it and trip MultiplexConfigError. POPs the marker (terminal branch).
"""
platform_config = config.platforms.setdefault(platform, PlatformConfig())
explicit = platform_config.extra.pop("_enabled_explicit", False)
@@ -210,12 +196,12 @@ def _enable_port_bound_from_env(config: GatewayConfig, platform: Platform) -> Pl
class _Cred:
"""Credential-gated platform enable, applied as ``step(config)``.
``creds``: env names that must ALL be truthy; an inner tuple lists alternatives (ANY).
``token``: env whose truthy value becomes ``PlatformConfig.token``.
``fixed``: ``(extra_key, env[, default[, fn]])`` always written once enabled (``env`` may be a
tuple of alternatives). ``optional`` / ``optional_stripped``: ``_env_extras`` specs, written only
when truthy. ``warn_missing``: ``(env, msg)`` logged BEFORE enabling when *env* is blank. ``then``: unique tail ``fn(config, platform_config)``.
``home``: ``_env_home_channel`` env base applied only when the gate passed.
``creds``: env names that must ALL be truthy (inner tuple = ANY of). ``token``: env
stored as ``PlatformConfig.token`` even when yaml disables the adapter (sending skills
use it). ``fixed``: ``(extra_key, env[, default[, fn]])`` always written once enabled.
``optional*``: ``_env_extras`` specs. ``warn_missing``: ``(env, msg)`` logged BEFORE
enabling when blank. ``then``: tail ``fn(config, platform_config)``. ``home``:
``_env_home_channel`` env base applied only when the gate passed.
"""
platform: Platform
creds: tuple
@@ -268,14 +254,12 @@ def _telegram_fallback_ips(config: GatewayConfig) -> None:
def _whatsapp(config: GatewayConfig) -> None:
"""WhatsApp (Baileys bridge) uses a flag, not credentials."""
"""WhatsApp (Baileys bridge) uses a flag, not credentials; an explicit false overrides YAML."""
raw = getenv("WHATSAPP_ENABLED")
enabled = is_truthy_value(raw)
disabled_explicitly = raw.lower() in {"false", "0", "no"}
if Platform.WHATSAPP in config.platforms:
# YAML config exists — respect explicit disable; otherwise keep whatever the YAML set.
wa_cfg = config.platforms[Platform.WHATSAPP]
if disabled_explicitly:
wa_cfg = config.platforms.get(Platform.WHATSAPP)
if wa_cfg is not None:
if raw.lower() in {"false", "0", "no"}:
wa_cfg.enabled = False
elif enabled:
wa_cfg.enabled = True
@@ -283,34 +267,21 @@ def _whatsapp(config: GatewayConfig) -> None:
config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True)
def _slack(config: GatewayConfig) -> None:
slack_token = getenv("SLACK_BOT_TOKEN")
if slack_token:
if Platform.SLACK not in config.platforms:
config.platforms[Platform.SLACK] = PlatformConfig(enabled=True)
else:
slack_config = config.platforms[Platform.SLACK]
if not slack_config.enabled and not slack_config.extra.get("_enabled_explicit", False):
# Top-level Slack settings such as channel prompts must not turn an env-token setup
# into a disabled platform; only an explicit enabled: false should.
slack_config.enabled = True
elif not slack_config.enabled:
_warn_explicit_disable_beats_env(Platform.SLACK)
# Token is stored even when yaml disables the adapter so Slack-sending skills can use it.
config.platforms[Platform.SLACK].token = slack_token
def _slack_home(config: GatewayConfig) -> None:
"""SLACK_HOME_CHANNEL creates a disabled Slack entry if needed and keeps
user_id/scope_id provenance when the chat_id is unchanged."""
slack_home = getenv("SLACK_HOME_CHANNEL")
if slack_home:
slack_config = config.platforms.setdefault(Platform.SLACK, PlatformConfig(enabled=False))
existing_home = slack_config.home_channel
same_home = existing_home is not None and existing_home.chat_id == slack_home
slack_config.home_channel = HomeChannel(
platform=Platform.SLACK,
chat_id=slack_home,
name=getenv("SLACK_HOME_CHANNEL_NAME"),
thread_id=getenv("SLACK_HOME_CHANNEL_THREAD_ID") or None,
user_id=existing_home.user_id if same_home else None,
scope_id=existing_home.scope_id if same_home else None,
)
if not slack_home:
return
slack_config = config.platforms.setdefault(Platform.SLACK, PlatformConfig(enabled=False))
existing_home = slack_config.home_channel
same_home = existing_home is not None and existing_home.chat_id == slack_home
slack_config.home_channel = HomeChannel(
platform=Platform.SLACK, chat_id=slack_home,
name=getenv("SLACK_HOME_CHANNEL_NAME"), thread_id=getenv("SLACK_HOME_CHANNEL_THREAD_ID") or None,
user_id=existing_home.user_id if same_home else None,
scope_id=existing_home.scope_id if same_home else None,
)
def _matrix_e2ee(config: GatewayConfig, matrix_config: PlatformConfig) -> None:
@@ -324,15 +295,12 @@ def _matrix_e2ee(config: GatewayConfig, matrix_config: PlatformConfig) -> None:
_env_extras(matrix_config.extra, (("device_id", "MATRIX_DEVICE_ID"),))
def _sms(config: GatewayConfig) -> None:
if getenv("TWILIO_ACCOUNT_SID"):
_enable_from_env(config, Platform.SMS).api_key = getenv("TWILIO_AUTH_TOKEN")
def _sms_api_key(config: GatewayConfig, sms_config: PlatformConfig) -> None:
sms_config.api_key = getenv("TWILIO_AUTH_TOKEN")
def _api_server(config: GatewayConfig) -> None:
"""Require a usable key: API_SERVER_ENABLED alone would load an unauthenticated platform whose
adapter refuses to start at connect() anyway, leaving the reconnect watcher spinning forever.
Same strength bar as the startup guard (has_usable_secret, min_length=16)."""
"""Require a usable key: an unauthenticated adapter refuses to start and the reconnect watcher would spin."""
key = getenv("API_SERVER_KEY")
if not _has_usable_api_server_key(key):
return
@@ -355,18 +323,11 @@ def _msgraph_webhook(config: GatewayConfig) -> None:
client_state = getenv("MSGRAPH_WEBHOOK_CLIENT_STATE")
resources = getenv("MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES")
allowed_cidrs = getenv("MSGRAPH_WEBHOOK_ALLOWED_SOURCE_CIDRS")
if not (
enabled
or Platform.MSGRAPH_WEBHOOK in config.platforms
or getenv("MSGRAPH_WEBHOOK_PORT")
or client_state
or resources
or allowed_cidrs
):
if not (enabled or Platform.MSGRAPH_WEBHOOK in config.platforms or getenv("MSGRAPH_WEBHOOK_PORT")
or client_state or resources or allowed_cidrs):
return
msgraph_cfg = config.platforms.setdefault(Platform.MSGRAPH_WEBHOOK, PlatformConfig())
# Same explicit-disable guard as the webhook branch, but READ (don't pop) the marker:
# the relay-exclusive pass below still consults it; the end-of-function scrub removes it.
# Same guard as webhook, but READ (don't pop) the marker: the relay pass still consults it.
if enabled and (not msgraph_cfg.extra.get("_enabled_explicit", False) or msgraph_cfg.enabled):
msgraph_cfg.enabled = True
_env_extras(msgraph_cfg.extra, (("port", "MSGRAPH_WEBHOOK_PORT", _INT),))
@@ -383,9 +344,8 @@ def _qq_home(config: GatewayConfig, qq_config: PlatformConfig) -> None:
name_env = "QQBOT_HOME_CHANNEL_NAME"
if not qq_home:
# Back-compat: accept the pre-rename name and log a one-time warning.
legacy_home = getenv("QQ_HOME_CHANNEL").strip()
if legacy_home:
qq_home = legacy_home
qq_home = getenv("QQ_HOME_CHANNEL").strip()
if qq_home:
name_env = "QQ_HOME_CHANNEL_NAME"
logger.warning(
"QQ_HOME_CHANNEL is deprecated; rename to QQBOT_HOME_CHANNEL "
@@ -393,8 +353,7 @@ def _qq_home(config: GatewayConfig, qq_config: PlatformConfig) -> None:
)
if qq_home:
qq_config.home_channel = HomeChannel(
platform=Platform.QQBOT,
chat_id=qq_home,
platform=Platform.QQBOT, chat_id=qq_home,
name=getenv("QQBOT_HOME_CHANNEL_NAME") or getenv(name_env, "Home"),
thread_id=getenv("QQBOT_HOME_CHANNEL_THREAD_ID") or getenv("QQ_HOME_CHANNEL_THREAD_ID") or None,
)
@@ -408,114 +367,106 @@ def _session_settings(config: GatewayConfig) -> None:
setattr(config.default_reset_policy, attr, int(raw))
def _enable_plugin_platforms_from_env(config: GatewayConfig) -> None:
"""Registry-driven enable for plugin platforms (built-ins have explicit rows in ``_ENV_STEPS``).
def _plugin_probe_seed(entry) -> Optional[dict]:
"""``env_enablement_fn()`` result as a non-empty dict, else None."""
if entry.env_enablement_fn is None:
return None
try:
seed = entry.env_enablement_fn()
except Exception as e:
logger.debug("env_enablement_fn for %s raised: %s", entry.name, e)
return None
return seed if isinstance(seed, dict) and seed else None
A plugin platform is enabled when its credentials are configured (``is_connected``) and its deps
are present (passive ``check_fn``) or installable on demand (``ensure_deps_fn`` — run later by
``create_adapter()``, never here: the active installer used to be wired as ``check_fn`` and this
sweep pip-installed SDKs on every ``load_gateway_config()`` call, boot-looping the desktop app).
``is_connected`` MUST gate enablement: ``check_fn`` alone ("is the SDK importable?") would enable
platforms the user never configured and the gateway would retry-connect forever with no token.
def _plugin_is_configured(entry, existing_extra: dict, seed: Optional[dict]) -> bool:
"""``entry.is_connected`` on a transient ``enabled=True`` view seeded with env extras."""
try:
# Seed extras so ``is_connected`` implementations reading ``config.extra`` see
# post-enable state; never mutate the real config for the probe.
for k, v in (seed or {}).items():
if k != "home_channel":
existing_extra.setdefault(k, v)
configured = bool(entry.is_connected(PlatformConfig(enabled=True, extra=existing_extra)))
except Exception as exc:
logger.debug("is_connected for %s raised: %s — skipping enablement", entry.name, exc)
return False
if not configured:
logger.debug(
"Plugin platform '%s' available but not configured "
"(is_connected returned False) — skipping enable",
entry.name,
)
return configured
def _enable_plugin_platform(config: GatewayConfig, entry) -> None:
try:
platform = Platform(entry.name)
except Exception as e:
logger.debug("unknown platform name %r: %s", entry.name, e)
return
existing_cfg = config.platforms.get(platform)
already_enabled = existing_cfg is not None and existing_cfg.enabled
existing_extra = dict(existing_cfg.extra or {}) if existing_cfg is not None else {}
# Never re-enable a platform the user explicitly disabled (marker set by the YAML loader).
if existing_cfg is not None and not already_enabled and existing_extra.get("_enabled_explicit", False):
return
seed = _plugin_probe_seed(entry)
# Only consult is_connected for platforms not already enabled by YAML/env.
if not already_enabled and entry.is_connected is not None and not _plugin_is_configured(entry, existing_extra, seed):
return
# Verify dependencies LAST — only for platforms already enabled or past the credential gate.
try:
deps_ok = bool(entry.check_fn())
except Exception as e:
logger.debug("check_fn for %s raised: %s", entry.name, e)
deps_ok = False
if not deps_ok and entry.ensure_deps_fn is None:
return
platform_config = config.platforms.setdefault(platform, PlatformConfig())
platform_config.enabled = True
if seed:
# Commit the env-seeded extras (reuse the probe result; don't call env_enablement_fn twice).
seed = dict(seed)
home = seed.pop("home_channel", None)
platform_config.extra.update(seed)
if isinstance(home, dict) and home.get("chat_id"):
platform_config.home_channel = HomeChannel(
platform=platform, chat_id=str(home["chat_id"]), name=str(home.get("name") or "Home"),
thread_id=str(home["thread_id"]) if home.get("thread_id") else None,
)
def _enable_plugin_platforms_from_env(config: GatewayConfig) -> None:
"""Registry-driven enable for plugin platforms (built-ins have rows in ``_ENV_STEPS``).
Enabled when credentials are configured (``is_connected`` MUST gate: ``check_fn``
alone would enable unconfigured platforms that retry-connect forever) and deps are
present (``check_fn``) or installable later by ``create_adapter()`` — never here:
installing in this sweep pip-installed SDKs on every load and boot-looped the app.
"""
try:
from hermes_cli.plugins import discover_plugins
discover_plugins() # idempotent
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
try:
platform = Platform(entry.name)
except Exception as e:
logger.debug("unknown platform name %r: %s", entry.name, e)
continue
existing_cfg = config.platforms.get(platform)
# Never re-enable a platform the user explicitly disabled (marker set by the YAML loader).
if (
existing_cfg is not None
and not existing_cfg.enabled
and bool((existing_cfg.extra or {}).get("_enabled_explicit", False))
):
continue
# Seed candidate extras so plugins whose ``is_connected`` reads ``config.extra`` (Google Chat)
# see the same state they will after enablement.
seed_for_probe = None
if entry.env_enablement_fn is not None:
try:
seed_for_probe = entry.env_enablement_fn()
except Exception as e:
logger.debug("env_enablement_fn for %s raised: %s", entry.name, e)
seed_for_probe = None
has_seed = isinstance(seed_for_probe, dict) and bool(seed_for_probe)
# Only consult is_connected for platforms not already enabled by YAML/env (keep that decision).
if (existing_cfg is None or not existing_cfg.enabled) and entry.is_connected is not None:
try:
# Probe with ``enabled=True``: we ask "would this be configured if enabled?" —
# some ``is_connected`` short-circuit on ``config.enabled`` being False.
probe_cfg = existing_cfg
if probe_cfg is None or not probe_cfg.enabled:
probe_cfg = PlatformConfig(
enabled=True,
extra=dict(existing_cfg.extra or {}) if existing_cfg is not None else {},
)
if has_seed:
# Transient view; never mutate ``existing_cfg`` for the probe.
probe_extra = dict(probe_cfg.extra or {})
for k, v in seed_for_probe.items():
if k != "home_channel":
probe_extra.setdefault(k, v)
probe_cfg = PlatformConfig(enabled=True, extra=probe_extra)
configured = bool(entry.is_connected(probe_cfg))
except Exception as exc:
logger.debug("is_connected for %s raised: %s — skipping enablement", entry.name, exc)
configured = False
if not configured:
logger.debug(
"Plugin platform '%s' available but not configured "
"(is_connected returned False) — skipping enable",
entry.name,
)
continue
# Verify dependencies LAST — only for platforms already enabled or past the credential gate.
try:
deps_ok = bool(entry.check_fn())
except Exception as e:
logger.debug("check_fn for %s raised: %s", entry.name, e)
deps_ok = False
if not deps_ok and entry.ensure_deps_fn is None:
continue
platform_config = config.platforms.setdefault(platform, PlatformConfig())
platform_config.enabled = True
# Commit the env-seeded extras (reuse the probe result; don't call env_enablement_fn twice).
if has_seed:
seed = dict(seed_for_probe)
home = seed.pop("home_channel", None)
platform_config.extra.update(seed)
if isinstance(home, dict) and home.get("chat_id"):
platform_config.home_channel = HomeChannel(
platform=platform,
chat_id=str(home["chat_id"]),
name=str(home.get("name") or "Home"),
thread_id=str(home["thread_id"]) if home.get("thread_id") else None,
)
_enable_plugin_platform(config, entry)
except Exception as e:
logger.debug("Plugin platform enable pass failed: %s", e)
def _relay(config: GatewayConfig) -> None:
"""Relay (generic connector-fronted platform, EXPERIMENTAL). Enabled by GATEWAY_RELAY_URL (env)
or gateway.relay_url (config.yaml). The adapter dials OUT to the connector (no inbound port),
so it only needs Platform.RELAY present+enabled for start_gateway()'s connect loop. The
connected-checker keys on extra["relay_url"], so the URL is mirrored into extra here.
"""Relay (connector-fronted, EXPERIMENTAL): enabled by GATEWAY_RELAY_URL or
gateway.relay_url; the URL is mirrored into extra["relay_url"] for the connected-checker.
Relay-exclusive: a GATEWAY_RELAY_URL env stamp marks a connector-fronted deployment where the
connector owns every platform connection; a directly-connected adapter in the same process
would be a second unmanaged ingress (duplicate deliveries, split sessions, a live socket that
disarms scale-to-zero). So the env stamp disables all other messaging platforms — even ones
explicitly enabled in config.yaml. Non-messaging surfaces (local, api_server, webhook — same
exclusion set as the scale-to-zero arm gate) are untouched; relay via gateway.relay_url only
keeps the old additive behavior. Opt-out GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS=true is likewise
a deploy-stamp env var read through the profile-scope-aware getenv.
Relay-exclusive: the GATEWAY_RELAY_URL env stamp means the connector owns every
platform connection; a directly-connected adapter would be a second unmanaged ingress
(duplicate deliveries, split sessions, a socket disarming scale-to-zero). So the env
stamp disables all other messaging platforms — even ones explicitly enabled — except
non-messaging surfaces (local, api_server, webhook; same set as the scale-to-zero arm
gate). relay_url from YAML only keeps the additive behavior. Opt out with
GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS=true.
"""
relay_url_env = getenv("GATEWAY_RELAY_URL").strip()
existing_relay = config.platforms.get(Platform.RELAY)
@@ -554,10 +505,9 @@ def _scrub_explicit_markers(config: GatewayConfig) -> None:
platform_config.extra.pop("_enabled_explicit", None)
# Application order is significant: e.g. Telegram's reply mode may create the (disabled) platform
# entry BEFORE its home channel is read, while Discord reads home first; a home channel is only
# attached to a platform that already exists. Relay-exclusive disabling runs after the plugin
# pass and before the marker scrub, which must be last.
# Order is significant: a home channel only attaches to a platform that already exists
# (Telegram's reply mode may create the entry first; Discord reads home first). Relay
# disabling runs after the plugin pass; the marker scrub must be last.
_ENV_STEPS: tuple = (
_Cred(Platform.TELEGRAM, ("TELEGRAM_BOT_TOKEN",), token="TELEGRAM_BOT_TOKEN"),
_ReplyMode(Platform.TELEGRAM, "TELEGRAM_REPLY_TO_MODE"),
@@ -568,29 +518,25 @@ _ENV_STEPS: tuple = (
_ReplyMode(Platform.DISCORD, "DISCORD_REPLY_TO_MODE"),
_whatsapp,
_Home(Platform.WHATSAPP, "WHATSAPP_HOME_CHANNEL"),
# WhatsApp Cloud API (official Business Platform via Meta). Distinct from the Baileys bridge;
# WhatsApp Cloud API (Meta Business Platform). Distinct from the Baileys bridge;
# both adapters can run in parallel against different phone numbers.
_Cred(
Platform.WHATSAPP_CLOUD, ("WHATSAPP_CLOUD_PHONE_NUMBER_ID", "WHATSAPP_CLOUD_ACCESS_TOKEN"),
fixed=(("phone_number_id", "WHATSAPP_CLOUD_PHONE_NUMBER_ID"), ("access_token", "WHATSAPP_CLOUD_ACCESS_TOKEN")),
optional=(
("app_id", "WHATSAPP_CLOUD_APP_ID"),
("app_secret", "WHATSAPP_CLOUD_APP_SECRET"),
("waba_id", "WHATSAPP_CLOUD_WABA_ID"),
("verify_token", "WHATSAPP_CLOUD_VERIFY_TOKEN"), # Meta hub.verify_token shared secret
("webhook_host", "WHATSAPP_CLOUD_WEBHOOK_HOST"),
("webhook_port", "WHATSAPP_CLOUD_WEBHOOK_PORT", _INT),
("webhook_path", "WHATSAPP_CLOUD_WEBHOOK_PATH"),
("api_version", "WHATSAPP_CLOUD_API_VERSION"),
("app_id", "WHATSAPP_CLOUD_APP_ID"), ("app_secret", "WHATSAPP_CLOUD_APP_SECRET"),
("waba_id", "WHATSAPP_CLOUD_WABA_ID"), ("verify_token", "WHATSAPP_CLOUD_VERIFY_TOKEN"),
("webhook_host", "WHATSAPP_CLOUD_WEBHOOK_HOST"), ("webhook_port", "WHATSAPP_CLOUD_WEBHOOK_PORT", _INT),
("webhook_path", "WHATSAPP_CLOUD_WEBHOOK_PATH"), ("api_version", "WHATSAPP_CLOUD_API_VERSION"),
),
),
_Home(Platform.WHATSAPP_CLOUD, "WHATSAPP_CLOUD_HOME_CHANNEL"),
_slack,
_Cred(Platform.SLACK, ("SLACK_BOT_TOKEN",), token="SLACK_BOT_TOKEN"),
_slack_home,
_Cred(
Platform.SIGNAL, ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT"),
fixed=(
("http_url", "SIGNAL_HTTP_URL"),
("account", "SIGNAL_ACCOUNT"),
("http_url", "SIGNAL_HTTP_URL"), ("account", "SIGNAL_ACCOUNT"),
("ignore_stories", "SIGNAL_IGNORE_STORIES", "true", is_truthy_value),
),
),
@@ -615,7 +561,7 @@ _ENV_STEPS: tuple = (
fixed=(("address", "EMAIL_ADDRESS"), ("imap_host", "EMAIL_IMAP_HOST"), ("smtp_host", "EMAIL_SMTP_HOST")),
),
_Home(Platform.EMAIL, "EMAIL_HOME_ADDRESS"),
_sms,
_Cred(Platform.SMS, ("TWILIO_ACCOUNT_SID",), then=_sms_api_key),
_Home(Platform.SMS, "SMS_HOME_CHANNEL"),
_api_server,
_webhook,
@@ -628,10 +574,8 @@ _ENV_STEPS: tuple = (
_Cred(
Platform.FEISHU, ("FEISHU_APP_ID", "FEISHU_APP_SECRET"),
fixed=(
("app_id", "FEISHU_APP_ID"),
("app_secret", "FEISHU_APP_SECRET"),
("domain", "FEISHU_DOMAIN", "feishu"),
("connection_mode", "FEISHU_CONNECTION_MODE", "websocket"),
("app_id", "FEISHU_APP_ID"), ("app_secret", "FEISHU_APP_SECRET"),
("domain", "FEISHU_DOMAIN", "feishu"), ("connection_mode", "FEISHU_CONNECTION_MODE", "websocket"),
),
optional=(("encrypt_key", "FEISHU_ENCRYPT_KEY"), ("verification_token", "FEISHU_VERIFICATION_TOKEN")),
home="FEISHU_HOME_CHANNEL",
@@ -645,15 +589,12 @@ _ENV_STEPS: tuple = (
_Cred(
Platform.WECOM_CALLBACK, ("WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"),
fixed=(
("corp_id", "WECOM_CALLBACK_CORP_ID"),
("corp_secret", "WECOM_CALLBACK_CORP_SECRET"),
("agent_id", "WECOM_CALLBACK_AGENT_ID"),
("token", "WECOM_CALLBACK_TOKEN"),
("corp_id", "WECOM_CALLBACK_CORP_ID"), ("corp_secret", "WECOM_CALLBACK_CORP_SECRET"),
("agent_id", "WECOM_CALLBACK_AGENT_ID"), ("token", "WECOM_CALLBACK_TOKEN"),
("encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY"),
# No default: an unset WECOM_CALLBACK_HOST leaves extra.host falsy so the adapter's
# dual-stack DEFAULT_HOST=None applies (binds IPv4 + IPv6; "0.0.0.0" was IPv4-only).
("host", "WECOM_CALLBACK_HOST"),
("port", "WECOM_CALLBACK_PORT", "", _int_or(8645)),
# No host default: a falsy extra.host lets the adapter's dual-stack DEFAULT_HOST=None
# apply (binds IPv4 + IPv6; "0.0.0.0" was IPv4-only).
("host", "WECOM_CALLBACK_HOST"), ("port", "WECOM_CALLBACK_PORT", "", _int_or(8645)),
),
),
# Weixin (personal WeChat via iLink Bot API)
@@ -661,12 +602,9 @@ _ENV_STEPS: tuple = (
Platform.WEIXIN, (("WEIXIN_TOKEN", "WEIXIN_ACCOUNT_ID"),), token="WEIXIN_TOKEN",
optional=(("account_id", "WEIXIN_ACCOUNT_ID"),),
optional_stripped=(
("base_url", "WEIXIN_BASE_URL", _strip_slash),
("cdn_base_url", "WEIXIN_CDN_BASE_URL", _strip_slash),
("dm_policy", "WEIXIN_DM_POLICY", str.lower),
("group_policy", "WEIXIN_GROUP_POLICY", str.lower),
("allow_from", "WEIXIN_ALLOWED_USERS"),
("group_allow_from", "WEIXIN_GROUP_ALLOWED_USERS"),
("base_url", "WEIXIN_BASE_URL", _strip_slash), ("cdn_base_url", "WEIXIN_CDN_BASE_URL", _strip_slash),
("dm_policy", "WEIXIN_DM_POLICY", str.lower), ("group_policy", "WEIXIN_GROUP_POLICY", str.lower),
("allow_from", "WEIXIN_ALLOWED_USERS"), ("group_allow_from", "WEIXIN_GROUP_ALLOWED_USERS"),
("split_multiline_messages", "WEIXIN_SPLIT_MULTILINE_MESSAGES"),
),
home="WEIXIN_HOME_CHANNEL", home_strip=True,
@@ -675,8 +613,7 @@ _ENV_STEPS: tuple = (
_Cred(
Platform.BLUEBUBBLES, ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"),
fixed=(
("server_url", "BLUEBUBBLES_SERVER_URL", "", _strip_slash),
("password", "BLUEBUBBLES_PASSWORD"),
("server_url", "BLUEBUBBLES_SERVER_URL", "", _strip_slash), ("password", "BLUEBUBBLES_PASSWORD"),
("webhook_host", "BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"),
("webhook_port", "BLUEBUBBLES_WEBHOOK_PORT", "", _int_or(8645)),
("webhook_path", "BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"),
@@ -698,14 +635,10 @@ _ENV_STEPS: tuple = (
Platform.YUANBAO, (("YUANBAO_APP_ID", "YUANBAO_APP_KEY"), "YUANBAO_APP_SECRET"),
fixed=(("app_id", ("YUANBAO_APP_ID", "YUANBAO_APP_KEY")), ("app_secret", "YUANBAO_APP_SECRET")),
optional=(
("bot_id", "YUANBAO_BOT_ID"),
("ws_url", "YUANBAO_WS_URL"),
("api_domain", "YUANBAO_API_DOMAIN"),
("route_env", "YUANBAO_ROUTE_ENV"),
("dm_policy", "YUANBAO_DM_POLICY", lambda v: v.strip().lower()),
("dm_allow_from", "YUANBAO_DM_ALLOW_FROM"),
("group_policy", "YUANBAO_GROUP_POLICY", lambda v: v.strip().lower()),
("group_allow_from", "YUANBAO_GROUP_ALLOW_FROM"),
("bot_id", "YUANBAO_BOT_ID"), ("ws_url", "YUANBAO_WS_URL"),
("api_domain", "YUANBAO_API_DOMAIN"), ("route_env", "YUANBAO_ROUTE_ENV"),
("dm_policy", "YUANBAO_DM_POLICY", _strip_lower), ("dm_allow_from", "YUANBAO_DM_ALLOW_FROM"),
("group_policy", "YUANBAO_GROUP_POLICY", _strip_lower), ("group_allow_from", "YUANBAO_GROUP_ALLOW_FROM"),
),
home="YUANBAO_HOME_CHANNEL",
),
+52 -68
View File
@@ -62,31 +62,32 @@ def _is_dict(value: Any) -> bool:
return isinstance(value, dict)
def _dm_behavior_choice(value: Any, default: str = "pair") -> str:
return _normalize_choice(value, {"pair", "ignore"}, default)
def _presence(*keys: str) -> tuple:
return tuple((k, k, "presence", None, None) for k in keys)
# (yaml key, gw_data key, mode, accept(value) -> bool, transform(value))
_TOPLEVEL_BRIDGE: tuple = (
("session_reset", "default_reset_policy", "presence", lambda v: bool(v) and isinstance(v, dict), None),
("quick_commands", "quick_commands", "none", _quick_commands_ok, None),
("stt", "stt", "presence", _is_dict, None),
("stt_echo_transcripts", "stt_echo_transcripts", "presence", None, None),
("group_sessions_per_user", "group_sessions_per_user", "presence", None, None),
("thread_sessions_per_user", "thread_sessions_per_user", "presence", None, None),
*_presence("stt_echo_transcripts", "group_sessions_per_user", "thread_sessions_per_user"),
("multiplex_profiles", "multiplex_profiles", "gwdata", None, None),
("multiplex_profile_allowlist", "multiplex_profile_allowlist", "presence", None, None),
("room_link_url", "room_link_url", "presence", None, None),
*_presence("multiplex_profile_allowlist", "room_link_url"),
("profile_routes", "profile_routes", "none", lambda v: isinstance(v, list), None),
("max_concurrent_sessions", "max_concurrent_sessions", "presence", None, None),
*_presence("max_concurrent_sessions"),
("systemd_watchdog_seconds", "systemd_watchdog_seconds", "nested", None, None),
("streaming", "streaming", "dict", None, None),
("reset_triggers", "reset_triggers", "presence", None, None),
("always_log_local", "always_log_local", "presence", None, None),
("write_sessions_json", "write_sessions_json", "presence", None, None),
("loop_watchdog", "loop_watchdog", "presence", None, None),
("loop_watchdog_probe_interval_s", "loop_watchdog_probe_interval_s", "presence", None, None),
("loop_watchdog_probe_timeout_s", "loop_watchdog_probe_timeout_s", "presence", None, None),
("loop_watchdog_max_strikes", "loop_watchdog_max_strikes", "presence", None, None),
("filter_silence_narration", "filter_silence_narration", "presence", None, None),
("unauthorized_dm_behavior", "unauthorized_dm_behavior", "presence", None,
lambda v: _normalize_choice(v, {"pair", "ignore"}, "pair")),
*_presence(
"reset_triggers", "always_log_local", "write_sessions_json", "loop_watchdog",
"loop_watchdog_probe_interval_s", "loop_watchdog_probe_timeout_s", "loop_watchdog_max_strikes",
"filter_silence_narration",
),
("unauthorized_dm_behavior", "unauthorized_dm_behavior", "presence", None, _dm_behavior_choice),
)
@@ -153,21 +154,10 @@ def merge_platform_sections(yaml_cfg: dict, gateway_cfg: Any, gw_data: dict) ->
merged["extra"] = merged_extra
platforms_data[plat_name] = merged
gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None
merge(gateway_platforms)
nested_gateway = gateway_cfg if isinstance(gateway_cfg, dict) else {}
merge(nested_gateway.get("platforms"))
merge(yaml_cfg.get("platforms"))
if isinstance(gateway_cfg, dict):
nested: dict = {}
for key, value in gateway_cfg.items():
if key == "platforms":
continue
try:
Platform(key)
except (ValueError, AttributeError):
continue
if isinstance(value, dict):
nested[key] = value
merge(nested)
merge({k: v for k, v in nested_gateway.items() if k != "platforms" and isinstance(v, dict) and _is_platform_name(k)})
api_plat = platforms_data.get("api_server")
if isinstance(api_plat, dict):
@@ -178,6 +168,14 @@ def merge_platform_sections(yaml_cfg: dict, gateway_cfg: Any, gw_data: dict) ->
return platforms_data
def _is_platform_name(key: Any) -> bool:
try:
Platform(key)
except (ValueError, AttributeError):
return False
return True
def platform_section(yaml_cfg: dict, name: str, gateway_platforms: Any) -> tuple:
"""Return ``(section, is_toplevel)`` for platform *name*.
@@ -199,43 +197,32 @@ def _str_keyed(value: Any) -> Any:
return {str(k): v for k, v in value.items()} if isinstance(value, dict) else value
def _dm_behavior(gw_data: dict):
return lambda v: _normalize_choice(v, {"pair", "ignore"}, gw_data.get("unauthorized_dm_behavior", "pair"))
_TELEGRAM = frozenset({Platform.TELEGRAM})
_DISCORD_SLACK = frozenset({Platform.DISCORD, Platform.SLACK})
# Keys copied from a platform's YAML section into ``extra`` (``None`` = every platform);
# third element transforms the value.
def _plain(*keys: str) -> tuple:
"""Keys copied verbatim into ``extra`` for every platform."""
return tuple((k, None, None) for k in keys)
# (key, platforms-or-None, transform-or-None), in ``extra`` insertion order.
# ``"dm"`` defers to the global unauthorized_dm_behavior.
_SHARED_KEYS: tuple = (
("unauthorized_dm_behavior", None, "dm"),
("notice_delivery", None, lambda v: _normalize_choice(v, {"public", "private"}, "public")),
("reply_prefix", None, None),
("reply_in_thread", None, None),
("cron_continuable_surface", None, None),
("require_mention", None, None),
("send_read_receipts", None, None),
*_plain("reply_prefix", "reply_in_thread", "cron_continuable_surface", "require_mention", "send_read_receipts"),
("allowed_chats", _TELEGRAM, None),
("group_allowed_chats", _TELEGRAM, None),
("allowed_topics", _TELEGRAM, None),
("free_response_channels", None, None),
("mention_patterns", None, None),
("exclusive_bot_mentions", None, None),
*_plain("free_response_channels", "mention_patterns", "exclusive_bot_mentions"),
("observe_unmentioned_group_messages", _TELEGRAM, None),
("dm_policy", None, None),
("allow_from", None, None),
("allow_admin_from", None, None),
("user_allowed_commands", None, None),
("group_policy", None, None),
("group_allow_from", None, None),
("group_allow_admin_from", None, None),
("group_user_allowed_commands", None, None),
*_plain(
"dm_policy", "allow_from", "allow_admin_from", "user_allowed_commands",
"group_policy", "group_allow_from", "group_allow_admin_from", "group_user_allowed_commands",
),
("channel_skill_bindings", _DISCORD_SLACK, None),
("channel_prompts", None, _str_keyed),
("gateway_restart_notification", None, None),
("typing_indicator", None, None),
("typing_status_text", None, None),
*_plain("gateway_restart_notification", "typing_indicator", "typing_status_text"),
)
# Top-level port/host/secret bridged into ``extra`` for adapters that read them from config.extra.
@@ -254,8 +241,9 @@ def _bridged_keys(plat: Platform, platform_cfg: dict, gw_data: dict) -> dict:
if key not in platform_cfg or (only is not None and plat not in only):
continue
if transform == "dm":
transform = _dm_behavior(gw_data)
bridged[key] = transform(platform_cfg[key]) if transform else platform_cfg[key]
bridged[key] = _dm_behavior_choice(platform_cfg[key], gw_data.get("unauthorized_dm_behavior", "pair"))
else:
bridged[key] = transform(platform_cfg[key]) if transform else platform_cfg[key]
for key in _PORT_BRIDGE_KEYS.get(plat, ()):
if key in platform_cfg and key not in platform_cfg.get("extra", {}):
bridged[key] = platform_cfg[key]
@@ -285,7 +273,7 @@ def bridge_platform_shared_keys(
``merge_platform_sections`` already merged it with the correct precedence, and re-applying it here
would overwrite that. An explicit top-level enable/disable sets ``_enabled_explicit`` so the env
pass honors ``enabled: false`` for migrated plugin platforms instead of re-enabling them on
token/SDK presence (#41112).
token/SDK presence.
"""
for plat in targets:
if plat == Platform.LOCAL:
@@ -315,7 +303,7 @@ def bridge_platform_shared_keys(
def apply_plugin_yaml_hooks(yaml_cfg: dict, gateway_platforms: Any, platforms_data: dict, registry) -> None:
"""Plugin-owned YAML→env config bridges (``PlatformEntry.apply_yaml_config_fn``, #24836).
"""Plugin-owned YAML→env config bridges (``PlatformEntry.apply_yaml_config_fn``).
Order: shared-key loop → this dispatch → core-only bridges (require_mention/signal) →
``_apply_env_overrides()`` after ``GatewayConfig.from_dict``.
@@ -333,20 +321,16 @@ def apply_plugin_yaml_hooks(yaml_cfg: dict, gateway_platforms: Any, platforms_da
except Exception as e:
logger.debug("apply_yaml_config_fn for %s raised: %s", entry.name, e)
continue
if not isinstance(seeded, dict) or not seeded:
continue
_dict_slot(_dict_slot(platforms_data, entry.name), "extra").update(seeded)
if isinstance(seeded, dict) and seeded:
_dict_slot(_dict_slot(platforms_data, entry.name), "extra").update(seeded)
def bridge_core_env_settings(yaml_cfg: dict, platforms_data: dict) -> None:
"""The two YAML→env bridges that stay in core.
Slack/Telegram/WhatsApp/DingTalk/Mattermost/Matrix/Feishu settings→env bridges migrated to
each plugin's ``apply_yaml_config_fn`` hook (#41112 / #3823 / #25443).
"""The two YAML→env bridges that stay in core (the per-platform ones live in plugin hooks).
Top-level ``require_mention`` → Telegram when the ``telegram:`` section has none: users write it
alongside ``group_sessions_per_user`` expecting it to work (#3979). It keys off the TOP-LEVEL key,
so the telegram plugin's hook (which only runs when a telegram block exists) can't cover it.
alongside ``group_sessions_per_user`` expecting it to work. It keys off the TOP-LEVEL key, so
the telegram plugin's hook (which only runs when a telegram block exists) can't cover it.
Signal ``require_mention`` → ``SIGNAL_REQUIRE_MENTION`` (env wins when already set).
"""
tl_require_mention = yaml_cfg.get("require_mention")
+74 -195
View File
@@ -1,18 +1,10 @@
"""Gateway control socket — the gateway-owned local coordination surface.
Instead of process-table scans or ``gateway_state.json`` (which can outlive
its writer), the gateway serves a local-only socket answering versioned JSON
verbs (``identify``, ``status``). A connectable socket with a well-formed
``identify`` answer IS liveness — no PID-reuse heuristics.
Transport — never a TCP port; filesystem/pipe ACLs are the auth boundary.
POSIX: ``$HERMES_HOME/gateway.sock``, or a temp-dir socket plus a
``gateway.sock.path`` pointer file when the home path exceeds ``sun_path``.
Windows: named pipe ``\\\\.\\pipe\\hermes-gateway-<home-hash>``.
Wire contract: ONE request per connection — one JSON line in, one out, then
the server closes (no keep-alive/pipelining). Consumers PREFER the socket and
fall back to the state-file/scan layer when it doesn't answer.
"""Gateway control socket — the gateway-owned local coordination surface: a local-only socket answering
versioned JSON verbs (``identify``, ``status``). A connectable socket with a well-formed ``identify``
answer IS liveness — no PID-reuse heuristics. Never a TCP port: filesystem/pipe ACLs are the auth
boundary. POSIX: ``$HERMES_HOME/gateway.sock`` (or a temp-dir socket + ``gateway.sock.path`` pointer
file when the home path exceeds ``sun_path``); Windows: named pipe ``\\\\.\\pipe\\hermes-gateway-<hash>``.
Wire contract: ONE request per connection — one JSON line in, one out, then the server closes.
Consumers PREFER the socket and fall back to the state-file/scan layer when it doesn't answer.
"""
from __future__ import annotations
@@ -33,28 +25,18 @@ from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
CONTROL_PROTOCOL_VERSION = 1
_SOCKET_FILENAME = "gateway.sock"
_POINTER_FILENAME = "gateway.sock.path"
_IS_WINDOWS = sys.platform == "win32"
# sun_path limit is 104 on macOS/BSD, 108 on Linux; leave margin for the NUL.
_MAX_UNIX_PATH = 100
_MAX_UNIX_PATH = 100 # sun_path limit is 104 on macOS/BSD, 108 on Linux; margin for the NUL
# Single-line JSON in/out; bounded so a misbehaving peer can't balloon memory.
_MAX_REQUEST_BYTES = 64 * 1024
_MAX_RESPONSE_BYTES = 512 * 1024
_DEFAULT_CLIENT_TIMEOUT = 2.0
# ---------------------------------------------------------------------------
# Path resolution (shared by server and client)
# ---------------------------------------------------------------------------
def _home_hash(home: Path) -> str:
canonical = os.path.normcase(str(Path(home).expanduser().resolve(strict=False)))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
return hashlib.sha256(os.path.normcase(str(Path(home).expanduser().resolve(strict=False))).encode("utf-8")).hexdigest()[:16]
def windows_pipe_name(home: Path) -> str:
@@ -67,29 +49,18 @@ def _fits_sun_path(path: Path) -> bool:
def _fallback_socket_path(home: Path) -> Path:
"""Short temp-dir path for homes whose direct socket path exceeds sun_path.
Tries ``tempfile.gettempdir()`` then ``/tmp`` (POSIX). If nothing fits the
tempdir candidate is returned anyway — bind fails non-fatally and
consumers use the scan layer.
"""
"""Short temp-dir path for homes whose direct socket path exceeds sun_path: ``tempfile.gettempdir()``
then ``/tmp`` (POSIX); if nothing fits the tempdir candidate is returned anyway — bind fails
non-fatally and consumers use the scan layer."""
name = f"hermes-gw-{_home_hash(home)}.sock"
candidates = [Path(tempfile.gettempdir()) / name]
if not _IS_WINDOWS:
candidates.append(Path("/tmp") / name)
for candidate in candidates:
if _fits_sun_path(candidate):
return candidate
return candidates[0]
candidates = [Path(tempfile.gettempdir()) / name] + ([] if _IS_WINDOWS else [Path("/tmp") / name])
return next((c for c in candidates if _fits_sun_path(c)), candidates[0])
def resolve_server_socket_path(home: Path) -> tuple[Path, Optional[Path]]:
"""Return ``(bind_path, pointer_file)``; pointer_file is non-None only
when the temp-dir fallback is in use and the server must record it."""
"""Return ``(bind_path, pointer_file)``; pointer_file is set only for the temp-dir fallback."""
direct = Path(home) / _SOCKET_FILENAME
if _fits_sun_path(direct):
return direct, None
return _fallback_socket_path(home), Path(home) / _POINTER_FILENAME
return (direct, None) if _fits_sun_path(direct) else (_fallback_socket_path(home), Path(home) / _POINTER_FILENAME)
def resolve_client_socket_path(home: Path) -> Optional[Path]:
@@ -97,98 +68,60 @@ def resolve_client_socket_path(home: Path) -> Optional[Path]:
direct = Path(home) / _SOCKET_FILENAME
if direct.exists():
return direct
pointer = Path(home) / _POINTER_FILENAME
try:
if pointer.is_file():
target = pointer.read_text(encoding="utf-8").strip()
if target and Path(target).exists():
return Path(target)
except OSError:
pass
with contextlib.suppress(OSError):
pointer = Path(home) / _POINTER_FILENAME
target = pointer.read_text(encoding="utf-8").strip() if pointer.is_file() else ""
if target and Path(target).exists():
return Path(target)
return None
# ---------------------------------------------------------------------------
# Default payload builders (import-light; overridable at wiring time)
# ---------------------------------------------------------------------------
def _detect_supervisor() -> str:
"""Supervisor kind for THIS process, declared from its own launch env
(not inferred outside-in like ``_detect_supervisor_for_pid``)."""
"""Supervisor kind for THIS process from its own launch env (not inferred outside-in)."""
env = os.environ
if env.get("INVOCATION_ID"):
return "systemd"
if sys.platform == "darwin" and (
env.get("XPC_SERVICE_NAME", "").startswith("ai.hermes")
or env.get("LAUNCHD_SOCKET")
):
if sys.platform == "darwin" and (env.get("XPC_SERVICE_NAME", "").startswith("ai.hermes")
or env.get("LAUNCHD_SOCKET")):
return "launchd"
if env.get("HERMES_DESKTOP_MANAGED"):
return "desktop"
if "--external-supervisor" in sys.argv:
return "external"
return "manual"
return "external" if "--external-supervisor" in sys.argv else "manual"
def build_identify_payload() -> dict[str, Any]:
"""Default ``identify`` answer, built from gateway.status primitives."""
from gateway.status import (
_build_pid_record,
_get_code_identity_fields,
_profile_label_for_home,
read_runtime_status,
)
from gateway.status import _build_pid_record, _get_code_identity_fields, _profile_label_for_home, read_runtime_status
record = _build_pid_record()
payload: dict[str, Any] = {
"protocol": CONTROL_PROTOCOL_VERSION,
**{k: record.get(k) for k in ("kind", "pid", "start_time", "hermes_home")},
"profile": _profile_label_for_home(record.get("hermes_home") or ""),
"supervisor": _detect_supervisor(),
}
payload.update(_get_code_identity_fields())
# served_profiles (multiplex mode) is stamped into runtime status by the runner.
try:
"supervisor": _detect_supervisor(), **_get_code_identity_fields()}
with contextlib.suppress(Exception):
# served_profiles (multiplex mode) is stamped into runtime status by the runner.
served = (read_runtime_status() or {}).get("served_profiles")
if isinstance(served, list) and served:
payload["served_profiles"] = served
except Exception:
pass
return payload
def build_status_payload() -> dict[str, Any]:
"""Default ``status`` answer — current runtime status, answered live."""
from gateway.status import read_runtime_status
return {**(read_runtime_status() or {}), "protocol": CONTROL_PROTOCOL_VERSION,
"answered_at": time.time(), "answering_pid": os.getpid()}
payload = dict(read_runtime_status() or {})
payload["protocol"] = CONTROL_PROTOCOL_VERSION
payload["answered_at"] = time.time()
payload["answering_pid"] = os.getpid()
return payload
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
class GatewayControlServer:
"""Gateway-owned control socket server (identify/status, v1).
"""Gateway-owned control socket server (identify/status, v1): ``start()`` after the PID-file claim,
``stop()`` on shutdown. All failures are non-fatal — the gateway never refuses to serve messaging
because its control socket couldn't bind; consumers fall back to the scan layer."""
``start()`` after the PID-file claim, ``stop()`` on shutdown. All failures
are non-fatal — the gateway never refuses to serve messaging because its
control socket couldn't bind; consumers fall back to the scan layer.
"""
def __init__(
self,
home: Optional[Path] = None,
*,
verb_handlers: Optional[dict[str, Callable[[], dict[str, Any]]]] = None,
) -> None:
def __init__(self, home: Optional[Path] = None, *,
verb_handlers: Optional[dict[str, Callable[[], dict[str, Any]]]] = None) -> None:
if home is None:
from gateway.status import _get_process_hermes_home
home = _get_process_hermes_home()
self._home = Path(home)
self._server: Optional[asyncio.AbstractServer] = None
@@ -196,33 +129,24 @@ class GatewayControlServer:
self._bind_path: Optional[Path] = None
self._pointer_file: Optional[Path] = None
self._handlers: dict[str, Callable[[], dict[str, Any]]] = {
"identify": build_identify_payload,
"status": build_status_payload,
}
if verb_handlers:
self._handlers.update(verb_handlers)
# -- lifecycle ---------------------------------------------------------
"identify": build_identify_payload, "status": build_status_payload, **(verb_handlers or {})}
async def start(self) -> bool:
"""Bind and start serving. Returns True on success, False otherwise."""
try:
if _IS_WINDOWS:
return await self._start_windows()
return await self._start_posix()
return await (self._start_windows() if _IS_WINDOWS else self._start_posix())
except Exception as exc:
logger.warning("Gateway control socket failed to start (non-fatal): %s", exc)
return False
async def _start_posix(self) -> bool:
bind_path, pointer_file = resolve_server_socket_path(self._home)
# We only get here after winning the PID-file O_EXCL race, so any
# existing file is stale or a collision — never a live sibling.
# We only get here after winning the PID-file O_EXCL race, so any existing
# file is stale or a collision — never a live sibling.
with contextlib.suppress(OSError):
if bind_path.exists():
bind_path.unlink()
# Restrictive umask so the socket is never world-connectable, even
# for the instant before chmod.
# Restrictive umask so the socket is never world-connectable, even for the instant before chmod.
old_umask = os.umask(0o177)
try:
self._server = await asyncio.start_unix_server(self._handle_connection, path=str(bind_path))
@@ -241,10 +165,8 @@ class GatewayControlServer:
loop = asyncio.get_running_loop()
start_serving_pipe = getattr(loop, "start_serving_pipe", None)
if start_serving_pipe is None:
logger.debug(
"Event loop %s has no start_serving_pipe — control socket "
"disabled (selector loop on Windows).", type(loop).__name__,
)
logger.debug("Event loop %s has no start_serving_pipe — control socket "
"disabled (selector loop on Windows).", type(loop).__name__)
return False
pipe_name = windows_pipe_name(self._home)
servers = await start_serving_pipe(lambda: _PipeControlProtocol(self), pipe_name)
@@ -258,48 +180,34 @@ class GatewayControlServer:
self._server.close()
with contextlib.suppress(Exception):
await self._server.wait_closed()
self._server = None
if self._pipe_server is not None:
with contextlib.suppress(Exception):
self._pipe_server.close()
self._pipe_server = None
self._server = self._pipe_server = None
self.cleanup_files()
def cleanup_files(self) -> None:
"""Best-effort removal of socket + pointer files (atexit-safe)."""
for path in (self._bind_path, self._pointer_file):
if path is not None:
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
# -- request handling ----------------------------------------------------
for path in filter(None, (self._bind_path, self._pointer_file)):
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
def handle_request_line(self, raw: bytes) -> bytes:
"""One JSON request line -> one JSON response line. Never raises.
Shared by the POSIX stream handler and the Windows pipe protocol.
"""
"""One JSON request line -> one JSON response line. Never raises (shared by POSIX + pipe)."""
request_id: Any = None
try:
request = json.loads(raw.decode("utf-8"))
if not isinstance(request, dict):
raise ValueError("request must be a JSON object")
request_id = request.get("id")
verb = request.get("verb")
request_id, verb = request.get("id"), request.get("verb")
handler = self._handlers.get(verb) if isinstance(verb, str) else None
if handler is None:
response: dict[str, Any] = {
"ok": False, "error": f"unknown verb: {verb!r}",
"protocol": CONTROL_PROTOCOL_VERSION,
"supported_verbs": sorted(self._handlers),
}
response: dict[str, Any] = {"ok": False, "error": f"unknown verb: {verb!r}",
"protocol": CONTROL_PROTOCOL_VERSION, "supported_verbs": sorted(self._handlers)}
else:
response = {"ok": True, "protocol": CONTROL_PROTOCOL_VERSION, "result": handler()}
except Exception as exc:
response = {
"ok": False, "error": f"{type(exc).__name__}: {exc}",
"protocol": CONTROL_PROTOCOL_VERSION,
}
response = {"ok": False, "error": f"{type(exc).__name__}: {exc}", "protocol": CONTROL_PROTOCOL_VERSION}
if request_id is not None:
response["id"] = request_id
try:
@@ -315,10 +223,10 @@ class GatewayControlServer:
raw = await asyncio.wait_for(reader.readline(), timeout=_DEFAULT_CLIENT_TIMEOUT)
if not raw or len(raw) > _MAX_REQUEST_BYTES:
return
# Handlers read disk; keep that off the loop that drives every
# platform adapter so a fast-polling consumer can't stall heartbeats.
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(None, self.handle_request_line, raw.rstrip(b"\n"))
# Handlers read disk; keep that off the loop that drives every platform
# adapter so a fast-polling consumer can't stall heartbeats.
response = await asyncio.get_running_loop().run_in_executor(
None, self.handle_request_line, raw.rstrip(b"\n"))
writer.write(response)
await writer.drain()
except (asyncio.TimeoutError, ConnectionError, OSError):
@@ -332,7 +240,6 @@ class GatewayControlServer:
class _PipeControlProtocol(asyncio.Protocol):
"""One-shot request/response protocol for the Windows named pipe."""
def __init__(self, server: GatewayControlServer) -> None:
self._server = server
self._transport: Any = None
@@ -345,43 +252,25 @@ class _PipeControlProtocol(asyncio.Protocol):
self._buffer.extend(data)
if len(self._buffer) > _MAX_REQUEST_BYTES:
self._transport.close()
return
if b"\n" in self._buffer:
line, _, _ = bytes(self._buffer).partition(b"\n")
elif b"\n" in self._buffer:
try:
self._transport.write(self._server.handle_request_line(line))
self._transport.write(self._server.handle_request_line(bytes(self._buffer).partition(b"\n")[0]))
finally:
self._transport.close()
# ---------------------------------------------------------------------------
# Client (synchronous — used by CLI/updater consumers)
# ---------------------------------------------------------------------------
def query_gateway_control(
home: Path, verb: str, *, timeout: float = _DEFAULT_CLIENT_TIMEOUT
) -> Optional[dict[str, Any]]:
"""Ask the gateway serving ``home`` a control verb; None when unanswered.
Returns the verb's ``result`` payload. Any failure (no/stale socket,
timeout, malformed answer, ``ok: false``) returns None so callers fall
back to the scan layer. Never raises.
"""
def query_gateway_control(home: Path, verb: str, *, timeout: float = _DEFAULT_CLIENT_TIMEOUT) -> Optional[dict[str, Any]]:
"""Ask the gateway serving ``home`` a control verb; returns its ``result`` payload. Any failure (no/stale
socket, timeout, malformed answer, ``ok: false``) returns None so callers fall back to the scan layer.
Never raises."""
request = json.dumps({"verb": verb, "id": 1, "protocol": CONTROL_PROTOCOL_VERSION}).encode("utf-8") + b"\n"
query = _query_windows_pipe if _IS_WINDOWS else _query_unix_socket
try:
raw = query(Path(home), request, timeout)
response = json.loads(raw.decode("utf-8")) if raw else None
except Exception:
return None
if not raw:
return None
try:
response = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return None
if not isinstance(response, dict) or response.get("ok") is not True:
return None
result = response.get("result")
result = response.get("result") if isinstance(response, dict) and response.get("ok") is True else None
return result if isinstance(result, dict) else None
@@ -390,32 +279,25 @@ def _read_response_line(read: Callable[[], bytes], deadline: float) -> Optional[
chunks: list[bytes] = []
while time.monotonic() < deadline:
chunk = read()
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
if not chunk or b"\n" in chunk:
break
if sum(len(c) for c in chunks) > _MAX_RESPONSE_BYTES:
return None
line, _, _ = b"".join(chunks).partition(b"\n")
return line or None
return b"".join(chunks).partition(b"\n")[0] or None
def _query_unix_socket(home: Path, request: bytes, timeout: float) -> Optional[bytes]:
path = resolve_client_socket_path(home)
if path is None:
return None
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
# OSError covers ConnectionRefusedError / FileNotFoundError on connect and socket.timeout on read.
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock, contextlib.suppress(OSError):
sock.settimeout(timeout)
try:
sock.connect(str(path))
except (ConnectionRefusedError, FileNotFoundError, OSError):
return None
sock.connect(str(path))
sock.sendall(request)
try:
return _read_response_line(lambda: sock.recv(65536), time.monotonic() + timeout)
except socket.timeout:
return None
return _read_response_line(lambda: sock.recv(65536), time.monotonic() + timeout)
return None
def _query_windows_pipe(home: Path, request: bytes, timeout: float) -> Optional[bytes]: # pragma: no cover - wine2e lane
@@ -446,10 +328,7 @@ def identify_gateway(home: Path, *, timeout: float = _DEFAULT_CLIENT_TIMEOUT) ->
def pause_gateway_for_update(home: Path, *, timeout: float = _DEFAULT_CLIENT_TIMEOUT) -> Optional[dict[str, Any]]:
"""Ask the gateway serving ``home`` to drain and exit for an update.
Returns the ACK ``{"pausing", "already_stopping", "pid", "drain_timeout"}``
or None when no gateway answers (old gateway without the verb, no/dead
socket) — the caller then uses the legacy signal/tree-kill pause path.
"""
"""Ask the gateway serving ``home`` to drain and exit for an update. Returns the ACK ``{"pausing",
"already_stopping", "pid", "drain_timeout"}`` or None when no gateway answers (old gateway without
the verb, no/dead socket) — the caller then uses the legacy signal/tree-kill pause path."""
return query_gateway_control(home, "pause-for-update", timeout=timeout)
+3 -7
View File
@@ -22,16 +22,12 @@ def resolve_placeholder_terminal_cwd(
) -> str | None:
"""Return the ``TERMINAL_CWD`` value to set, or ``None`` to leave it unset.
Cases:
- **local** + placeholder → ``MESSAGING_CWD`` or ``home_fallback``
- **docker** + placeholder + mount on + host ``MESSAGING_CWD`` → host path
(for ``terminal_tool`` ``/workspace`` mapping)
- **docker** + placeholder + mount off → ``None`` (sandbox default)
- other non-local backends + placeholder → ``None``
local + placeholder → ``MESSAGING_CWD`` or ``home_fallback``; docker +
placeholder + mount on + host ``MESSAGING_CWD`` → that host path (for the
``/workspace`` mapping); any other non-local backend → ``None`` (sandbox default).
"""
if configured_cwd and configured_cwd not in CWD_PLACEHOLDERS:
return configured_cwd
backend = (terminal_backend or "local").strip().lower()
messaging = (messaging_cwd or "").strip()
if backend == "local":
+40 -61
View File
@@ -1,18 +1,9 @@
"""Persistent registry of delivery targets that are confirmed unreachable.
When a platform reports a target chat is permanently gone (deleted group,
bot kicked/blocked, deactivated user), re-sending on every cron tick wastes a
send against flood control and spams logs. The delivery layer short-circuits
targets proven dead; any later successful send clears the flag (self-healing).
Scope is deliberately narrow: only *whole-chat* deaths (``forbidden`` and
chat-level ``not_found``) are recorded. Thread/topic-level ``not_found`` is
NOT — adapters self-heal that by retrying without ``reply_to``, and a deleted
topic does not mean the parent chat is dead.
Storage is a JSON file under the active profile's HERMES_HOME (each profile
keeps its own dead set). Reads/writes are best-effort: a corrupt or unwritable
file degrades to in-memory-only rather than raising on the delivery path.
"""Persistent registry of delivery targets confirmed unreachable. Re-sending to a permanently gone chat
(deleted group, bot kicked/blocked, deactivated user) every cron tick wastes flood-control budget; delivery
short-circuits targets proven dead and any later successful send clears the flag. Only *whole-chat* deaths
(``forbidden``, chat-level ``not_found``) are recorded: adapters self-heal thread/topic-level ``not_found``
by retrying without ``reply_to``. Storage is a per-profile JSON file; reads/writes are best-effort (a
corrupt or unwritable file degrades to in-memory-only rather than raising on the delivery path).
"""
from __future__ import annotations
@@ -28,8 +19,7 @@ from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
# Error kinds (gateway.platforms.base.classify_send_error) meaning the whole
# chat is unreachable, not a transient or thread-level problem.
# classify_send_error kinds meaning the whole chat is unreachable (not transient / thread-level).
_DEAD_ERROR_KINDS = frozenset({"forbidden", "not_found"})
@@ -38,29 +28,35 @@ def _normalize(platform: str, chat_id: str) -> str:
return f"{str(platform).strip().lower()}:{str(chat_id).strip()}"
class DeadTargetRegistry:
"""Thread-safe, persistent set of confirmed-dead targets keyed ``platform:chat_id``.
def classify_dead_error(error_text: Optional[str]) -> Optional[str]:
"""Best-effort dead-target error_kind from a raised error's text, else None. ``_deliver_to_platform``
raises on hard failure (no SendResult), so ``deliver()`` only has the exception string. ``not_found``
collapses chat-level and thread/topic/message-level failures: only a whole-chat not_found means the
target is dead — a deleted forum topic or edited-away message must not mark the entire chat dead."""
if not error_text:
return None
try:
from .platforms.base import classify_send_error, is_chat_level_not_found
except Exception: # pragma: no cover - import guard
return None
kind = classify_send_error(None, error_text=error_text)
dead = kind in _DEAD_ERROR_KINDS and (kind != "not_found" or is_chat_level_not_found(error_text=error_text))
return kind if dead else None
Each entry stores reason + timestamp for observability; :meth:`clear`
(called on a successful send) removes the flag.
"""
class DeadTargetRegistry:
"""Thread-safe, persistent set of confirmed-dead targets keyed ``platform:chat_id``. Each entry stores
reason + timestamp for observability; :meth:`clear` (called on a successful send) removes the flag."""
def __init__(self, path: Optional[Path] = None) -> None:
self._lock = threading.RLock()
self._dead: Dict[str, Dict[str, object]] = {}
self._path = path if path is not None else get_hermes_home() / "gateway" / "dead_targets.json"
self._load()
def _load(self) -> None:
try:
if self._path.exists():
raw = json.loads(self._path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
self._dead = {k: v for k, v in raw.items() if isinstance(v, dict)}
raw = json.loads(self._path.read_text(encoding="utf-8")) if self._path.exists() else {}
self._dead = {k: v for k, v in raw.items() if isinstance(v, dict)} if isinstance(raw, dict) else {}
except (OSError, ValueError) as exc:
logger.debug("dead_targets: could not load %s (%s) — starting empty",
self._path, exc)
self._dead = {}
logger.debug("dead_targets: could not load %s (%s) — starting empty", self._path, exc)
def _flush_locked(self) -> None:
try:
@@ -68,42 +64,26 @@ class DeadTargetRegistry:
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(self._dead, indent=2), encoding="utf-8")
tmp.replace(self._path)
except OSError as exc:
# Best-effort: keep in-memory state, never break delivery.
except OSError as exc: # best-effort: keep in-memory state, never break delivery
logger.debug("dead_targets: could not persist %s (%s)", self._path, exc)
@staticmethod
def is_dead_error_kind(error_kind: Optional[str]) -> bool:
"""True when ``error_kind`` denotes a permanent whole-chat death."""
return bool(error_kind) and error_kind in _DEAD_ERROR_KINDS
def is_dead(self, platform: str, chat_id: Optional[str]) -> bool:
if not chat_id:
return False
with self._lock:
return _normalize(platform, chat_id) in self._dead
return bool(chat_id) and _normalize(platform, chat_id) in self._dead
def mark_dead(self, platform: str, chat_id: Optional[str],
reason: str = "") -> bool:
def mark_dead(self, platform: str, chat_id: Optional[str], reason: str = "") -> bool:
"""Record a target as confirmed-dead. Returns True if newly added."""
if not chat_id:
return False
key = _normalize(platform, chat_id)
with self._lock:
existed = key in self._dead
self._dead[key] = {
"platform": str(platform).strip().lower(),
"chat_id": str(chat_id),
"reason": str(reason)[:200],
"marked_at": time.time(),
}
self._dead[key] = {"platform": str(platform).strip().lower(), "chat_id": str(chat_id),
"reason": str(reason)[:200], "marked_at": time.time()}
self._flush_locked()
if not existed:
logger.info(
"dead_targets: marked %s as unreachable (%s) — future deliveries "
"to this target will be skipped until a send succeeds",
key, reason or "no reason given",
)
logger.info("dead_targets: marked %s as unreachable (%s) — future deliveries "
"to this target will be skipped until a send succeeds", key, reason or "no reason given")
return not existed
def clear(self, platform: str, chat_id: Optional[str]) -> bool:
@@ -112,9 +92,8 @@ class DeadTargetRegistry:
return False
key = _normalize(platform, chat_id)
with self._lock:
if key in self._dead:
del self._dead[key]
self._flush_locked()
logger.info("dead_targets: cleared %s (delivery succeeded again)", key)
return True
return False
if self._dead.pop(key, None) is None:
return False
self._flush_locked()
logger.info("dead_targets: cleared %s (delivery succeeded again)", key)
return True
+135 -341
View File
@@ -1,10 +1,5 @@
"""
Delivery routing for cron job outputs and agent responses.
Routes messages by target: explicit ("telegram:123456789"), platform home
channel ("telegram"), origin (back to where the job was created), or local
(saved to files).
"""
"""Delivery routing for cron job outputs and agent responses, by target: explicit ("telegram:123456789"),
platform home channel ("telegram"), origin (back to where the job was created), or local (files)."""
import logging
import os
@@ -16,43 +11,35 @@ from typing import Dict, List, Optional, Any
from hermes_cli.config import get_hermes_home
from .config import Platform, GatewayConfig, PlatformConfig
from .session import SessionSource
from .dead_targets import DeadTargetRegistry, classify_dead_error
logger = logging.getLogger(__name__)
# Cap before gateway-level truncation of cron output for non-chunking platform
# delivery. Telegram's hard API limit is 4096; the headroom covers the "full
# output saved to …" footer. Adapters that split long messages natively
# (BasePlatformAdapter.splits_long_messages) bypass this entirely.
# Cap before gateway-level truncation of cron output for non-chunking platform delivery. Telegram's hard
# API limit is 4096; the headroom covers the "full output saved to …" footer. Adapters that split long
# messages natively (splits_long_messages) bypass this entirely.
MAX_PLATFORM_OUTPUT = 4000
# Matches strings that are *only* a "silence" narration with optional markdown
# wrappers: *(silent)*, _silent_, `silent`, (silent), silent, 🔇, a bare ".",
# "…", and marker-padded variants. Anchored so substantive messages that merely
# *contain* the word "silent" never match.
# Matches strings that are *only* a "silence" narration with optional markdown wrappers (*(silent)*,
# _silent_, 🔇, a bare ".", "…"). Anchored so messages that merely *contain* "silent" never match.
_SILENCE_NARRATION = re.compile(
r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
re.IGNORECASE,
)
_THREAD_ROUTING_KEYS = ("thread_id", "message_thread_id", "direct_messages_topic_id", "telegram_direct_messages_topic_id")
def _is_silence_narration(content: Optional[str]) -> bool:
"""True when ``content`` is *only* a silence-narration token (length-guarded)."""
if not content:
return False
stripped = content.strip()
if not stripped or len(stripped) > 64:
return False
return bool(_SILENCE_NARRATION.match(stripped))
from .config import Platform, GatewayConfig, PlatformConfig
from .session import SessionSource
from .dead_targets import DeadTargetRegistry
stripped = content.strip() if content else ""
return bool(stripped) and len(stripped) <= 64 and bool(_SILENCE_NARRATION.match(stripped))
@dataclass(frozen=True)
class DeliveryTransport:
"""Resolved live transport for one logical delivery platform."""
adapter: Any
config: Optional[PlatformConfig]
transport_platform: Platform
@@ -61,128 +48,59 @@ class DeliveryTransport:
def is_relay(self) -> bool:
return self.transport_platform == Platform.RELAY
async def send(
self,
logical_platform: Platform,
chat_id: str,
content: str,
metadata: Optional[Dict[str, Any]],
) -> Any:
async def send(self, logical_platform: Platform, chat_id: str, content: str,
metadata: Optional[Dict[str, Any]]) -> Any:
"""Send through this transport while preserving the logical platform."""
if self.is_relay:
return await self.adapter.send_for_platform(
logical_platform, chat_id, content, metadata=metadata,
)
return await self.adapter.send(chat_id, content, metadata=metadata)
return await (self.adapter.send_for_platform(logical_platform, chat_id, content, metadata=metadata)
if self.is_relay else self.adapter.send(chat_id, content, metadata=metadata))
def resolve_delivery_transport(
platform: Platform,
config: GatewayConfig,
adapters: Optional[Dict[Platform, Any]],
) -> Optional[DeliveryTransport]:
"""Resolve a logical platform to its live delivery transport.
A concrete native adapter always wins. Relay is eligible only when its
authenticated transport explicitly advertises that it fronts the logical
platform, so restart-time delivery is independent of per-chat caches without
letting Relay hijack unrelated platform targets.
"""
def resolve_delivery_transport(platform: Platform, config: GatewayConfig,
adapters: Optional[Dict[Platform, Any]]) -> Optional[DeliveryTransport]:
"""Resolve a logical platform to its live delivery transport. A concrete native adapter always wins;
Relay is eligible only when its authenticated transport explicitly advertises that it fronts the
logical platform, so restart-time delivery is independent of per-chat caches without letting Relay
hijack unrelated platform targets."""
live_adapters = adapters or {}
native = live_adapters.get(platform)
native_config = config.platforms.get(platform)
native, native_config = live_adapters.get(platform), config.platforms.get(platform)
# Explicitly supplied live adapters with no config block are honored, but an
# explicitly disabled native adapter never shadows an enabled Relay transport.
if native is not None and (native_config is None or native_config.enabled):
return DeliveryTransport(native, native_config, platform)
relay = live_adapters.get(Platform.RELAY)
relay_config = config.platforms.get(Platform.RELAY)
relay, relay_config = live_adapters.get(Platform.RELAY), config.platforms.get(Platform.RELAY)
fronts_platform = getattr(relay, "fronts_platform", None)
if (
relay is not None
and (relay_config is None or relay_config.enabled)
and callable(fronts_platform)
and fronts_platform(platform)
):
if (relay is not None and (relay_config is None or relay_config.enabled)
and callable(fronts_platform) and fronts_platform(platform)):
return DeliveryTransport(relay, relay_config, Platform.RELAY)
return None
def _as_int(value: Any) -> Optional[int]:
try:
return int(value)
except (TypeError, ValueError):
return None
def looks_like_telegram_private_chat_id(chat_id: Optional[str]) -> bool:
"""True when ``chat_id`` is a positive int — Telegram's private-chat shape.
Groups/channels/supergroups use negative IDs. Single source of truth for the
heuristic, reused by the handoff seed path in ``gateway/run.py`` so
handoff-created DM topics key the same way as inbound DM-topic messages.
"""
if chat_id is None:
"""True when ``chat_id`` is a positive int — Telegram's private-chat shape (groups/channels are negative).
Single source of truth, reused by the handoff seed path in ``gateway/run.py`` so handoff-created DM
topics key the same way as inbound DM-topic messages."""
try:
return int(chat_id) > 0
except (TypeError, ValueError):
return False
parsed = _as_int(chat_id)
return parsed is not None and parsed > 0
def _looks_like_int(value: Optional[str]) -> bool:
return value is not None and _as_int(value) is not None
def _result_field(result: Any, name: str, default: Any = None) -> Any:
"""Read ``name`` from a SendResult object or a plain result dict."""
if isinstance(result, dict):
return result.get(name, default)
return getattr(result, name, default)
def _send_result_failed(result: Any) -> bool:
return _result_field(result, "success", True) is False
try:
return int(value) is not None
except (TypeError, ValueError):
return False
def _send_result_error(result: Any) -> Optional[str]:
error = _result_field(result, "error")
return str(error) if error else None
def _is_thread_not_found_delivery_error(result: Any) -> bool:
error = _send_result_error(result)
return bool(error and "thread not found" in error.lower())
def _classify_dead_from_error_text(error_text: Optional[str]) -> Optional[str]:
"""Best-effort dead-target classification from a raised error's text.
``_deliver_to_platform`` raises on hard failure (no SendResult), so the
``deliver()`` loop only has the exception string; reuse the platform-neutral
classifier to recover the error_kind from it.
"""
if not error_text:
return None
try:
from .platforms.base import classify_send_error, is_chat_level_not_found
except Exception: # pragma: no cover - import guard
return None
kind = classify_send_error(None, error_text=error_text)
if not DeadTargetRegistry.is_dead_error_kind(kind):
return None
# ``not_found`` collapses chat-level and thread/topic/message-level failures.
# Only a whole-chat not_found means the target is dead — a deleted forum
# topic or an edited-away message must not mark the entire chat (and all its
# future deliveries) dead (see gateway.dead_targets).
if kind == "not_found" and not is_chat_level_not_found(error_text=error_text):
return None
return kind
"""Error string of a failed SendResult object / plain result dict ("" if none), or None on success."""
get = result.get if isinstance(result, dict) else (lambda name, default=None: getattr(result, name, default))
return None if get("success", True) is not False else str(get("error") or "")
@dataclass
class DeliveryTarget:
"""A single delivery target: "origin", "local", "telegram" (home channel),
or "telegram:123456[:thread]" (specific chat)."""
"""One target: "origin", "local", "telegram" (home channel) or "telegram:123456[:thread]"."""
platform: Platform
chat_id: Optional[str] = None # None means use home channel
thread_id: Optional[str] = None
@@ -192,38 +110,18 @@ class DeliveryTarget:
@classmethod
def parse(cls, target: str, origin: Optional[SessionSource] = None) -> "DeliveryTarget":
"""Parse "origin" | "local" | "<platform>" | "<platform>:<chat_id>[:<thread_id>]"."""
target_stripped = target.strip()
target_lower = target_stripped.lower()
if target_lower == "origin":
if origin:
return cls(
platform=origin.platform,
chat_id=origin.chat_id,
thread_id=origin.thread_id,
is_origin=True,
)
return cls(platform=Platform.LOCAL, is_origin=True)
if target_lower == "local":
return cls(platform=Platform.LOCAL)
# Platform names are case-insensitive; chat/thread ids keep original case.
# Unknown platforms are treated as local.
if ":" in target_stripped:
parts = target_stripped.split(":", 2)
chat_id = parts[1] if len(parts) > 1 else None
thread_id = parts[2] if len(parts) > 2 else None
try:
platform = Platform(parts[0].lower())
except ValueError:
return cls(platform=Platform.LOCAL)
return cls(platform=platform, chat_id=chat_id, thread_id=thread_id, is_explicit=True)
target = target.strip()
if target.lower() == "origin":
return (cls(platform=origin.platform, chat_id=origin.chat_id, thread_id=origin.thread_id, is_origin=True)
if origin else cls(platform=Platform.LOCAL, is_origin=True))
# Platform names are case-insensitive; chat/thread ids keep case. Unknown platforms -> local.
parts = target.split(":", 2)
try:
return cls(platform=Platform(target_lower))
platform = Platform(parts[0].lower())
except ValueError:
return cls(platform=Platform.LOCAL)
return (cls(platform=platform, chat_id=parts[1], thread_id=parts[2] if len(parts) > 2 else None, is_explicit=True)
if len(parts) > 1 else cls(platform=platform))
def to_string(self) -> str:
"""Convert back to string format."""
@@ -231,23 +129,16 @@ class DeliveryTarget:
return "origin"
if self.platform == Platform.LOCAL:
return "local"
if self.chat_id and self.thread_id:
return f"{self.platform.value}:{self.chat_id}:{self.thread_id}"
if self.chat_id:
return f"{self.platform.value}:{self.chat_id}"
return self.platform.value
parts = [self.platform.value, self.chat_id, self.thread_id if self.chat_id else None]
return ":".join(p for p in parts if p)
async def _ensure_named_dm_topic(adapter: Any, chat_id: str, name: str, *, refresh: bool) -> str:
"""Create (or force-recreate) a named Telegram private DM topic; return its thread id."""
verb = "refresh" if refresh else "create"
ensure_dm_topic = getattr(adapter, "ensure_dm_topic", None)
verb, ensure_dm_topic = "refresh" if refresh else "create", getattr(adapter, "ensure_dm_topic", None)
if ensure_dm_topic is None:
raise RuntimeError(f"Telegram adapter cannot {verb} named private DM topics")
if refresh:
thread_id = await ensure_dm_topic(chat_id, name, force_create=True)
else:
thread_id = await ensure_dm_topic(chat_id, name)
thread_id = await ensure_dm_topic(chat_id, name, **({"force_create": True} if refresh else {}))
if not thread_id:
raise RuntimeError(f"Failed to {verb} Telegram private DM topic '{name}'")
return str(thread_id)
@@ -257,251 +148,154 @@ class DeliveryRouter:
"""Resolves delivery targets and dispatches messages to platform adapters."""
def __init__(self, config: GatewayConfig, adapters: Dict[Platform, Any] = None,
dead_targets: Optional[DeadTargetRegistry] = None):
"""``dead_targets``: shared registry of confirmed-unreachable targets;
a profile-local registry is created when omitted."""
dead_targets: Optional[DeadTargetRegistry] = None): # profile-local registry when omitted
self.config = config
self.adapters = adapters or {}
self.output_dir = get_hermes_home() / "cron" / "output"
self.dead_targets = dead_targets or DeadTargetRegistry()
async def deliver(
self,
content: str,
targets: List[DeliveryTarget],
job_id: Optional[str] = None,
job_name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
async def deliver(self, content: str, targets: List[DeliveryTarget], job_id: Optional[str] = None,
job_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Deliver content to all targets; returns per-target results keyed by target string."""
results = {}
for target in targets:
# Skip targets proven permanently unreachable (deleted group, blocked
# bot, deactivated user) — re-sending each tick wastes flood-control
# budget. Self-healing: a later successful send clears the flag.
# LOCAL/origin-without-chat targets are never dead-tracked.
# Skip targets proven permanently unreachable (deleted group, blocked bot, deactivated user) —
# re-sending each tick wastes flood-control budget. Self-healing: a later successful send
# clears the flag. LOCAL/origin-without-chat targets are never dead-tracked.
tracked = target.platform != Platform.LOCAL and target.chat_id
if tracked and self.dead_targets.is_dead(target.platform.value, target.chat_id):
logger.info(
"Skipping delivery to known-dead target %s:%s "
"(send to it again to clear)",
target.platform.value, target.chat_id,
)
results[target.to_string()] = {
"success": False,
"skipped": "dead_target",
"error": "target previously confirmed unreachable",
}
logger.info("Skipping delivery to known-dead target %s:%s (send to it again to clear)",
target.platform.value, target.chat_id)
results[target.to_string()] = {"success": False, "skipped": "dead_target",
"error": "target previously confirmed unreachable"}
continue
try:
if target.platform == Platform.LOCAL:
result = self._deliver_local(content, job_id, job_name, metadata)
else:
result = await self._deliver_to_platform(target, content, metadata)
if target.chat_id and not _send_result_failed(result):
if target.chat_id and _send_result_error(result) is None:
self.dead_targets.clear(target.platform.value, target.chat_id)
results[target.to_string()] = {"success": True, "result": result}
except Exception as e:
# Hard failures raise. Record a whole-chat death so future
# deliveries short-circuit.
if tracked:
dead_kind = _classify_dead_from_error_text(str(e))
if dead_kind:
self.dead_targets.mark_dead(
target.platform.value, target.chat_id,
reason=f"{dead_kind}: {str(e)[:120]}",
)
# Hard failures raise. Record a whole-chat death so future deliveries short-circuit.
dead_kind = classify_dead_error(str(e)) if tracked else None
if dead_kind:
self.dead_targets.mark_dead(target.platform.value, target.chat_id,
reason=f"{dead_kind}: {str(e)[:120]}")
results[target.to_string()] = {"success": False, "error": str(e)}
return results
def _deliver_local(
self,
content: str,
job_id: Optional[str],
job_name: Optional[str],
metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
def _deliver_local(self, content: str, job_id: Optional[str], job_name: Optional[str],
metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Save content to local files."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = self.output_dir / (job_id or "misc") / f"{timestamp}.md"
output_path.parent.mkdir(parents=True, exist_ok=True)
lines = [
f"# {job_name}" if job_name else "# Delivery Output",
"",
f"**Timestamp:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
]
if job_id:
lines.append(f"**Job ID:** {job_id}")
for key, value in (metadata or {}).items():
lines.append(f"**{key}:** {value}")
lines += ["", "---", "", content]
lines = [f"# {job_name}" if job_name else "# Delivery Output", "",
f"**Timestamp:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"]
lines += [f"**Job ID:** {job_id}"] if job_id else []
lines += [f"**{key}:** {value}" for key, value in (metadata or {}).items()] + ["", "---", "", content]
output_path.write_text("\n".join(lines), encoding="utf-8")
return {"path": str(output_path), "timestamp": timestamp}
def _save_full_output(self, content: str, job_id: str) -> Path:
"""Save full cron output to disk and return the file path."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = get_hermes_home() / "cron" / "output"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{job_id}_{timestamp}.txt"
path = get_hermes_home() / "cron" / "output" / f"{job_id}_{timestamp}.txt"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def _filter_silence_narration_enabled(self) -> bool:
"""``HERMES_FILTER_SILENCE_NARRATION`` env overrides the
``gateway.filter_silence_narration`` config flag (default True)."""
"""``HERMES_FILTER_SILENCE_NARRATION`` env overrides the ``gateway.filter_silence_narration`` flag."""
env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
if env is not None:
return env.strip().lower() in ("1", "true", "yes", "on")
return bool(getattr(self.config, "filter_silence_narration", True))
return (bool(getattr(self.config, "filter_silence_narration", True)) if env is None
else env.strip().lower() in ("1", "true", "yes", "on"))
def _cap_oversized_output(self, adapter: Any, content: str, job_id: str) -> str:
"""Audit-save oversized cron output; truncate it for non-chunking adapters.
Two independent decisions: (1) above MAX_PLATFORM_OUTPUT the full output
is always written to disk as an audit trail, best-effort — a failed save
(full disk, permissions) never blocks delivery; (2) non-chunking adapters
get the content truncated with a footer pointing to the saved file, while
``splits_long_messages`` adapters receive the full payload.
"""
"""Audit-save oversized cron output; truncate it for non-chunking adapters. Above MAX_PLATFORM_OUTPUT
the full output is always written to disk as an audit trail, best-effort — a failed save (full disk,
permissions) never blocks delivery. Non-chunking adapters then get the content truncated with a
footer pointing to the saved file; ``splits_long_messages`` adapters receive the full payload."""
if len(content) <= MAX_PLATFORM_OUTPUT:
return content
saved_path: Optional[Path] = None
try:
saved_path = self._save_full_output(content, job_id)
except OSError as exc:
logger.warning(
"Audit save failed for cron output (%d chars, job=%s): %s — "
"delivery proceeds without audit copy",
len(content), job_id, exc,
)
logger.warning("Audit save failed for cron output (%d chars, job=%s): %s — "
"delivery proceeds without audit copy", len(content), job_id, exc)
if getattr(adapter, "splits_long_messages", False):
if saved_path:
logger.info(
"Cron output preserved for chunking adapter (%d chars) — "
"full output saved to %s",
len(content), saved_path,
)
logger.info("Cron output preserved for chunking adapter (%d chars) — "
"full output saved to %s", len(content), saved_path)
return content
# The footer needs a valid path: if the best-effort save failed, retry
# (a failure now is a real delivery problem and propagates).
if saved_path is None:
saved_path = self._save_full_output(content, job_id)
saved_path = saved_path or self._save_full_output(content, job_id)
footer = f"\n\n... [truncated, full output saved to {saved_path}]"
visible = max(0, MAX_PLATFORM_OUTPUT - len(footer))
logger.info(
"Cron output truncated (%d chars) — full output: %s",
len(content), saved_path,
)
return content[:visible] + footer
logger.info("Cron output truncated (%d chars) — full output: %s", len(content), saved_path)
return content[:max(0, MAX_PLATFORM_OUTPUT - len(footer))] + footer
async def _deliver_to_platform(
self,
target: DeliveryTarget,
content: str,
metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
async def _deliver_to_platform(self, target: DeliveryTarget, content: str,
metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Deliver content to a messaging platform."""
transport = resolve_delivery_transport(target.platform, self.config, self.adapters)
if transport is None:
raise ValueError(f"No adapter configured for {target.platform.value}")
adapter = transport.adapter
if not target.chat_id:
raise ValueError(f"No chat ID for {target.platform.value} delivery")
adapter = transport.adapter
content = self._cap_oversized_output(adapter, content, (metadata or {}).get("job_id", "unknown"))
content = self._cap_oversized_output(
adapter, content, (metadata or {}).get("job_id", "unknown")
)
# Substrate-level anti-loop guard: drop hallucinated "silence narration"
# (*(silent)*, 🔇, a bare ".") before it reaches any adapter — in
# bot-to-bot channels these mirror back and forth until a model crashes
# with "no content after all retries"; prompt rules drift across
# providers, so this single chokepoint covers every platform. Local/file
# delivery is never filtered (saved silence has no loop risk).
# Cron output is an ARTIFACT, not model chatter: a legitimately terse job
# ("...", a single 🔇) has no mirror loop, and dropping it while returning
# success is how a cron gets logged as delivered with nothing on the
# wire. Cron sends carry job_id in metadata; everything else is filtered.
# Substrate-level anti-loop guard: drop hallucinated "silence narration" (*(silent)*, 🔇, a bare ".")
# before it reaches any adapter — in bot-to-bot channels these mirror back and forth until a model
# crashes with "no content after all retries"; prompt rules drift across providers, so this single
# chokepoint covers every platform. Local/file delivery is never filtered (saved silence has no loop
# risk). Cron output is an ARTIFACT, not model chatter: a legitimately terse job ("...", a single 🔇)
# has no mirror loop, and dropping it while returning success is how a cron gets logged as delivered
# with nothing on the wire. Cron sends carry job_id in metadata; everything else is filtered.
is_cron_artifact = "job_id" in (metadata or {})
if (
self._filter_silence_narration_enabled()
and not is_cron_artifact
and _is_silence_narration(content)
):
logger.warning(
"Dropped silence-narration outbound to %s (chat=%s): %r",
target.platform.value,
target.chat_id,
content[:40],
)
return {
"success": True,
"filtered": "silence_narration",
"delivered": False,
}
if self._filter_silence_narration_enabled() and not is_cron_artifact and _is_silence_narration(content):
logger.warning("Dropped silence-narration outbound to %s (chat=%s): %r",
target.platform.value, target.chat_id, content[:40])
return {"success": True, "filtered": "silence_narration", "delivered": False}
send_metadata = dict(metadata or {})
if transport.is_relay:
home = self.config.get_home_channel(target.platform)
if home is not None and home.chat_id == target.chat_id:
if home.user_id:
send_metadata["user_id"] = home.user_id
if home.scope_id:
send_metadata["scope_id"] = home.scope_id
home = self.config.get_home_channel(target.platform) if transport.is_relay else None
if home is not None and home.chat_id == target.chat_id:
send_metadata.update({k: v for k, v in (("user_id", home.user_id), ("scope_id", home.scope_id)) if v})
# Caller-supplied thread routing always wins over target.thread_id.
named_telegram_private_topic_name: Optional[str] = None
named_topic: Optional[str] = None # named Telegram private topic created for this send
thread_id = target.thread_id
thread_unrouted = thread_id and not any(
key in send_metadata
for key in ("thread_id", "message_thread_id",
"direct_messages_topic_id", "telegram_direct_messages_topic_id")
)
if thread_unrouted:
is_telegram_private = (
target.platform == Platform.TELEGRAM
and looks_like_telegram_private_chat_id(target.chat_id)
)
if is_telegram_private and not _looks_like_int(thread_id):
# Named topic: create via createForumTopic, use message_thread_id directly.
named_telegram_private_topic_name = thread_id
send_metadata["thread_id"] = await _ensure_named_dm_topic(
adapter, target.chat_id, thread_id, refresh=False
)
send_metadata["telegram_dm_topic_created_for_send"] = True
elif is_telegram_private:
# Legacy numeric private topic ids not created by this send path
# need a reply anchor to stay visible in the requested lane.
if send_metadata.get("telegram_reply_to_message_id") is None:
if thread_id and not any(key in send_metadata for key in _THREAD_ROUTING_KEYS):
send_metadata["thread_id"] = thread_id
if target.platform == Platform.TELEGRAM and looks_like_telegram_private_chat_id(target.chat_id):
if not _looks_like_int(thread_id):
# Named topic: create via createForumTopic, use message_thread_id directly.
named_topic = thread_id
send_metadata["thread_id"] = await _ensure_named_dm_topic(adapter, target.chat_id, thread_id, refresh=False)
send_metadata["telegram_dm_topic_created_for_send"] = True
elif send_metadata.get("telegram_reply_to_message_id") is None:
# Legacy numeric private topic ids not created by this send path need a reply
# anchor to stay visible in the requested lane.
raise RuntimeError(
"Telegram private DM topic delivery requires telegram_reply_to_message_id; "
"send to the bare chat or provide a reply anchor"
)
send_metadata["thread_id"] = thread_id
send_metadata["telegram_dm_topic_reply_fallback"] = True
else:
send_metadata["thread_id"] = thread_id
else:
send_metadata["telegram_dm_topic_reply_fallback"] = True
result = await transport.send(
target.platform, target.chat_id, content, metadata=send_metadata or None,
)
if _send_result_failed(result):
if named_telegram_private_topic_name and _is_thread_not_found_delivery_error(result):
send_metadata["thread_id"] = await _ensure_named_dm_topic(
adapter, target.chat_id, named_telegram_private_topic_name, refresh=True
)
send_metadata["telegram_dm_topic_created_for_send"] = True
result = await transport.send(
target.platform, target.chat_id, content, metadata=send_metadata or None,
)
if _send_result_failed(result):
raise RuntimeError(_send_result_error(result) or f"{target.platform.value} delivery failed")
for retry in (False, True):
result = await transport.send(target.platform, target.chat_id, content, metadata=send_metadata or None)
error = _send_result_error(result)
if retry or error is None or not named_topic or "thread not found" not in error.lower():
break
# The named topic vanished under us: recreate it once and resend.
send_metadata["thread_id"] = await _ensure_named_dm_topic(adapter, target.chat_id, named_topic, refresh=True)
send_metadata["telegram_dm_topic_created_for_send"] = True
if error is not None:
raise RuntimeError(error or f"{target.platform.value} delivery failed")
return result
+111 -249
View File
@@ -1,37 +1,13 @@
"""Durable delivery-obligation ledger for gateway final responses.
A final agent response generated but not yet confirmed-delivered is the one
artifact the gateway can lose without a trace: the turn already burned its
tokens, the text exists only in a Python local, and a crash / restart between
finalize and platform ACK drops it silently.
Each outbound final response gets a small durable row in the shared ``state.db``
(same conventions as ``tools.async_delegation`` — WAL, owner pid + process-start
liveness, bounded retention). The gateway writes three checkpoints:
record_obligation() state='pending' before any send attempt
mark_attempting() state='attempting' immediately before the await
mark_delivered() / state='delivered' only on SendResult.success
mark_failed() state='failed' on a definitive rejection
On startup ``sweep_recoverable()`` claims rows whose owner is dead and hands
them back for redelivery. After an adapter reconnects without a restart,
``sweep_failed_for_runtime()`` may claim only the same live process's
allowlisted transient failures. Crash semantics are explicit about ambiguity
(never silently resend an ambiguous send):
- ``pending`` — send never started: redeliver plainly, no dup risk.
- ``attempting`` — crashed mid-await: the platform MAY already have it.
Redelivered WITH a visible recovered-reply marker (honest at-least-once).
- ``failed`` — definitively rejected once; restart is a natural retry
boundary. Also carries the marker.
- ``delivered`` — nothing to do; retention prunes.
Poison rows cannot spin: attempts are capped and stale rows expire, both
transitioning to ``abandoned`` (kept briefly, then pruned).
Everything here is best-effort: ledger failures must never block or delay an
actual send. Callers wrap every call in try/except.
"""Durable delivery-obligation ledger for gateway final responses (rows in the shared ``state.db``;
WAL, owner pid + process-start liveness, bounded retention) so a crash between finalize and
platform ACK cannot lose a response silently. Checkpoints: record_obligation() 'pending' before
any send | mark_attempting() 'attempting' right before the await | mark_delivered() 'delivered'
only on SendResult.success | mark_failed() 'failed' on a definitive rejection. Crash semantics
(never silently resend an ambiguous send): pending = never started, redeliver plainly; attempting
= crashed mid-await, platform MAY have it, redeliver WITH a visible recovered marker; failed =
rejected once, restart is a retry boundary, also marked; delivered = prune. Attempts are capped
and stale rows expire, both -> 'abandoned' (kept briefly, then pruned). Everything is
best-effort: ledger failures must never block a send; callers wrap every call in try/except.
"""
from __future__ import annotations
@@ -42,13 +18,12 @@ import os
import sqlite3
import threading
import time
from contextlib import contextmanager
from contextlib import closing, contextmanager
from typing import Any, Dict, Iterator, List, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
_DB_LOCK = threading.Lock()
# Redelivery policy knobs (deliberately not config — the ledger is gated by
@@ -58,23 +33,15 @@ STALE_AFTER_SECONDS = 24 * 60 * 60
_RETENTION_SECONDS = 7 * 24 * 60 * 60
_MAX_ROWS = 500
# Visible prefix for redeliveries that might duplicate an already-received
# message (crash mid-send / post-rejection retry). Honest at-least-once.
RECOVERED_MARKER = (
"♻️ Recovered reply — the gateway restarted during delivery, "
"so this may be a duplicate:\n\n"
)
# Visible prefixes for redeliveries that might duplicate an already-received message (crash mid-send /
# post-rejection retry) — honest at-least-once. Runtime recovery uses a distinct marker: no restart
# occurred, but a network rejection's acknowledgement can still have been lost independently.
RECOVERED_MARKER = "♻️ Recovered reply — the gateway restarted during delivery, so this may be a duplicate:\n\n"
RECONNECTED_MARKER = ("♻️ Recovered reply — the messaging platform reconnected after the original "
"delivery failed, so this may be a duplicate:\n\n")
# Runtime recovery uses a distinct marker: no restart occurred, but a network
# rejection's acknowledgement can still have been lost independently.
RECONNECTED_MARKER = (
"♻️ Recovered reply — the messaging platform reconnected after the original "
"delivery failed, so this may be a duplicate:\n\n"
)
# Runtime replay is fail-closed: only errors whose send contract proves they are
# transient reconnect failures. Permanent rejects (blocked bot, bad auth, missing
# chat) must not be retried merely because an adapter reconnected.
# Runtime replay is fail-closed: only errors whose send contract proves they are transient reconnect
# failures. Permanent rejects (blocked bot, bad auth, missing chat) must not be retried on reconnect.
_RUNTIME_RETRYABLE_ERRORS = frozenset({"send_path_degraded"})
@@ -89,15 +56,13 @@ def _connect() -> sqlite3.Connection:
try:
_initialize_schema(conn)
except Exception:
# A PRAGMA/DDL failure after connect() must not leak the connection.
conn.close()
conn.close() # a PRAGMA/DDL failure after connect() must not leak the connection
raise
return conn
def _initialize_schema(conn: sqlite3.Connection) -> None:
from hermes_state import apply_wal_with_fallback
apply_wal_with_fallback(conn, db_label="state.db (delivery_ledger)")
conn.execute(
"""CREATE TABLE IF NOT EXISTS delivery_obligations (
@@ -117,8 +82,7 @@ def _initialize_schema(conn: sqlite3.Connection) -> None:
adapter_profile TEXT
)"""
)
columns = {row[1] for row in conn.execute("PRAGMA table_info(delivery_obligations)")}
if "adapter_profile" not in columns:
if "adapter_profile" not in {row[1] for row in conn.execute("PRAGMA table_info(delivery_obligations)")}:
try:
conn.execute("ALTER TABLE delivery_obligations ADD COLUMN adapter_profile TEXT")
except sqlite3.OperationalError as exc:
@@ -129,29 +93,25 @@ def _initialize_schema(conn: sqlite3.Connection) -> None:
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
"""Open a connection, commit/rollback on exit, and ALWAYS close it.
``sqlite3.Connection`` as a context manager only commits/rolls back; it does
not close. ``with _connect()`` alone would leak a connection (and its WAL/SHM
fds) per call until GC — ``record_obligation`` runs on every outbound final
response, so this ledger would exhaust ``RLIMIT_NOFILE`` on a long-running gateway.
"""
"""Open a connection, commit/rollback on exit, and ALWAYS close it: ``sqlite3.Connection`` as a
context manager only commits/rolls back, so ``with _connect()`` alone leaks a connection (and its
WAL/SHM fds) per call — ``record_obligation`` runs on every final response; exhausts RLIMIT_NOFILE."""
conn = _connect()
with closing(conn), conn:
yield conn
def _start_time(pid: int) -> Optional[int]:
try:
with conn:
yield conn
finally:
conn.close()
from gateway.status import get_process_start_time # lazy: tests monkeypatch gateway.status
return get_process_start_time(pid)
except Exception:
return None
def _owner_stamp() -> tuple[int, Optional[int]]:
pid = os.getpid()
try:
from gateway.status import get_process_start_time
return pid, get_process_start_time(pid)
except Exception:
return pid, None
return pid, _start_time(pid)
def _owner_alive(pid: Any, started_at: Any) -> bool:
@@ -162,18 +122,11 @@ def _owner_alive(pid: Any, started_at: Any) -> bool:
pid = int(pid)
except (TypeError, ValueError):
return False
try:
from gateway.status import get_process_start_time
current_start = get_process_start_time(pid)
except Exception:
current_start = None
current_start = _start_time(pid)
if current_start is None:
# Start time unreadable: alive iff the pid exists. Route through the
# cross-platform probe — ``os.kill(pid, 0)`` on Windows is NOT a no-op
# (bpo-14484: it maps to ``GenerateConsoleCtrlEvent(0, pid)`` and could
# Ctrl+C the gateway's own console group). ``_pid_exists`` keeps the
# EPERM-means-alive semantics (pid exists but is owned by another user).
# Start time unreadable: alive iff the pid exists. Route through the cross-platform probe — on Windows
# ``os.kill(pid, 0)`` is NOT a no-op (bpo-14484: it maps to ``GenerateConsoleCtrlEvent(0, pid)`` and could
# Ctrl+C the gateway's own console group). ``_pid_exists`` keeps EPERM-means-alive (pid owned by another user).
try:
from gateway.status import _pid_exists
except Exception:
@@ -181,47 +134,29 @@ def _owner_alive(pid: Any, started_at: Any) -> bool:
return False # never fall back to a raw sig-0 probe on Windows
try:
os.kill(pid, 0) # windows-footgun: ok — POSIX-only fallback branch
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
except OSError as exc: # incl. ProcessLookupError; EPERM means the pid exists
return isinstance(exc, PermissionError)
try:
return bool(_pid_exists(pid))
except Exception:
return False
if started_at is None:
return True
try:
return int(current_start) == int(started_at)
return started_at is None or int(current_start) == int(started_at)
except (TypeError, ValueError):
return True
def compute_obligation_id(session_key: str, message_ref: str, content: str) -> str:
"""Stable id: same turn + same content re-records idempotently, while distinct
threads/topics on one chat never collide (session_key carries platform, chat and
thread; ``message_ref`` is the triggering inbound message id)."""
payload = f"{session_key}|{message_ref}|{content}"
return hashlib.sha256(payload.encode("utf-8", "replace")).hexdigest()[:24]
"""Stable id: same turn + same content re-records idempotently, while distinct threads/topics on one
chat never collide (session_key carries platform/chat/thread; ``message_ref`` = inbound message id)."""
return hashlib.sha256(f"{session_key}|{message_ref}|{content}".encode("utf-8", "replace")).hexdigest()[:24]
def record_obligation(
*,
obligation_id: str,
session_key: str,
platform: str,
chat_id: str,
thread_id: Optional[str],
content: str,
adapter_profile: Optional[str] = None,
) -> None:
def record_obligation(*, obligation_id: str, session_key: str, platform: str, chat_id: str,
thread_id: Optional[str], content: str, adapter_profile: Optional[str] = None) -> None:
"""Record a final response as owed to the platform (state='pending')."""
now = time.time()
stored_profile = str(adapter_profile).strip() if adapter_profile else "default"
pid, started = _owner_stamp()
now, (pid, started) = time.time(), _owner_stamp()
with _DB_LOCK, _transaction() as conn:
conn.execute(
"""INSERT OR REPLACE INTO delivery_obligations
@@ -229,10 +164,8 @@ def record_obligation(
content, state, attempts, created_at, updated_at,
owner_pid, owner_started_at, adapter_profile)
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?, ?)""",
(obligation_id, session_key, platform, str(chat_id),
str(thread_id) if thread_id else None, content, now, now,
pid, started, stored_profile),
)
(obligation_id, session_key, platform, str(chat_id), str(thread_id) if thread_id else None,
content, now, now, pid, started, str(adapter_profile).strip() if adapter_profile else "default"))
_prune()
@@ -251,11 +184,9 @@ def mark_failed(obligation_id: str, error: str = "") -> None:
def release_runtime_claim(obligation_id: str, error: str = "") -> bool:
"""Return an unsent runtime claim to ``failed`` without spending an attempt.
Runtime recovery claims before clearing ``resume_pending`` so two reconnect
paths cannot send the same row. If the session flag cannot be cleared, no
send was attempted and the claim must not consume the redelivery budget.
Fail-closed to the exact current process instance and ``attempting`` state.
"""
Runtime recovery claims before clearing ``resume_pending`` so two reconnect paths cannot send the
same row; if the flag cannot be cleared no send was attempted and the claim must not consume the
redelivery budget. Fail-closed to the exact current process instance and ``attempting`` state."""
pid, started = _owner_stamp()
if started is None:
return False
@@ -267,9 +198,7 @@ def release_runtime_claim(obligation_id: str, error: str = "") -> bool:
updated_at=?, last_error=?
WHERE obligation_id=? AND state='attempting'
AND owner_pid IS ? AND owner_started_at IS ?""",
(time.time(), error[:500] if error else None,
obligation_id, pid, started),
)
(time.time(), error[:500] if error else None, obligation_id, pid, started))
return bool(cursor.rowcount)
@@ -279,39 +208,31 @@ def _update_state(obligation_id: str, state: str, error: str = "") -> None:
"""UPDATE delivery_obligations
SET state=?, updated_at=?, last_error=?
WHERE obligation_id=?""",
(state, time.time(), error[:500] if error else None, obligation_id),
)
(state, time.time(), error[:500] if error else None, obligation_id))
def _exhausted(attempts: int, created_at: float, now: float) -> bool:
return attempts >= MAX_ATTEMPTS or (now - created_at) > STALE_AFTER_SECONDS
def _claimed_row(oid, session_key, platform, chat_id, thread_id, content, attempts, profile, *,
needs_marker: bool, runtime: bool = False) -> Dict[str, Any]:
"""Claimed-row dict handed back for redelivery; ``runtime`` adds the reconnect-marker fields."""
return {"obligation_id": oid, "session_key": session_key, "platform": platform, "chat_id": chat_id,
"thread_id": thread_id, "content": content, "needs_marker": needs_marker,
**({"marker": RECONNECTED_MARKER} if runtime else {}), "profile": profile,
**({"runtime_recovery": True} if runtime else {}), "attempts": attempts + 1}
def sweep_recoverable(
now: Optional[float] = None,
*,
deliverable_platforms: Optional[set] = None,
deliverable_targets: Optional[set] = None,
) -> List[Dict[str, Any]]:
def sweep_recoverable(now: Optional[float] = None, *, deliverable_platforms: Optional[set] = None,
deliverable_targets: Optional[set] = None) -> List[Dict[str, Any]]:
"""Claim undelivered rows owned by dead processes; return them for redelivery.
Claiming atomically re-stamps the owner to THIS process and increments
``attempts`` (the UPDATE is guarded on the previous owner stamp, so a second
gateway racing the same sweep cannot double-claim). Rows over the attempts
cap or stale cutoff transition to 'abandoned' instead of being returned.
``deliverable_platforms`` restricts claiming to platforms the caller can
actually send on this boot: ``attempts`` is the redelivery budget and must
only be spent on a real send, else a platform that failed to connect burns
one attempt per boot and hits the cap having never been sent once. Rows for
absent platforms are left untouched (the stale cutoff still bounds them).
``deliverable_targets`` further scopes multiplexed gateways by exact
``(platform, adapter_profile)`` so one connected bot cannot spend another
disconnected bot's retry budget.
"""
now = now if now is not None else time.time()
pid, started = _owner_stamp()
Claiming atomically re-stamps the owner to THIS process and increments ``attempts`` (the UPDATE is
guarded on the previous owner stamp, so a second gateway racing the same sweep cannot double-claim).
Rows over the attempts cap or stale cutoff become 'abandoned'. ``deliverable_platforms`` restricts
claiming to platforms the caller can send on this boot: ``attempts`` is the redelivery budget and
must only be spent on a real send, else a platform that failed to connect burns one attempt per boot
and hits the cap having never been sent once (the stale cutoff still bounds untouched rows).
``deliverable_targets`` further scopes multiplexed gateways by exact ``(platform, adapter_profile)``
so one connected bot cannot spend another disconnected bot's retry budget."""
now, (pid, started) = now if now is not None else time.time(), _owner_stamp()
claimed: List[Dict[str, Any]] = []
with _DB_LOCK, _transaction() as conn:
rows = conn.execute(
@@ -321,73 +242,45 @@ def sweep_recoverable(
FROM delivery_obligations
WHERE state IN ('pending', 'attempting', 'failed')"""
).fetchall()
for (oid, session_key, platform, chat_id, thread_id, content, state,
attempts, created_at, owner_pid, owner_started_at,
adapter_profile) in rows:
for (oid, session_key, platform, chat_id, thread_id, content, state, attempts, created_at,
owner_pid, owner_started_at, adapter_profile) in rows:
if _owner_alive(owner_pid, owner_started_at):
continue # a live gateway still owns this row
if _exhausted(attempts, created_at, now):
if attempts >= MAX_ATTEMPTS or (now - created_at) > STALE_AFTER_SECONDS: # exhausted -> abandoned
conn.execute(
"""UPDATE delivery_obligations
SET state='abandoned', updated_at=? WHERE obligation_id=?""",
(now, oid),
)
SET state='abandoned', updated_at=? WHERE obligation_id=?""", (now, oid))
continue
if deliverable_platforms is not None and platform not in deliverable_platforms:
if ((deliverable_platforms is not None and platform not in deliverable_platforms)
or (deliverable_targets is not None and (platform, adapter_profile) not in deliverable_targets)):
continue # no adapter this boot — claiming would spend an attempt on a no-op
if deliverable_targets is not None and (platform, adapter_profile) not in deliverable_targets:
continue
cursor = conn.execute(
"""UPDATE delivery_obligations
SET owner_pid=?, owner_started_at=?, attempts=attempts+1,
updated_at=?
WHERE obligation_id=? AND (owner_pid IS ? OR owner_pid=?)""",
(pid, started, now, oid, owner_pid, owner_pid),
)
if cursor.rowcount:
claimed.append({
"obligation_id": oid,
"session_key": session_key,
"platform": platform,
"chat_id": chat_id,
"thread_id": thread_id,
"content": content,
# pending = never started, redeliver plainly; else carry marker.
"needs_marker": state != "pending",
"profile": adapter_profile,
"attempts": attempts + 1,
})
(pid, started, now, oid, owner_pid, owner_pid))
if cursor.rowcount: # pending = never started, redeliver plainly; else carry marker
claimed.append(_claimed_row(oid, session_key, platform, chat_id, thread_id, content, attempts,
adapter_profile, needs_marker=state != "pending"))
return claimed
def sweep_failed_for_runtime(
platform: str,
now: Optional[float] = None,
*,
profile: Optional[str] = None,
) -> List[Dict[str, Any]]:
def sweep_failed_for_runtime(platform: str, now: Optional[float] = None, *,
profile: Optional[str] = None) -> List[Dict[str, Any]]:
"""Claim this process's reconnect-retryable failed rows for one adapter.
``profile`` scopes multiplexed gateways to the bot identity that owned the
failed send; ``None`` means the primary/default adapter. The persisted
adapter owner is independent of the routed session namespace. Unowned rows
and rows owned by another process are left for the startup/dead-owner sweep.
Startup recovery ignores rows owned by a live gateway, so a final response
rejected with ``send_path_degraded`` would stay stranded when only the
adapter reconnects. This sweep closes that gap without weakening ownership:
only rows stamped to this exact process instance, only allowlisted transient
errors, same attempts/staleness bounds, every update guarded by the prior
owner stamp and ``failed`` state. Claimed rows always carry the reconnect
marker because the failed send's acknowledgement is not safe to infer.
"""
now = now if now is not None else time.time()
pid, started = _owner_stamp()
if started is None:
# PID alone cannot distinguish this process from a stale row left after
# PID reuse. Runtime replay is optional, so fail closed; startup
# recovery remains the durable fallback.
return []
``profile`` scopes multiplexed gateways to the bot identity that owned the failed send (``None`` =
primary/default adapter); unowned rows and rows owned by another process are left for the
startup/dead-owner sweep. Startup recovery ignores rows owned by a live gateway, so a response
rejected with ``send_path_degraded`` would stay stranded when only the adapter reconnects; this closes
that gap without weakening ownership: only rows stamped to this exact process instance, only
allowlisted transient errors, same attempts/staleness bounds, every update guarded by the prior owner
stamp and ``failed`` state. Claimed rows always carry the reconnect marker (the failed send's ack is
not safe to infer)."""
now, (pid, started) = now if now is not None else time.time(), _owner_stamp()
if started is None: # PID alone cannot distinguish this process from a stale row left after PID
return [] # reuse; runtime replay is optional, so fail closed (startup recovery remains).
expected_profile = "default" if not profile or profile == "default" else str(profile)
claimed: List[Dict[str, Any]] = []
with _DB_LOCK, _transaction() as conn:
@@ -396,66 +289,41 @@ def sweep_failed_for_runtime(
content, attempts, created_at, owner_pid,
owner_started_at, last_error, adapter_profile
FROM delivery_obligations
WHERE state='failed' AND platform=?""",
(platform,),
).fetchall()
for (oid, session_key, row_platform, chat_id, thread_id, content,
attempts, created_at, owner_pid, owner_started_at, last_error,
adapter_profile) in rows:
if adapter_profile != expected_profile:
continue
WHERE state='failed' AND platform=?""", (platform,)).fetchall()
for (oid, session_key, row_platform, chat_id, thread_id, content, attempts, created_at,
owner_pid, owner_started_at, last_error, adapter_profile) in rows:
# Exact process-start matching prevents PID reuse from stealing work.
if owner_pid != pid or owner_started_at != started:
if (adapter_profile != expected_profile or owner_pid != pid or owner_started_at != started
or str(last_error or "").strip().lower() not in _RUNTIME_RETRYABLE_ERRORS):
continue
if str(last_error or "").strip().lower() not in _RUNTIME_RETRYABLE_ERRORS:
continue
owner_guard = (oid, owner_pid, owner_started_at)
if _exhausted(attempts, created_at, now):
owner_guard = (now, oid, owner_pid, owner_started_at)
if attempts >= MAX_ATTEMPTS or (now - created_at) > STALE_AFTER_SECONDS: # exhausted -> abandoned
conn.execute(
"""UPDATE delivery_obligations
SET state='abandoned', updated_at=?
WHERE obligation_id=? AND state='failed'
AND owner_pid IS ? AND owner_started_at IS ?""",
(now, *owner_guard),
)
AND owner_pid IS ? AND owner_started_at IS ?""", owner_guard)
continue
cursor = conn.execute(
"""UPDATE delivery_obligations
SET state='attempting', attempts=attempts+1, updated_at=?
WHERE obligation_id=? AND state='failed'
AND owner_pid IS ? AND owner_started_at IS ?""",
(now, *owner_guard),
)
AND owner_pid IS ? AND owner_started_at IS ?""", owner_guard)
if cursor.rowcount:
claimed.append({
"obligation_id": oid,
"session_key": session_key,
"platform": row_platform,
"chat_id": chat_id,
"thread_id": thread_id,
"content": content,
"needs_marker": True,
"marker": RECONNECTED_MARKER,
"profile": adapter_profile,
"runtime_recovery": True,
"attempts": attempts + 1,
})
claimed.append(_claimed_row(oid, session_key, row_platform, chat_id, thread_id, content,
attempts, adapter_profile, needs_marker=True, runtime=True))
return claimed
def _prune(now: Optional[float] = None) -> None:
now = now if now is not None else time.time()
cutoff = now - _RETENTION_SECONDS
try:
with _transaction() as conn:
conn.execute(
"""DELETE FROM delivery_obligations
WHERE state IN ('delivered', 'abandoned') AND updated_at < ?""",
(cutoff,),
)
WHERE state IN ('delivered', 'abandoned') AND updated_at < ?""", (now - _RETENTION_SECONDS,))
total = conn.execute("SELECT COUNT(*) FROM delivery_obligations").fetchone()[0]
excess = max(0, total - _MAX_ROWS)
if excess:
if total > _MAX_ROWS:
conn.execute(
"""DELETE FROM delivery_obligations WHERE obligation_id IN (
SELECT obligation_id FROM delivery_obligations
@@ -464,9 +332,7 @@ def _prune(now: Optional[float] = None) -> None:
WHEN 'abandoned' THEN 1
ELSE 2
END, updated_at ASC
LIMIT ?)""",
(excess,),
)
LIMIT ?)""", (total - _MAX_ROWS,))
except Exception:
logger.debug("delivery ledger prune failed", exc_info=True)
@@ -476,12 +342,8 @@ def ledger_enabled(config: Optional[Dict[str, Any]] = None) -> bool:
try:
if config is None:
from hermes_cli.config import load_config
config = load_config()
gw = config.get("gateway") or {}
value = gw.get("delivery_ledger", True)
if isinstance(value, str):
return value.strip().lower() not in {"false", "0", "no", "off"}
return bool(value)
value = (config.get("gateway") or {}).get("delivery_ledger", True)
return value.strip().lower() not in {"false", "0", "no", "off"} if isinstance(value, str) else bool(value)
except Exception:
return True
+24 -64
View File
@@ -1,86 +1,49 @@
"""Disk-usage rollup for ``/api/status``.
Companion to :mod:`gateway.memory_status`: a hosted agent can fill its data
volume (SQLite writes failing, sessions and config saves lost) while its
dashboard and the NAS agent card look healthy. ``gateway/readiness.py``
already probes disk, but readiness is a component verdict, not user-facing
telemetry — this module produces the public block the dashboard SPA and the
NAS availability sweep consume.
Disk is sampled live via :func:`shutil.disk_usage` (one ``statvfs`` call), so
there is no staleness dimension and no ``sampled_at``.
Public-safety: ``/api/status`` is unauthenticated (``PUBLIC_API_PATHS``); this
block carries only coarse numbers (MB, one-decimal percent) and an enum — the same
disclosure class as the ``memory`` block.
Best-effort and read-only: an unreadable filesystem degrades to
volume (SQLite writes failing, config saves lost) while its dashboard looks
healthy. Sampled live via one ``statvfs`` call, so there is no ``sampled_at``.
``/api/status`` is unauthenticated: only coarse numbers (MB, one-decimal
percent) and an enum. Best-effort: an unreadable filesystem degrades to
``pressure="unknown"`` rather than raising into the status endpoint.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Percent alone misleads both ways: 90% used on 100 GB leaves 10 GB, while 50%
# on a tiny volume is one download from write failures. So percent triggers are
# gated on absolute headroom also being low, and a hard absolute floor applies
# regardless of size (below it SQLite journaling / config writes are at risk).
_CRITICAL_FREE_MB = 256 # < 256 MB free: critical on any volume
_CRITICAL_PERCENT = 95.0 # >= 95% used AND < 1 GB free: critical
_CRITICAL_HEADROOM_MB = 1024
_ELEVATED_FREE_MB = 512 # < 512 MB free: elevated on any volume
_ELEVATED_PERCENT = 85.0 # >= 85% used AND < 4 GB free: elevated
_ELEVATED_HEADROOM_MB = 4096
from gateway.memory_status import _nonneg_int
# Percent alone misleads both ways: 90% used on 100 GB leaves 10 GB, while 50% on a tiny
# volume is one download from write failures. So percent triggers are gated on absolute
# headroom also being low, and a hard floor applies regardless of size (below it SQLite
# journaling / config writes are at risk). Rows: (level, free floor, used-%, headroom); worst first.
_PRESSURE_TIERS = (
("critical", 256, 95.0, 1024), # < 256 MB free, or >= 95% used AND < 1 GB free
("elevated", 512, 85.0, 4096), # < 512 MB free, or >= 85% used AND < 4 GB free
)
_BYTES_PER_MB = 1024 * 1024
def _coerce_mb(value: Any) -> Optional[int]:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None
return value
def classify_disk_pressure(free_mb: Any, total_mb: Any) -> str:
"""Map free/total MB to ``ok``/``elevated``/``critical``.
``unknown`` when the sample is missing or malformed — the caller must
not treat "we could not read it" as "disk is fine".
"""
free = _coerce_mb(free_mb)
total = _coerce_mb(total_mb)
if free is None or total is None or total <= 0:
"""``ok``/``elevated``/``critical`` from free/total MB; ``unknown`` when the sample
is missing/malformed — "could not read it" must never read as "fine"."""
free, total = _nonneg_int(free_mb), _nonneg_int(total_mb)
if free is None or not total:
return "unknown"
used_percent = (1 - free / total) * 100.0
for level, free_floor, percent_floor, headroom in (
("critical", _CRITICAL_FREE_MB, _CRITICAL_PERCENT, _CRITICAL_HEADROOM_MB),
("elevated", _ELEVATED_FREE_MB, _ELEVATED_PERCENT, _ELEVATED_HEADROOM_MB),
):
for level, free_floor, percent_floor, headroom in _PRESSURE_TIERS:
if free < free_floor or (used_percent >= percent_floor and free < headroom):
return level
return "ok"
def collect_disk_status(home: Optional[Path] = None) -> Dict[str, Any]:
"""Build the ``disk`` block for ``/api/status``.
``home`` scopes the sample to a profile's HERMES_HOME (same contract as the
``memory`` block; on hosted images every profile shares one volume).
Always returns a dict and never raises — an unreadable/unmounted filesystem
yields ``{"pressure": "unknown", ...}``.
"""
status: Dict[str, Any] = {
"pressure": "unknown",
"total_mb": None,
"free_mb": None,
"used_percent": None,
}
"""``disk`` block for ``/api/status`` (same ``home`` contract as ``memory``).
Never raises — an unreadable/unmounted filesystem yields ``pressure="unknown"``."""
status: Dict[str, Any] = {"pressure": "unknown", "total_mb": None, "free_mb": None, "used_percent": None}
try:
if home is None:
from hermes_constants import get_hermes_home
@@ -91,10 +54,7 @@ def collect_disk_status(home: Optional[Path] = None) -> Dict[str, Any]:
return status
if usage.total <= 0:
return status
total_mb = usage.total // _BYTES_PER_MB
free_mb = usage.free // _BYTES_PER_MB
status["total_mb"] = total_mb
status["free_mb"] = free_mb
status["used_percent"] = round((usage.used / usage.total) * 100, 1)
status["pressure"] = classify_disk_pressure(free_mb, total_mb)
total_mb, free_mb = usage.total // _BYTES_PER_MB, usage.free // _BYTES_PER_MB
status.update(total_mb=total_mb, free_mb=free_mb, used_percent=round((usage.used / usage.total) * 100, 1),
pressure=classify_disk_pressure(free_mb, total_mb))
return status
+25 -57
View File
@@ -1,63 +1,42 @@
"""Per-platform display/verbosity configuration resolver.
"""Per-platform display/verbosity resolver (``resolve_display_setting``).
``resolve_display_setting()`` is the single entry-point. Resolution order
(first non-None wins):
1. ``display.platforms.<platform>.<key>`` — explicit per-platform override
2. ``display.<key>`` — global user setting
3. ``_PLATFORM_DEFAULTS[<platform>][<key>]`` — built-in platform default
4. ``_GLOBAL_DEFAULTS[<key>]`` — built-in global default
Exception: ``display.streaming`` is CLI-only. Gateway streaming follows the
top-level ``streaming`` config unless ``display.platforms.<platform>.streaming``
sets an explicit per-platform override.
Backward compatibility: ``display.tool_progress_overrides`` is still read as a
fallback for ``tool_progress`` when no ``display.platforms`` entry exists (a
config migration moves the old format into ``display.platforms``).
Resolution order, first non-None wins: ``display.platforms.<platform>.<key>`` →
``display.<key>`` → ``_PLATFORM_DEFAULTS[platform][key]`` → ``_GLOBAL_DEFAULTS[key]``.
Exception: ``display.streaming`` is CLI-only; gateway streaming follows the top-level
``streaming`` config unless a per-platform override sets it. Legacy
``display.tool_progress_overrides`` is still read as a ``tool_progress`` fallback.
"""
from __future__ import annotations
from typing import Any
# Settings configurable per-platform, with global defaults. Other display
# settings (compact, personality, skin, ...) are CLI-only.
# Settings configurable per-platform; other display settings are CLI-only.
_GLOBAL_DEFAULTS: dict[str, Any] = {
"tool_progress": "all",
"tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool
"show_reasoning": False,
# Reasoning summary rendering: "code" (💭 **Reasoning:** + fenced block),
# "blockquote" ("> " lines), "subtext" ("-# " Discord small grey text).
# "code" (💭 **Reasoning:** + fenced block), "blockquote" ("> "), "subtext" ("-# " Discord).
"reasoning_style": "code",
"tool_preview_length": 0,
"streaming": None, # None = follow top-level streaming config
# Gateway-only assistant/status chatter; default on for back-compat, mobile
# platforms opt down to final-answer-first.
# Gateway-only assistant/status chatter; mobile platforms opt down to final-answer-first.
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
# busy_input_mode=steer confirmation echo ("Steered into current run").
# Disabling only suppresses the echo; the text still lands in the run.
# busy_input_mode=steer echo ("Steered into current run"); the text still lands in the run.
"busy_steer_ack_enabled": True,
# Delete tool-progress / "⏳ Working" / status bubbles after the final
# response on platforms that support deletion (e.g. Telegram). Off by
# default; progress is still shown live, only cleaned up after success so
# the chat doesn't fill with stale breadcrumbs. Failed runs leave bubbles
# in place as breadcrumbs.
# Delete tool-progress / "⏳ Working" bubbles after a SUCCESSFUL final response on
# platforms that support deletion (Telegram); failed runs keep them as breadcrumbs.
"cleanup_progress": False,
# Live working-state text on platforms whose typing indicator renders text
# (Slack assistant status): "full"/true = verb + argument preview,
# "verb" = verb only (keeps paths/commands out of shared channels),
# "off"/false = static text. Independent of tool_progress and free: the
# existing typing refresh cadence just renders different text.
# Working-state text on text-rendering indicators (Slack assistant status):
# "full"/true = verb + argument preview, "verb" = verb only (keeps paths out of
# shared channels), "off"/false = static text.
"live_status": "full",
}
# Per-platform defaults tiered by capability:
# HIGH — message editing, personal/team use
# MEDIUM — editing, but often workspace/customer-facing
# LOW — no edit support: each progress message is permanent
# MINIMAL — batch / non-interactive delivery
# Tiers: HIGH = editing, personal/team use; MEDIUM = editing but customer-facing;
# LOW = no edit support (progress messages are permanent); MINIMAL = batch delivery.
_TIER_HIGH = {
"tool_progress": "all",
"show_reasoning": False,
@@ -79,8 +58,7 @@ _TIER_LOW = {
_TIER_MINIMAL = {**_TIER_LOW, "tool_preview_length": 0}
_PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
# Telegram is usually a mobile inbox: quiet tool_progress and no busy-ack
# iteration counter, but DO surface interim assistant commentary and
# Mobile inbox: quiet tool_progress / busy-ack, but keep interim commentary and
# heartbeats so it doesn't look like "typing..." for 30 minutes.
"telegram": {**_TIER_HIGH, "tool_progress": "off", "busy_ack_detail": False},
# Discord's "-# " subtext reads as metadata, so reasoning defaults to it.
@@ -96,25 +74,18 @@ _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
"mattermost": _TIER_MEDIUM,
"matrix": _TIER_MEDIUM,
"feishu": _TIER_MEDIUM,
# Buzz (Nostr via buzz-cli) can edit in place but channels are shared
# community spaces; without an entry it inherited the verbose globals.
# Buzz (Nostr) can edit in place but channels are shared community spaces.
"buzz": _TIER_MEDIUM,
"signal": _TIER_LOW,
"whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit
# Cloud API supports editing but our adapter lacks edit_message; promote
# to MEDIUM once it lands.
"whatsapp_cloud": _TIER_LOW,
# Photon and BlueBubbles are permanent-message iMessage inboxes (no edit);
# without an entry Photon inherited the noisy "all" globals and narrated
# on nearly every turn.
"whatsapp_cloud": _TIER_LOW, # adapter lacks edit_message; promote once it lands
# Permanent-message iMessage inboxes (no edit).
"photon": _TIER_LOW,
"bluebubbles": _TIER_LOW,
"weixin": _TIER_LOW,
# WeCom is non-editable but has a native streaming transport (msgtype
# "stream") the consumer routes mid-stream content through; streaming on
# gives the client a typing animation + cumulative updates instead of a
# single one-shot markdown drop.
# Non-editable, but its native "stream" msgtype gives a typing animation +
# cumulative updates instead of a one-shot markdown drop.
"wecom": {**_TIER_LOW, "streaming": True},
"wecom_callback": _TIER_LOW,
"dingtalk": _TIER_LOW,
@@ -155,8 +126,7 @@ def resolve_display_setting(
if isinstance(legacy, dict) and legacy.get(platform_key) is not None:
return _normalise(setting, legacy[platform_key])
# 2. Global user setting. display.streaming controls only CLI terminal
# streaming; gateway streaming follows the top-level config + platform overrides.
# 2. Global user setting (display.streaming is CLI-only, see module docstring).
if setting != "streaming" and display_cfg.get(setting) is not None:
return _normalise(setting, display_cfg[setting])
@@ -167,9 +137,7 @@ def resolve_display_setting(
return fallback if val is None else val
# ---------------------------------------------------------------------------
# Normalisation of YAML quirks (bare ``off`` → False in YAML 1.1, etc.)
# ---------------------------------------------------------------------------
# --- Normalisation of YAML quirks (bare ``off`` → False in YAML 1.1, etc.) ---
_TRUTHY = {"true", "1", "yes", "on"}
_FALSY = {"false", "0", "no"}
+54 -124
View File
@@ -1,38 +1,18 @@
"""External drain-control marker contract (dashboard → gateway).
There is no control channel into a running gateway, so the dashboard's
begin/cancel-drain endpoint talks to it the same way ``.restart_notify.json``
does: it writes (or removes) a marker file and a gateway watcher reacts. This
module owns the contract so writer and reader can never disagree.
Contract (presence-based):
* begin-drain → write ``{HERMES_HOME}/.drain_request.json`` with
``{"action": "drain", "requested_at": <iso>, "principal": <str>,
"epoch": <instantiation-epoch>, "suppress_notification": <bool>}``.
* cancel-drain → remove the marker.
* The watcher treats presence of an ACTIVE marker as "external drain": flip
``gateway_state -> "draining"`` and stop accepting new turns. Absence or a
stale marker means "not draining" (revert to ``running``).
Staleness — two independent, individually-lenient signals (either suffices):
* epoch mismatch: HERMES_HOME is a durable volume on Hermes Cloud, so a
marker survives the machine restart that the drain-gated action (update,
migrate, env edit) ends in; honouring it would park the fresh gateway in
``draining`` forever. A marker is stamped with this instantiation's epoch
and ignored only on a *definite* mismatch.
* expiry: a same-epoch orphan (action completed without a restart, writer
never cancelled) is ignored once ``requested_at`` is older than
:data:`DRAIN_REQUEST_MAX_AGE_SECONDS`. Re-calling :func:`write_drain_request`
refreshes the timestamp — the sanctioned keep-alive for a long drain.
Reading never raises: a malformed/half-written file reads as "present but
contentless" (``{}``), which still counts as drain-active — fail-safe toward
quiescing. Both staleness checks only reject on a *definite* verdict; a marker
with no epoch/timestamp, or a host without ``/proc``, degrades to the
presence-only behaviour and never fails closed.
No control channel exists into a running gateway, so begin/cancel-drain writes
(or removes) ``{HERMES_HOME}/.drain_request.json`` and a gateway watcher reacts;
an ACTIVE marker means ``gateway_state -> "draining"``. Two lenient staleness
signals (either suffices): epoch mismatch (HERMES_HOME is a durable volume on
Hermes Cloud, so a marker survives the restart a drain-gated action ends in and
would park the fresh gateway in ``draining`` forever) and expiry (same-epoch
orphan past :data:`DRAIN_REQUEST_MAX_AGE_SECONDS`; re-writing refreshes it).
Reading never raises: a malformed file reads as ``{}`` — still drain-active
(fail-safe toward quiescing). Staleness rejects only on a *definite* verdict.
"""
from __future__ import annotations
import contextlib
import functools
import json
import logging
@@ -40,21 +20,18 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from gateway.memory_status import _parse_iso
from hermes_constants import get_hermes_home
from utils import atomic_json_write
import contextlib
_log = logging.getLogger(__name__)
_DRAIN_REQUEST_FILENAME = ".drain_request.json"
# Drain-gated lifecycle actions complete in minutes; an hour bounds the wedge a
# leaked marker can cause. Long drains refresh the marker instead of raising this.
# Drain-gated lifecycle actions complete in minutes; an hour bounds the wedge a leaked
# marker can cause. Long drains refresh the marker instead of raising this.
DRAIN_REQUEST_MAX_AGE_SECONDS = 3600.0
# Dedup for the expired-marker warning: the watcher re-reads every second and an
# expired orphan sits on disk until removed. Keyed by ``requested_at`` so a
# keep-alive re-write that later expires again logs again.
# Dedup for the expired-marker warning (the watcher re-reads every second); keyed by
# ``requested_at`` so a keep-alive re-write that later expires logs again.
_expiry_logged_for: Optional[str] = None
@@ -62,62 +39,40 @@ _expiry_logged_for: Optional[str] = None
def current_instantiation_epoch() -> str:
"""Identity of THIS container / VM instantiation ("<boot_id>:<pid1_start>").
Stable for the life of PID 1 (so an s6 respawn of just the gateway, or a
host ``hermes gateway restart`` under systemd/launchd, keeps honouring an
in-flight drain) but changes whenever the machine is recreated: boot_id
changes on a VM/microVM reboot, PID 1's start time on a plain
``docker restart`` (same host kernel, brand-new ``/init``).
Returns ``""`` when neither source is readable (non-Linux, no ``/proc``),
which disables the epoch check downstream — never fail-closed.
Stable for the life of PID 1 (a gateway-only respawn keeps honouring an
in-flight drain) but changes when the machine is recreated: boot_id on a VM
reboot, PID 1's start time on ``docker restart``. ``""`` when neither is
readable (non-Linux, no ``/proc``) disables the epoch check — never fail-closed.
"""
boot_id = ""
boot_id = pid1_start = ""
with contextlib.suppress(OSError):
boot_id = Path("/proc/sys/kernel/random/boot_id").read_text(encoding="utf-8").strip()
pid1_start = ""
try:
# "<pid> (<comm>) <state> ...": comm may contain spaces/parens, so split
# on the LAST ')'. starttime is field 22 (1-indexed) = tail index 19.
stat = Path("/proc/1/stat").read_text(encoding="utf-8")
pid1_start = stat.rsplit(")", 1)[1].split()[19]
except (OSError, IndexError):
pass
if not boot_id and not pid1_start:
return ""
return f"{boot_id}:{pid1_start}"
with contextlib.suppress(OSError, IndexError):
# "<pid> (<comm>) <state> ...": comm may contain spaces/parens, so split on the
# LAST ')'. starttime is field 22 (1-indexed) = tail index 19.
pid1_start = Path("/proc/1/stat").read_text(encoding="utf-8").rsplit(")", 1)[1].split()[19]
return f"{boot_id}:{pid1_start}" if (boot_id or pid1_start) else ""
def drain_request_path(home: Optional[Path] = None) -> Path:
"""Absolute path to the drain-request marker, respecting HERMES_HOME."""
base = home if home is not None else get_hermes_home()
return Path(base) / _DRAIN_REQUEST_FILENAME
return Path(home if home is not None else get_hermes_home()) / _DRAIN_REQUEST_FILENAME
def write_drain_request(
*,
principal: str = "drain-control",
suppress_notification: bool = False,
home: Optional[Path] = None,
*, principal: str = "drain-control", suppress_notification: bool = False, home: Optional[Path] = None
) -> dict[str, Any]:
"""Write the begin-drain marker atomically. Returns the payload written.
"""Write the begin-drain marker atomically; returns the payload.
Idempotent: re-writing refreshes ``requested_at`` (keep-alive past the
max-age). ``suppress_notification`` asks the shutdown that ends this drain
to skip ONLY the home-channel "gateway shutting down" broadcast (the
per-active-session interrupt ping is never suppressed); the policy of
which drains are quiet lives entirely in the caller. Defaults False so
legacy/operator drains behave exactly as before. The marker is stamped
with :func:`current_instantiation_epoch` so a copy surviving a machine
restart on the durable volume can be recognised as stale.
Re-writing refreshes ``requested_at`` (keep-alive past the max-age).
``suppress_notification`` skips ONLY the home-channel "gateway shutting down"
broadcast (the per-session interrupt ping is never suppressed); which drains
are quiet is the caller's policy. Stamped with the instantiation epoch so a
copy surviving a machine restart on the durable volume reads as stale.
"""
payload = {
"action": "drain",
"requested_at": datetime.now(timezone.utc).isoformat(),
"principal": principal,
"epoch": current_instantiation_epoch(),
"suppress_notification": bool(suppress_notification),
"action": "drain", "requested_at": datetime.now(timezone.utc).isoformat(), "principal": principal,
"epoch": current_instantiation_epoch(), "suppress_notification": bool(suppress_notification),
}
atomic_json_write(drain_request_path(home), payload)
return payload
@@ -129,65 +84,43 @@ def clear_drain_request(*, home: Optional[Path] = None) -> bool:
try:
path.unlink()
return True
except FileNotFoundError:
return False
except OSError as e:
_log.warning("drain-control: failed to remove %s: %s", path, e)
if not isinstance(e, FileNotFoundError):
_log.warning("drain-control: failed to remove %s: %s", path, e)
return False
def _marker_epoch_is_stale(body: dict[str, Any]) -> bool:
"""True iff ``body``'s epoch is a *definite* mismatch (both epochs known and differ)."""
current = current_instantiation_epoch()
marker_epoch = body.get("epoch")
return bool(current and marker_epoch and marker_epoch != current)
def _marker_is_expired(body: dict[str, Any]) -> bool:
"""True iff ``requested_at`` parses AND is older than the max-age.
Missing/unparseable timestamps and future-dated ones (clock skew) are
honoured. The expiry is logged once per marker, not per poll: this path
only fires when a writer leaked a marker, and the warning is the
operator's breadcrumb.
Missing/unparseable and future-dated (clock skew) timestamps are honoured.
Logged once per marker, not per poll — the operator's breadcrumb for a leak.
"""
global _expiry_logged_for
raw = body.get("requested_at")
if not isinstance(raw, str) or not raw:
requested_at = _parse_iso(raw)
if requested_at is None:
return False
try:
requested_at = datetime.fromisoformat(raw)
except ValueError:
return False
if requested_at.tzinfo is None:
requested_at = requested_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - requested_at).total_seconds()
if age <= DRAIN_REQUEST_MAX_AGE_SECONDS:
return False
if _expiry_logged_for != raw:
_expiry_logged_for = raw
_log.warning(
"drain-control: ignoring expired drain marker (requested_at=%s, "
"age=%.0fs > max %.0fs, principal=%s) — the drain that wrote it "
"was never cancelled; treating as stale so the gateway keeps "
"accepting turns.",
raw,
age,
DRAIN_REQUEST_MAX_AGE_SECONDS,
body.get("principal"),
"drain-control: ignoring expired drain marker (requested_at=%s, age=%.0fs > max %.0fs, principal=%s) "
"— the drain that wrote it was never cancelled; treating as stale so the gateway keeps accepting turns.",
raw, age, DRAIN_REQUEST_MAX_AGE_SECONDS, body.get("principal"),
)
return True
def _marker_is_stale(body: dict[str, Any]) -> bool:
"""True iff the marker is definitely from a drain that is already over."""
return _marker_epoch_is_stale(body) or _marker_is_expired(body)
def _active_drain_body(home: Optional[Path]) -> Optional[dict[str, Any]]:
"""Marker body if present AND not stale, else None."""
"""Marker body if present AND not stale (definite epoch mismatch or expired), else None."""
body = read_drain_request(home=home)
if body is None or _marker_is_stale(body):
if body is None:
return None
current, marker_epoch = current_instantiation_epoch(), body.get("epoch")
if (current and marker_epoch and marker_epoch != current) or _marker_is_expired(body):
return None
return body
@@ -198,12 +131,10 @@ def drain_requested(*, home: Optional[Path] = None) -> bool:
def drain_notification_suppressed(*, home: Optional[Path] = None) -> bool:
"""True iff an ACTIVE drain marker explicitly asks to suppress the shutdown broadcast.
"""True iff an ACTIVE marker asks to suppress the shutdown broadcast.
Uses the same activeness rule as :func:`drain_requested`, so an orphaned
marker can never silence a fresh gateway's legitimate broadcast. A legacy
marker without the field or a contentless ``{}`` body reads as False
(fail toward the louder behaviour).
Same activeness rule as :func:`drain_requested`, so an orphan can never silence
a fresh gateway; a marker without the field reads False (fail toward louder).
"""
body = _active_drain_body(home)
return bool(body and body.get("suppress_notification"))
@@ -214,10 +145,9 @@ def read_drain_request(*, home: Optional[Path] = None) -> Optional[dict[str, Any
path = drain_request_path(home)
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except OSError as e:
_log.warning("drain-control: failed to read %s: %s", path, e)
if not isinstance(e, FileNotFoundError):
_log.warning("drain-control: failed to read %s: %s", path, e)
return None
try:
data = json.loads(raw)
+62 -96
View File
@@ -1,30 +1,18 @@
"""
Event Hook System
"""Event hook system: fires handlers at gateway lifecycle points.
Fires handlers at gateway lifecycle points. Hooks are discovered from
~/.hermes/hooks/<name>/ directories, each containing HOOK.yaml (name,
description, events) and handler.py (``def handle(event_type, context)``,
sync or async). Handler errors are logged and never block the pipeline.
Events:
gateway:startup, session:start, session:end (user ran /new or /reset),
session:reset, agent:start, agent:step (each tool-loop turn), agent:end,
command:* (any slash command; wildcard match).
Context passed to ``agent:start`` / ``agent:end``:
platform, user_id, chat_id, thread_id (Telegram forum-topic / thread root
id as string; empty when not in a thread), chat_type ("dm" | "group" |
"forum" | ""), session_id, message (truncated to 500 chars).
``agent:end`` adds: response (truncated to 500 chars), model, provider.
Handlers posting a follow-up into the same Telegram forum-topic should pass
``message_thread_id=int(thread_id)`` when ``chat_type == "forum"`` and
``thread_id`` is non-empty.
Hooks live in ~/.hermes/hooks/<name>/ with HOOK.yaml (name, description, events) and
handler.py (``def handle(event_type, context)``, sync or async); errors never block
the pipeline. Events: gateway:startup, session:start/end/reset, agent:start,
agent:step (each tool-loop turn), agent:end, command:* (wildcard). agent:* context:
platform, user_id, chat_id, thread_id ("" outside a thread), chat_type
("dm"|"group"|"forum"|""), session_id, message (500 chars); agent:end adds response,
model, provider. Forum follow-ups pass ``message_thread_id=int(thread_id)``.
"""
import asyncio
import importlib.util
import sys
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import yaml
@@ -35,6 +23,42 @@ from hermes_cli.config import get_hermes_home
HOOKS_DIR = get_hermes_home() / "hooks"
def _skip(name: str, reason: str) -> None:
print(f"[hooks] Skipping {name}: {reason}", flush=True)
def _load_hook_dir(hook_dir: Path) -> Optional[tuple]:
"""``(name, events, handle_fn, description)`` for a valid hook dir, else None (reason printed)."""
manifest_path, handler_path = hook_dir / "HOOK.yaml", hook_dir / "handler.py"
if not manifest_path.exists() or not handler_path.exists():
return None
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
if not manifest or not isinstance(manifest, dict):
return _skip(hook_dir.name, "invalid HOOK.yaml")
hook_name = manifest.get("name", hook_dir.name)
events = manifest.get("events", [])
if not events:
return _skip(hook_name, "no events declared")
# Register in sys.modules BEFORE exec_module so Pydantic/dataclass forward references
# (``from __future__ import annotations``) resolve; otherwise a handler declaring a
# BaseModel fails at first dispatch with "TypeAdapter ... is not fully defined".
module_name = f"hermes_hook_{hook_name}"
spec = importlib.util.spec_from_file_location(module_name, handler_path)
if spec is None or spec.loader is None:
return _skip(hook_name, "could not load handler.py")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_name, None)
raise
handle_fn = getattr(module, "handle", None)
if handle_fn is None:
return _skip(hook_name, "no 'handle' function found")
return hook_name, events, handle_fn, manifest.get("description", "")
class HookRegistry:
"""Discovers, loads, and fires event hooks."""
@@ -48,81 +72,33 @@ class HookRegistry:
def _register_builtin_hooks(self) -> None:
"""Extension point for always-on built-in hooks; currently none shipped."""
return
def discover_and_load(self) -> None:
"""Register built-in hooks, then load every valid hook dir under HOOKS_DIR."""
self._register_builtin_hooks()
if not HOOKS_DIR.exists():
return
for hook_dir in sorted(HOOKS_DIR.iterdir()):
if not hook_dir.is_dir():
continue
manifest_path = hook_dir / "HOOK.yaml"
handler_path = hook_dir / "handler.py"
if not manifest_path.exists() or not handler_path.exists():
continue
try:
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
if not manifest or not isinstance(manifest, dict):
print(f"[hooks] Skipping {hook_dir.name}: invalid HOOK.yaml", flush=True)
continue
hook_name = manifest.get("name", hook_dir.name)
events = manifest.get("events", [])
if not events:
print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
continue
# Register in sys.modules BEFORE exec_module so Pydantic/dataclass
# forward references (from ``from __future__ import annotations``)
# resolve; otherwise a handler declaring a BaseModel fails at first
# dispatch with "TypeAdapter ... is not fully defined".
module_name = f"hermes_hook_{hook_name}"
spec = importlib.util.spec_from_file_location(module_name, handler_path)
if spec is None or spec.loader is None:
print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True)
continue
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_name, None)
raise
handle_fn = getattr(module, "handle", None)
if handle_fn is None:
print(f"[hooks] Skipping {hook_name}: no 'handle' function found", flush=True)
continue
for event in events:
self._handlers.setdefault(event, []).append(handle_fn)
self._loaded_hooks.append({
"name": hook_name,
"description": manifest.get("description", ""),
"events": events,
"path": str(hook_dir),
})
print(f"[hooks] Loaded hook '{hook_name}' for events: {events}", flush=True)
loaded = _load_hook_dir(hook_dir)
except Exception as e:
print(f"[hooks] Error loading hook {hook_dir.name}: {e}", flush=True)
continue
if loaded is None:
continue
hook_name, events, handle_fn, description = loaded
for event in events:
self._handlers.setdefault(event, []).append(handle_fn)
self._loaded_hooks.append(
{"name": hook_name, "description": description, "events": events, "path": str(hook_dir)}
)
print(f"[hooks] Loaded hook '{hook_name}' for events: {events}", flush=True)
def _resolve_handlers(self, event_type: str) -> List[Callable]:
"""Exact-match handlers first, then ``<base>:*`` wildcard handlers.
A handler registered for a bare base type ("agent") does NOT fire for
"agent:start" — only exact matches and explicit wildcards.
"""
"""Exact-match handlers first, then ``<base>:*`` wildcards. A bare base type
("agent") does NOT fire for "agent:start" — only exact matches and explicit wildcards."""
handlers = list(self._handlers.get(event_type, []))
if ":" in event_type:
handlers.extend(self._handlers.get(f"{event_type.split(':')[0]}:*", []))
@@ -132,26 +108,16 @@ class HookRegistry:
"""Fire all handlers for an event, discarding return values."""
await self.emit_collect(event_type, context)
async def emit_collect(
self,
event_type: str,
context: Optional[Dict[str, Any]] = None,
) -> List[Any]:
"""Fire handlers and return their non-None return values in order.
Used for decision-style hooks (e.g. ``command:<name>`` policies that
allow/deny/rewrite a command). A failing handler is logged and does not
abort the remaining handlers.
"""
async def emit_collect(self, event_type: str, context: Optional[Dict[str, Any]] = None) -> List[Any]:
"""Fire handlers and return their non-None return values in order (decision-style
hooks, e.g. ``command:<name>`` policies). A failing handler is logged, not fatal."""
if context is None:
context = {}
results: List[Any] = []
for fn in self._resolve_handlers(event_type):
try:
result = fn(event_type, context)
if asyncio.iscoroutine(result): # sync and async handlers both supported
result = await result
result = await result if asyncio.iscoroutine(result) else result # sync or async handlers
if result is not None:
results.append(result)
except Exception as e:
+127 -275
View File
@@ -1,20 +1,15 @@
"""Deterministic policy for same-gateway hosted-room Discussions.
Translates a frozen local member roster plus the complete typed room log into
one next driver task. Pure: no I/O, no workers, no transport or model
knowledge. Callers persist the task with :mod:`gateway.hosted_room_driver`
and append publication plans with :mod:`gateway.hosted_rooms`.
The driver payload is deliberately not widened: Discussion coordinates live
in deterministic ``TaskIdentity`` values and typed terminal events, so a
restart can reconstruct a task from durable state. Callers must reconcile
terminal driver rows into publication plans before asking for the next task.
Pure (no I/O, transport or model knowledge): a frozen local roster plus the complete
typed room log yields at most one next driver task. Discussion coordinates live in
deterministic ``TaskIdentity`` values and typed terminal events rather than a widened
driver payload, so a restart reconstructs tasks from durable state. Callers must
reconcile terminal driver rows into publication plans before asking for the next task.
"""
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
@@ -24,6 +19,7 @@ from typing import Any, Literal
from gateway import hosted_room_driver as driver
from gateway import hosted_rooms
from gateway import hosted_rooms_common as common
from gateway.hosted_rooms_common import compact_json
MAX_DISCUSSION_MEMBERS = 6
@@ -42,29 +38,22 @@ _MENTION_RE = re.compile(r"@([A-Za-z0-9][A-Za-z0-9._:-]*)", re.IGNORECASE)
_TURN_ID_RE = re.compile(
r"^d(?P<source>[1-9][0-9]*)\.r(?P<round>[0-2])\."
r"p(?P<position>[0-5])\.s(?P<seen>[1-9][0-9]*)\."
r"m(?P<member>[0-9a-f]{24})$"
)
r"m(?P<member>[0-9a-f]{24})$")
_LOCAL_TARGET_FIELDS = frozenset({"kind", "profile"})
_PEER_TARGET_FIELDS = frozenset({"kind", "peer_id", "installation_id", "profile", "capability_digest"})
_REMOTE_MEMBER_FIELDS = frozenset({
"connectionId", "connectionKind", "connectionLabel", "connection_id",
"connection_kind", "connection_label", "remoteSource", "route",
"sourceMissing", "sourceReachable", "sourceScoped", "targetProfile",
"target_profile",
"connectionId", "connectionKind", "connectionLabel", "connection_id", "connection_kind", "connection_label",
"remoteSource", "route", "sourceMissing", "sourceReachable", "sourceScoped", "targetProfile", "target_profile",
})
_USER_PAYLOAD_FIELDS = frozenset({"text", "thread_id"})
_TURN_COORDINATE_FIELDS = frozenset({
"discussion_event_id", "member_id", "member_index", "round_index",
"task_id", "thread_id", "turn_id",
})
_TURN_COORDINATE_FIELDS = frozenset(
{"discussion_event_id", "member_id", "member_index", "round_index", "task_id", "thread_id", "turn_id", })
_MEMBER_MESSAGE_FIELDS = _TURN_COORDINATE_FIELDS | {"text"}
_TERMINAL_COMMON_FIELDS = _TURN_COORDINATE_FIELDS | {"seen_through_seq"}
_TERMINAL_EXTRA_FIELDS = {
"turn.settled": frozenset({"message_event_id", "passed"}),
"turn.failed": frozenset({"error"}),
"turn.cancelled": frozenset({"reason"}),
"turn.deferred": frozenset({"execution_generation", "reason"}),
"turn.settled": frozenset({"message_event_id", "passed"}), "turn.failed": frozenset({"error"}),
"turn.cancelled": frozenset({"reason"}), "turn.deferred": frozenset({"execution_generation", "reason"}),
}
_TERMINAL_OPTIONAL_FIELDS = {"turn.failed": frozenset({"reason_code"})}
_TERMINAL_EVENT_KINDS = frozenset(_TERMINAL_EXTRA_FIELDS)
@@ -72,15 +61,12 @@ _TERMINAL_EVENT_KINDS = frozenset(_TERMINAL_EXTRA_FIELDS)
_GATEWAY_EVENT_FIELDS = {
"room.activity": (
frozenset({"status", "reason_code", "thread_id", "discussion_event_id"}),
("reason_code", "thread_id", "discussion_event_id"),
),
"room.stop_requested": (frozenset({"cancel_id"}), ("cancel_id",)),
}
("reason_code", "thread_id", "discussion_event_id")),
"room.stop_requested": (frozenset({"cancel_id"}), ("cancel_id",))}
_EPOCH_STAMPED_KINDS = _TERMINAL_EVENT_KINDS | {"message.member", *_GATEWAY_EVENT_FIELDS}
class DiscussionPolicyError(ValueError):
"""Base class for invalid policy input or unreconstructable state."""
class DiscussionPolicyError(ValueError): """Base class for invalid policy input or unreconstructable state."""
class DiscussionValidationError(DiscussionPolicyError):
@@ -155,8 +141,7 @@ class EventPlan:
"room_id": room_id, "event_id": self.event_id, "kind": self.kind,
"actor": dict(self.actor), "payload": dict(self.payload),
"authority_gateway_id": self.authority_gateway_id,
"authority_epoch": self.authority_epoch,
}
"authority_epoch": self.authority_epoch}
@dataclass(frozen=True)
@@ -178,22 +163,20 @@ class _ValidatedEvent:
payload: Mapping[str, Any]
_identifier = partial(
common.identifier, error=DiscussionValidationError, max_chars=driver.MAX_IDENTIFIER_CHARS
)
_identifier = partial(common.identifier, error=DiscussionValidationError, max_chars=driver.MAX_IDENTIFIER_CHARS)
_exact_fields = partial(common.exact_fields, error=DiscussionValidationError)
_bounded_int = partial(common.bounded_int, error=DiscussionValidationError)
def _positive_int(value: Any, *, label: str) -> int:
return common.positive_int(
value, error=DiscussionValidationError, message=f"{label} must be a positive integer"
)
return _bounded_int(value, message=f"{label} must be a positive integer", low=1)
def _zero_based_int(value: Any, *, label: str, maximum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= maximum:
raise DiscussionValidationError(f"{label} must be an integer between 0 and {maximum}")
return value
message = f"{label} must be an integer between 0 and {maximum}"
return _bounded_int(value, message=message, high=maximum)
_text = partial(common.text, error=DiscussionValidationError)
def _peer_target(member: DiscussionMember) -> Mapping[str, Any] | None:
@@ -204,25 +187,15 @@ def _peer_target(member: DiscussionMember) -> Mapping[str, Any] | None:
def validate_user_payload(value: Any) -> dict[str, Any]:
"""Validate and normalize the exact ``message.user`` Discussion payload."""
payload = _exact_fields(value, label="user payload", required=_USER_PAYLOAD_FIELDS)
text = payload["text"]
if not isinstance(text, str):
raise DiscussionValidationError("user payload text must be a string")
text = text.strip()
if not text:
raise DiscussionValidationError("user payload text must not be empty")
if len(text.encode("utf-8")) > MAX_USER_TEXT_BYTES:
raise DiscussionValidationError("user payload text is too large")
text = _text(payload["text"], label="user payload text", max_bytes=MAX_USER_TEXT_BYTES)
return {"text": text, "thread_id": _identifier(payload["thread_id"], label="thread_id")}
def _validate_member_target(
value: Any, *, profile: str, known_profiles: set[str], index: int
) -> dict[str, Any]:
def _validate_member_target(value: Any, *, profile: str, known_profiles: set[str], index: int) -> dict[str, Any]:
if value is None:
if profile not in known_profiles:
raise DiscussionValidationError(
f"member {index} profile '{profile}' is not local to this gateway"
)
f"member {index} profile '{profile}' is not local to this gateway")
return {"kind": "local", "profile": profile}
if not isinstance(value, Mapping):
raise DiscussionValidationError(f"member {index} target must be an object")
@@ -231,19 +204,16 @@ def _validate_member_target(
raise DiscussionValidationError(f"member {index} target kind must be local or peer")
target = _exact_fields(
value, label=f"member {index} {kind} target",
required=_LOCAL_TARGET_FIELDS if kind == "local" else _PEER_TARGET_FIELDS,
)
required=_LOCAL_TARGET_FIELDS if kind == "local" else _PEER_TARGET_FIELDS)
target_profile = _identifier(target["profile"], label=f"member {index} target profile")
if kind == "local":
if target_profile != profile or profile not in known_profiles:
raise DiscussionValidationError(
f"member {index} local target does not match a local profile"
)
f"member {index} local target does not match a local profile")
return {"kind": "local", "profile": profile}
if target_profile != profile:
raise DiscussionValidationError(
f"member {index} peer target profile does not match member profile"
)
f"member {index} peer target profile does not match member profile")
capability_digest = target["capability_digest"]
if not isinstance(capability_digest, str) or not re.fullmatch(r"[0-9a-f]{64}", capability_digest):
raise DiscussionValidationError(f"member {index} capability_digest must be a sha256 digest")
@@ -252,8 +222,7 @@ def _validate_member_target(
"peer_id": _identifier(target["peer_id"], label=f"member {index} peer_id"),
"installation_id": _identifier(target["installation_id"], label=f"member {index} installation_id"),
"profile": target_profile,
"capability_digest": capability_digest,
}
"capability_digest": capability_digest}
def validate_roster(value: Any, *, local_profiles: Iterable[str]) -> tuple[DiscussionMember, ...]:
@@ -263,8 +232,7 @@ def validate_roster(value: Any, *, local_profiles: Iterable[str]) -> tuple[Discu
if not MIN_DISCUSSION_MEMBERS <= len(value) <= MAX_DISCUSSION_MEMBERS:
raise DiscussionValidationError(
f"members must contain between {MIN_DISCUSSION_MEMBERS} and "
f"{MAX_DISCUSSION_MEMBERS} entries"
)
f"{MAX_DISCUSSION_MEMBERS} entries")
known_profiles = {_identifier(profile, label="local profile") for profile in local_profiles}
members: list[DiscussionMember] = []
@@ -278,19 +246,16 @@ def validate_roster(value: Any, *, local_profiles: Iterable[str]) -> tuple[Discu
remote_fields = frozenset(raw) & _REMOTE_MEMBER_FIELDS
if remote_fields:
raise DiscussionValidationError(
f"member {index} contains cross-gateway fields: {', '.join(sorted(remote_fields))}"
)
f"member {index} contains cross-gateway fields: {', '.join(sorted(remote_fields))}")
member = _exact_fields(
raw, label=f"member {index}",
required=frozenset({"member_id", "profile", "handle"}),
optional=frozenset({"display_name", "target"}),
)
optional=frozenset({"display_name", "target"}))
member_id = _identifier(member["member_id"], label=f"member {index} id")
profile = _identifier(member["profile"], label=f"member {index} profile")
handle = _identifier(member["handle"], label=f"member {index} handle")
target = _validate_member_target(
member.get("target"), profile=profile, known_profiles=known_profiles, index=index
)
member.get("target"), profile=profile, known_profiles=known_profiles, index=index)
display_name = member.get("display_name", "")
if not isinstance(display_name, str):
raise DiscussionValidationError(f"member {index} display_name must be a string")
@@ -298,24 +263,20 @@ def validate_roster(value: Any, *, local_profiles: Iterable[str]) -> tuple[Discu
if len(display_name) > hosted_rooms.MAX_ACTOR_LABEL_CHARS:
raise DiscussionValidationError(f"member {index} display_name is too long")
target_key = json.dumps(target, sort_keys=True, separators=(",", ":")).casefold()
target_key = compact_json(target, ensure_ascii=False).casefold()
target_message = (
"member profiles must be unique" if target.get("kind") == "local"
else "member targets must be unique"
)
else "member targets must be unique")
for key, seen, message in (
(target_key, targets, target_message),
(handle.casefold(), handles,
"member handles must be unique and cannot reserve @all or @everyone"),
(member_id.casefold(), member_ids, "member ids must be unique"),
):
(handle.casefold(), handles, "member handles must be unique and cannot reserve @all or @everyone"),
(member_id.casefold(), member_ids, "member ids must be unique")):
if key in seen:
raise DiscussionValidationError(message)
seen.add(key)
members.append(DiscussionMember(
member_id=member_id, profile=profile, handle=handle,
display_name=display_name, target=target,
))
display_name=display_name, target=target))
return tuple(members)
@@ -336,9 +297,7 @@ def validate_room(value: Any, *, local_profiles: Iterable[str]) -> DiscussionRoo
authority_epoch = _positive_int(value.get("authority_epoch"), label="authority_epoch")
members = validate_roster(value.get("members"), local_profiles=local_profiles)
return DiscussionRoom(
room_id=room_id, name=name, members=members,
gateway_id=gateway_id, authority_epoch=authority_epoch,
)
room_id=room_id, name=name, members=members, gateway_id=gateway_id, authority_epoch=authority_epoch)
def is_pass_text(value: Any) -> bool:
@@ -367,8 +326,7 @@ def resolve_mentions(
def _unaddressed_member_mentions(
messages: Sequence[_ValidatedEvent], room: DiscussionRoom
) -> tuple[DiscussionMember, ...]:
messages: Sequence[_ValidatedEvent], room: DiscussionRoom) -> tuple[DiscussionMember, ...]:
"""Return peers explicitly cited by a Bot and not heard from afterward."""
cited_at: dict[str, int] = {}
last_post_at: dict[str, int] = {}
@@ -384,8 +342,7 @@ def _unaddressed_member_mentions(
return tuple(
member for member in room.members
if member.member_id in cited_at
and last_post_at.get(member.member_id, 0) <= cited_at[member.member_id]
)
and last_post_at.get(member.member_id, 0) <= cited_at[member.member_id])
def _require_gateway_actor(actor: Mapping[str, Any], room: DiscussionRoom, message: str) -> None:
@@ -395,10 +352,10 @@ def _require_gateway_actor(actor: Mapping[str, Any], room: DiscussionRoom, messa
def _member_by_id(room: DiscussionRoom, member_id: Any) -> DiscussionMember:
normalized = _identifier(member_id, label="member_id")
for member in room.members:
if member.member_id == normalized:
return member
raise DiscussionValidationError(f"unknown Discussion member '{normalized}'")
member = next((m for m in room.members if m.member_id == normalized), None)
if member is None:
raise DiscussionValidationError(f"unknown Discussion member '{normalized}'")
return member
def _validate_turn_coordinates(payload: Mapping[str, Any], room: DiscussionRoom) -> None:
@@ -414,8 +371,7 @@ def _validate_turn_coordinates(payload: Mapping[str, Any], room: DiscussionRoom)
def _validate_user_event(
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom
) -> Mapping[str, Any]:
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom) -> Mapping[str, Any]:
payload = validate_user_payload(payload)
if actor.get("kind") != "user":
raise DiscussionValidationError("message.user requires a user actor")
@@ -423,8 +379,7 @@ def _validate_user_event(
def _validate_member_message(
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom
) -> Mapping[str, Any]:
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom) -> Mapping[str, Any]:
_exact_fields(payload, label="message.member payload", required=_MEMBER_MESSAGE_FIELDS)
_validate_turn_coordinates(payload, room)
text = payload.get("text")
@@ -433,9 +388,7 @@ def _validate_member_message(
member = _member_by_id(room, payload.get("member_id"))
peer = _peer_target(member)
expected = {
"kind": "member",
"id": member.member_id,
"profile": member.profile,
"kind": "member", "id": member.member_id, "profile": member.profile,
"connection_id": peer.get("peer_id") if peer else None,
}
if any(actor.get(key) != value for key, value in expected.items()):
@@ -444,13 +397,11 @@ def _validate_member_message(
def _validate_terminal_event(
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom
) -> Mapping[str, Any]:
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom) -> Mapping[str, Any]:
_exact_fields(
payload, label=f"{kind} payload",
required=_TERMINAL_COMMON_FIELDS | _TERMINAL_EXTRA_FIELDS[kind],
optional=_TERMINAL_OPTIONAL_FIELDS.get(kind, frozenset()),
)
optional=_TERMINAL_OPTIONAL_FIELDS.get(kind, frozenset()))
_validate_turn_coordinates(payload, room)
_positive_int(payload.get("seen_through_seq"), label="seen_through_seq")
if actor.get("connection_id") is not None:
@@ -474,15 +425,12 @@ def _validate_terminal_event(
from tools.bot_failure_reasons import ALL_REASONS
if payload["reason_code"] not in ALL_REASONS:
raise DiscussionValidationError(
"turn.failed reason_code must use the shared failure vocabulary"
)
raise DiscussionValidationError("turn.failed reason_code must use the shared failure vocabulary")
return payload
def _validate_gateway_event(
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom
) -> Mapping[str, Any]:
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom) -> Mapping[str, Any]:
fields, identifier_fields = _GATEWAY_EVENT_FIELDS[kind]
_exact_fields(payload, label=f"{kind} payload", required=fields)
if kind == "room.activity" and payload.get("status") not in {"settled", "bounded"}:
@@ -497,8 +445,7 @@ _EVENT_VALIDATORS = {
"message.user": _validate_user_event,
"message.member": _validate_member_message,
**dict.fromkeys(_TERMINAL_EVENT_KINDS, _validate_terminal_event),
**dict.fromkeys(_GATEWAY_EVENT_FIELDS, _validate_gateway_event),
}
**dict.fromkeys(_GATEWAY_EVENT_FIELDS, _validate_gateway_event)}
def _validate_event(raw: Any, *, room: DiscussionRoom, previous_seq: int) -> _ValidatedEvent:
@@ -514,8 +461,7 @@ def _validate_event(raw: Any, *, room: DiscussionRoom, previous_seq: int) -> _Va
for value, expected, message in (
(kind, str, "event kind must be a string"),
(actor, Mapping, "event actor must be an object"),
(payload, Mapping, "event payload must be an object"),
):
(payload, Mapping, "event payload must be an object")):
if not isinstance(value, expected):
raise DiscussionValidationError(message)
if kind in _EPOCH_STAMPED_KINDS and raw.get("authority_epoch") != room.authority_epoch:
@@ -526,9 +472,7 @@ def _validate_event(raw: Any, *, room: DiscussionRoom, previous_seq: int) -> _Va
return _ValidatedEvent(raw=raw, seq=seq, event_id=event_id, kind=kind, actor=actor, payload=payload)
def _validated_events(
events: Sequence[Mapping[str, Any]], *, room: DiscussionRoom
) -> tuple[_ValidatedEvent, ...]:
def _validated_events(events: Sequence[Mapping[str, Any]], *, room: DiscussionRoom) -> tuple[_ValidatedEvent, ...]:
validated: list[_ValidatedEvent] = []
previous_seq = 0
event_ids: set[str] = set()
@@ -562,14 +506,12 @@ def _derive_member_watermarks(events: Sequence[_ValidatedEvent]) -> dict[tuple[s
if previous is not None:
if previous.kind != "turn.deferred":
raise DiscussionValidationError(
f"task '{task_id}' has more than one terminal room event"
)
f"task '{task_id}' has more than one terminal room event")
if event.kind == "turn.deferred" and int(
event.payload["execution_generation"]
) <= int(previous.payload["execution_generation"]):
raise DiscussionValidationError(
f"task '{task_id}' deferral generation did not advance"
)
f"task '{task_id}' deferral generation did not advance")
terminal_by_task[task_id] = event
key = (str(event.payload["thread_id"]), str(event.payload["member_id"]))
watermark = int(event.payload["seen_through_seq"])
@@ -577,8 +519,7 @@ def _derive_member_watermarks(events: Sequence[_ValidatedEvent]) -> dict[tuple[s
message = messages_by_id.get(str(event.payload["message_event_id"]))
if message is None or any(
message.payload.get(field) != event.payload.get(field)
for field in ("task_id", "member_id", "thread_id")
):
for field in ("task_id", "member_id", "thread_id")):
raise DiscussionValidationError("turn.settled references no matching member message")
watermark = max(watermark, message.seq)
watermarks[key] = max(watermarks.get(key, 0), watermark)
@@ -586,10 +527,7 @@ def _derive_member_watermarks(events: Sequence[_ValidatedEvent]) -> dict[tuple[s
def _member_digest(member: DiscussionMember) -> str:
target = json.dumps(
member.target or {"kind": "local", "profile": member.profile},
sort_keys=True, separators=(",", ":"),
)
target = compact_json(member.target or {"kind": "local", "profile": member.profile}, ensure_ascii=False)
seed = f"{member.member_id}\0{member.profile}\0{member.handle}\0{target}"
return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
@@ -626,28 +564,22 @@ def _truncate_utf8_text(value: Any, *, max_bytes: int, suffix: str = "") -> str:
def _build_prompt(
*, room: DiscussionRoom, member: DiscussionMember, messages: Sequence[_ValidatedEvent],
watermark: int, seen_through_seq: int,
) -> str:
delta = [event for event in messages if watermark < event.seq <= seen_through_seq][
-MAX_DISCUSSION_DELTA_LINES:
]
watermark: int, seen_through_seq: int) -> str:
delta = [event for event in messages if watermark < event.seq <= seen_through_seq][- MAX_DISCUSSION_DELTA_LINES:]
peers = ", ".join(
f"@{candidate.handle}" for candidate in room.members if candidate.member_id != member.member_id
)
f"@{candidate.handle}" for candidate in room.members if candidate.member_id != member.member_id)
opening = [
f'[Discussion: "{room.name}"] You are @{member.handle}, one participant '
f"with {peers or 'no other members'} and the user.",
"",
"New messages in this thread since your last turn (oldest first):",
]
"New messages in this thread since your last turn (oldest first):"]
rules = [
"",
"Rules for this Discussion:",
"- Reply with one conversational message only when you have something new worth adding.",
'- If you have nothing new to add, reply with exactly "(pass)".',
"- Mention a teammate by handle to pull them into the next round; do not repeat points already made.",
"- Never reveal content from private conversations. Your reply is published verbatim.",
]
"- Never reveal content from private conversations. Your reply is published verbatim."]
fixed_bytes = len("\n".join([*opening, *rules]).encode("utf-8"))
available = max(0, driver.MAX_PROMPT_BYTES - fixed_bytes - 1)
selected: list[str] = []
@@ -674,54 +606,36 @@ def _build_prompt(
def _make_task_plan(
*, room: DiscussionRoom, discussion_event: _ValidatedEvent, member: DiscussionMember,
member_index: int, round_index: int, seen_through_seq: int, prompt: str,
) -> DiscussionTaskPlan:
member_index: int, round_index: int, seen_through_seq: int, prompt: str) -> DiscussionTaskPlan:
turn_id = (
f"d{discussion_event.seq}.r{round_index}.p{member_index}."
f"s{seen_through_seq}.m{_member_digest(member)}"
)
seed = json.dumps(
{
"discussion_event_id": discussion_event.event_id,
"member_id": member.member_id,
"member_index": member_index,
"prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
"room_id": room.room_id,
"round_index": round_index,
"seen_through_seq": seen_through_seq,
"source_event_seq": discussion_event.seq,
"thread_id": discussion_event.payload["thread_id"],
},
ensure_ascii=True, sort_keys=True, separators=(",", ":"),
)
f"s{seen_through_seq}.m{_member_digest(member)}")
seed = compact_json({
"discussion_event_id": discussion_event.event_id, "member_id": member.member_id, "member_index": member_index,
"prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), "room_id": room.room_id,
"round_index": round_index, "seen_through_seq": seen_through_seq, "source_event_seq": discussion_event.seq,
"thread_id": discussion_event.payload["thread_id"],
})
task_id = f"dtask:{hashlib.sha256(seed.encode('utf-8')).hexdigest()[:48]}"
identity = driver.TaskIdentity(
room_id=room.room_id, task_id=task_id,
thread_id=str(discussion_event.payload["thread_id"]), turn_id=turn_id,
)
thread_id=str(discussion_event.payload["thread_id"]), turn_id=turn_id)
payload = {
"target_member_id": member.member_id,
"target_profile": member.profile,
"prompt": prompt,
"target_member_id": member.member_id, "target_profile": member.profile, "prompt": prompt,
"source_event_seq": discussion_event.seq,
}
return DiscussionTaskPlan(
identity=identity, payload=payload, discussion_event_id=discussion_event.event_id,
member=member, member_index=member_index, round_index=round_index,
seen_through_seq=seen_through_seq,
)
identity=identity, payload=payload, discussion_event_id=discussion_event.event_id, member=member,
member_index=member_index, round_index=round_index, seen_through_seq=seen_through_seq)
def _pending_discussion(validated: Sequence[_ValidatedEvent]) -> _ValidatedEvent | None:
"""Oldest latest-per-thread user message not stopped and not yet completed."""
stopped_through_seq = max(
(event.seq for event in validated if event.kind == "room.stop_requested"), default=0
)
stopped_through_seq = max((event.seq for event in validated if event.kind == "room.stop_requested"), default=0)
completed_discussion_ids = {
str(event.payload["discussion_event_id"])
for event in validated
if event.kind == "room.activity" and event.payload.get("status") in {"settled", "bounded"}
}
if event.kind == "room.activity" and event.payload.get("status") in {"settled", "bounded"}}
latest_by_thread: dict[str, _ValidatedEvent] = {}
for event in validated:
if event.kind == "message.user":
@@ -729,8 +643,7 @@ def _pending_discussion(validated: Sequence[_ValidatedEvent]) -> _ValidatedEvent
pending = [
event
for event in sorted(latest_by_thread.values(), key=lambda item: item.seq)
if event.seq > stopped_through_seq and event.event_id not in completed_discussion_ids
]
if event.seq > stopped_through_seq and event.event_id not in completed_discussion_ids]
return pending[0] if pending else None
@@ -742,8 +655,7 @@ def _thread_messages(
committed_member_message_ids = {
str(event.payload["message_event_id"])
for event in validated
if event.kind == "turn.settled" and event.payload.get("message_event_id") is not None
}
if event.kind == "turn.settled" and event.payload.get("message_event_id") is not None}
# Publication writes the visible member message before the terminal event.
# A crash in that gap leaves the message in the log, but it is not committed
# policy input yet: ignoring it reproduces the original task coordinates so
@@ -754,15 +666,12 @@ def _thread_messages(
if event.payload.get("thread_id") == thread_id
and (
event.kind == "message.user"
or (event.kind == "message.member" and event.event_id in committed_member_message_ids)
)
)
or (event.kind == "message.member" and event.event_id in committed_member_message_ids)))
discussion_messages = tuple(event for event in thread_messages if event.seq >= discussion.seq)
member_messages = tuple(
event for event in thread_messages
if event.kind == "message.member"
and event.payload.get("discussion_event_id") == discussion.event_id
)
and event.payload.get("discussion_event_id") == discussion.event_id)
return thread_messages, discussion_messages, member_messages
@@ -772,20 +681,15 @@ def _effective_watermarks(
watermarks = {
(str(thread_id), str(member_id)): int(value)
for (thread_id, member_id), value in (initial_watermarks or {}).items()
if int(value) >= 0
}
if int(value) >= 0}
for key, value in _derive_member_watermarks(validated).items():
watermarks[key] = max(watermarks.get(key, 0), value)
return watermarks
def plan_next_task(
room_value: Any,
events: Sequence[Mapping[str, Any]],
*,
local_profiles: Iterable[str],
initial_watermarks: Mapping[tuple[str, str], int] | None = None,
) -> DiscussionDecision:
room_value: Any, events: Sequence[Mapping[str, Any]], *, local_profiles: Iterable[str],
initial_watermarks: Mapping[tuple[str, str], int] | None = None) -> DiscussionDecision:
"""Replay the complete room log and return at most one next member task."""
room = validate_room(room_value, local_profiles=local_profiles)
validated = _validated_events(events, room=room)
@@ -795,8 +699,7 @@ def plan_next_task(
thread_id = str(discussion.payload["thread_id"])
decide = partial(
DiscussionDecision, discussion_event_id=discussion.event_id,
source_event_seq=discussion.seq, thread_id=thread_id,
)
source_event_seq=discussion.seq, thread_id=thread_id)
thread_messages, discussion_messages, member_messages = _thread_messages(validated, discussion)
if len(member_messages) >= MAX_DISCUSSION_MESSAGES:
@@ -806,8 +709,7 @@ def plan_next_task(
(int(event.payload["round_index"]), str(event.payload["member_id"])): event
for event in validated
if event.kind in _TERMINAL_EVENT_KINDS
and event.payload.get("discussion_event_id") == discussion.event_id
}
and event.payload.get("discussion_event_id") == discussion.event_id}
watermarks = _effective_watermarks(validated, initial_watermarks)
seen_through_seq = max(event.seq for event in thread_messages)
@@ -820,8 +722,7 @@ def plan_next_task(
responders = (
resolve_mentions((str(discussion.payload["text"]),), room.members)
if round_index == 0
else _unaddressed_member_mentions(discussion_messages, room)
)
else _unaddressed_member_mentions(discussion_messages, room))
for member_index, member in enumerate(_rotate(responders, round_index)):
if (round_index, member.member_id) in terminals:
continue
@@ -830,12 +731,10 @@ def plan_next_task(
continue
prompt = _build_prompt(
room=room, member=member, messages=thread_messages,
watermark=watermark, seen_through_seq=seen_through_seq,
)
watermark=watermark, seen_through_seq=seen_through_seq)
task = _make_task_plan(
room=room, discussion_event=discussion, member=member, member_index=member_index,
round_index=round_index, seen_through_seq=seen_through_seq, prompt=prompt,
)
round_index=round_index, seen_through_seq=seen_through_seq, prompt=prompt)
return decide("task", "member_turn", task=task)
if not any(int(event.payload["round_index"]) == round_index for event in member_messages):
@@ -848,8 +747,7 @@ def plan_next_task(
def reconstruct_task_plan(
room_value: Any, events: Sequence[Mapping[str, Any]], task: Mapping[str, Any],
*, local_profiles: Iterable[str],
) -> DiscussionTaskPlan:
*, local_profiles: Iterable[str]) -> DiscussionTaskPlan:
"""Reconstruct and verify one persisted driver task after a restart."""
room = validate_room(room_value, local_profiles=local_profiles)
validated = _validated_events(events, room=room)
@@ -858,9 +756,7 @@ def reconstruct_task_plan(
if not isinstance(identity, driver.TaskIdentity) or not isinstance(payload, Mapping):
raise DiscussionReconstructionError("driver task has no valid identity or payload")
required_payload = frozenset({"target_profile", "prompt", "source_event_seq"})
if not required_payload <= frozenset(payload) or (
frozenset(payload) - required_payload - {"target_member_id"}
):
if not required_payload <= frozenset(payload) or (frozenset(payload) - required_payload - {"target_member_id"}):
raise DiscussionReconstructionError("driver task payload shape changed")
match = _TURN_ID_RE.fullmatch(identity.turn_id)
if match is None:
@@ -868,9 +764,7 @@ def reconstruct_task_plan(
source_event_seq = int(match.group("source"))
if payload.get("source_event_seq") != source_event_seq:
raise DiscussionReconstructionError("task source event does not match turn_id")
discussion = next(
(e for e in validated if e.seq == source_event_seq and e.kind == "message.user"), None
)
discussion = next((e for e in validated if e.seq == source_event_seq and e.kind == "message.user"), None)
if discussion is None:
raise DiscussionReconstructionError("task source user event is missing")
if identity.room_id != room.room_id or identity.thread_id != discussion.payload["thread_id"]:
@@ -880,16 +774,9 @@ def reconstruct_task_plan(
member = next(
(
candidate for candidate in room.members
if (
candidate.member_id == target_member_id
if target_member_id is not None
else candidate.profile == profile
)
),
None,
)
if member is not None and member.profile != profile:
member = None
if candidate.profile == profile
and (target_member_id is None or candidate.member_id == target_member_id)),
None)
if member is None or _member_digest(member) != match.group("member"):
raise DiscussionReconstructionError("task target member does not match turn_id")
prompt = payload.get("prompt")
@@ -898,15 +785,22 @@ def reconstruct_task_plan(
if len(prompt.encode("utf-8")) > driver.MAX_PROMPT_BYTES:
raise DiscussionReconstructionError("task prompt exceeds the driver limit")
reconstructed = _make_task_plan(
room=room, discussion_event=discussion, member=member,
member_index=int(match.group("position")), round_index=int(match.group("round")),
seen_through_seq=int(match.group("seen")), prompt=prompt,
)
room=room, discussion_event=discussion, member=member, member_index=int(match.group("position")),
round_index=int(match.group("round")), seen_through_seq=int(match.group("seen")), prompt=prompt)
if reconstructed.identity != identity or dict(reconstructed.payload) != dict(payload):
raise DiscussionReconstructionError("driver task failed deterministic reconstruction")
return reconstructed
def _turn_coordinates(task: DiscussionTaskPlan) -> dict[str, Any]:
"""The coordinate fields every member message and terminal event carries."""
return {
"discussion_event_id": task.discussion_event_id, "member_id": task.member.member_id,
"member_index": task.member_index, "round_index": task.round_index, "task_id": task.identity.task_id,
"thread_id": task.identity.thread_id, "turn_id": task.identity.turn_id,
}
def _terminal_text(result: Any, *, field: str, fallback: str) -> str:
if isinstance(result, Mapping):
value = result.get(field)
@@ -927,8 +821,7 @@ def _settled_effects(
) -> tuple[dict[str, Any], list[EventPlan]]:
text = _truncate_utf8_text(
_terminal_text(result, field="text", fallback=""),
max_bytes=MAX_MEMBER_TEXT_BYTES, suffix=_TRUNCATED_REPLY_NOTICE,
)
max_bytes=MAX_MEMBER_TEXT_BYTES, suffix=_TRUNCATED_REPLY_NOTICE)
passed = is_pass_text(text)
effects: list[EventPlan] = []
if not passed:
@@ -940,18 +833,8 @@ def _settled_effects(
member_actor["display_name"] = task.member.display_name
effects.append(EventPlan(
event_id=message_event_id, kind="message.member", actor=member_actor,
payload={
"discussion_event_id": task.discussion_event_id,
"member_id": task.member.member_id,
"member_index": task.member_index,
"round_index": task.round_index,
"task_id": task.identity.task_id,
"text": text,
"thread_id": task.identity.thread_id,
"turn_id": task.identity.turn_id,
},
authority_gateway_id=room.gateway_id, authority_epoch=room.authority_epoch,
))
payload={**_turn_coordinates(task), "text": text},
authority_gateway_id=room.gateway_id, authority_epoch=room.authority_epoch))
return {"message_event_id": None if passed else message_event_id, "passed": passed}, effects
@@ -961,25 +844,20 @@ def _failed_effects(result: Any, **_: Any) -> tuple[dict[str, Any], list[EventPl
supplied_reason = (
str(result.get("reason_code") or result.get("reason") or "").strip()
if isinstance(result, Mapping) else ""
)
if isinstance(result, Mapping) else "")
reason_code = supplied_reason if supplied_reason in ALL_REASONS else classify_agent_error(error_text)
return {"error": error_text, "reason_code": reason_code}, []
def _cancelled_effects(
result: Any, *, newer_same_thread: bool, **_: Any
) -> tuple[dict[str, Any], list[EventPlan]]:
def _cancelled_effects(result: Any, *, newer_same_thread: bool, ** _: Any) -> tuple[dict[str, Any], list[EventPlan]]:
reason = (
"superseded_by_newer_user_event" if newer_same_thread
else _terminal_text(result, field="reason", fallback="member turn cancelled")
)
else _terminal_text(result, field="reason", fallback="member turn cancelled"))
return {"reason": reason}, []
def _deferred_effects(
result: Any, *, execution_generation: int | None, **_: Any
) -> tuple[dict[str, Any], list[EventPlan]]:
result: Any, *, execution_generation: int | None, **_: Any) -> tuple[dict[str, Any], list[EventPlan]]:
return {
"execution_generation": execution_generation,
"reason": _terminal_text(result, field="reason", fallback="member_unavailable"),
@@ -987,23 +865,14 @@ def _deferred_effects(
_TERMINAL_EFFECTS = {
"settled": _settled_effects,
"failed": _failed_effects,
"cancelled": _cancelled_effects,
"settled": _settled_effects, "failed": _failed_effects, "cancelled": _cancelled_effects,
"deferred": _deferred_effects,
}
def plan_publication(
room_value: Any,
events: Sequence[Mapping[str, Any]],
task: DiscussionTaskPlan,
*,
status: TerminalKind,
result: Any = None,
execution_generation: int | None = None,
local_profiles: Iterable[str],
) -> PublicationPlan:
room_value: Any, events: Sequence[Mapping[str, Any]], task: DiscussionTaskPlan, *, status: TerminalKind,
result: Any = None, execution_generation: int | None = None, local_profiles: Iterable[str]) -> PublicationPlan:
"""Plan idempotent room effects for one terminal driver task.
A newer user event in the same thread supersedes a late result: the task
@@ -1020,43 +889,26 @@ def plan_publication(
raise DiscussionValidationError("invalid terminal publication status")
if status == "deferred":
message = "deferred publication requires an execution generation"
common.positive_int(execution_generation, error=DiscussionValidationError, message=message)
_bounded_int(execution_generation, message=message, low=1)
newer_same_thread = any(
event.kind == "message.user"
and event.seq > task.seen_through_seq
and event.payload.get("thread_id") == task.identity.thread_id
for event in validated
)
effective_status: TerminalKind = (
"cancelled" if newer_same_thread and status != "deferred" else status
)
for event in validated)
effective_status: TerminalKind = ("cancelled" if newer_same_thread and status != "deferred" else status)
digest = task.identity.task_id.removeprefix("dtask:")
terminal_event_id = (
f"ddeferred:{digest}:g{execution_generation}"
if effective_status == "deferred" else f"dterminal:{digest}"
)
common_payload = {
"discussion_event_id": task.discussion_event_id,
"member_id": task.member.member_id,
"member_index": task.member_index,
"round_index": task.round_index,
"seen_through_seq": task.seen_through_seq,
"task_id": task.identity.task_id,
"thread_id": task.identity.thread_id,
"turn_id": task.identity.turn_id,
}
if effective_status == "deferred" else f"dterminal:{digest}")
common_payload = {**_turn_coordinates(task), "seen_through_seq": task.seen_through_seq}
extra, effects = _TERMINAL_EFFECTS[effective_status](
result, task=task, room=room, message_event_id=f"dmessage:{digest}",
newer_same_thread=newer_same_thread, execution_generation=execution_generation,
)
newer_same_thread=newer_same_thread, execution_generation=execution_generation)
terminal_kind = f"turn.{effective_status}"
effects.append(EventPlan(
event_id=terminal_event_id, kind=terminal_kind,
actor={"kind": "gateway", "id": room.gateway_id},
payload={**common_payload, **extra},
authority_gateway_id=room.gateway_id, authority_epoch=room.authority_epoch,
))
return PublicationPlan(
task_id=task.identity.task_id, terminal_kind=terminal_kind, events=tuple(effects)
)
authority_gateway_id=room.gateway_id, authority_epoch=room.authority_epoch))
return PublicationPlan(task_id=task.identity.task_id, terminal_kind=terminal_kind, events=tuple(effects))
+115 -220
View File
@@ -14,12 +14,12 @@ import json
import math
import sqlite3
from dataclasses import dataclass
from functools import partial
from pathlib import Path
from typing import Any, Callable, Literal, get_args
from gateway.hosted_rooms_common import (
canonical_json, identifier, non_negative_int, positive_int, table_columns, transaction,
)
bounded_int, canonical_json, compact_json, connect, identifier, table_columns, text, transaction)
Clock = Callable[[], float]
TaskStatus = Literal["queued", "running", "settled", "failed", "cancelled", "indeterminate", "deferred", "stopping"]
@@ -38,16 +38,12 @@ _TASK_PAYLOAD_REQUIRED_FIELDS = frozenset({"target_profile", "prompt", "source_e
_TASK_PAYLOAD_OPTIONAL_FIELDS = frozenset({"target_member_id"})
_LEASE_COLUMNS = frozenset({
"room_id", "gateway_id", "authority_epoch", "process_generation",
"lease_generation", "expires_at", "acquired_at", "updated_at", "released_at",
})
"lease_generation", "expires_at", "acquired_at", "updated_at", "released_at"})
_TASK_COLUMN_ORDER = (
"room_id", "task_id", "thread_id", "turn_id", "source_event_seq",
"payload_json", "payload_digest", "status", "execution_generation",
"cancel_generation", "run_gateway_id", "run_process_generation",
"run_lease_generation", "cancel_id", "settlement_id", "settlement_status",
"result_json", "created_at", "updated_at", "started_at", "terminal_at",
"indeterminate_at",
)
"room_id", "task_id", "thread_id", "turn_id", "source_event_seq", "payload_json", "payload_digest",
"status", "execution_generation", "cancel_generation", "run_gateway_id", "run_process_generation",
"run_lease_generation", "cancel_id", "settlement_id", "settlement_status", "result_json", "created_at",
"updated_at", "started_at", "terminal_at", "indeterminate_at")
_TASK_COLUMNS = frozenset(_TASK_COLUMN_ORDER)
_TASK_ORDER = "ORDER BY source_event_seq, created_at, task_id"
_SELECT_LEASE = "SELECT * FROM hosted_room_driver_leases WHERE room_id=?"
@@ -78,79 +74,64 @@ _SETTLE_RUNNING_SQL = _generation_update(_SETTLE_SET, "running") + f" AND {_RUN_
_SETTLE_STOPPING_SQL = _generation_update(_SETTLE_SET, "stopping")
_REQUEUE_RUNNING_SQL = _task_update(
f"{_REQUEUE_SET}, started_at=NULL, updated_at=?",
f"status='running' AND {_GENERATION_FENCE} AND {_RUN_FENCE}",
)
f"status='running' AND {_GENERATION_FENCE} AND {_RUN_FENCE}")
_CANCEL_QUEUED_SQL = _task_update(_CANCEL_SET, "status IN ('queued', 'deferred') AND cancel_generation=?")
_BEGIN_STOP_SQL = _task_update(
"status='stopping', cancel_generation=?, cancel_id=?, updated_at=?",
"status IN ('running', 'indeterminate') AND cancel_generation=?",
)
"status IN ('running', 'indeterminate') AND cancel_generation=?")
_COMPLETE_STOP_SQL = _task_update(
"status='cancelled', terminal_at=?, updated_at=?",
"status='stopping' AND cancel_id=? AND cancel_generation=?",
)
"status='stopping' AND cancel_id=? AND cancel_generation=?")
# Lease-first recovery transitions: name -> (fenced status, SET clause, generation-guard stale message,
# row stale message); the UPDATE is _generation_update(set_clause, status).
_INDETERMINATE_STALE = "indeterminate task generation changed"
_GENERATION_TRANSITIONS = {
"resolve": (
"indeterminate", _SETTLE_SET, _INDETERMINATE_STALE, "indeterminate task changed during reconciliation",
),
"resolve": ("indeterminate", _SETTLE_SET, _INDETERMINATE_STALE, "indeterminate task changed during reconciliation"),
"resolve_cancel": (
"indeterminate", _CANCEL_SET, "indeterminate cancellation proof is stale",
"indeterminate cancellation proof lost its fence",
),
"indeterminate cancellation proof lost its fence"),
"requeue": (
"indeterminate", f"{_REQUEUE_SET}, started_at=NULL, indeterminate_at=NULL, updated_at=?",
_INDETERMINATE_STALE, "indeterminate task changed during requeue",
),
_INDETERMINATE_STALE, "indeterminate task changed during requeue"),
"defer": (
"indeterminate", "status='deferred', result_json=?, terminal_at=?, updated_at=?",
_INDETERMINATE_STALE, "indeterminate task changed during deferral",
),
_INDETERMINATE_STALE, "indeterminate task changed during deferral"),
"requeue_deferred": (
"deferred",
f"{_REQUEUE_SET}, result_json=NULL, started_at=NULL, terminal_at=NULL, indeterminate_at=NULL, updated_at=?",
"deferred task generation changed", "deferred task changed during requeue",
),
}
"deferred task generation changed", "deferred task changed during requeue")}
class DriverStateError(ValueError):
"""Base class for invalid or conflicting driver-state operations."""
class DriverStateError(ValueError): """Base class for invalid or conflicting driver-state operations."""
class DriverValidationError(DriverStateError):
"""Raised when an identifier, clock, TTL, or payload is invalid."""
class DriverValidationError(DriverStateError): """Raised when an identifier, clock, TTL, or payload is invalid."""
class RoomUnavailableError(DriverStateError):
"""Raised when the hosted room does not exist or was disbanded."""
class RoomUnavailableError(DriverStateError): """Raised when the hosted room does not exist or was disbanded."""
class LeaseHeldError(DriverStateError):
"""Raised when another unexpired driver generation owns the room."""
class LeaseHeldError(DriverStateError): """Raised when another unexpired driver generation owns the room."""
class StaleLeaseError(DriverStateError):
"""Raised when a lease generation can no longer mutate room state."""
class StaleLeaseError(DriverStateError): """Raised when a lease generation can no longer mutate room state."""
class TaskConflictError(DriverStateError):
"""Raised when an idempotency key is reused for different task state."""
class TaskConflictError(DriverStateError): """Raised when an idempotency key is reused for different task state."""
class StaleTaskError(DriverStateError):
"""Raised when an obsolete task attempt or cancellation tries to commit."""
class StaleTaskError(DriverStateError): """Raised when an obsolete task attempt or cancellation tries to commit."""
class InvalidTaskTransitionError(DriverStateError):
"""Raised when a requested task transition is not allowed."""
class InvalidTaskTransitionError(DriverStateError): """Raised when a requested task transition is not allowed."""
def _identifier(value: Any, *, label: str) -> str:
return identifier(value, label=label, error=DriverValidationError, max_chars=MAX_IDENTIFIER_CHARS)
_identifier = partial(identifier, error=DriverValidationError, max_chars=MAX_IDENTIFIER_CHARS)
_bounded_int = partial(bounded_int, error=DriverValidationError)
_canonical_json = partial(
canonical_json, error=DriverValidationError, label="result", max_bytes=MAX_RESULT_JSON_BYTES,
ensure_ascii=True)
def _finite(compute: Callable[[], Any], message: str, *, positive: bool = False) -> float:
@@ -180,12 +161,6 @@ def _expiry(now: float, ttl: float) -> float:
return expires_at
def _canonical_json(value: Any) -> str:
return canonical_json(
value, error=DriverValidationError, label="result", max_bytes=MAX_RESULT_JSON_BYTES, ensure_ascii=True
)
def _task_payload(value: Any) -> tuple[dict[str, Any], str, str]:
if not isinstance(value, dict):
raise DriverValidationError("payload must be an object")
@@ -196,21 +171,13 @@ def _task_payload(value: Any) -> tuple[dict[str, Any], str, str]:
if missing:
raise DriverValidationError(f"missing payload fields: {', '.join(sorted(missing))}")
target_profile = _identifier(value["target_profile"], label="target_profile")
prompt = value["prompt"]
if not isinstance(prompt, str):
raise DriverValidationError("prompt must be a string")
if not prompt.strip():
raise DriverValidationError("prompt must not be empty")
if len(prompt.encode("utf-8")) > MAX_PROMPT_BYTES:
raise DriverValidationError("prompt is too large")
source_event_seq = positive_int(
value["source_event_seq"], error=DriverValidationError,
message="source_event_seq must be a positive integer",
)
prompt = text(value["prompt"], error=DriverValidationError, label="prompt", max_bytes=MAX_PROMPT_BYTES, strip=False)
source_event_seq = _bounded_int(
value["source_event_seq"], message="source_event_seq must be a positive integer", low=1)
normalized = {"target_profile": target_profile, "prompt": prompt, "source_event_seq": source_event_seq}
if "target_member_id" in value:
normalized["target_member_id"] = _identifier(value["target_member_id"], label="target_member_id")
encoded = json.dumps(normalized, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
encoded = compact_json(normalized)
return normalized, encoded, hashlib.sha256(encoded.encode("utf-8")).hexdigest()
@@ -267,8 +234,7 @@ def _create_task_table(conn: sqlite3.Connection, table: str = "hosted_room_drive
settlement_id TEXT, settlement_status TEXT, result_json TEXT, created_at REAL NOT NULL,
updated_at REAL NOT NULL, started_at REAL, terminal_at REAL, indeterminate_at REAL,
PRIMARY KEY (room_id, task_id), UNIQUE (room_id, thread_id, turn_id),
FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id))"""
)
FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id))""")
def _initialize_schema(conn: sqlite3.Connection) -> None:
@@ -291,8 +257,7 @@ def _validate_schema(conn: sqlite3.Connection) -> None:
if lease_columns != _LEASE_COLUMNS or task_columns != _TASK_COLUMNS:
raise DriverStateError(
"unsupported unpublished hosted-room driver schema; "
"recreate the driver tables before starting the driver"
)
"recreate the driver tables before starting the driver")
for table in ("hosted_room_driver_leases", "hosted_room_driver_tasks"):
foreign_keys = conn.execute(f"PRAGMA foreign_key_list({table})").fetchall()
if not any(row[2] == "hosted_rooms" and row[3] == "room_id" and row[4] == "room_id" for row in foreign_keys):
@@ -307,15 +272,13 @@ def _schema_objects_exist(conn: sqlite3.Connection) -> bool:
if {row[0] for row in rows} != {"hosted_room_driver_leases", "hosted_room_driver_tasks"}:
return False
index = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_hosted_room_driver_tasks_status'"
).fetchone()
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_hosted_room_driver_tasks_status'").fetchone()
return index is not None
def _task_schema_supports_current_statuses(conn: sqlite3.Connection) -> bool:
row = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='hosted_room_driver_tasks'"
).fetchone()
"SELECT sql FROM sqlite_master WHERE type='table' AND name='hosted_room_driver_tasks'").fetchone()
sql = str(row[0] or "").lower() if row else ""
return "'stopping'" in sql and "'deferred'" in sql
@@ -326,40 +289,33 @@ def _migrate_task_status_constraint(conn: sqlite3.Connection) -> None:
_create_task_table(conn, "hosted_room_driver_tasks_next")
columns = ", ".join(_TASK_COLUMN_ORDER)
conn.execute(
f"INSERT INTO hosted_room_driver_tasks_next ({columns}) SELECT {columns} FROM hosted_room_driver_tasks"
)
f"INSERT INTO hosted_room_driver_tasks_next ({columns}) SELECT {columns} FROM hosted_room_driver_tasks")
conn.execute("DROP TABLE hosted_room_driver_tasks")
conn.execute("ALTER TABLE hosted_room_driver_tasks_next RENAME TO hosted_room_driver_tasks")
conn.execute(_TASK_INDEX_SQL.format(if_not_exists=""))
def _connect(db_path: Path | str) -> sqlite3.Connection:
from hermes_state import apply_wal_with_fallback
"""Open the store; existing tables are validated (after the status-constraint migration when needed).
path = Path(db_path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=10)
conn.row_factory = sqlite3.Row
try:
apply_wal_with_fallback(conn, db_label="state.db (hosted_room_driver)")
conn.execute("PRAGMA foreign_keys=ON")
if _schema_objects_exist(conn):
if not _task_schema_supports_current_statuses(conn):
conn.execute("BEGIN IMMEDIATE")
_migrate_task_status_constraint(conn)
conn.commit()
The driver schema never shipped, so an incompatible draft fails closed in ``_validate_schema``.
"""
existing: list[bool] = []
def ready(conn: sqlite3.Connection) -> bool:
existing.append(_schema_objects_exist(conn))
return existing[0] and _task_schema_supports_current_statuses(conn)
def initialize(conn: sqlite3.Connection) -> None:
(_migrate_task_status_constraint if existing[0] else _initialize_schema)(conn)
conn = connect(db_path, db_label="state.db (hosted_room_driver)", ready=ready, initialize=initialize)
if existing[0]:
try:
_validate_schema(conn)
return conn
# Schema creation is one database-wide transaction. The driver schema
# has never shipped, so an incompatible draft schema fails closed
# instead of attempting a partial in-place migration.
conn.execute("BEGIN IMMEDIATE")
_initialize_schema(conn)
conn.commit()
except Exception:
conn.rollback()
conn.close()
raise
except Exception:
conn.close()
raise
return conn
@@ -369,17 +325,14 @@ def _transaction(db_path: Path | str):
def _lease_from_row(row: sqlite3.Row | dict[str, Any], *, reclaimed: bool = False) -> DriverLease:
return DriverLease(
room_id=row["room_id"], gateway_id=row["gateway_id"],
authority_epoch=int(row["authority_epoch"]), process_generation=row["process_generation"],
lease_generation=int(row["lease_generation"]), expires_at=float(row["expires_at"]),
reclaimed=reclaimed,
)
room_id=row["room_id"], gateway_id=row["gateway_id"], authority_epoch=int(row["authority_epoch"]),
process_generation=row["process_generation"], lease_generation=int(row["lease_generation"]),
expires_at=float(row["expires_at"]), reclaimed=reclaimed)
def _task_identity_from_row(row: sqlite3.Row) -> TaskIdentity:
return TaskIdentity(
room_id=row["room_id"], task_id=row["task_id"], thread_id=row["thread_id"], turn_id=row["turn_id"]
)
room_id=row["room_id"], task_id=row["task_id"], thread_id=row["thread_id"], turn_id=row["turn_id"])
def _optional(cast: Callable[[Any], Any]) -> Callable[[Any], Any]:
@@ -401,8 +354,7 @@ def _task_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, A
except (TypeError, json.JSONDecodeError, DriverValidationError) as exc:
raise TaskConflictError("stored task payload is invalid") from exc
if (encoded_payload, payload_digest, payload["source_event_seq"]) != (
row["payload_json"], row["payload_digest"], int(row["source_event_seq"])
):
row["payload_json"], row["payload_digest"], int(row["source_event_seq"])):
raise TaskConflictError("stored task payload failed its integrity check")
task: dict[str, Any] = {"identity": _task_identity_from_row(row), "payload": payload}
for column in _TASK_COLUMN_ORDER[_TASK_COLUMN_ORDER.index("payload_digest"):]:
@@ -430,8 +382,7 @@ def _load_active_room(conn: sqlite3.Connection, room_id: str) -> sqlite3.Row:
try:
row = conn.execute(
"SELECT room_id, authority_gateway_id, authority_epoch, disbanded_at FROM hosted_rooms WHERE room_id=?",
(room_id,),
).fetchone()
(room_id,)).fetchone()
except sqlite3.OperationalError as exc:
if "no such table" in str(exc).lower():
raise RoomUnavailableError("hosted room does not exist") from exc
@@ -444,8 +395,7 @@ def _load_active_room(conn: sqlite3.Connection, room_id: str) -> sqlite3.Row:
def _require_room_authority(
conn: sqlite3.Connection, *, room_id: str, gateway_id: str, authority_epoch: int
) -> sqlite3.Row:
conn: sqlite3.Connection, *, room_id: str, gateway_id: str, authority_epoch: int) -> sqlite3.Row:
room = _load_active_room(conn, room_id)
if room["authority_gateway_id"] != gateway_id or int(room["authority_epoch"]) != authority_epoch:
raise StaleLeaseError("hosted room authority changed")
@@ -454,8 +404,7 @@ def _require_room_authority(
def _require_lease_authority(conn: sqlite3.Connection, lease: DriverLease) -> sqlite3.Row:
return _require_room_authority(
conn, room_id=lease.room_id, gateway_id=lease.gateway_id, authority_epoch=lease.authority_epoch
)
conn, room_id=lease.room_id, gateway_id=lease.gateway_id, authority_epoch=lease.authority_epoch)
def _lease_row_matches(row: sqlite3.Row | None, lease: DriverLease) -> bool:
@@ -485,8 +434,7 @@ def _cancel_generation(value: int) -> int:
def _expected_generations(
lease: DriverLease, identity: TaskIdentity, execution_generation: int, cancel_generation: int
) -> None:
lease: DriverLease, identity: TaskIdentity, execution_generation: int, cancel_generation: int) -> None:
_check_same_room(lease, identity)
if not isinstance(execution_generation, int) or execution_generation < 1:
raise DriverValidationError("expected_execution_generation must be a positive integer")
@@ -515,14 +463,12 @@ def _settlement_replay(settlement_id: str, status: str, result_json: str) -> Cal
def _cancel_replay(cancel_id: str, status: str = "cancelled") -> Callable[[sqlite3.Row], Any]:
"""Replay predicate: same cancel_id already committed in ``status``."""
return lambda row: (
_task_from_row(row, idempotent=True) if row["status"] == status and row["cancel_id"] == cancel_id else None
)
_task_from_row(row, idempotent=True) if row["status"] == status and row["cancel_id"] == cancel_id else None)
def _generations_match(row: sqlite3.Row, status: str, execution_generation: int, cancel_generation: int) -> bool:
return (row["status"], int(row["execution_generation"]), int(row["cancel_generation"])) == (
status, execution_generation, cancel_generation
)
status, execution_generation, cancel_generation)
def _require_cancel_generation(row: sqlite3.Row, expected_cancel_generation: int) -> None:
@@ -534,14 +480,12 @@ def _transition(
db_path: Path | str, identity: TaskIdentity, *, sql: str, set_params: tuple[Any, ...],
fence_params: tuple[Any, ...], stale: str, now: float, lease: DriverLease | None = None,
lease_first: bool = True, replay: Callable[[sqlite3.Row], dict[str, Any] | None] | None = None,
guard: Callable[[sqlite3.Row], None] | None = None,
) -> dict[str, Any]:
guard: Callable[[sqlite3.Row], None] | None = None) -> dict[str, Any]:
"""Run one fenced task transition: load -> idempotent replay -> lease/fence guard -> UPDATE.
``sql`` binds ``(*set_params, room_id, task_id, *fence_params)`` and must hit exactly one row
or ``stale`` is raised. ``lease_first`` puts the active-lease check before the row load
(recovery paths) instead of after the replay (settlement paths: an identical replay still
succeeds after the lease moved on).
``sql`` binds ``(*set_params, room_id, task_id, *fence_params)`` and must hit exactly one row or
``stale`` is raised. ``lease_first`` checks the lease before the row load (recovery paths) instead
of after the replay (settlement paths: an identical replay still succeeds after the lease moved on).
"""
params = (*set_params, identity.room_id, identity.task_id, *fence_params)
with _transaction(db_path) as conn:
@@ -564,8 +508,7 @@ def _transition(
def _generation_transition(
db_path: Path | str, identity: TaskIdentity, lease: DriverLease, name: str, execution_generation: int,
cancel_generation: int, *, now: float, set_params: tuple[Any, ...],
replay: Callable[[sqlite3.Row], Any] | None = None,
) -> dict[str, Any]:
replay: Callable[[sqlite3.Row], Any] | None = None) -> dict[str, Any]:
"""Lease-first transition from ``_GENERATION_TRANSITIONS`` fenced on status + both generations."""
status, set_clause, generation_stale, stale = _GENERATION_TRANSITIONS[name]
@@ -576,18 +519,15 @@ def _generation_transition(
return _transition(
db_path, identity, lease=lease, now=now, replay=replay, guard=guard,
sql=_generation_update(set_clause, status), set_params=set_params,
fence_params=(execution_generation, cancel_generation), stale=stale,
)
fence_params=(execution_generation, cancel_generation), stale=stale)
def _run_fence_transition(
db_path: Path | str, attempt: TaskAttempt, *, guard_stale: str,
lease_generation: Callable[[Any], int] = int, **transition: Any,
) -> dict[str, Any]:
lease_generation: Callable[[Any], int] = int, **transition: Any) -> dict[str, Any]:
"""Transition fenced on this attempt's running generation under its exact lease (row guard + SQL fence).
``lease_generation`` casts the stored run_lease_generation: ``int`` raises on NULL,
``int(v or 0)`` reads it as generation 0.
``lease_generation`` casts the stored run_lease_generation: ``int`` raises on NULL, ``int(v or 0)`` reads 0.
"""
lease = attempt.lease
@@ -596,30 +536,24 @@ def _run_fence_transition(
_generations_match(row, "running", attempt.execution_generation, attempt.cancel_generation)
and row["run_gateway_id"] == lease.gateway_id
and row["run_process_generation"] == lease.process_generation
and lease_generation(row["run_lease_generation"]) == lease.lease_generation
):
and lease_generation(row["run_lease_generation"]) == lease.lease_generation):
raise StaleTaskError(guard_stale)
return _transition(
db_path, attempt.identity, lease=lease, guard=guard,
fence_params=(
attempt.execution_generation, attempt.cancel_generation,
lease.gateway_id, lease.process_generation, lease.lease_generation,
),
**transition,
)
lease.gateway_id, lease.process_generation, lease.lease_generation),
**transition)
def acquire_lease(
db_path: Path | str, *, room_id: Any, gateway_id: Any, authority_epoch: Any, process_generation: Any,
ttl_seconds: Any, clock: Clock,
) -> DriverLease:
ttl_seconds: Any, clock: Clock) -> DriverLease:
"""Acquire an empty or expired room lease with a monotonic generation."""
room_id = _identifier(room_id, label="room_id")
gateway_id = _identifier(gateway_id, label="gateway_id")
authority_epoch = positive_int(
authority_epoch, error=DriverValidationError, message="authority_epoch must be a positive integer"
)
authority_epoch = _bounded_int(authority_epoch, message="authority_epoch must be a positive integer", low=1)
process_generation = _identifier(process_generation, label="process_generation")
ttl_seconds = _ttl(ttl_seconds)
now = _timestamp(clock)
@@ -634,8 +568,7 @@ def acquire_lease(
room_id, gateway_id, authority_epoch, process_generation, lease_generation,
expires_at, acquired_at, updated_at, released_at
) VALUES (?, ?, ?, ?, 1, ?, ?, ?, NULL)""",
(room_id, gateway_id, authority_epoch, process_generation, expires_at, now, now),
)
(room_id, gateway_id, authority_epoch, process_generation, expires_at, now, now))
return _lease_from_row(conn.execute(_SELECT_LEASE, (room_id,)).fetchone())
same_authority = row["gateway_id"] == gateway_id and int(row["authority_epoch"]) == authority_epoch
@@ -644,8 +577,7 @@ def acquire_lease(
renewed_expiry = max(float(row["expires_at"]), expires_at)
conn.execute(
"UPDATE hosted_room_driver_leases SET expires_at=?, updated_at=? WHERE room_id=? AND lease_generation=?",
(renewed_expiry, now, room_id, int(row["lease_generation"])),
)
(renewed_expiry, now, room_id, int(row["lease_generation"])))
return _lease_from_row({**dict(row), "expires_at": renewed_expiry})
if same_authority and live:
raise LeaseHeldError("room driver lease is held by another generation")
@@ -658,9 +590,7 @@ def acquire_lease(
gateway_id != ? OR authority_epoch != ? OR released_at IS NOT NULL OR expires_at <= ?)""",
(
gateway_id, authority_epoch, process_generation, expires_at, now, now,
room_id, int(row["lease_generation"]), gateway_id, authority_epoch, now,
),
)
room_id, int(row["lease_generation"]), gateway_id, authority_epoch, now))
if updated.rowcount != 1:
raise LeaseHeldError("room driver lease changed during acquisition")
return _lease_from_row(conn.execute(_SELECT_LEASE, (room_id,)).fetchone(), reclaimed=True)
@@ -678,8 +608,7 @@ def renew_lease(db_path: Path | str, lease: DriverLease, *, ttl_seconds: Any, cl
"""UPDATE hosted_room_driver_leases SET expires_at=?, updated_at=?
WHERE room_id=? AND gateway_id=? AND process_generation=?
AND lease_generation=? AND released_at IS NULL AND expires_at > ?""",
(expires_at, now, lease.room_id, lease.gateway_id, lease.process_generation, lease.lease_generation, now),
)
(expires_at, now, lease.room_id, lease.gateway_id, lease.process_generation, lease.lease_generation, now))
if updated.rowcount != 1:
raise StaleLeaseError("driver lease changed during renewal")
return dataclasses.replace(lease, expires_at=expires_at, reclaimed=False)
@@ -705,8 +634,7 @@ def release_lease(db_path: Path | str, lease: DriverLease, *, clock: Clock) -> d
conn.execute(
"""UPDATE hosted_room_driver_leases SET expires_at=?, updated_at=?, released_at=?
WHERE room_id=? AND lease_generation=?""",
(now, now, now, lease.room_id, lease.lease_generation),
)
(now, now, now, lease.room_id, lease.lease_generation))
current = {**dict(row), "expires_at": now, "updated_at": now, "released_at": now}
return {"lease": _lease_from_row(current), "idempotent": False}
@@ -726,8 +654,7 @@ def admit_task(db_path: Path | str, identity: TaskIdentity, *, payload: Any, clo
return _task_from_row(existing, idempotent=True)
turn = conn.execute(
"SELECT * FROM hosted_room_driver_tasks WHERE room_id=? AND thread_id=? AND turn_id=?",
(identity.room_id, identity.thread_id, identity.turn_id),
).fetchone()
(identity.room_id, identity.thread_id, identity.turn_id)).fetchone()
if turn is not None:
raise TaskConflictError("thread_id and turn_id are already bound to a task")
conn.execute(
@@ -737,9 +664,7 @@ def admit_task(db_path: Path | str, identity: TaskIdentity, *, payload: Any, clo
) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, 0, ?, ?)""",
(
identity.room_id, identity.task_id, identity.thread_id, identity.turn_id,
normalized_payload["source_event_seq"], payload_json, payload_digest, now, now,
),
)
normalized_payload["source_event_seq"], payload_json, payload_digest, now, now))
return _task_from_row(_load_task(conn, identity))
@@ -759,14 +684,12 @@ def start_task(
unresolved = conn.execute(
f"""SELECT task_id, status FROM hosted_room_driver_tasks
WHERE room_id=? AND status IN ('running', 'indeterminate', 'stopping') {_TASK_ORDER} LIMIT 1""",
(identity.room_id,),
).fetchone()
(identity.room_id,)).fetchone()
if unresolved is not None:
raise InvalidTaskTransitionError("room recovery must resolve the prior task before starting new work")
next_queued = conn.execute(
f"SELECT task_id FROM hosted_room_driver_tasks WHERE room_id=? AND status='queued' {_TASK_ORDER} LIMIT 1",
(identity.room_id,),
).fetchone()
(identity.room_id,)).fetchone()
if next_queued is None or next_queued["task_id"] != identity.task_id:
raise InvalidTaskTransitionError("task is not next in the hosted room event order")
execution_generation = int(row["execution_generation"]) + 1
@@ -777,15 +700,12 @@ def start_task(
WHERE room_id=? AND task_id=? AND status='queued' AND cancel_generation=?""",
(
execution_generation, lease.gateway_id, lease.process_generation, lease.lease_generation, now, now,
identity.room_id, identity.task_id, expected_cancel_generation,
),
)
identity.room_id, identity.task_id, expected_cancel_generation))
if updated.rowcount != 1:
raise StaleTaskError("task changed during start")
return TaskAttempt(
identity=identity, lease=lease,
execution_generation=execution_generation, cancel_generation=expected_cancel_generation,
)
execution_generation=execution_generation, cancel_generation=expected_cancel_generation)
def settle_task(
@@ -799,8 +719,7 @@ def settle_task(
db_path, attempt, guard_stale="task attempt is stale or cancelled", lease_first=False, now=now,
replay=_settlement_replay(settlement_id, status, result_json),
sql=_SETTLE_RUNNING_SQL, set_params=(status, settlement_id, status, result_json, now, now),
stale="task changed during settlement",
)
stale="task changed during settlement")
def settle_stopping_task(
@@ -818,8 +737,7 @@ def settle_stopping_task(
replay=_settlement_replay(settlement_id, status, result_json),
sql=_SETTLE_STOPPING_SQL, set_params=(status, settlement_id, status, result_json, now, now),
fence_params=(expected_execution_generation, expected_cancel_generation),
stale="task completion lost the stop race",
)
stale="task completion lost the stop race")
def resolve_indeterminate_task(
@@ -834,41 +752,35 @@ def resolve_indeterminate_task(
return _generation_transition(
db_path, identity, lease, "resolve", expected_execution_generation, expected_cancel_generation, now=now,
replay=_settlement_replay(settlement_id, status, result_json),
set_params=(status, settlement_id, status, result_json, now, now),
)
set_params=(status, settlement_id, status, result_json, now, now))
def resolve_indeterminate_cancellation(
db_path: Path | str, identity: TaskIdentity, lease: DriverLease, *, expected_execution_generation: int,
expected_cancel_generation: int, cancel_id: Any, clock: Clock,
) -> dict[str, Any]:
expected_cancel_generation: int, cancel_id: Any, clock: Clock) -> dict[str, Any]:
"""Commit a verified terminal cancellation for an uncertain attempt."""
_expected_generations(lease, identity, expected_execution_generation, expected_cancel_generation)
cancel_id = _identifier(cancel_id, label="cancel_id")
now = _timestamp(clock)
return _generation_transition(
db_path, identity, lease, "resolve_cancel", expected_execution_generation, expected_cancel_generation,
now=now, replay=_cancel_replay(cancel_id), set_params=(expected_cancel_generation + 1, cancel_id, now, now),
)
now=now, replay=_cancel_replay(cancel_id), set_params=(expected_cancel_generation + 1, cancel_id, now, now))
def requeue_indeterminate_task(
db_path: Path | str, identity: TaskIdentity, lease: DriverLease, *, expected_execution_generation: int,
expected_cancel_generation: int, clock: Clock,
) -> dict[str, Any]:
expected_cancel_generation: int, clock: Clock) -> dict[str, Any]:
"""Explicitly retry uncertain work after an operator accepts at-least-once risk."""
_expected_generations(lease, identity, expected_execution_generation, expected_cancel_generation)
now = _timestamp(clock)
return _generation_transition(
db_path, identity, lease, "requeue", expected_execution_generation, expected_cancel_generation, now=now,
set_params=(now,),
)
set_params=(now,))
def defer_indeterminate_task(
db_path: Path | str, identity: TaskIdentity, lease: DriverLease, *, expected_execution_generation: int,
expected_cancel_generation: int, reason: Any, clock: Clock,
) -> dict[str, Any]:
expected_cancel_generation: int, reason: Any, clock: Clock) -> dict[str, Any]:
"""Fence one uncertain attempt and release later room work."""
_expected_generations(lease, identity, expected_execution_generation, expected_cancel_generation)
reason = _identifier(reason, label="defer_reason")
@@ -881,21 +793,18 @@ def defer_indeterminate_task(
return _generation_transition(
db_path, identity, lease, "defer", expected_execution_generation, expected_cancel_generation, now=now,
replay=replay, set_params=(result_json, now, now),
)
replay=replay, set_params=(result_json, now, now))
def requeue_deferred_task(
db_path: Path | str, identity: TaskIdentity, lease: DriverLease, *, expected_execution_generation: int,
expected_cancel_generation: int, clock: Clock,
) -> dict[str, Any]:
expected_cancel_generation: int, clock: Clock) -> dict[str, Any]:
"""Explicitly retry a fenced deferred turn under a new generation."""
_expected_generations(lease, identity, expected_execution_generation, expected_cancel_generation)
now = _timestamp(clock)
return _generation_transition(
db_path, identity, lease, "requeue_deferred", expected_execution_generation, expected_cancel_generation,
now=now, set_params=(now,),
)
now=now, set_params=(now,))
def requeue_not_admitted_task(db_path: Path | str, attempt: TaskAttempt, *, clock: Clock) -> dict[str, Any]:
@@ -908,15 +817,13 @@ def requeue_not_admitted_task(db_path: Path | str, attempt: TaskAttempt, *, cloc
_generations_match(row, "queued", attempt.execution_generation, attempt.cancel_generation)
and row["run_gateway_id"] is None
and row["run_process_generation"] is None
and row["run_lease_generation"] is None
)
and row["run_lease_generation"] is None)
return _task_from_row(row, idempotent=True) if requeued else None
return _run_fence_transition(
db_path, attempt, guard_stale="not-admitted task attempt lost its fence",
lease_generation=lambda value: int(value or 0), now=now, replay=replay,
sql=_REQUEUE_RUNNING_SQL, set_params=(now,), stale="not-admitted task changed during requeue",
)
sql=_REQUEUE_RUNNING_SQL, set_params=(now,), stale="not-admitted task changed during requeue")
def cancel_task(
@@ -937,8 +844,7 @@ def cancel_task(
return _transition(
db_path, identity, now=now, replay=_cancel_replay(cancel_id), guard=guard, sql=_CANCEL_QUEUED_SQL,
set_params=(expected_cancel_generation + 1, cancel_id, now, now), fence_params=(expected_cancel_generation,),
stale="task changed during cancellation",
)
stale="task changed during cancellation")
def begin_task_cancel(
@@ -957,8 +863,7 @@ def begin_task_cancel(
return _transition(
db_path, identity, now=now, replay=_cancel_replay(cancel_id, "stopping"), guard=guard, sql=_BEGIN_STOP_SQL,
set_params=(expected_cancel_generation + 1, cancel_id, now), fence_params=(expected_cancel_generation,),
stale="task changed during stop request",
)
stale="task changed during stop request")
def complete_task_cancel(
@@ -970,15 +875,13 @@ def complete_task_cancel(
def guard(row: sqlite3.Row) -> None:
if (row["status"], row["cancel_id"], int(row["cancel_generation"])) != (
"stopping", cancel_id, expected_cancel_generation
):
"stopping", cancel_id, expected_cancel_generation):
raise StaleTaskError("task stop acknowledgement is stale")
return _transition(
db_path, identity, now=now, replay=_cancel_replay(cancel_id), guard=guard, sql=_COMPLETE_STOP_SQL,
set_params=(now, now), fence_params=(cancel_id, expected_cancel_generation),
stale="task changed during stop acknowledgement",
)
stale="task changed during stop acknowledgement")
def recover_room(db_path: Path | str, lease: DriverLease, *, clock: Clock) -> dict[str, list[TaskIdentity]]:
@@ -989,18 +892,15 @@ def recover_room(db_path: Path | str, lease: DriverLease, *, clock: Clock) -> di
with _transaction(db_path) as conn:
_require_active_lease(conn, lease, now=now)
stale_rows = conn.execute(
f"SELECT * FROM hosted_room_driver_tasks WHERE {foreign_running} {_TASK_ORDER}", fence
).fetchall()
f"SELECT * FROM hosted_room_driver_tasks WHERE {foreign_running} {_TASK_ORDER}", fence).fetchall()
if stale_rows:
conn.execute(
f"""UPDATE hosted_room_driver_tasks SET status='indeterminate', indeterminate_at=?, updated_at=?
WHERE {foreign_running}""",
(now, now, *fence),
)
(now, now, *fence))
return {
status: [_task_identity_from_row(row) for row in _tasks_in_order(conn, lease.room_id, status)]
for status in ("queued", "indeterminate")
}
for status in ("queued", "indeterminate")}
def _read(db_path: Path | str, query: Callable[[sqlite3.Connection], Any]) -> Any:
@@ -1026,19 +926,17 @@ def list_tasks(db_path: Path | str, *, room_id: Any, status: TaskStatus | None =
def prune_published_terminal_tasks(
db_path: Path | str, *, room_id: Any, clock: Clock,
retention_seconds: float = TERMINAL_TASK_RETENTION_SECONDS, retain: int = MAX_RETAINED_TERMINAL_TASKS,
) -> int:
retention_seconds: float = TERMINAL_TASK_RETENTION_SECONDS, retain: int = MAX_RETAINED_TERMINAL_TASKS) -> int:
"""Bound execution rows after outcomes are durable in the room log."""
room_id = _identifier(room_id, label="room_id")
now = _timestamp(clock)
if retention_seconds <= 0:
raise DriverValidationError("retention_seconds must be positive")
non_negative_int(retain, error=DriverValidationError, message="retain must be a non-negative integer")
_bounded_int(retain, message="retain must be a non-negative integer")
with _transaction(db_path) as conn:
publications = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='hosted_room_policy_publications'"
).fetchone()
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='hosted_room_policy_publications'").fetchone()
if publications is None:
return 0
rows = conn.execute(
@@ -1047,9 +945,7 @@ def prune_published_terminal_tasks(
AND EXISTS (SELECT 1 FROM hosted_room_policy_publications p
WHERE p.room_id=t.room_id AND p.task_id=t.task_id
AND p.kind IN ('turn.settled', 'turn.failed', 'turn.cancelled'))
ORDER BY t.terminal_at DESC, t.task_id ASC""",
(room_id,),
).fetchall()
ORDER BY t.terminal_at DESC, t.task_id ASC""", (room_id,)).fetchall()
cutoff = now - float(retention_seconds)
candidates = [
str(row["task_id"])
@@ -1061,6 +957,5 @@ def prune_published_terminal_tasks(
placeholders = ",".join("?" for _ in candidates)
deleted = conn.execute(
f"DELETE FROM hosted_room_driver_tasks WHERE room_id=? AND task_id IN ({placeholders})",
(room_id, *candidates),
)
(room_id, *candidates))
return max(0, int(deleted.rowcount))
+9 -24
View File
@@ -14,13 +14,10 @@ POLICY_VERSION = 1
MAX_POLICY_TOOLSETS = 128
MAX_POLICY_ITERATIONS = (1 << 53) - 1
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
_POLICY_FIELDS = {
"version", "target_profile", "enabled_toolsets", "approval_mode", "max_iterations", "policy_digest",
}
_POLICY_FIELDS = {"version", "target_profile", "enabled_toolsets", "approval_mode", "max_iterations", "policy_digest"}
class RoomExecutionPolicyError(ValueError):
"""A RoomLink execution policy is malformed or no longer current."""
class RoomExecutionPolicyError(ValueError): """A RoomLink execution policy is malformed or no longer current."""
def _policy_digest(unsigned: Mapping[str, Any]) -> str:
@@ -67,15 +64,11 @@ class RoomExecutionPolicy:
if (
isinstance(max_iterations, bool)
or not isinstance(max_iterations, int)
or not 1 <= max_iterations <= MAX_POLICY_ITERATIONS
):
or not 1 <= max_iterations <= MAX_POLICY_ITERATIONS):
raise RoomExecutionPolicyError("max_iterations is invalid")
unsigned = {
"version": POLICY_VERSION,
"target_profile": target_profile,
"enabled_toolsets": list(toolsets),
"approval_mode": approval_mode,
"max_iterations": max_iterations,
"version": POLICY_VERSION, "target_profile": target_profile, "enabled_toolsets": list(toolsets),
"approval_mode": approval_mode, "max_iterations": max_iterations,
}
supplied = str(value["policy_digest"] or "").strip().lower()
if supplied != _policy_digest(unsigned):
@@ -103,12 +96,9 @@ def execution_policy_mapping(*, target_profile: str, config: Mapping[str, Any] |
agent = config.get("agent") if isinstance(config.get("agent"), Mapping) else {}
approvals = config.get("approvals") if isinstance(config.get("approvals"), Mapping) else {}
unsigned = {
"version": POLICY_VERSION,
"target_profile": _identifier(target_profile, field="target_profile"),
"version": POLICY_VERSION, "target_profile": _identifier(target_profile, field="target_profile"),
"enabled_toolsets": toolsets,
"approval_mode": (
"off" if _YOLO_MODE_FROZEN else _normalize_approval_mode(approvals.get("mode", "manual"))
),
"approval_mode": ("off" if _YOLO_MODE_FROZEN else _normalize_approval_mode(approvals.get("mode", "manual"))),
"max_iterations": min(resolve_turn_limit(agent.get("max_turns")), MAX_POLICY_ITERATIONS),
}
value = {**unsigned, "policy_digest": _policy_digest(unsigned)}
@@ -131,12 +121,7 @@ def current_room_execution_policy() -> RoomExecutionPolicy | None:
__all__ = [
"MAX_POLICY_ITERATIONS",
"POLICY_VERSION",
"RoomExecutionPolicy",
"RoomExecutionPolicyError",
"bind_room_execution_policy",
"current_room_execution_policy",
"execution_policy_mapping",
"MAX_POLICY_ITERATIONS", "POLICY_VERSION", "RoomExecutionPolicy", "RoomExecutionPolicyError",
"bind_room_execution_policy", "current_room_execution_policy", "execution_policy_mapping",
"reset_room_execution_policy",
]
+20 -34
View File
@@ -12,6 +12,7 @@ import json
import os
import time
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from typing import Any, Mapping
@@ -20,23 +21,21 @@ from gateway.hosted_room_peer import (
GatewayRoomCatalog,
HostedRoomPeerError,
TransportSecurity,
validate_room_link_url,
)
validate_room_link_url)
from gateway.hosted_rooms_common import compact_json, exact_fields
MAX_LINKS = 512
MAX_GRANT_CHARS = 16 * 1024
_LEGACY_FIELDS = {
"room_id", "member_id", "target_url", "target_profile", "grant", "catalog",
"cancellation_scope_id", "trace_id", "updated_at",
}
"cancellation_scope_id", "trace_id", "updated_at"}
_OPTIONAL_FIELDS = {"transport_security", "status"}
# SQLite record columns that map 1:1 onto mapping fields, in record order
# (``catalog_json`` is the serialized ``catalog``).
_RECORD_FIELDS = (
"room_id", "member_id", "target_url", "target_profile", "grant", "cancellation_scope_id",
"trace_id", "transport_security", "status", "updated_at",
)
"trace_id", "transport_security", "status", "updated_at")
_STATUSES = {"ready", "unavailable", "needs_reauthorization"}
@@ -56,8 +55,7 @@ class StoredRoomLink:
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "StoredRoomLink":
if set(value) - _LEGACY_FIELDS - _OPTIONAL_FIELDS or not _LEGACY_FIELDS.issubset(value):
raise HostedRoomPeerError("stored room link fields are invalid")
_link_fields(value)
room_id = _short_string(value["room_id"], "room_id")
member_id = _short_string(value["member_id"], "member_id")
target_profile = _short_string(value["target_profile"], "target_profile")
@@ -85,8 +83,7 @@ class StoredRoomLink:
trace_id=_short_string(value["trace_id"], "trace_id"),
transport_security=transport_security, # type: ignore[arg-type]
status=status,
updated_at=updated_at,
)
updated_at=updated_at)
@classmethod
def from_record(cls, value: Mapping[str, Any]) -> "StoredRoomLink":
@@ -101,10 +98,16 @@ class StoredRoomLink:
def as_record(self) -> dict[str, Any]:
record = {name: getattr(self, name) for name in _RECORD_FIELDS}
record["catalog_json"] = json.dumps(self.catalog_mapping(), sort_keys=True, separators=(",", ":"))
record["catalog_json"] = compact_json(self.catalog_mapping(), ensure_ascii=False)
return record
_link_fields = partial(
exact_fields, label="stored room link", required=_LEGACY_FIELDS, optional=_OPTIONAL_FIELDS,
error=HostedRoomPeerError, not_object="stored room link fields are invalid",
missing_fmt="stored room link fields are invalid", unknown_fmt="stored room link fields are invalid")
def _short_string(value: Any, field: str) -> str:
normalized = str(value or "").strip()
if not normalized or len(normalized) > 256:
@@ -149,32 +152,15 @@ def mark_room_link_status(db_path: Path | str, *, room_id: str, member_id: str,
raise HostedRoomPeerError("stored room link status is invalid")
return hosted_rooms.update_room_link_status(
db_path, room_id=_short_string(room_id, "room_id"), member_id=_short_string(member_id, "member_id"),
status=status,
)
status=status)
def make_stored_link(
*,
room_id: str,
member_id: str,
target_url: str,
target_profile: str,
grant: str,
catalog: GatewayRoomCatalog,
cancellation_scope_id: str,
trace_id: str,
) -> StoredRoomLink:
*, room_id: str, member_id: str, target_url: str, target_profile: str, grant: str,
catalog: GatewayRoomCatalog, cancellation_scope_id: str, trace_id: str) -> StoredRoomLink:
target_url, transport_security = validate_room_link_url(target_url)
return StoredRoomLink.from_mapping({
"room_id": room_id,
"member_id": member_id,
"target_url": target_url,
"target_profile": target_profile,
"grant": grant,
"catalog": catalog.as_mapping(),
"cancellation_scope_id": cancellation_scope_id,
"trace_id": trace_id,
"transport_security": transport_security,
"status": "ready",
"updated_at": time.time(),
"room_id": room_id, "member_id": member_id, "target_url": target_url, "target_profile": target_profile,
"grant": grant, "catalog": catalog.as_mapping(), "cancellation_scope_id": cancellation_scope_id,
"trace_id": trace_id, "transport_security": transport_security, "status": "ready", "updated_at": time.time(),
})
+42 -87
View File
@@ -16,7 +16,6 @@ import math
import os
import re
import stat
import time
import urllib.parse
from dataclasses import asdict, dataclass
from functools import lru_cache, partial
@@ -24,7 +23,7 @@ from pathlib import Path
from typing import Any, Callable, Iterable, Literal, Mapping
from gateway.hosted_room_execution_policy import RoomExecutionPolicy, execution_policy_mapping
from gateway.hosted_rooms_common import exact_fields, identifier, positive_int
from gateway.hosted_rooms_common import bounded_int, clock, compact_json, exact_fields, identifier, text
# v2 adds authority/member lineage to scoped grants and is deliberately not
@@ -40,12 +39,10 @@ LinkMode = Literal["direct", "overlay", "relay", "pull", "desktop"]
TransportSecurity = Literal["tls", "loopback"]
class HostedRoomPeerError(ValueError):
"""Base error for malformed or unauthorized peer-room input."""
class HostedRoomPeerError(ValueError): """Base error for malformed or unauthorized peer-room input."""
class HostedRoomGrantError(HostedRoomPeerError):
"""Raised when a room-scoped grant is invalid or expired."""
class HostedRoomGrantError(HostedRoomPeerError): """Raised when a room-scoped grant is invalid or expired."""
_ROOM_GRANT_SECRET_FILE = ".room-link-grant-secret"
@@ -104,10 +101,9 @@ def _gateway_room_grant_secret_for_home(home_value: str) -> bytes:
def gateway_room_grant_secret(root: Path | str | None = None) -> bytes:
"""Load or atomically mint the gateway-only RoomLink signing secret.
API keys are bearer credentials known to clients and may be profile scoped;
they must never become grant-signing authority. This secret lives in the
installation root, is not exposed by configuration or capability RPCs, and
is shared only by the gateway processes that serve this installation.
API keys are client-known, possibly profile-scoped bearer credentials and must never become
grant-signing authority; this secret lives in the installation root, is never exposed by
config or capability RPCs, and is shared only by this installation's gateway processes.
"""
if root is None:
from hermes_constants import get_hermes_home
@@ -121,8 +117,7 @@ def gateway_room_grant_secret(root: Path | str | None = None) -> bytes:
def derive_room_grant_secret(api_key: str) -> bytes:
"""Domain-separate room grants from the configured API key.
The API-server startup guard enforces the production key-strength policy;
this helper keeps a lower structural floor for isolated contract tests.
Production key strength is enforced by the API-server startup guard; this floor is for contract tests.
"""
if not isinstance(api_key, str) or len(api_key) < 8:
raise HostedRoomGrantError("room grants require a strong gateway API key")
@@ -132,12 +127,12 @@ def derive_room_grant_secret(api_key: str) -> bytes:
def _identifier(value: Any, *, field: str) -> str:
return identifier(
value, label=field, error=HostedRoomPeerError, max_chars=256, pattern=_IDENTIFIER_RE,
invalid=f"{field} is invalid",
)
invalid=f"{field} is invalid")
def _positive_int(value: Any, *, field: str) -> int:
return positive_int(value, error=HostedRoomPeerError, message=f"{field} must be a positive integer")
return bounded_int(
value, error=HostedRoomPeerError, message=f"{field} must be a positive integer", low=1)
def _digest(value: Any, *, field: str) -> str:
@@ -148,12 +143,11 @@ def _digest(value: Any, *, field: str) -> str:
_exact_fields = partial(
exact_fields, error=HostedRoomPeerError, missing_fmt="{label} missing fields: {fields}",
unknown_fmt="{label} unknown fields: {fields}",
)
unknown_fmt="{label} unknown fields: {fields}")
def _canonical_json(value: Mapping[str, Any]) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")
return compact_json(value).encode("ascii")
def _b64encode(value: bytes) -> str:
@@ -198,8 +192,7 @@ def _parse_endpoint(endpoint: Any) -> tuple[str | None, str | None, TransportSec
_CATALOG_FIELDS = {
"installation_id", "protocol_versions", "link_modes", "persistent_process", "text",
"attachments", "execution_policy", "catalog_digest",
}
"attachments", "execution_policy", "catalog_digest"}
@dataclass(frozen=True)
@@ -239,9 +232,7 @@ class GatewayRoomCatalog:
persistent_process=value["persistent_process"], text=value["text"],
attachments=value["attachments"], execution_policy=policy,
catalog_digest=_digest(value["catalog_digest"], field="catalog_digest"),
endpoint_url=endpoint_url, endpoint_reason=endpoint_reason,
transport_security=transport_security,
)
endpoint_url=endpoint_url, endpoint_reason=endpoint_reason, transport_security=transport_security)
unsigned = catalog.as_mapping()
del unsigned["catalog_digest"]
expected = hashlib.sha256(_canonical_json(unsigned)).hexdigest()
@@ -255,8 +246,7 @@ class GatewayRoomCatalog:
"installation_id": self.installation_id, "protocol_versions": list(self.protocol_versions),
"link_modes": list(self.link_modes), "persistent_process": self.persistent_process,
"text": self.text, "attachments": self.attachments,
"execution_policy": self.execution_policy.as_mapping(), "catalog_digest": self.catalog_digest,
}
"execution_policy": self.execution_policy.as_mapping(), "catalog_digest": self.catalog_digest}
if self.endpoint_url is not None or self.endpoint_reason is not None:
value["endpoint"] = self.endpoint_mapping()
return value
@@ -264,9 +254,7 @@ class GatewayRoomCatalog:
def endpoint_mapping(self) -> dict[str, Any]:
"""Return the normalized self-advertised endpoint capability."""
if self.endpoint_url is not None:
return {
"available": True, "url": self.endpoint_url, "transport_security": self.transport_security,
}
return {"available": True, "url": self.endpoint_url, "transport_security": self.transport_security}
return {"available": False, "reason": self.endpoint_reason or "not_configured"}
@@ -274,18 +262,14 @@ def catalog_mapping(
*, installation_id: str, protocol_versions: Iterable[int] = (PROTOCOL_VERSION,),
link_modes: Iterable[LinkMode] = ("direct", "pull"), persistent_process: bool, text: bool = True,
attachments: bool = False, endpoint: Mapping[str, Any] | None = None, target_profile: str | None = None,
execution_policy: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
execution_policy: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""Build a canonical catalog mapping with its digest."""
# A Desktop-managed gateway exits with the app: the caller's flag is only an
# upper bound, so call sites that still pass ``True`` stay honest.
persistent_process = bool(persistent_process and os.getenv("HERMES_DESKTOP") != "1")
profile = (
str(target_profile or "").strip() or (os.getenv("HERMES_PROFILE") or "default").strip() or "default"
)
profile = (str(target_profile or "").strip() or (os.getenv("HERMES_PROFILE") or "default").strip() or "default")
checked_policy = RoomExecutionPolicy.from_mapping(
execution_policy or execution_policy_mapping(target_profile=profile)
)
execution_policy or execution_policy_mapping(target_profile=profile))
# A RoomLink run is initiated by another installation. Process-wide YOLO
# mode bypasses the scoped approval ContextVar, so rewriting the advertised
# policy cannot make it safe: refuse until manual or smart approvals are on.
@@ -293,16 +277,13 @@ def catalog_mapping(
raise HostedRoomPeerError("remote room execution requires manual or smart approvals")
value = {
"installation_id": _identifier(installation_id, field="installation_id"),
"protocol_versions": sorted(
{_positive_int(item, field="protocol_version") for item in protocol_versions}
),
"protocol_versions": sorted({_positive_int(item, field="protocol_version") for item in protocol_versions}),
# Direct HTTPS/loopback is the only RoomLink transport implemented by
# this backend slice. Do not advertise pull/relay placeholders.
"link_modes": [mode for mode in dict.fromkeys(link_modes) if mode == "direct"],
"persistent_process": persistent_process, "text": bool(text), "attachments": bool(attachments),
"execution_policy": checked_policy.as_mapping(),
"endpoint": dict(local_room_link_endpoint() if endpoint is None else endpoint),
}
"endpoint": dict(local_room_link_endpoint() if endpoint is None else endpoint)}
value["catalog_digest"] = hashlib.sha256(_canonical_json(value)).hexdigest()
GatewayRoomCatalog.from_mapping(value)
return value
@@ -311,14 +292,12 @@ def catalog_mapping(
def local_catalog_mapping(
*, installation_id: str, protocol_versions: Iterable[int] = (PROTOCOL_VERSION,),
link_modes: Iterable[LinkMode] = ("direct", "pull"), text: bool = True, attachments: bool = False,
target_profile: str | None = None, execution_policy: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
target_profile: str | None = None, execution_policy: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""Build the one truthful catalog advertised by this local process."""
return catalog_mapping(
installation_id=installation_id, protocol_versions=protocol_versions, link_modes=link_modes,
persistent_process=True, text=text, attachments=attachments, target_profile=target_profile,
execution_policy=execution_policy,
)
execution_policy=execution_policy)
def local_room_link_endpoint(value: Any | None = None) -> dict[str, Any]:
@@ -369,11 +348,7 @@ def _configured_room_link_url() -> str | None:
def validate_room_link_url(value: Any) -> tuple[str, TransportSecurity]:
"""Validate a RoomLink endpoint and classify its transport protection.
Plaintext HTTP is allowed only toward the local loopback interface; every
non-loopback endpoint must use HTTPS.
"""
"""Validate a RoomLink endpoint and classify its transport: plaintext HTTP only toward loopback."""
raw = str(value or "").strip().rstrip("/")
try:
parsed = urllib.parse.urlsplit(raw)
@@ -445,27 +420,23 @@ class HostedMemberDispatch:
prompt = value["prompt"]
if not isinstance(prompt, str) or not prompt.strip():
raise HostedRoomPeerError("prompt must be a non-empty string")
if len(prompt.encode("utf-8")) > MAX_PROMPT_BYTES:
raise HostedRoomPeerError("prompt is too large")
text(prompt, error=HostedRoomPeerError, label="prompt", max_bytes=MAX_PROMPT_BYTES, strip=False)
expected_prompt_digest = hashlib.sha256(prompt.encode("utf-8")).hexdigest()
prompt_digest = _digest(value["prompt_digest"], field="prompt_digest")
if not hmac.compare_digest(expected_prompt_digest, prompt_digest):
raise HostedRoomPeerError("prompt_digest does not match prompt")
return cls(
prompt=prompt, prompt_digest=prompt_digest,
**{name: check(value[name], field=name) for name, check in _DISPATCH_FIELDS.items()},
)
**{name: check(value[name], field=name) for name, check in _DISPATCH_FIELDS.items()})
# Grant scope fields, in issue-time validation order; each is validated with
# the same checker as the matching dispatch field (``grant_id`` is an identifier).
_GRANT_SCOPE = (
"grant_id", "room_id", "home_install_id", "authority_gateway_id", "authority_epoch",
"member_id", "target_install_id", "target_profile",
)
"member_id", "target_install_id", "target_profile")
_GRANT_FIELDS = frozenset({
"version", *_GRANT_SCOPE, "execution_policy_digest", "permissions", "issued_at", "expires_at",
})
"version", *_GRANT_SCOPE, "execution_policy_digest", "permissions", "issued_at", "expires_at"})
_GRANT_REFRESH_FIELDS = _GRANT_FIELDS | {"status_expires_at"}
_GRANT_PERMISSIONS = {"approve", "dispatch", "status", "stop"}
MAX_DISPATCH_GRANT_TTL_SECONDS = 24 * 60 * 60
@@ -476,24 +447,20 @@ def issue_room_grant(
secret: bytes, *, grant_id: str, room_id: str, home_install_id: str, authority_gateway_id: str,
authority_epoch: int, member_id: str, target_install_id: str, target_profile: str,
execution_policy_digest: str | None = None,
permissions: Iterable[str] = ("approve", "dispatch", "status", "stop"),
issued_at: float | None = None, ttl_seconds: float = 3600, status_ttl_seconds: float | None = None,
status_expires_at: float | None = None,
) -> str:
permissions: Iterable[str] = ("approve", "dispatch", "status", "stop"), issued_at: float | None = None,
ttl_seconds: float = 3600, status_ttl_seconds: float | None = None, status_expires_at: float | None = None) -> str:
"""Issue a target-verifiable bearer grant scoped to one room member."""
if len(secret) < 32:
raise HostedRoomGrantError("room grant secret must be at least 32 bytes")
now = time.time() if issued_at is None else float(issued_at)
now = clock(issued_at)
bounded_status_expiry = (
now + float(ttl_seconds if status_ttl_seconds is None else status_ttl_seconds)
if status_expires_at is None
else float(status_expires_at)
)
else float(status_expires_at))
if (
not math.isfinite(now) or ttl_seconds <= 0 or ttl_seconds > MAX_DISPATCH_GRANT_TTL_SECONDS
or not math.isfinite(bounded_status_expiry) or bounded_status_expiry < now + float(ttl_seconds)
or bounded_status_expiry > now + MAX_STATUS_GRANT_TTL_SECONDS
):
or bounded_status_expiry > now + MAX_STATUS_GRANT_TTL_SECONDS):
raise HostedRoomGrantError("room grant lifetime is invalid")
allowed = tuple(sorted(set(permissions)))
if not allowed or not set(allowed) <= _GRANT_PERMISSIONS:
@@ -501,19 +468,16 @@ def issue_room_grant(
scope = {
"grant_id": grant_id, "room_id": room_id, "home_install_id": home_install_id,
"authority_gateway_id": authority_gateway_id, "authority_epoch": authority_epoch,
"member_id": member_id, "target_install_id": target_install_id, "target_profile": target_profile,
}
"member_id": member_id, "target_install_id": target_install_id, "target_profile": target_profile}
payload = {
"version": PROTOCOL_VERSION,
**{name: _DISPATCH_FIELDS.get(name, _identifier)(scope[name], field=name) for name in _GRANT_SCOPE},
"execution_policy_digest": _digest(
execution_policy_digest
or execution_policy_mapping(target_profile=target_profile)["policy_digest"],
field="execution_policy_digest",
),
field="execution_policy_digest"),
"permissions": list(allowed), "issued_at": now, "expires_at": now + float(ttl_seconds),
"status_expires_at": bounded_status_expiry,
}
"status_expires_at": bounded_status_expiry}
encoded = _canonical_json(payload)
signature = hmac.new(secret, encoded, hashlib.sha256).digest()
token = f"{_b64encode(encoded)}.{_b64encode(signature)}"
@@ -524,8 +488,7 @@ def issue_room_grant(
def verify_room_grant(
secret: bytes, token: str, dispatch: HostedMemberDispatch, *, permission: str = "dispatch",
now: float | None = None,
) -> dict[str, Any]:
now: float | None = None) -> dict[str, Any]:
"""Verify one room grant against exact recipient dispatch coordinates."""
payload = decode_room_grant(secret, token, permission=permission, now=now)
if payload["version"] != dispatch.protocol_version:
@@ -536,9 +499,7 @@ def verify_room_grant(
return payload
def decode_room_grant(
secret: bytes, token: str, *, permission: str, now: float | None = None
) -> dict[str, Any]:
def decode_room_grant(secret: bytes, token: str, *, permission: str, now: float | None = None) -> dict[str, Any]:
"""Verify grant signature, lifetime and operation without a dispatch."""
if not isinstance(token, str) or len(token.encode("utf-8")) > MAX_TOKEN_BYTES:
raise HostedRoomGrantError("room grant is invalid")
@@ -554,7 +515,7 @@ def decode_room_grant(
raise HostedRoomGrantError("room grant payload is invalid") from exc
if not isinstance(payload, dict) or frozenset(payload) not in {_GRANT_FIELDS, _GRANT_REFRESH_FIELDS}:
raise HostedRoomGrantError("room grant fields are invalid")
checked_now = time.time() if now is None else float(now)
checked_now = clock(now)
if not math.isfinite(checked_now):
raise HostedRoomGrantError("room grant clock is invalid")
try:
@@ -575,18 +536,12 @@ def decode_room_grant(
return payload
def room_grant_needs_dispatch_refresh(
token: str, *, now: float | None = None, leeway_seconds: float = 5 * 60
) -> bool:
"""Read only grant timing to schedule target-validated refresh.
This deliberately does not establish trust; the target validates the
signature and immutable scope before issuing a replacement.
"""
def room_grant_needs_dispatch_refresh(token: str, *, now: float | None = None, leeway_seconds: float = 5 * 60) -> bool:
"""Read only grant timing to schedule refresh; trust is established by the target, not here."""
try:
payload = json.loads(_b64decode(_split_token(token)[0]).decode("ascii"))
expires_at = float(payload["expires_at"])
checked_now = time.time() if now is None else float(now)
checked_now = clock(now)
return checked_now + max(0.0, float(leeway_seconds)) >= expires_at
except Exception:
return True
+51 -129
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from typing import Any, Callable, Mapping
from gateway import hosted_rooms
from gateway.hosted_rooms_common import compact_json
MAX_ACTIVE_POLICY_EVENTS = 64
@@ -52,12 +53,8 @@ _SCHEMA_DDL = (
room_id TEXT PRIMARY KEY, schema_version INTEGER NOT NULL)""",
)
_ROOM_EVENT_COLUMNS = (
"room_id, seq, event_id, kind, actor_json, authority_epoch, payload_json, created_at"
)
_DELETE_ACTIVE_EVENTS_SQL = (
"DELETE FROM hosted_room_policy_events WHERE room_id=? AND discussion_event_id=?"
)
_ROOM_EVENT_COLUMNS = hosted_rooms._EVENT_COLUMNS
_DELETE_ACTIVE_EVENTS_SQL = ("DELETE FROM hosted_room_policy_events WHERE room_id=? AND discussion_event_id=?")
_TRANSCRIPT_EVENTS_SQL = f"""WITH transcript_events(seq) AS (
SELECT seq FROM hosted_room_policy_transcript WHERE room_id=? AND thread_id=?
UNION ALL
@@ -79,13 +76,7 @@ class PolicySnapshot:
watermarks: Mapping[tuple[str, str], int]
def _event_from_room_row(row: sqlite3.Row) -> dict[str, Any]:
return {
"room_id": str(row["room_id"]), "seq": int(row["seq"]), "event_id": str(row["event_id"]),
"kind": str(row["kind"]), "actor": json.loads(row["actor_json"]),
"authority_epoch": row["authority_epoch"], "payload": json.loads(row["payload_json"]),
"created_at": float(row["created_at"]), "idempotent": False,
}
_event_from_room_row = hosted_rooms._event_from_row
def _require_room(conn: sqlite3.Connection, room_id: str) -> None:
@@ -94,17 +85,14 @@ def _require_room(conn: sqlite3.Connection, room_id: str) -> None:
def _settled_message(
conn: sqlite3.Connection, room_id: str, discussion_event_id: str, message_event_id: Any
) -> dict[str, Any] | None:
conn: sqlite3.Connection, room_id: str, discussion_event_id: str, message_event_id: Any) -> dict[str, Any] | None:
"""Return the indexed member message a ``turn.settled`` event committed, if it is in the projection."""
rows = conn.execute(
"SELECT seq, event_json FROM hosted_room_policy_events WHERE room_id=? AND discussion_event_id=?",
(room_id, discussion_event_id),
).fetchall()
(room_id, discussion_event_id)).fetchall()
return next(
(m for m in (json.loads(row["event_json"]) for row in rows) if m.get("event_id") == message_event_id),
None,
)
None)
class HostedRoomPolicyCheckpoint:
@@ -127,43 +115,33 @@ class HostedRoomPolicyCheckpoint:
@staticmethod
def _store_active_event(
conn: sqlite3.Connection, *, event: Mapping[str, Any], thread_id: str, discussion_event_id: str
) -> None:
conn: sqlite3.Connection, *, event: Mapping[str, Any], thread_id: str, discussion_event_id: str) -> None:
conn.execute(
"""INSERT OR IGNORE INTO hosted_room_policy_events(
room_id, thread_id, discussion_event_id, seq, event_json
) VALUES (?, ?, ?, ?, ?)""",
(
event["room_id"], thread_id, discussion_event_id, int(event["seq"]),
json.dumps(dict(event), ensure_ascii=True, sort_keys=True, separators=(",", ":")),
),
)
(event["room_id"], thread_id, discussion_event_id, int(event["seq"]), compact_json(dict(event))))
@staticmethod
def _store_transcript_event(
conn: sqlite3.Connection, *, event: Mapping[str, Any], thread_id: str,
settled_seq: int | None = None,
) -> None:
conn: sqlite3.Connection, *, event: Mapping[str, Any], thread_id: str, settled_seq: int | None = None) -> None:
conn.execute(
"""INSERT INTO hosted_room_policy_transcript(
room_id, thread_id, seq, kind, settled_seq
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(room_id, thread_id, seq) DO UPDATE SET
settled_seq=COALESCE(excluded.settled_seq, hosted_room_policy_transcript.settled_seq)""",
(event["room_id"], thread_id, int(event["seq"]), str(event["kind"]), settled_seq),
)
(event["room_id"], thread_id, int(event["seq"]), str(event["kind"]), settled_seq))
if event["kind"] in {"message.user", "message.member"}:
cutoff = conn.execute(
"""SELECT seq FROM hosted_room_policy_transcript
WHERE room_id=? AND thread_id=? AND kind IN ('message.user', 'message.member')
ORDER BY seq DESC LIMIT 1 OFFSET ?""",
(event["room_id"], thread_id, MAX_THREAD_TRANSCRIPT_EVENTS - 1),
).fetchone()
(event["room_id"], thread_id, MAX_THREAD_TRANSCRIPT_EVENTS - 1)).fetchone()
if cutoff is not None:
conn.execute(
"DELETE FROM hosted_room_policy_transcript WHERE room_id=? AND thread_id=? AND seq<?",
(event["room_id"], thread_id, int(cutoff["seq"])),
)
(event["room_id"], thread_id, int(cutoff["seq"])))
def _backfill_transcript(self, conn: sqlite3.Connection, *, room_id: str, through_seq: int) -> None:
"""Migrate bounded committed thread history from the durable room log."""
@@ -172,9 +150,7 @@ class HostedRoomPolicyCheckpoint:
settled_seq_by_message: dict[str, int] = {}
for row in conn.execute(
"""SELECT seq, payload_json FROM hosted_room_events
WHERE room_id=? AND seq<=? AND kind='turn.settled' ORDER BY seq""",
(room_id, through_seq),
):
WHERE room_id=? AND seq<=? AND kind='turn.settled' ORDER BY seq""", (room_id, through_seq)):
message_event_id = str(json.loads(row["payload_json"]).get("message_event_id") or "")
if message_event_id:
settled_seq_by_message[message_event_id] = int(row["seq"])
@@ -182,8 +158,7 @@ class HostedRoomPolicyCheckpoint:
f"""SELECT {_ROOM_EVENT_COLUMNS} FROM hosted_room_events
WHERE room_id=? AND seq<=? AND kind IN ('message.user', 'message.member')
ORDER BY seq""",
(room_id, through_seq),
)
(room_id, through_seq))
for row in rows:
if row["kind"] == "message.member" and row["event_id"] not in settled_seq_by_message:
continue
@@ -192,38 +167,30 @@ class HostedRoomPolicyCheckpoint:
if thread_id:
self._store_transcript_event(
conn, event=event, thread_id=thread_id,
settled_seq=settled_seq_by_message.get(str(row["event_id"])),
)
settled_seq=settled_seq_by_message.get(str(row["event_id"])))
def _discussion_events(
self, conn: sqlite3.Connection, *, room_id: str, thread_id: str, discussion_event_id: str,
bound_error: str,
) -> list[dict[str, Any]]:
bound_error: str) -> list[dict[str, Any]]:
"""Merge the thread transcript with the active projection, ordered by seq."""
active_rows = conn.execute(
"""SELECT event_json FROM hosted_room_policy_events
WHERE room_id=? AND discussion_event_id=? ORDER BY seq LIMIT ?""",
(room_id, discussion_event_id, MAX_ACTIVE_POLICY_EVENTS + 1),
).fetchall()
(room_id, discussion_event_id, MAX_ACTIVE_POLICY_EVENTS + 1)).fetchall()
if len(active_rows) > MAX_ACTIVE_POLICY_EVENTS:
raise RuntimeError(bound_error)
rows = conn.execute(
_TRANSCRIPT_EVENTS_SQL, (room_id, thread_id, room_id, thread_id, room_id)
).fetchall()
rows = conn.execute(_TRANSCRIPT_EVENTS_SQL, (room_id, thread_id, room_id, thread_id, room_id)).fetchall()
events_by_seq = {
int(event["seq"]): event
for event in (
*(_event_from_room_row(row) for row in rows),
*(json.loads(row["event_json"]) for row in active_rows),
)
}
*(json.loads(row["event_json"]) for row in active_rows))}
return [events_by_seq[seq] for seq in sorted(events_by_seq)]
# -- per-kind projection handlers (dispatched by _apply_event) -----------
def _apply_user_message(
self, conn: sqlite3.Connection, event: Mapping[str, Any], payload: Mapping[str, Any]
) -> None:
self, conn: sqlite3.Connection, event: Mapping[str, Any], payload: Mapping[str, Any]) -> None:
room_id = str(event["room_id"])
thread_id = str(payload.get("thread_id") or "")
event_id = str(event.get("event_id") or "")
@@ -236,14 +203,12 @@ class HostedRoomPolicyCheckpoint:
ON CONFLICT(room_id, thread_id) DO UPDATE SET
discussion_event_id=excluded.discussion_event_id,
latest_user_seq=excluded.latest_user_seq, completed=0""",
(room_id, thread_id, event_id, int(event["seq"])),
)
(room_id, thread_id, event_id, int(event["seq"])))
self._store_active_event(conn, event=event, thread_id=thread_id, discussion_event_id=event_id)
self._store_transcript_event(conn, event=event, thread_id=thread_id)
def _apply_discussion_event(
self, conn: sqlite3.Connection, event: Mapping[str, Any], payload: Mapping[str, Any]
) -> None:
self, conn: sqlite3.Connection, event: Mapping[str, Any], payload: Mapping[str, Any]) -> None:
"""Index member messages and terminal turn outcomes of a known discussion."""
room_id = str(event["room_id"])
seq = int(event["seq"])
@@ -252,26 +217,20 @@ class HostedRoomPolicyCheckpoint:
discussion_event_id = str(payload.get("discussion_event_id") or "")
source = conn.execute(
"SELECT 1 FROM hosted_room_policy_events WHERE room_id=? AND discussion_event_id=? LIMIT 1",
(room_id, discussion_event_id),
).fetchone()
(room_id, discussion_event_id)).fetchone()
if source is None:
return
self._store_active_event(
conn, event=event, thread_id=thread_id, discussion_event_id=discussion_event_id
)
self._store_active_event(conn, event=event, thread_id=thread_id, discussion_event_id=discussion_event_id)
if kind not in _TERMINAL_KINDS:
return
task_id = str(payload.get("task_id") or "")
execution_generation = (
int(payload.get("execution_generation") or 0) if kind == "turn.deferred" else 0
)
execution_generation = (int(payload.get("execution_generation") or 0) if kind == "turn.deferred" else 0)
if task_id:
conn.execute(
"""INSERT OR IGNORE INTO hosted_room_policy_publications(
room_id, task_id, kind, execution_generation, seq
) VALUES (?, ?, ?, ?, ?)""",
(room_id, task_id, kind, execution_generation, seq),
)
(room_id, task_id, kind, execution_generation, seq))
member_id = str(payload.get("member_id") or "")
seen_through_seq = int(payload.get("seen_through_seq") or 0)
if kind == "turn.settled" and payload.get("message_event_id"):
@@ -286,35 +245,26 @@ class HostedRoomPolicyCheckpoint:
) VALUES (?, ?, ?, ?)
ON CONFLICT(room_id, thread_id, member_id) DO UPDATE SET
seen_through_seq=MAX(hosted_room_policy_watermarks.seen_through_seq, excluded.seen_through_seq)""",
(room_id, thread_id, member_id, seen_through_seq),
)
(room_id, thread_id, member_id, seen_through_seq))
def _apply_room_activity(
self, conn: sqlite3.Connection, event: Mapping[str, Any], payload: Mapping[str, Any]
) -> None:
self, conn: sqlite3.Connection, event: Mapping[str, Any], payload: Mapping[str, Any]) -> None:
room_id = str(event["room_id"])
thread_id = str(payload.get("thread_id") or "")
discussion_event_id = str(payload.get("discussion_event_id") or "")
conn.execute(_DELETE_ACTIVE_EVENTS_SQL, (room_id, discussion_event_id))
conn.execute(
"DELETE FROM hosted_room_policy_threads WHERE room_id=? AND thread_id=?",
(room_id, thread_id),
)
conn.execute("DELETE FROM hosted_room_policy_threads WHERE room_id=? AND thread_id=?", (room_id, thread_id))
def _apply_stop_requested(
self, conn: sqlite3.Connection, event: Mapping[str, Any], payload: Mapping[str, Any]
) -> None:
self, conn: sqlite3.Connection, event: Mapping[str, Any], payload: Mapping[str, Any]) -> None:
conn.execute(
"""UPDATE hosted_room_policy_cursors
SET stopped_through_seq=MAX(stopped_through_seq, ?) WHERE room_id=?""",
(int(event["seq"]), str(event["room_id"])),
)
(int(event["seq"]), str(event["room_id"])))
_APPLY_BY_KIND: dict[str, Callable[..., None]] = {
"message.user": _apply_user_message,
"message.member": _apply_discussion_event,
**dict.fromkeys(_TERMINAL_KINDS, _apply_discussion_event),
"room.activity": _apply_room_activity,
"message.user": _apply_user_message, "message.member": _apply_discussion_event,
**dict.fromkeys(_TERMINAL_KINDS, _apply_discussion_event), "room.activity": _apply_room_activity,
"room.stop_requested": _apply_stop_requested,
}
@@ -331,25 +281,18 @@ class HostedRoomPolicyCheckpoint:
conn.execute(
"""INSERT OR IGNORE INTO hosted_room_policy_cursors(
room_id, through_seq, stopped_through_seq, updated_at
) VALUES (?, 0, 0, 0)""",
(room_id,),
)
row = conn.execute(
"SELECT through_seq FROM hosted_room_policy_cursors WHERE room_id=?", (room_id,)
).fetchone()
) VALUES (?, 0, 0, 0)""", (room_id,))
row = conn.execute("SELECT through_seq FROM hosted_room_policy_cursors WHERE room_id=?", (room_id,)).fetchone()
cursor = int(row["through_seq"])
transcript_state = conn.execute(
"SELECT schema_version FROM hosted_room_policy_transcript_state WHERE room_id=?",
(room_id,),
).fetchone()
"SELECT schema_version FROM hosted_room_policy_transcript_state WHERE room_id=?", (room_id,)).fetchone()
if transcript_state is None or int(transcript_state["schema_version"]) < _TRANSCRIPT_SCHEMA_VERSION:
self._backfill_transcript(conn, room_id=room_id, through_seq=cursor)
conn.execute(
"""INSERT INTO hosted_room_policy_transcript_state(room_id, schema_version)
VALUES (?, ?)
ON CONFLICT(room_id) DO UPDATE SET schema_version=excluded.schema_version""",
(room_id, _TRANSCRIPT_SCHEMA_VERSION),
)
(room_id, _TRANSCRIPT_SCHEMA_VERSION))
return cursor
def sync(self, *, room_id: str, latest_seq: int) -> int:
@@ -362,8 +305,7 @@ class HostedRoomPolicyCheckpoint:
while cursor < latest_seq:
page = hosted_rooms.read_events(
self.db_path, room_id=room_id, since_seq=cursor, limit=hosted_rooms.MAX_LOG_LIMIT
)
self.db_path, room_id=room_id, since_seq=cursor, limit=hosted_rooms.MAX_LOG_LIMIT)
rows = [event for event in page.get("events", []) if isinstance(event, Mapping)]
next_cursor = int(page.get("cursor") or cursor)
if not rows or next_cursor <= cursor:
@@ -375,8 +317,7 @@ class HostedRoomPolicyCheckpoint:
self._apply_event(conn, event)
updated = conn.execute(
"UPDATE hosted_room_policy_cursors SET through_seq=?, updated_at=? WHERE room_id=?",
(next_cursor, float(rows[-1].get("created_at") or 0), room_id),
)
(next_cursor, float(rows[-1].get("created_at") or 0), room_id))
if updated.rowcount != 1:
raise RuntimeError("room policy cursor disappeared during replay")
cursor = next_cursor
@@ -387,43 +328,29 @@ class HostedRoomPolicyCheckpoint:
through_seq = self.sync(room_id=room_id, latest_seq=latest_seq)
with self._connect() as conn:
cursor = conn.execute(
"SELECT stopped_through_seq FROM hosted_room_policy_cursors WHERE room_id=?",
(room_id,),
).fetchone()
"SELECT stopped_through_seq FROM hosted_room_policy_cursors WHERE room_id=?", (room_id,)).fetchone()
stopped_through_seq = int(cursor["stopped_through_seq"])
thread = conn.execute(
"""SELECT thread_id, discussion_event_id FROM hosted_room_policy_threads
WHERE room_id=? AND completed=0 AND latest_user_seq>?
ORDER BY latest_user_seq, thread_id LIMIT 1""",
(room_id, stopped_through_seq),
).fetchone()
ORDER BY latest_user_seq, thread_id LIMIT 1""", (room_id, stopped_through_seq)).fetchone()
if thread is None:
return PolicySnapshot(
through_seq=through_seq, stopped_through_seq=stopped_through_seq,
events=(), watermarks={},
)
events=(), watermarks={})
thread_id = str(thread["thread_id"])
events = self._discussion_events(
conn, room_id=room_id, thread_id=thread_id,
discussion_event_id=str(thread["discussion_event_id"]),
bound_error="active room policy projection exceeded its bound",
)
bound_error="active room policy projection exceeded its bound")
watermark_rows = conn.execute(
"""SELECT member_id, seen_through_seq FROM hosted_room_policy_watermarks
WHERE room_id=? AND thread_id=?""",
(room_id, thread_id),
).fetchall()
WHERE room_id=? AND thread_id=?""", (room_id, thread_id)).fetchall()
return PolicySnapshot(
through_seq=through_seq, stopped_through_seq=stopped_through_seq, events=tuple(events),
watermarks={
(thread_id, str(row["member_id"])): int(row["seen_through_seq"])
for row in watermark_rows
},
)
watermarks={(thread_id, str(row["member_id"])): int(row["seen_through_seq"]) for row in watermark_rows})
def publication_exists(
self, *, room_id: str, task_id: str, status: str, execution_generation: int
) -> bool:
def publication_exists(self, *, room_id: str, task_id: str, status: str, execution_generation: int) -> bool:
"""Return whether one exact driver outcome is already in the room log."""
if status == "deferred":
sql = """SELECT 1 FROM hosted_room_policy_publications
@@ -441,25 +368,20 @@ class HostedRoomPolicyCheckpoint:
with self._connect() as conn:
source = conn.execute(
"SELECT discussion_event_id, thread_id FROM hosted_room_policy_events WHERE room_id=? AND seq=?",
(room_id, source_event_seq),
).fetchone()
(room_id, source_event_seq)).fetchone()
if source is None:
return []
return self._discussion_events(
conn, room_id=room_id, thread_id=str(source["thread_id"]),
discussion_event_id=str(source["discussion_event_id"]),
bound_error="task policy projection exceeded its bound",
)
bound_error="task policy projection exceeded its bound")
def compact_completed(self, *, room_id: str) -> None:
"""Drop any completed projections left by an interrupted sync."""
with self._connect() as conn:
completed = conn.execute(
"SELECT discussion_event_id FROM hosted_room_policy_threads WHERE room_id=? AND completed=1",
(room_id,),
).fetchall()
(room_id,)).fetchall()
for row in completed:
conn.execute(_DELETE_ACTIVE_EVENTS_SQL, (room_id, str(row["discussion_event_id"])))
conn.execute(
"DELETE FROM hosted_room_policy_threads WHERE room_id=? AND completed=1", (room_id,)
)
conn.execute("DELETE FROM hosted_room_policy_threads WHERE room_id=? AND completed=1", (room_id,))
+58 -124
View File
@@ -1,41 +1,35 @@
"""Replica store and takeover primitives for hosted Group Chat rooms.
The authority gateway owns a room's ordered log in ``gateway/hosted_rooms.py``;
this module gives every OTHER participant gateway a durable local copy plus the
fenced primitives to continue the room when the authority host dies:
``ingest_page()`` (idempotent, gap- and epoch-regression-safe replay),
``promote_replica()`` (resume locally at ``epoch + 1`` with a lineage-proving
``authority.claimed`` event) and ``demote_room()`` (a returning stale authority
records ``authority.lost`` when shown proof of a newer epoch).
Storage primitives only: none decide *when* takeover is safe. The caller must
establish that the previous owner can no longer commit before promoting.
Non-authority gateways keep a durable local copy of the room log (``ingest_page()``: idempotent,
gap- and epoch-regression-safe) plus fenced primitives to continue the room when the authority host
dies: ``promote_replica()`` resumes locally at ``epoch + 1`` with a lineage-proving ``authority.claimed``
event; ``demote_room()`` records ``authority.lost`` when a returning stale authority is shown a newer
epoch. Storage primitives only: the caller decides *when* takeover is safe.
"""
from __future__ import annotations
import json
import sqlite3
import time
from contextlib import contextmanager
from functools import partial
from pathlib import Path
from typing import Any, Iterator
from gateway.hosted_rooms import (
MAX_ACTOR_ID_CHARS,
MAX_EVENT_JSON_BYTES,
MAX_ROOM_ID_CHARS,
HostedRoomError,
RoomConflictError,
_canonical_json,
_actor_json,
_connect,
_payload_json,
_room_id,
_transaction,
_validate_identifier,
_validate_members,
_validate_room_name,
local_authority_gateway_id,
)
from gateway.hosted_rooms_common import positive_int, utf8_len
local_authority_gateway_id)
from gateway.hosted_rooms_common import bounded_int, clock, utf8_len
MAX_REPLICA_ROOMS = 256
MAX_REPLICA_EVENT_BYTES = 256 * 1024 * 1024
@@ -46,16 +40,13 @@ _INSERT_REPLICA_EVENT = f"INSERT INTO hosted_room_replica_events {_EVENT_COLUMNS
_SELECT_REPLICA = "SELECT * FROM hosted_room_replicas WHERE room_id=?"
class ReplicaError(HostedRoomError):
"""Base class for invalid or conflicting replica operations."""
class ReplicaError(HostedRoomError): """Base class for invalid or conflicting replica operations."""
class ReplicaGapError(ReplicaError):
"""A page does not start at the replica's next expected sequence."""
class ReplicaGapError(ReplicaError): """A page does not start at the replica's next expected sequence."""
class ReplicaEpochRegressionError(ReplicaError):
"""A page or demotion carries an older authority epoch than stored."""
class ReplicaEpochRegressionError(ReplicaError): """A page or demotion carries an older authority epoch than stored."""
def _initialize_replica_schema(conn: sqlite3.Connection) -> None:
@@ -83,8 +74,7 @@ def _initialize_replica_schema(conn: sqlite3.Connection) -> None:
def _replica_transaction(db_path: Path | str) -> Iterator[sqlite3.Connection]:
"""Ensure the replica schema (own autocommit connection), then open an IMMEDIATE transaction.
The schema DDL is re-run inside the transaction too; that double init is the
established statement order and is intentionally preserved.
The DDL is deliberately re-run inside the transaction: that double init is the established statement order.
"""
conn = _connect(db_path)
try:
@@ -97,28 +87,19 @@ def _replica_transaction(db_path: Path | str) -> Iterator[sqlite3.Connection]:
yield conn
def _room_id(value: Any) -> str:
return _validate_identifier(value, label="room_id", max_chars=MAX_ROOM_ID_CHARS)
def _positive_int(value: Any, message: str) -> int:
return positive_int(value, error=ReplicaError, message=message)
_positive_int = partial(bounded_int, error=ReplicaError, low=1)
def _control_event_json(payload: dict[str, Any]) -> tuple[str, str]:
"""Canonical (actor_json, payload_json) for a system authority-control event."""
return (
_canonical_json(_SYSTEM_ACTOR, label="actor", max_bytes=4 * 1024),
_canonical_json(payload, label="payload", max_bytes=MAX_EVENT_JSON_BYTES),
)
return _actor_json(_SYSTEM_ACTOR), _payload_json(payload)
def _event_bytes(event: dict[str, Any]) -> int:
return utf8_len(
str(event["event_id"]), str(event["kind"]),
json.dumps(event["actor"], ensure_ascii=False, separators=(",", ":")),
json.dumps(event["payload"], ensure_ascii=False, separators=(",", ":")),
)
json.dumps(event["payload"], ensure_ascii=False, separators=(",", ":")))
def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]:
@@ -131,14 +112,13 @@ def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]:
if not isinstance(authority, dict):
raise ReplicaError("page.authority is required for replication")
gateway_id = _validate_identifier(
authority.get("gateway_id"), label="page.authority.gateway_id", max_chars=MAX_ACTOR_ID_CHARS
)
epoch = _positive_int(authority.get("epoch"), "page.authority.epoch must be a positive integer")
authority.get("gateway_id"), label="page.authority.gateway_id", max_chars=MAX_ACTOR_ID_CHARS)
epoch = _positive_int(authority.get("epoch"), message="page.authority.epoch must be a positive integer")
previous_seq: int | None = None
for event in events:
if not isinstance(event, dict):
raise ReplicaError("page events must be objects")
seq = _positive_int(event.get("seq"), "event.seq must be a positive integer")
seq = _positive_int(event.get("seq"), message="event.seq must be a positive integer")
if previous_seq is not None and seq != previous_seq + 1:
raise ReplicaGapError("page events must be contiguous")
previous_seq = seq
@@ -156,9 +136,7 @@ def _replica_row_state(conn: sqlite3.Connection, room_id: str) -> tuple[sqlite3.
"""Return (row, stored_epoch, last_seq, stored_bytes); a new room is admitted only under the room cap."""
row = conn.execute(
"""SELECT authority_gateway_id, authority_epoch, last_seq, latest_seq, event_bytes
FROM hosted_room_replicas WHERE room_id=?""",
(room_id,),
).fetchone()
FROM hosted_room_replicas WHERE room_id=?""", (room_id,)).fetchone()
if row is None:
count = conn.execute("SELECT COUNT(*) FROM hosted_room_replicas").fetchone()[0]
if int(count) >= MAX_REPLICA_ROOMS:
@@ -169,8 +147,7 @@ def _replica_row_state(conn: sqlite3.Connection, room_id: str) -> tuple[sqlite3.
def _store_replica(
conn: sqlite3.Connection, *, is_new: bool, room_id: str, room_name: str, members_json: str,
authority: dict[str, Any], new_last: int, latest_seq: int, added_bytes: int, now: float,
) -> None:
authority: dict[str, Any], new_last: int, latest_seq: int, added_bytes: int, now: float) -> None:
"""INSERT the replica row for a new room, else UPDATE it (event_bytes accumulates)."""
values = (room_name, members_json, authority["gateway_id"], authority["epoch"], new_last, max(latest_seq, new_last))
if is_new:
@@ -178,30 +155,23 @@ def _store_replica(
"""INSERT INTO hosted_room_replicas (room_id, name, members_json,
authority_gateway_id, authority_epoch, last_seq, latest_seq, event_bytes,
created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(room_id, *values, added_bytes, now, now),
)
(room_id, *values, added_bytes, now, now))
else:
conn.execute(
"""UPDATE hosted_room_replicas SET name=?, members_json=?, authority_gateway_id=?,
authority_epoch=?, last_seq=?, latest_seq=?, event_bytes=event_bytes+?,
updated_at=? WHERE room_id=?""",
(*values, added_bytes, now, room_id),
)
updated_at=? WHERE room_id=?""", (*values, added_bytes, now, room_id))
def ingest_page(
db_path: Path | str, *, room_id: Any, room_name: Any, members: Any, page: Any, now: float | None = None
) -> dict[str, Any]:
"""Persist one replay page idempotently; refuses seq gaps and epoch regressions.
``page`` is the verbatim ``groups.log`` (``read_events()``) result whose
``authority`` stamp proves lineage.
"""
"""Persist one verbatim ``read_events()`` page idempotently; refuses seq gaps and epoch regressions."""
room_id = _room_id(room_id)
room_name = _validate_room_name(room_name)
_, members_json = _validate_members(members)
events, authority = _validate_page(page)
now = time.time() if now is None else float(now)
now = clock(now)
with _replica_transaction(db_path) as conn:
row, stored_epoch, last_seq, stored_bytes = _replica_row_state(conn, room_id)
@@ -216,15 +186,13 @@ def ingest_page(
size = _event_bytes(event)
if stored_bytes + added_bytes + size > MAX_REPLICA_EVENT_BYTES:
raise ReplicaError("replica event storage exhausted")
actor_json = _canonical_json(event["actor"], label="actor", max_bytes=4 * 1024)
payload_json = _canonical_json(event["payload"], label="payload", max_bytes=MAX_EVENT_JSON_BYTES)
actor_json = _actor_json(event["actor"])
payload_json = _payload_json(event["payload"])
conn.execute(
_INSERT_REPLICA_EVENT,
(
room_id, int(event["seq"]), event["event_id"], event["kind"], actor_json,
event.get("authority_epoch"), payload_json, float(event.get("created_at") or now),
),
)
event.get("authority_epoch"), payload_json, float(event.get("created_at") or now)))
added_bytes += size
new_last = int(new_events[-1]["seq"]) if new_events else last_seq
latest_seq = page.get("latest_seq")
@@ -232,13 +200,9 @@ def ingest_page(
latest_seq = new_last
_store_replica(
conn, is_new=row is None, room_id=room_id, room_name=room_name, members_json=members_json,
authority=authority, new_last=new_last, latest_seq=latest_seq, added_bytes=added_bytes, now=now,
)
authority=authority, new_last=new_last, latest_seq=latest_seq, added_bytes=added_bytes, now=now)
return {
"room_id": room_id,
"stored_seq": new_last,
"ingested": len(new_events),
"authority": authority,
"room_id": room_id, "stored_seq": new_last, "ingested": len(new_events), "authority": authority,
"caught_up": new_last >= max(latest_seq, new_last),
}
@@ -251,15 +215,10 @@ def replica_state(db_path: Path | str, *, room_id: Any) -> dict[str, Any]:
if row is None:
raise ReplicaError("replica not found")
return {
"room_id": row["room_id"],
"name": row["name"],
"members": json.loads(row["members_json"]),
"room_id": row["room_id"], "name": row["name"], "members": json.loads(row["members_json"]),
"authority": {"gateway_id": row["authority_gateway_id"], "epoch": int(row["authority_epoch"])},
"last_seq": int(row["last_seq"]),
"latest_seq": int(row["latest_seq"]),
"event_bytes": int(row["event_bytes"]),
"created_at": float(row["created_at"]),
"updated_at": float(row["updated_at"]),
"last_seq": int(row["last_seq"]), "latest_seq": int(row["latest_seq"]), "event_bytes": int(row["event_bytes"]),
"created_at": float(row["created_at"]), "updated_at": float(row["updated_at"]),
}
@@ -268,15 +227,14 @@ def promote_replica(
) -> dict[str, Any]:
"""Continue a replicated room on THIS gateway at ``epoch + 1``.
Copies the replica log into the authoritative store and appends a
lineage-proving ``authority.claimed`` event; wherever the claim replicates,
the old epoch is stale and every fenced primitive rejects it. The caller
decides takeover is safe; this only makes it atomic and provable.
Copies the replica log into the authoritative store and appends a lineage-proving
``authority.claimed`` event, so wherever the claim replicates the old epoch is stale and every
fenced primitive rejects it. The caller decides takeover is safe; this makes it atomic and provable.
"""
room_id = _room_id(room_id)
if not isinstance(reason, str) or not reason or len(reason) > 200:
raise ReplicaError("reason must be a non-empty string of at most 200 chars")
now = time.time() if now is None else float(now)
now = clock(now)
local_gateway = local_authority_gateway_id()
with _replica_transaction(db_path) as conn:
@@ -296,11 +254,8 @@ def promote_replica(
claim_seq = int(replica["last_seq"]) + 1
claim_event_id = f"system:authority-claimed:{target_epoch}"
claim_actor_json, claim_payload_json = _control_event_json({
"previous_gateway_id": previous_gateway,
"authority_gateway_id": local_gateway,
"authority_epoch": target_epoch,
"promoted_from_replica": True,
"reason": reason,
"previous_gateway_id": previous_gateway, "authority_gateway_id": local_gateway,
"authority_epoch": target_epoch, "promoted_from_replica": True, "reason": reason,
})
claim_bytes = utf8_len(claim_event_id, "authority.claimed", claim_actor_json, claim_payload_json)
@@ -311,31 +266,22 @@ def promote_replica(
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, NULL)""",
(
room_id, replica["name"], replica["members_json"], local_gateway, target_epoch,
claim_seq + 1, int(replica["event_bytes"]) + claim_bytes, now, now,
),
)
claim_seq + 1, int(replica["event_bytes"]) + claim_bytes, now, now))
conn.execute(
f"""INSERT INTO hosted_room_events {_EVENT_COLUMNS}
SELECT room_id, seq, event_id, kind, actor_json, authority_epoch, payload_json, created_at
FROM hosted_room_replica_events WHERE room_id=?""",
(room_id,),
)
(room_id,))
conn.execute(
_INSERT_ROOM_EVENT,
(
room_id, claim_seq, claim_event_id, "authority.claimed",
claim_actor_json, target_epoch, claim_payload_json, now,
),
)
claim_actor_json, target_epoch, claim_payload_json, now))
conn.execute("DELETE FROM hosted_room_replica_events WHERE room_id=?", (room_id,))
conn.execute("DELETE FROM hosted_room_replicas WHERE room_id=?", (room_id,))
return {
"room_id": room_id,
"authority_gateway_id": local_gateway,
"authority_epoch": target_epoch,
"previous_gateway_id": previous_gateway,
"previous_epoch": previous_epoch,
"claim_seq": claim_seq,
"room_id": room_id, "authority_gateway_id": local_gateway, "authority_epoch": target_epoch,
"previous_gateway_id": previous_gateway, "previous_epoch": previous_epoch, "claim_seq": claim_seq,
"latest_seq": claim_seq,
}
@@ -345,34 +291,28 @@ def demote_room(
) -> dict[str, Any]:
"""Fence THIS gateway's stale room authority against a proven newer epoch.
Called when a returning gateway observes (replicated ``authority.claimed``
or a transport rejection) that another gateway owns the room at a higher
epoch. Appends ``authority.lost`` and adopts the observed lineage so no
local send can commit at the stale epoch. Idempotent per lineage.
When a returning gateway observes (replicated ``authority.claimed`` or a transport rejection) that
another gateway owns the room at a higher epoch, append ``authority.lost`` and adopt the observed
lineage so no local send can commit at the stale epoch. Idempotent per lineage.
"""
room_id = _room_id(room_id)
observed_gateway_id = _validate_identifier(
observed_gateway_id, label="observed_gateway_id", max_chars=MAX_ACTOR_ID_CHARS
)
observed_epoch = _positive_int(observed_epoch, "observed_epoch must be a positive integer")
now = time.time() if now is None else float(now)
observed_gateway_id, label="observed_gateway_id", max_chars=MAX_ACTOR_ID_CHARS)
observed_epoch = _positive_int(observed_epoch, message="observed_epoch must be a positive integer")
now = clock(now)
local_gateway = local_authority_gateway_id()
with _transaction(db_path, immediate=True) as conn:
row = conn.execute(
"""SELECT authority_gateway_id, authority_epoch, next_seq
FROM hosted_rooms WHERE room_id=? AND disbanded_at IS NULL""",
(room_id,),
).fetchone()
FROM hosted_rooms WHERE room_id=? AND disbanded_at IS NULL""", (room_id,)).fetchone()
if row is None:
raise ReplicaError("room not found in the local authoritative store")
current_gateway = str(row["authority_gateway_id"])
current_epoch = int(row["authority_epoch"])
if current_gateway == observed_gateway_id and current_epoch == observed_epoch:
return {
"room_id": room_id,
"authority_gateway_id": current_gateway,
"authority_epoch": current_epoch,
"room_id": room_id, "authority_gateway_id": current_gateway, "authority_epoch": current_epoch,
"idempotent": True,
}
if observed_epoch <= current_epoch:
@@ -382,24 +322,18 @@ def demote_room(
lost_actor_json, lost_payload_json = _control_event_json({
"previous_gateway_id": current_gateway,
"authority_gateway_id": observed_gateway_id,
"authority_epoch": observed_epoch,
})
"authority_epoch": observed_epoch})
conn.execute(
_INSERT_ROOM_EVENT,
(
room_id, int(row["next_seq"]), f"system:authority-lost:{observed_epoch}",
"authority.lost", lost_actor_json, observed_epoch, lost_payload_json, now,
),
)
"authority.lost", lost_actor_json, observed_epoch, lost_payload_json, now))
conn.execute(
"""UPDATE hosted_rooms
SET authority_gateway_id=?, authority_epoch=?, next_seq=next_seq+1, revision=revision+1, updated_at=?
WHERE room_id=?""",
(observed_gateway_id, observed_epoch, now, room_id),
)
(observed_gateway_id, observed_epoch, now, room_id))
return {
"room_id": room_id,
"authority_gateway_id": observed_gateway_id,
"authority_epoch": observed_epoch,
"room_id": room_id, "authority_gateway_id": observed_gateway_id, "authority_epoch": observed_epoch,
"idempotent": False,
}
+272 -601
View File
File diff suppressed because it is too large Load Diff
+84 -51
View File
@@ -1,11 +1,9 @@
"""Shared leaf helpers for the gateway hosted-room modules.
Every hosted-room module validates identifiers, bounded integers, exact field
sets and canonical JSON with its own error class and its own error strings
(tests pin those strings). These helpers take the error class and message
templates as parameters so each caller keeps byte-identical failures while the
logic lives once. This module must stay a leaf: never import a hosted_room*
origin module from here (import cycle).
Each hosted-room module validates identifiers, bounded integers, exact field sets and
canonical JSON with its own error class and pinned error strings; these helpers take the
error class and message templates as parameters so failures stay byte-identical while the
logic lives once. Must stay a leaf: never import a hosted_room* module (import cycle).
"""
from __future__ import annotations
@@ -13,7 +11,9 @@ from __future__ import annotations
import json
import re
import sqlite3
import time
from contextlib import contextmanager
from functools import partial
from pathlib import Path
from typing import Any, Callable, Iterator, Mapping
@@ -21,47 +21,33 @@ IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
def identifier(
value: Any,
*,
label: str,
error: type[Exception],
max_chars: int = 128,
pattern: re.Pattern[str] = IDENTIFIER_RE,
invalid: str | None = None,
) -> str:
"""Strip and validate a bounded identifier; ``invalid`` overrides the fail message."""
value: Any, *, label: str, error: type[Exception], max_chars: int = 128,
pattern: re.Pattern[str] | None = IDENTIFIER_RE, invalid: str | None = None) -> str:
"""Strip and validate a bounded string; ``pattern=None`` skips the shape check."""
if not isinstance(value, str):
raise error(f"{label} must be a string")
value = value.strip()
if not value or len(value) > max_chars or not pattern.fullmatch(value):
if not value or len(value) > max_chars or (pattern is not None and not pattern.fullmatch(value)):
raise error(invalid or f"invalid {label}")
return value
def positive_int(value: Any, *, error: type[Exception], message: str) -> int:
"""Reject bools, non-ints and values below 1 (``message`` is the exact error text)."""
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
def bounded_int(value: Any, *, error: type[Exception], message: str, low: int = 0, high: int | None = None) -> int:
"""Reject bools, non-ints and values outside ``[low, high]`` (``message`` is the exact text)."""
if isinstance(value, bool) or not isinstance(value, int) or value < low or (high is not None and value > high):
raise error(message)
return value
def non_negative_int(value: Any, *, error: type[Exception], message: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise error(message)
return value
positive_int = partial(bounded_int, low=1)
non_negative_int = partial(bounded_int, low=0)
def exact_fields(
value: Any,
*,
label: str,
required: frozenset[str] | set[str],
optional: frozenset[str] | set[str] = frozenset(),
error: type[Exception],
not_object: str | None = None,
value: Any, *, label: str, required: frozenset[str] | set[str],
optional: frozenset[str] | set[str] = frozenset(), error: type[Exception], not_object: str | None = None,
missing_fmt: str = "{label} is missing fields: {fields}",
unknown_fmt: str = "{label} has unknown fields: {fields}",
) -> Mapping[str, Any]:
unknown_fmt: str = "{label} has unknown fields: {fields}") -> Mapping[str, Any]:
"""Require exactly ``required`` (+ any ``optional``) keys; formats name the offenders sorted."""
if not isinstance(value, Mapping):
raise error(not_object or f"{label} must be an object")
@@ -75,19 +61,28 @@ def exact_fields(
return value
def canonical_json(
value: Any,
*,
error: type[Exception],
label: str,
max_bytes: int,
ensure_ascii: bool,
) -> str:
"""Sorted, compact JSON bounded by ``max_bytes`` of UTF-8."""
def text(value: Any, *, error: type[Exception], label: str, max_bytes: int, strip: bool = True) -> str:
"""Non-blank string bounded by ``max_bytes`` of UTF-8; ``strip=False`` keeps and measures the raw text."""
if not isinstance(value, str):
raise error(f"{label} must be a string")
if not value.strip():
raise error(f"{label} must not be empty")
if strip:
value = value.strip()
if len(value.encode("utf-8")) > max_bytes:
raise error(f"{label} is too large")
return value
def compact_json(value: Any, *, ensure_ascii: bool = True) -> str:
"""Sorted-key, separator-free JSON (the digest/storage canonical form)."""
return json.dumps(value, ensure_ascii=ensure_ascii, sort_keys=True, separators=(",", ":"))
def canonical_json(value: Any, *, error: type[Exception], label: str, max_bytes: int, ensure_ascii: bool) -> str:
"""``compact_json`` bounded by ``max_bytes`` of UTF-8; unserializable input raises ``error``."""
try:
encoded = json.dumps(
value, ensure_ascii=ensure_ascii, sort_keys=True, separators=(",", ":")
)
encoded = compact_json(value, ensure_ascii=ensure_ascii)
except (TypeError, ValueError, RecursionError) as exc:
raise error(f"{label} must be JSON-serializable") from exc
if len(encoded.encode("utf-8")) > max_bytes:
@@ -99,6 +94,11 @@ def utf8_len(*parts: str) -> int:
return len("".join(parts).encode("utf-8"))
def clock(now: float | None) -> float:
"""``now`` as a float, or the current wall clock when ``None``."""
return time.time() if now is None else float(now)
def open_sqlite(path: Path | str, *, timeout: float = 10) -> sqlite3.Connection:
"""Row-factory connection with foreign keys on; no journal or schema work."""
conn = sqlite3.connect(path, timeout=timeout)
@@ -107,10 +107,46 @@ def open_sqlite(path: Path | str, *, timeout: float = 10) -> sqlite3.Connection:
return conn
def connect(
db_path: Path | str, *, db_label: str, ready: Callable[[sqlite3.Connection], bool],
initialize: Callable[[sqlite3.Connection], None], lock_retries: int = 1) -> sqlite3.Connection:
"""Open the shared root store: WAL, foreign keys, then ``initialize`` in one IMMEDIATE txn if not ``ready``.
Multiple profile gateways share this database, so every draft-schema transition
is serialized in SQLite itself: a crash rolls back the whole DDL/data migration and
another process can safely retry it. Only the transient "database is locked" class
from the journal-mode pragma is retried (it may ignore the busy timeout while another
first opener initializes the DB, especially on Windows).
"""
from hermes_state import apply_wal_with_fallback
path = Path(db_path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=10)
conn.row_factory = sqlite3.Row
try:
for attempt in range(lock_retries):
try:
apply_wal_with_fallback(conn, db_label=db_label)
break
except sqlite3.OperationalError as exc:
if str(exc).lower() != "database is locked" or attempt + 1 == lock_retries:
raise
time.sleep(0.01 * (2**attempt))
conn.execute("PRAGMA foreign_keys=ON")
if not ready(conn):
conn.execute("BEGIN IMMEDIATE")
initialize(conn)
conn.commit()
except Exception:
conn.rollback()
conn.close()
raise
return conn
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone()
row = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
return row is not None
@@ -120,10 +156,7 @@ def table_columns(conn: sqlite3.Connection, table: str) -> frozenset[str]:
@contextmanager
def transaction(
connect: Callable[[Path | str], sqlite3.Connection],
db_path: Path | str,
*,
immediate: bool,
connect: Callable[[Path | str], sqlite3.Connection], db_path: Path | str, *, immediate: bool
) -> Iterator[sqlite3.Connection]:
"""Open via ``connect``, optionally ``BEGIN IMMEDIATE``, commit on success, always close."""
conn = connect(db_path)
+111 -260
View File
@@ -3,19 +3,27 @@
Background loops that subscribe to kanban boards, deliver notifications and
artifacts, and drive the multi-agent dispatcher. They use only ``self`` state,
so they live on a mixin ``GatewayRunner`` inherits. Per-tick work lives in
``kanban_watchers_notifier`` / ``kanban_watchers_dispatcher``.
``kanban_watchers_notifier`` / ``kanban_watchers_dispatcher``; shared plumbing
in ``kanban_watchers_common``.
"""
from __future__ import annotations
import asyncio
import contextlib
import os
import time
from pathlib import Path
from typing import Any, Callable, Optional
from typing import Any, Optional
from gateway.kanban_watchers_common import _to_thread_process_service, logger # noqa: F401 (tests import via origin)
from gateway.kanban_watchers_common import ( # noqa: F401 (tests import via origin)
_acquire_singleton_lock,
_kanban_dispatch_allowed,
_release_singleton_lock,
_resolve_auto_decompose_settings,
_gc_retention_days,
_to_thread_process_service,
logger,
)
from gateway.kanban_watchers_notifier import ( # noqa: F401 (_wake_scope_id: tests import via origin)
_KanbanNotification,
_notifier_collect,
@@ -27,100 +35,16 @@ from gateway.kanban_watchers_dispatcher import (
_resolve_dispatcher_settings,
)
def _resolve_auto_decompose_settings(
load_config: Callable[[], Any],
) -> "tuple[bool, int]":
"""Resolve the live (enabled, per_tick) auto-decompose settings.
Read fresh every dispatcher tick so flipping ``kanban.auto_decompose:
false`` stops runaway fan-out on the next tick without a restart. Fails
safe: a config read error returns ``(False, 3)`` rather than re-enabling
a feature the user turned off. ``per_tick`` is clamped to ``>= 1``.
"""
try:
cfg = load_config()
except Exception:
return False, 3
kcfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
enabled = bool(kcfg.get("auto_decompose", True))
try:
per_tick = int(kcfg.get("auto_decompose_per_tick", 3) or 3)
except (TypeError, ValueError):
per_tick = 3
return enabled, max(per_tick, 1)
def _kanban_dispatch_allowed() -> bool:
"""Return False while the global emergency stop (`hermes pause`) is engaged.
Checked every tick before spawning, so a pause applies on the next tick;
in-flight workers are never touched. Fails open if estop is unimportable.
"""
try:
from agent.estop import check_paused
except ImportError:
return True
return not check_paused("kanban", logger)
def _acquire_singleton_lock(lock_path) -> "tuple[Optional[object], str]":
"""Take the exclusive, non-blocking advisory lock for the sole dispatcher.
Only one gateway machine-wide may run the embedded dispatcher: concurrent
dispatchers double reclaim frequency and claim events, and with
``wal_autocheckpoint=0`` concurrent manual checkpoints can corrupt index
pages. ``dispatch_in_gateway`` is the primary control; this is the backstop.
Returns ``(handle, "held")`` (caller must release via
:func:`_release_singleton_lock`), ``(None, "contended")`` when another
process holds it (caller must NOT dispatch), or ``(None, "unavailable")``
when locking cannot be performed (caller falls back to config control).
"""
try:
from gateway.status import _try_acquire_file_lock # deferred; same package
except ImportError:
return None, "unavailable"
try:
Path(lock_path).parent.mkdir(parents=True, exist_ok=True)
handle = open(str(lock_path), "a+", encoding="utf-8")
except OSError:
return None, "unavailable"
if not _try_acquire_file_lock(handle):
handle.close()
return None, "contended"
return handle, "held"
def _release_singleton_lock(handle) -> None:
"""Release a lock acquired via :func:`_acquire_singleton_lock`."""
if handle is None:
return
try:
from gateway.status import _release_file_lock
_release_file_lock(handle)
except Exception:
pass
with contextlib.suppress(Exception):
handle.close()
def _gc_retention_days() -> int:
"""``kanban.done_sub_retention_days`` (default 30; 0 disables), re-read per sweep; fails safe to 30."""
try:
from hermes_cli.config import load_config as _load_cfg
_kanban_cfg = (_load_cfg() or {}).get("kanban") or {}
return int(_kanban_cfg.get("done_sub_retention_days", 30))
except Exception:
return 30
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
_GC_INTERVAL_SECONDS = 3600.0
_HEALTH_WINDOW = 6
class GatewayKanbanWatchersMixin:
"""Kanban watcher / notifier / dispatcher loops for GatewayRunner."""
def _owns_kanban_dispatcher_lock(self) -> bool:
"""Return whether this gateway currently owns the singleton lock."""
return getattr(self, "_kanban_dispatcher_lock_handle", None) is not None
def _release_kanban_dispatcher_lock(self) -> None:
@@ -141,15 +65,12 @@ class GatewayKanbanWatchersMixin:
"""Poll ``kanban_notify_subs`` and deliver terminal events to users.
Per subscription, claims ``task_events`` newer than the stored cursor
(kinds in TERMINAL_KINDS), sends one message per event to
``(platform, chat_id, thread_id)``, then advances the cursor. The
subscription is removed only when the task is ``archived``: ``done``
is reversible (review/continuation), so the cursor — not unsubscribing
— is the dedup mechanism. Earlier unsub-on-terminal silently dropped
users when the dispatcher respawned a crashed task.
All SQLite work runs in a thread; one tick's failure never stops the
next. Iterates every board on disk per tick.
(kinds in TERMINAL_KINDS), sends one message per event, then advances
the cursor. The subscription is removed only when the task is
``archived``: ``done`` is reversible, so the cursor — not unsubscribing
— is the dedup mechanism (unsub-on-terminal dropped users when the
dispatcher respawned a crashed task). All SQLite work runs in a thread;
one tick's failure never stops the next.
"""
from gateway.config import Platform as _Platform
try:
@@ -158,23 +79,18 @@ class GatewayKanbanWatchersMixin:
logger.warning("kanban notifier: kanban_db not importable; notifier disabled")
return
sub_fail_counts: dict[tuple, int] = getattr(
self, "_kanban_sub_fail_counts", {}
)
sub_fail_counts: dict[tuple, int] = getattr(self, "_kanban_sub_fail_counts", {})
self._kanban_sub_fail_counts = sub_fail_counts
notifier_profile = getattr(self, "_kanban_notifier_profile", None)
if not notifier_profile:
notifier_profile = self._active_profile_name()
self._kanban_notifier_profile = notifier_profile
notifier_profile = getattr(self, "_kanban_notifier_profile", None) or self._active_profile_name()
self._kanban_notifier_profile = notifier_profile
# Initial delay so the gateway can finish wiring adapters.
await asyncio.sleep(5)
# Stale done-sub GC: subs survive ``done``, so boards that never
# archive would accumulate rows scanned every tick. One DELETE per
# board, at startup and at most hourly.
_GC_INTERVAL_SECONDS = 3600.0
_gc_next_at = 0.0 # 0 → sweep on the first tick after startup
# board, at startup (0 → first tick) and at most hourly.
_gc_next_at = 0.0
while self._running:
try:
@@ -186,9 +102,7 @@ class GatewayKanbanWatchersMixin:
deliveries = await asyncio.to_thread(
_notifier_collect, self, _kb,
notifier_profile=notifier_profile,
gc_due=_gc_due,
gc_retention_days=_retention,
notifier_profile=notifier_profile, gc_due=_gc_due, gc_retention_days=_retention,
)
for d in deliveries:
await _KanbanNotification(
@@ -204,47 +118,23 @@ class GatewayKanbanWatchersMixin:
conn = _kb.connect(board=board)
try:
getattr(_kb, op)(
conn,
task_id=sub["task_id"],
platform=sub["platform"],
chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
**extra,
conn, task_id=sub["task_id"], platform=sub["platform"], chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "", **extra,
)
finally:
conn.close()
def _kanban_advance(
self, sub: dict, cursor: int, board: Optional[str] = None,
) -> None:
"""Advance a subscription's cursor on the board that owns it."""
def _kanban_advance(self, sub: dict, cursor: int, board: Optional[str] = None) -> None:
self._kanban_sub_op(board, "advance_notify_cursor", sub, new_cursor=cursor)
def _kanban_unsub(self, sub: dict, board: Optional[str] = None) -> None:
self._kanban_sub_op(board, "remove_notify_sub", sub)
def _kanban_rewind(
self,
sub: dict,
claimed_cursor: int,
old_cursor: int,
board: Optional[str] = None,
) -> None:
def _kanban_rewind(self, sub: dict, claimed_cursor: int, old_cursor: int, board: Optional[str] = None) -> None:
"""Undo a claimed notification cursor after send failure."""
self._kanban_sub_op(
board, "rewind_notify_cursor", sub,
claimed_cursor=claimed_cursor, old_cursor=old_cursor,
)
self._kanban_sub_op(board, "rewind_notify_cursor", sub, claimed_cursor=claimed_cursor, old_cursor=old_cursor)
async def _deliver_kanban_artifacts(
self,
*,
adapter,
chat_id: str,
metadata: dict,
event_payload: Optional[dict],
task,
) -> None:
async def _deliver_kanban_artifacts(self, *, adapter, chat_id: str, metadata: dict, event_payload: Optional[dict], task) -> None:
"""Upload artifact files referenced by a completed kanban task.
Sources, in priority order: ``event_payload['artifacts']``,
@@ -252,36 +142,21 @@ class GatewayKanbanWatchersMixin:
deduplicated, missing files are skipped (may be mentioned for
reference only), and upload errors are logged, never raised.
"""
candidates: list[str] = []
seen: set[str] = set()
def _add(path: str) -> None:
if not path:
return
expanded = os.path.expanduser(path)
if expanded in seen or not os.path.isfile(expanded):
return
seen.add(expanded)
candidates.append(expanded)
raw_paths: list[str] = []
if isinstance(event_payload, dict):
raw = event_payload.get("artifacts")
if isinstance(raw, (list, tuple)):
for item in raw:
if isinstance(item, str):
_add(item)
raw_paths += [item for item in raw if isinstance(item, str)]
summary = event_payload.get("summary")
if isinstance(summary, str) and summary:
paths, _ = adapter.extract_local_files(summary)
for p in paths:
_add(p)
raw_paths += adapter.extract_local_files(summary)[0]
if task is not None and getattr(task, "result", None):
paths, _ = adapter.extract_local_files(str(task.result))
for p in paths:
_add(p)
raw_paths += adapter.extract_local_files(str(task.result))[0]
candidates: list[str] = []
for path in raw_paths:
expanded = os.path.expanduser(path) if path else ""
if expanded and expanded not in candidates and os.path.isfile(expanded):
candidates.append(expanded)
if not candidates:
return
@@ -290,41 +165,73 @@ class GatewayKanbanWatchersMixin:
if not candidates:
return
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
from urllib.parse import quote as _quote
# Images ride one send_multiple_images call (batch uploads on Signal/Slack).
image_paths = [p for p in candidates if Path(p).suffix.lower() in _IMAGE_EXTS]
other_paths = [p for p in candidates if Path(p).suffix.lower() not in _IMAGE_EXTS]
if image_paths:
try:
batch = [(f"file://{_quote(p)}", "") for p in image_paths]
await adapter.send_multiple_images(
chat_id=chat_id, images=batch, metadata=metadata,
)
await adapter.send_multiple_images(chat_id=chat_id, images=batch, metadata=metadata)
except Exception as exc:
logger.warning(
"kanban notifier: image batch upload failed: %s", exc,
)
logger.warning("kanban notifier: image batch upload failed: %s", exc)
for path in other_paths:
try:
if Path(path).suffix.lower() in _VIDEO_EXTS:
await adapter.send_video(
chat_id=chat_id, video_path=path, metadata=metadata,
)
await adapter.send_video(chat_id=chat_id, video_path=path, metadata=metadata)
else:
await adapter.send_document(
chat_id=chat_id, file_path=path, metadata=metadata,
)
await adapter.send_document(chat_id=chat_id, file_path=path, metadata=metadata)
except Exception as exc:
logger.warning(
"kanban notifier: artifact upload (%s) failed: %s",
path, exc,
)
logger.warning("kanban notifier: artifact upload (%s) failed: %s", path, exc)
def _kanban_dispatcher_boot(self) -> Optional[tuple]:
"""Resolve config, kanban_db and the singleton lock; None when the dispatcher must not run.
Config is read once at boot (restart to apply), except the auto-decompose
toggle which is re-read every tick. The env var is an escape hatch to
disable without editing YAML.
"""
try:
from hermes_cli.config import load_config as _load_config
except Exception:
logger.warning("kanban dispatcher: config loader unavailable; disabled")
return None
env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower()
if env_override in {"0", "false", "no", "off"}:
logger.info("kanban dispatcher: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env")
return None
try:
cfg = _load_config()
except Exception as exc:
logger.warning("kanban dispatcher: cannot load config (%s); disabled", exc)
return None
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
if not kanban_cfg.get("dispatch_in_gateway", True):
logger.info("kanban dispatcher: disabled via config kanban.dispatch_in_gateway=false")
return None
try:
from hermes_cli import kanban_db as _kb
except Exception:
logger.warning("kanban dispatcher: kanban_db not importable; dispatcher disabled")
return None
# Single-dispatcher backstop (see _acquire_singleton_lock). The lock
# lives at the machine-global kanban root, so it serialises ALL gateways.
self._kanban_dispatcher_lock_handle = None
_lock_path = _kb.kanban_home() / "kanban" / ".dispatcher.lock"
_lock_handle, _lock_state = _acquire_singleton_lock(_lock_path)
if _lock_state == "contended":
logger.info("kanban dispatcher: another gateway already holds the dispatcher "
"lock (%s); this gateway will NOT dispatch.", _lock_path)
return None
if _lock_state == "held":
self._kanban_dispatcher_lock_handle = _lock_handle # hold for process lifetime
logger.info("kanban dispatcher: holding singleton dispatcher lock (%s)", _lock_path)
else:
logger.warning("kanban dispatcher: advisory lock unavailable at %s; proceeding "
"on config control alone.", _lock_path)
return _load_config, _kb, kanban_cfg
async def _kanban_dispatcher_watcher(self) -> None:
"""Embedded kanban dispatcher — one tick every `dispatch_interval_seconds`.
@@ -335,57 +242,10 @@ class GatewayKanbanWatchersMixin:
failure never stops the next. Shutdown: ``self._running`` is checked
between ticks and the in-flight ``to_thread`` returns on its own.
"""
# Config is read once at boot (restart to apply), except the
# auto-decompose toggle which is re-read every tick. The env var is
# an escape hatch to disable without editing YAML.
try:
from hermes_cli.config import load_config as _load_config
except Exception:
logger.warning("kanban dispatcher: config loader unavailable; disabled")
boot = self._kanban_dispatcher_boot()
if boot is None:
return
env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower()
if env_override in {"0", "false", "no", "off"}:
logger.info("kanban dispatcher: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env")
return
try:
cfg = _load_config()
except Exception as exc:
logger.warning("kanban dispatcher: cannot load config (%s); disabled", exc)
return
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
if not kanban_cfg.get("dispatch_in_gateway", True):
logger.info(
"kanban dispatcher: disabled via config kanban.dispatch_in_gateway=false"
)
return
try:
from hermes_cli import kanban_db as _kb
except Exception:
logger.warning("kanban dispatcher: kanban_db not importable; dispatcher disabled")
return
# Single-dispatcher backstop (see _acquire_singleton_lock). The lock
# lives at the machine-global kanban root, so it serialises ALL gateways.
self._kanban_dispatcher_lock_handle = None
_lock_path = _kb.kanban_home() / "kanban" / ".dispatcher.lock"
_lock_handle, _lock_state = _acquire_singleton_lock(_lock_path)
if _lock_state == "contended":
logger.info(
"kanban dispatcher: another gateway already holds the dispatcher "
"lock (%s); this gateway will NOT dispatch.", _lock_path,
)
return
if _lock_state == "held":
self._kanban_dispatcher_lock_handle = _lock_handle # hold for process lifetime
logger.info("kanban dispatcher: holding singleton dispatcher lock (%s)", _lock_path)
else:
logger.warning(
"kanban dispatcher: advisory lock unavailable at %s; proceeding "
"on config control alone.", _lock_path,
)
_load_config, _kb, kanban_cfg = boot
settings = _resolve_dispatcher_settings(kanban_cfg, _kb)
interval = settings.interval
@@ -395,25 +255,18 @@ class GatewayKanbanWatchersMixin:
# Health telemetry (mirrors `_cmd_daemon`): warn when the ready queue
# is non-empty but spawns are 0 for N consecutive ticks — usually a
# broken PATH, missing venv, or credential loss.
HEALTH_WINDOW = 6
bad_ticks = 0
last_warn_at = 0
dispatcher = _KanbanDispatcher(_kb, settings)
logger.info(
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
)
logger.info("kanban dispatcher: embedded in gateway (interval=%.1fs)", interval)
while self._running:
try:
# Reap zombies before per-board work so a board DB failure
# cannot block cleanup of unrelated workers.
pids = await _to_thread_process_service(_kb.reap_worker_zombies)
if pids:
logger.info(
"kanban dispatcher: reaped %d zombie worker(s), pids=%s",
len(pids),
pids,
)
logger.info("kanban dispatcher: reaped %d zombie worker(s), pids=%s", len(pids), pids)
except Exception:
logger.exception("kanban dispatcher: zombie reaper failed")
@@ -430,20 +283,18 @@ class GatewayKanbanWatchersMixin:
await _to_thread_process_service(dispatcher.auto_decompose_tick, _ad_per_tick)
results = await _to_thread_process_service(dispatcher.tick_once)
any_spawned = _log_spawn_results(results)
# Health telemetry (aggregate across boards)
ready_pending = await _to_thread_process_service(dispatcher.ready_nonempty)
bad_ticks = bad_ticks + 1 if ready_pending and not any_spawned else 0
if bad_ticks >= HEALTH_WINDOW:
now = int(time.time())
if now - last_warn_at >= 300:
logger.warning(
"kanban dispatcher stuck: ready queue non-empty for "
"%d consecutive ticks but 0 workers spawned. Check "
"profile health (venv, PATH, credentials) and "
"`hermes kanban list --status ready`.",
bad_ticks,
)
last_warn_at = now
now = int(time.time())
if bad_ticks >= _HEALTH_WINDOW and now - last_warn_at >= 300:
logger.warning(
"kanban dispatcher stuck: ready queue non-empty for "
"%d consecutive ticks but 0 workers spawned. Check "
"profile health (venv, PATH, credentials) and "
"`hermes kanban list --status ready`.",
bad_ticks,
)
last_warn_at = now
except asyncio.CancelledError:
logger.debug("kanban dispatcher: cancelled")
self._release_kanban_dispatcher_lock()
+111 -2
View File
@@ -1,11 +1,18 @@
"""Plumbing shared by the kanban notifier and dispatcher loops."""
"""Plumbing shared by the kanban notifier and dispatcher loops.
Thread offload, board enumeration, live-config coercers and the singleton
dispatcher lock live here so the notifier, dispatcher and mixin modules read
them from one place.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from contextvars import Context
from typing import Any, Callable
from pathlib import Path
from typing import Any, Callable, Optional
# Keep the logger name run.py used so extracted log records are unchanged.
logger = logging.getLogger("gateway.run")
@@ -33,3 +40,105 @@ def _list_boards(kb: Any) -> list:
return kb.list_boards(include_archived=False)
except Exception:
return [kb.read_board_metadata(kb.DEFAULT_BOARD)]
def _board_slugs(kb: Any) -> list:
return [b.get("slug") or kb.DEFAULT_BOARD for b in _list_boards(kb)]
def _positive_int_setting(kanban_cfg: dict, key: str) -> Optional[int]:
"""Parse an optional ``kanban.<key>`` int cap; None when unset or invalid (< 1 is invalid)."""
raw = kanban_cfg.get(key)
if raw is None:
return None
try:
value = int(raw)
except (TypeError, ValueError):
logger.warning("kanban dispatcher: invalid kanban.%s=%r; ignoring", key, raw)
return None
if value < 1:
logger.warning("kanban dispatcher: kanban.%s=%r is below 1; ignoring", key, raw)
return None
logger.info("kanban dispatcher: %s=%d", key, value)
return value
def _resolve_auto_decompose_settings(load_config: Callable[[], Any]) -> "tuple[bool, int]":
"""Live (enabled, per_tick) auto-decompose settings, re-read every dispatcher tick.
Fails safe: a config read error returns ``(False, 3)`` rather than
re-enabling a feature the user turned off. ``per_tick`` is clamped to ``>= 1``.
"""
try:
cfg = load_config()
except Exception:
return False, 3
kcfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
try:
per_tick = int(kcfg.get("auto_decompose_per_tick", 3) or 3)
except (TypeError, ValueError):
per_tick = 3
return bool(kcfg.get("auto_decompose", True)), max(per_tick, 1)
def _gc_retention_days() -> int:
"""``kanban.done_sub_retention_days`` (default 30; 0 disables), re-read per sweep; fails safe to 30."""
try:
from hermes_cli.config import load_config
return int(((load_config() or {}).get("kanban") or {}).get("done_sub_retention_days", 30))
except Exception:
return 30
def _kanban_dispatch_allowed() -> bool:
"""False while the global emergency stop (`hermes pause`) is engaged.
Checked every tick before spawning, so a pause applies on the next tick;
in-flight workers are never touched. Fails open if estop is unimportable.
"""
try:
from agent.estop import check_paused
except ImportError:
return True
return not check_paused("kanban", logger)
def _acquire_singleton_lock(lock_path) -> "tuple[Optional[object], str]":
"""Take the exclusive, non-blocking advisory lock for the sole dispatcher.
Only one gateway machine-wide may run the embedded dispatcher: concurrent
dispatchers double reclaim frequency and claim events, and with
``wal_autocheckpoint=0`` concurrent manual checkpoints can corrupt index
pages. ``dispatch_in_gateway`` is the primary control; this is the backstop.
Returns ``(handle, "held")`` (release via :func:`_release_singleton_lock`),
``(None, "contended")`` when another process holds it (caller must NOT
dispatch), or ``(None, "unavailable")`` when locking cannot be performed
(caller falls back to config control).
"""
try:
from gateway.status import _try_acquire_file_lock # deferred; same package
except ImportError:
return None, "unavailable"
try:
Path(lock_path).parent.mkdir(parents=True, exist_ok=True)
handle = open(str(lock_path), "a+", encoding="utf-8")
except OSError:
return None, "unavailable"
if not _try_acquire_file_lock(handle):
handle.close()
return None, "contended"
return handle, "held"
def _release_singleton_lock(handle) -> None:
"""Release a lock acquired via :func:`_acquire_singleton_lock`."""
if handle is None:
return
with contextlib.suppress(Exception):
from gateway.status import _release_file_lock
_release_file_lock(handle)
with contextlib.suppress(Exception):
handle.close()
+42 -119
View File
@@ -11,10 +11,10 @@ import contextlib
import os
import sqlite3
import time
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from typing import Any, Optional
from gateway.kanban_watchers_common import _list_boards, logger
from gateway.kanban_watchers_common import _board_slugs, _positive_int_setting, logger
_CORRUPT_DB_MARKERS = ("file is not a database", "database disk image is malformed")
@@ -33,32 +33,13 @@ class _DispatcherSettings:
max_in_progress_per_profile: Optional[int]
def _positive_int_setting(kanban_cfg: dict, key: str) -> Optional[int]:
"""Parse an optional ``kanban.<key>`` int cap; None when unset or invalid (< 1 is invalid)."""
raw = kanban_cfg.get(key)
if raw is None:
return None
try:
value = int(raw)
except (TypeError, ValueError):
logger.warning("kanban dispatcher: invalid kanban.%s=%r; ignoring", key, raw)
return None
if value < 1:
logger.warning("kanban dispatcher: kanban.%s=%r is below 1; ignoring", key, raw)
return None
logger.info("kanban dispatcher: %s=%d", key, value)
return value
def _resolve_dispatcher_settings(kanban_cfg: dict, kb: Any) -> _DispatcherSettings:
"""Parse and log the dispatcher settings in their established order."""
try:
interval = float(kanban_cfg.get("dispatch_interval_seconds", 60) or 60)
except (ValueError, TypeError):
logger.warning(
"kanban dispatcher: invalid dispatch_interval_seconds=%r, using default 60",
kanban_cfg.get("dispatch_interval_seconds"),
)
logger.warning("kanban dispatcher: invalid dispatch_interval_seconds=%r, using default 60",
kanban_cfg.get("dispatch_interval_seconds"))
interval = 60.0
interval = max(interval, 1.0) # sanity floor — tighter than this is a footgun
@@ -66,10 +47,9 @@ def _resolve_dispatcher_settings(kanban_cfg: dict, kb: Any) -> _DispatcherSettin
if max_spawn is not None:
logger.info("kanban dispatcher: max_spawn=%s", max_spawn)
# Cap simultaneously running tasks so slow workers don't pile up and
# time out. Explicit config wins; otherwise a memory-derived default
# (unbounded fan-out swap-thrashes small hosts), or None where total
# memory can't be read.
# Cap simultaneously running tasks so slow workers don't pile up and time
# out. Explicit config wins; otherwise a memory-derived default (unbounded
# fan-out swap-thrashes small hosts), or None where total memory can't be read.
max_in_progress = _positive_int_setting(kanban_cfg, "max_in_progress")
effective_max_in_progress = kb.resolve_max_in_progress(max_in_progress)
if max_in_progress is None and effective_max_in_progress is not None:
@@ -84,18 +64,12 @@ def _resolve_dispatcher_settings(kanban_cfg: dict, kb: Any) -> _DispatcherSettin
try:
failure_limit = int(raw_failure_limit)
except (TypeError, ValueError):
logger.warning(
"kanban dispatcher: invalid kanban.failure_limit=%r; using default %d",
raw_failure_limit,
kb.DEFAULT_FAILURE_LIMIT,
)
logger.warning("kanban dispatcher: invalid kanban.failure_limit=%r; using default %d",
raw_failure_limit, kb.DEFAULT_FAILURE_LIMIT)
failure_limit = kb.DEFAULT_FAILURE_LIMIT
if failure_limit < 1:
logger.warning(
"kanban dispatcher: kanban.failure_limit=%r is below 1; using default %d",
raw_failure_limit,
kb.DEFAULT_FAILURE_LIMIT,
)
logger.warning("kanban dispatcher: kanban.failure_limit=%r is below 1; using default %d",
raw_failure_limit, kb.DEFAULT_FAILURE_LIMIT)
failure_limit = kb.DEFAULT_FAILURE_LIMIT
# 0 disables stale detection.
@@ -103,22 +77,16 @@ def _resolve_dispatcher_settings(kanban_cfg: dict, kb: Any) -> _DispatcherSettin
try:
stale_timeout_seconds = int(raw_stale or 0)
except (TypeError, ValueError):
logger.warning(
"kanban dispatcher: invalid kanban.dispatch_stale_timeout_seconds=%r; "
"disabling stale detection",
raw_stale,
)
logger.warning("kanban dispatcher: invalid kanban.dispatch_stale_timeout_seconds=%r; "
"disabling stale detection", raw_stale)
stale_timeout_seconds = 0
# Fallback profile for tasks created without an assignee (e.g. via the
# dashboard). Empty (the schema default) keeps skipping them.
default_assignee = (kanban_cfg.get("default_assignee") or "").strip() or None
if default_assignee:
logger.info(
"kanban dispatcher: default_assignee=%r (unassigned ready tasks "
"will route to this profile)",
default_assignee,
)
logger.info("kanban dispatcher: default_assignee=%r (unassigned ready tasks "
"will route to this profile)", default_assignee)
return _DispatcherSettings(
interval=interval,
@@ -150,12 +118,10 @@ class _KanbanDispatcher:
def __init__(self, kb: Any, settings: _DispatcherSettings) -> None:
self.kb = kb
self.settings = settings
self.disabled_corrupt_boards: dict[
str, tuple[tuple[str, int | None, int | None], float]
] = {}
self.disabled_corrupt_boards: dict[str, tuple[tuple[str, int | None, int | None], float]] = {}
def _board_slugs(self) -> list:
return [b.get("slug") or self.kb.DEFAULT_BOARD for b in _list_boards(self.kb)]
return _board_slugs(self.kb)
def board_db_fingerprint(self, slug: str) -> tuple[str, int | None, int | None]:
path = self.kb.kanban_db_path(slug)
@@ -173,10 +139,7 @@ class _KanbanDispatcher:
corrupt_guard_error = getattr(self.kb, "KanbanDbCorruptError", None)
if corrupt_guard_error is not None and isinstance(exc, corrupt_guard_error):
return True
if not isinstance(exc, sqlite3.DatabaseError):
return False
msg = str(exc).lower()
return any(marker in msg for marker in _CORRUPT_DB_MARKERS)
return isinstance(exc, sqlite3.DatabaseError) and any(m in str(exc).lower() for m in _CORRUPT_DB_MARKERS)
def _quarantine_lifted(self, slug: str, fingerprint: tuple) -> bool:
"""Return False while *slug* stays quarantined; lift (and log) otherwise."""
@@ -188,17 +151,10 @@ class _KanbanDispatcher:
if disabled_fingerprint == fingerprint and age < self.CORRUPT_BOARD_RETRY_AFTER_SECONDS:
return False
if disabled_fingerprint == fingerprint:
logger.info(
"kanban dispatcher: board %s database fingerprint unchanged "
"after %.0fs quarantine; retrying dispatch",
slug,
age,
)
logger.info("kanban dispatcher: board %s database fingerprint unchanged "
"after %.0fs quarantine; retrying dispatch", slug, age)
else:
logger.info(
"kanban dispatcher: board %s database changed; retrying dispatch",
slug,
)
logger.info("kanban dispatcher: board %s database changed; retrying dispatch", slug)
self.disabled_corrupt_boards.pop(slug, None)
return True
@@ -212,22 +168,12 @@ class _KanbanDispatcher:
fingerprint = self.board_db_fingerprint(slug)
if not self._quarantine_lifted(slug, fingerprint):
return None
s = self.settings
kwargs = {k: v for k, v in asdict(self.settings).items() if k != "interval"}
try:
# No explicit init_db(): connect() runs the migration once per
# process (see the matching note in the notifier collector).
conn = self.kb.connect(board=slug)
return self.kb.dispatch_once(
conn,
board=slug,
max_spawn=s.max_spawn,
max_in_progress=s.max_in_progress,
failure_limit=s.failure_limit,
stale_timeout_seconds=s.stale_timeout_seconds,
default_assignee=s.default_assignee,
max_in_progress_per_profile=s.max_in_progress_per_profile,
reconcile_orphans=s.reconcile_orphans,
)
return self.kb.dispatch_once(conn, board=slug, **kwargs)
except Exception as exc:
if self.is_corrupt_board_db_error(exc):
self.disabled_corrupt_boards[slug] = (fingerprint, time.monotonic())
@@ -237,8 +183,7 @@ class _KanbanDispatcher:
"the file changes, the gateway restarts, or the "
"quarantine timer expires. Move or restore the file, "
"then run `hermes kanban init` if you need a fresh board.",
slug,
fingerprint[0],
slug, fingerprint[0],
)
return None
logger.exception("kanban dispatcher: tick failed on board %s", slug)
@@ -253,14 +198,13 @@ class _KanbanDispatcher:
return [(slug, self.tick_once_for_board(slug)) for slug in self._board_slugs()]
def ready_nonempty(self) -> bool:
"""Is there a ready+assigned+unclaimed task on ANY board that the
dispatcher would actually spawn for?
"""Is there a ready+assigned+unclaimed task on ANY board the dispatcher would spawn for?
Control-plane lanes (e.g. ``orion-cc``) are pulled by terminals
via ``claim_task`` and never spawnable — a queue full of those is
"correctly idle", not "stuck". The review column is probed only
when review dispatch is on (same gate as the dispatcher): a task
waiting for a human reviewer is idle, not stuck.
Control-plane lanes (e.g. ``orion-cc``) are pulled by terminals via
``claim_task`` and never spawnable — a queue full of those is
"correctly idle", not "stuck". The review column is probed only when
review dispatch is on (same gate as the dispatcher): a task waiting
for a human reviewer is idle, not stuck.
"""
kb = self.kb
_review_probe = kb.review_dispatch_enabled()
@@ -268,9 +212,7 @@ class _KanbanDispatcher:
conn = None
try:
conn = kb.connect(board=slug)
if kb.has_spawnable_ready(conn):
return True
if _review_probe and kb.has_spawnable_review(conn):
if kb.has_spawnable_ready(conn) or (_review_probe and kb.has_spawnable_review(conn)):
return True
except Exception:
continue
@@ -281,17 +223,15 @@ class _KanbanDispatcher:
return False
def auto_decompose_tick(self, auto_decompose_per_tick: int) -> int:
"""Auto-decompose up to N triage tasks across all boards into
ready workgraphs before dispatch fans out; the per-tick cap keeps
a bulk triage load from burst-spending the aux LLM. Returns the
number decomposed/specified.
"""Auto-decompose up to N triage tasks across all boards into ready workgraphs.
Runs before dispatch fans out; the per-tick cap keeps a bulk triage
load from burst-spending the aux LLM. Returns the number decomposed.
"""
try:
from hermes_cli import kanban_decompose as _decomp
except Exception as exc: # pragma: no cover
logger.warning(
"kanban auto-decompose: import failed (%s); skipping", exc,
)
logger.warning("kanban auto-decompose: import failed (%s); skipping", exc)
return 0
attempted = 0
successes = 0
@@ -306,10 +246,7 @@ class _KanbanDispatcher:
try:
triage_ids = _decomp.list_triage_ids()
except Exception as exc:
logger.debug(
"kanban auto-decompose: list_triage_ids failed on board %s (%s)",
slug, exc,
)
logger.debug("kanban auto-decompose: list_triage_ids failed on board %s (%s)", slug, exc)
triage_ids = []
for tid in triage_ids:
if attempted >= auto_decompose_per_tick:
@@ -329,28 +266,16 @@ class _KanbanDispatcher:
try:
outcome = _decomp.decompose_task(tid, author="auto-decomposer")
except Exception:
logger.exception(
"kanban auto-decompose: decompose_task crashed on %s",
tid,
)
logger.exception("kanban auto-decompose: decompose_task crashed on %s", tid)
return 0
if not outcome.ok:
# Common no-op reasons (no aux client) must not spam logs every tick.
logger.debug(
"kanban auto-decompose [%s]: %s skipped: %s",
slug, tid, outcome.reason,
)
logger.debug("kanban auto-decompose [%s]: %s skipped: %s", slug, tid, outcome.reason)
return 0
if outcome.fanout and outcome.child_ids:
logger.info(
"kanban auto-decompose [%s]: %s → %d children",
slug, tid, len(outcome.child_ids),
)
logger.info("kanban auto-decompose [%s]: %s → %d children", slug, tid, len(outcome.child_ids))
else:
logger.info(
"kanban auto-decompose [%s]: %s → single task (no fanout)",
slug, tid,
)
logger.info("kanban auto-decompose [%s]: %s → single task (no fanout)", slug, tid)
return 1
@@ -364,9 +289,7 @@ def _log_spawn_results(results: Optional[list]) -> bool:
logger.info(
"kanban dispatcher [%s]: spawned=%d reclaimed=%d "
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
slug,
len(res.spawned),
res.reclaimed,
slug, len(res.spawned), res.reclaimed,
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
len(res.timed_out) if hasattr(res.timed_out, "__len__") else 0,
res.promoted,
+228 -390
View File
@@ -21,34 +21,21 @@ from gateway.kanban_watchers_common import _list_boards, _to_thread_process_serv
TERMINAL_KINDS = ("completed", "blocked", "gave_up", "crashed", "timed_out", "status", "archived", "unblocked", "block_loop_detected", "review_requested", "changes_requested")
# Kinds that hand a decision back to the origin, which must take a turn.
# status/archived/unblocked are bookkeeping.
_WAKE_KINDS = (
"completed", "gave_up", "crashed", "timed_out",
"blocked", "review_requested", "changes_requested",
"block_loop_detected",
)
# Consecutive send failures (adapter raised OR reported
# SendResult(success=False)) before a sub is dropped as a dead chat.
# 12 ≈ 60s at the 5s cadence: a transient API outage must not
# permanently unsubscribe a live review-gate channel.
_WAKE_KINDS = ("completed", "gave_up", "crashed", "timed_out", "blocked", "review_requested", "changes_requested", "block_loop_detected")
# Consecutive send failures (adapter raised OR reported SendResult(success=False))
# before a sub is dropped as a dead chat. 12 ≈ 60s at the 5s cadence: a transient
# API outage must not permanently unsubscribe a live review-gate channel.
MAX_SEND_FAILURES = 12
_LOCAL_PATH_RE = re.compile(
r"(?<![\w:/])(?:/(?:Users|home|private|tmp|var|etc|workspace)/[^\s,;]+|"
r"[A-Za-z]:\\[^\s,;]+)"
)
_LOCAL_PATH_RE = re.compile(r"(?<![\w:/])(?:/(?:Users|home|private|tmp|var|etc|workspace)/[^\s,;]+|" r"[A-Za-z]:\\[^\s,;]+)")
def _safe_review_reason(value: Any, limit: int = 160) -> str:
"""Return a mobile-friendly review reason safe for external delivery."""
from agent.redact import redact_sensitive_text
reason = redact_sensitive_text(
"" if value is None else str(value),
force=True,
redact_url_credentials=True,
)
reason = _LOCAL_PATH_RE.sub("[local path]", reason)
reason = " ".join(reason.split())
reason = redact_sensitive_text("" if value is None else str(value), force=True, redact_url_credentials=True)
reason = " ".join(_LOCAL_PATH_RE.sub("[local path]", reason).split())
if len(reason) > limit:
reason = reason[: limit - 1].rstrip() + "…"
return reason
@@ -70,21 +57,15 @@ def _wake_scope_id(adapter: Any, sub: dict) -> Optional[str]:
if value:
return str(value)
resolver = getattr(adapter, "scope_id_for_chat", None)
if callable(resolver):
try:
resolved = resolver(str(sub.get("chat_id") or ""))
except Exception as exc:
# An adapter-side lookup failure yields no scope, never an error.
logger.debug(
"kanban notifier: scope lookup failed for chat %s: %s",
sub.get("chat_id"),
exc,
exc_info=True,
)
return None
if resolved:
return str(resolved)
return None
if not callable(resolver):
return None
try:
resolved = resolver(str(sub.get("chat_id") or ""))
except Exception as exc:
# An adapter-side lookup failure yields no scope, never an error.
logger.debug("kanban notifier: scope lookup failed for chat %s: %s", sub.get("chat_id"), exc, exc_info=True)
return None
return str(resolved) if resolved else None
def _platform_names(mapping: Any) -> set[str]:
@@ -92,260 +73,177 @@ def _platform_names(mapping: Any) -> set[str]:
return {getattr(platform, "value", str(platform)).lower() for platform in mapping}
# ---------------------------------------------------------------------------
# Collection (runs in a worker thread)
# ---------------------------------------------------------------------------
# --- Collection (runs in a worker thread) ---
def _notifier_collect(
runner: Any,
kb: Any,
*,
notifier_profile: Optional[str],
gc_due: bool,
gc_retention_days: int,
) -> list[dict]:
class _Collector:
"""One tick's claim state: which profiles/platforms this gateway serves and the GC gate."""
def __init__(self, runner: Any, kb: Any, *, notifier_profile: Optional[str], gc_due: bool, gc_retention_days: int) -> None:
self.kb = kb
self.notifier_profile = notifier_profile
self.gc_due = gc_due
self.gc_retention_days = gc_retention_days
self.deliveries: list[dict] = []
self.include_unowned = runner._owns_kanban_dispatcher_lock()
self.profile_adapters = getattr(runner, "_profile_adapters", {})
self.notifier_profiles = {notifier_profile}
self.notifier_profiles.update(str(p).strip() for p in self.profile_adapters if str(p).strip())
# Include every platform any secondary profile has live. This is only a
# coarse pre-filter; the precise per-profile check (_authorization_adapter,
# no default fallback) runs at delivery and rewinds the claim if it
# resolves to None. An unclaimed event never retries, so dropping a
# secondary-profile sub here would lose it.
self.active_platforms = _platform_names(runner.adapters).union(
*(_platform_names(m) for m in self.profile_adapters.values()))
def collect(self) -> list[dict]:
if not self.active_platforms:
logger.debug("kanban notifier: no connected adapters; skipping tick")
return self.deliveries
# Poll each resolved DB path once: several slugs can map to one DB when
# HERMES_KANBAN_DB pins the board path.
kb = self.kb
seen_db_paths: set[str] = set()
for board_meta in _list_boards(kb):
slug = board_meta.get("slug") or kb.DEFAULT_BOARD
db_path = board_meta.get("db_path")
try:
resolved_db_path = str(Path(db_path).expanduser().resolve()) if db_path else str(kb.kanban_db_path(slug).resolve())
except Exception:
resolved_db_path = f"slug:{slug}"
if resolved_db_path in seen_db_paths:
logger.debug("kanban notifier: skipping duplicate board slug %s for DB %s", slug, resolved_db_path)
continue
seen_db_paths.add(resolved_db_path)
self.collect_board(slug)
return self.deliveries
def _board_has_subs(self, slug: str) -> bool:
"""Cheap read-only probe before the writable connect() (schema init, WAL
sidecars, checkpoints); a probe failure falls back to the writable open."""
try:
count = self.kb.count_notify_subs(
board=slug, notifier_profiles=self.notifier_profiles, include_unowned=self.include_unowned)
except Exception as exc:
logger.debug("kanban notifier: read-only subscription probe failed "
"for board %s (%s); falling back to writable open", slug, exc)
return True
if count == 0:
logger.debug("kanban notifier: board %s has no subscriptions owned by %s; skipping open",
slug, sorted(self.notifier_profiles))
return count != 0
def _gc_stale_subs(self, conn: Any, slug: str) -> None:
"""Best-effort stale-sub sweep: a failed sweep never blocks delivery; the next hourly gate retries."""
try:
_purged = self.kb.purge_stale_done_notify_subs(conn, max_age_days=self.gc_retention_days)
if _purged:
logger.info("kanban notifier: purged %d stale done/blocked-task subscription(s) on board %s (retention %dd)",
_purged, slug, self.gc_retention_days)
except Exception as _gc_exc:
logger.debug("kanban notifier: stale-sub GC failed for board %s: %s", slug, _gc_exc)
def _claim_for_sub(self, conn: Any, slug: str, sub: dict) -> Optional[dict]:
"""Claim one subscription's unseen events; None when skipped or nothing new."""
owner_profile = sub.get("notifier_profile") or None
if owner_profile and owner_profile != self.notifier_profile and not self.profile_adapters.get(owner_profile):
logger.debug("kanban notifier: subscription for %s owned by profile %s; current profile %s has no adapter for it, skipping",
sub.get("task_id"), owner_profile, self.notifier_profile)
return None
platform = (sub.get("platform") or "").lower()
if platform not in self.active_platforms:
logger.debug("kanban notifier: subscription for %s on %s skipped; adapter not connected",
sub.get("task_id"), platform or "<missing>")
return None
old_cursor, cursor, events = self.kb.claim_unseen_events_for_sub(
conn, task_id=sub["task_id"], platform=sub["platform"], chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "", kinds=TERMINAL_KINDS,
)
if not events:
return None
task = self.kb.get_task(conn, sub["task_id"])
logger.debug("kanban notifier: claimed %d event(s) for %s on board %s cursor %s→%s",
len(events), sub["task_id"], slug, old_cursor, cursor)
return {"sub": sub, "old_cursor": old_cursor, "cursor": cursor, "events": events, "task": task, "board": slug}
def collect_board(self, slug: str) -> None:
"""Claim events on one board, appending delivery dicts to ``deliveries``."""
if not self._board_has_subs(slug):
return
kb = self.kb
try:
conn = kb.connect(board=slug)
except Exception as exc:
logger.debug("kanban notifier: cannot open board %s: %s", slug, exc)
return
try:
if self.gc_due:
self._gc_stale_subs(conn, slug)
# No explicit init_db(): connect() already runs the migration once per
# process, and init_db() would re-run it on a second connection racing
# the first.
subs = kb.list_notify_subs(conn, notifier_profiles=self.notifier_profiles, include_unowned=self.include_unowned)
if not subs:
logger.debug("kanban notifier: board %s has no subscriptions", slug)
for sub in subs:
try:
claimed = self._claim_for_sub(conn, slug, sub)
if claimed is not None:
self.deliveries.append(claimed)
except Exception as sub_exc:
# One bad subscription must not block the rest of the tick.
logger.warning("kanban notifier: subscription for %s on board %s failed: %s",
sub.get("task_id"), slug, sub_exc)
finally:
conn.close()
def _notifier_collect(runner: Any, kb: Any, *, notifier_profile: Optional[str], gc_due: bool, gc_retention_days: int) -> list[dict]:
"""Claim unseen terminal events for every owned subscription on every board.
Each gateway polls only subscriptions owned by profiles whose adapters it
hosts; legacy rows without a profile stamp are visible only to the process
holding the singleton dispatcher lock.
"""
deliveries: list[dict] = []
include_unowned = runner._owns_kanban_dispatcher_lock()
profile_adapters = getattr(runner, "_profile_adapters", {})
notifier_profiles = {notifier_profile}
notifier_profiles.update(
str(profile).strip() for profile in profile_adapters if str(profile).strip()
)
active_platforms = _platform_names(runner.adapters)
# Include every platform any secondary profile has live. This is only a
# coarse pre-filter; the precise per-profile check (_authorization_adapter,
# no default fallback) runs at delivery and rewinds the claim if it
# resolves to None. An unclaimed event never retries, so dropping a
# secondary-profile sub here would lose it.
for _profile_adapter_map in profile_adapters.values():
active_platforms.update(_platform_names(_profile_adapter_map))
if not active_platforms:
logger.debug("kanban notifier: no connected adapters; skipping tick")
return deliveries
# Poll each resolved DB path once: several slugs can map to one DB when
# HERMES_KANBAN_DB pins the board path.
seen_db_paths: set[str] = set()
for board_meta in _list_boards(kb):
slug = board_meta.get("slug") or kb.DEFAULT_BOARD
db_path = board_meta.get("db_path")
try:
resolved_db_path = str(Path(db_path).expanduser().resolve()) if db_path else str(kb.kanban_db_path(slug).resolve())
except Exception:
resolved_db_path = f"slug:{slug}"
if resolved_db_path in seen_db_paths:
logger.debug(
"kanban notifier: skipping duplicate board slug %s for DB %s",
slug, resolved_db_path,
)
continue
seen_db_paths.add(resolved_db_path)
_notifier_collect_board(
kb, slug, deliveries,
notifier_profile=notifier_profile,
notifier_profiles=notifier_profiles,
include_unowned=include_unowned,
profile_adapters=profile_adapters,
active_platforms=active_platforms,
gc_due=gc_due,
gc_retention_days=gc_retention_days,
)
return deliveries
return _Collector(
runner, kb, notifier_profile=notifier_profile, gc_due=gc_due, gc_retention_days=gc_retention_days,
).collect()
def _notifier_collect_board(
kb: Any,
slug: str,
deliveries: list[dict],
*,
notifier_profile: Optional[str],
notifier_profiles: set,
include_unowned: bool,
profile_adapters: dict,
active_platforms: set[str],
gc_due: bool,
gc_retention_days: int,
) -> None:
"""Claim events on one board, appending delivery dicts to *deliveries*."""
# Cheap read-only probe before the writable connect() (schema init, WAL
# sidecars, checkpoints) — a board with no subscriptions has nothing to notify.
try:
if kb.count_notify_subs(
board=slug,
notifier_profiles=notifier_profiles,
include_unowned=include_unowned,
) == 0:
logger.debug(
"kanban notifier: board %s has no subscriptions owned by %s; skipping open",
slug, sorted(notifier_profiles),
)
return
except Exception as exc:
logger.debug(
"kanban notifier: read-only subscription probe failed "
"for board %s (%s); falling back to writable open",
slug, exc,
)
try:
conn = kb.connect(board=slug)
except Exception as exc:
logger.debug("kanban notifier: cannot open board %s: %s", slug, exc)
return
try:
if gc_due:
# Best-effort: a failed sweep never blocks delivery; the next
# hourly gate retries.
try:
_purged = kb.purge_stale_done_notify_subs(conn, max_age_days=gc_retention_days)
if _purged:
logger.info(
"kanban notifier: purged %d stale done/blocked-task subscription(s) on board %s (retention %dd)",
_purged, slug, gc_retention_days,
)
except Exception as _gc_exc:
logger.debug(
"kanban notifier: stale-sub GC failed for board %s: %s",
slug, _gc_exc,
)
# No explicit init_db(): connect() already runs the migration once per
# process, and init_db() would re-run it on a second connection racing
# the first.
subs = kb.list_notify_subs(
conn,
notifier_profiles=notifier_profiles,
include_unowned=include_unowned,
)
if not subs:
logger.debug("kanban notifier: board %s has no subscriptions", slug)
for sub in subs:
try:
owner_profile = sub.get("notifier_profile") or None
if owner_profile and owner_profile != notifier_profile and not profile_adapters.get(owner_profile):
logger.debug(
"kanban notifier: subscription for %s owned by profile %s; current profile %s has no adapter for it, skipping",
sub.get("task_id"), owner_profile, notifier_profile,
)
continue
platform = (sub.get("platform") or "").lower()
if platform not in active_platforms:
logger.debug(
"kanban notifier: subscription for %s on %s skipped; adapter not connected",
sub.get("task_id"), platform or "<missing>",
)
continue
old_cursor, cursor, events = kb.claim_unseen_events_for_sub(
conn,
task_id=sub["task_id"],
platform=sub["platform"],
chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
kinds=TERMINAL_KINDS,
)
if not events:
continue
task = kb.get_task(conn, sub["task_id"])
logger.debug(
"kanban notifier: claimed %d event(s) for %s on board %s cursor %s→%s",
len(events), sub["task_id"], slug, old_cursor, cursor,
)
deliveries.append({
"sub": sub,
"old_cursor": old_cursor,
"cursor": cursor,
"events": events,
"task": task,
"board": slug,
})
except Exception as sub_exc:
# One bad subscription must not block the rest of the tick.
logger.warning(
"kanban notifier: subscription for %s on board %s failed: %s",
sub.get("task_id"), slug, sub_exc,
)
finally:
conn.close()
# ---------------------------------------------------------------------------
# Per-event message formatting: kind -> (msg, wake_handoff, wake_review_detail)
# ---------------------------------------------------------------------------
# ``None`` for handoff / review_detail leaves the accumulated wake value
# untouched. ``_payload(ev, key)`` is the shared "payload present and truthy" read.
# --- Per-event message formatting: kind -> (msg, wake_handoff, wake_review_detail) ---
# ``None`` for handoff / review_detail leaves the accumulated wake value untouched.
def _payload(ev: Any, key: str) -> Any:
"""Shared "payload present and truthy" read."""
return ev.payload.get(key) if ev.payload and ev.payload.get(key) else None
def _clip(ev: Any, key: str, fmt: str, limit: int) -> str:
"""``fmt`` applied to the truncated payload value, or ``""`` when absent."""
value = _payload(ev, key)
return fmt.format(str(value)[:limit]) if value else ""
_NL = "\n{}"
def _first_line(text: str, limit: int) -> str:
lines = text.strip().splitlines()
return lines[0][:limit] if lines else text[:limit]
def _fmt_completed(ev, n) -> tuple:
# Prefer the run summary from the event payload; fall back to
# task.result for legacy rows.
handoff = ""
# Prefer the run summary from the event payload; fall back to task.result for legacy rows.
wake_handoff = None
payload_summary = _payload(ev, "summary")
if payload_summary:
wake_handoff = _first_line(str(payload_summary), 200)
handoff = f"\n{wake_handoff}"
elif n.task and n.task.result:
wake_handoff = _first_line(n.task.result, 160)
handoff = f"\n{wake_handoff}"
msg = (
f"✔ {n.board_tag}{n.tag}Kanban {n.task_id} done"
f" — {n.title}{handoff}"
)
return msg, wake_handoff, None
def _fmt_blocked(ev, n) -> tuple:
reason = _payload(ev, "reason")
reason = f": {str(reason)[:160]}" if reason else ""
return f"⏸ {n.board_tag}{n.tag}Kanban {n.task_id} blocked{reason}", None, None
def _fmt_gave_up(ev, n) -> tuple:
err = _payload(ev, "error")
err = f"\n{str(err)[:200]}" if err else ""
msg = (
f"✖ {n.board_tag}{n.tag}Kanban {n.task_id} gave up "
f"after repeated spawn failures{err}"
)
return msg, None, None
def _fmt_crashed(ev, n) -> tuple:
msg = (
f"✖ {n.board_tag}{n.tag}Kanban {n.task_id} worker crashed "
f"(pid gone); dispatcher will retry"
)
return msg, None, None
def _fmt_timed_out(ev, n) -> tuple:
limit = _payload(ev, "limit_seconds")
limit = int(limit) if limit else 0
msg = (
f"⏱ {n.board_tag}{n.tag}Kanban {n.task_id} timed out "
f"(max_runtime={limit}s); will retry"
)
return msg, None, None
def _fmt_status(ev, n) -> tuple:
new_status = _payload(ev, "status")
new_status = str(new_status) if new_status else ""
return f"🔄 {n.board_tag}{n.tag}Kanban {n.task_id} → {new_status}", None, None
handoff = f"\n{wake_handoff}" if wake_handoff is not None else ""
return f"✔ {n.head} done — {n.title}{handoff}", wake_handoff, None
def _fmt_review_requested(ev, n) -> tuple:
@@ -358,11 +256,7 @@ def _fmt_review_requested(ev, n) -> tuple:
summary = str(summary)
handoff = f"\n{summary[:200]}"
wake_handoff = _first_line(summary, 200)
msg = (
f"👀 {n.board_tag}{n.tag}Kanban {n.task_id} ready for review"
f" — {n.title}{handoff}"
)
return msg, wake_handoff, None
return f"👀 {n.head} ready for review — {n.title}{handoff}", wake_handoff, None
def _fmt_changes_requested(ev, n) -> tuple:
@@ -371,51 +265,40 @@ def _fmt_changes_requested(ev, n) -> tuple:
reviewer = _safe_review_reason(payload.get("reviewer"), 48)
implementer = _safe_review_reason(payload.get("implementer"), 48)
reason_text = reason or "reviewer feedback requires changes"
provenance = ""
if reviewer:
provenance += f" — reviewer @{reviewer}"
provenance = f" — reviewer @{reviewer}" if reviewer else ""
if implementer:
provenance += f" → implementer @{implementer}"
msg = (
f"🛑 {n.board_tag}Kanban {n.task_id} review requested "
f"changes/BLOCK: {reason_text}{provenance}"
)
msg = f"🛑 {n.board_tag}Kanban {n.task_id} review requested changes/BLOCK: {reason_text}{provenance}"
return msg, None, reason_text
def _fmt_block_loop_detected(ev, n) -> tuple:
# Re-blocked for the same cause past the limit and routed to `triage`
# for a human. It emits no blocked/status event, so ping loudly here.
reason = _payload(ev, "reason")
reason = f": {str(reason)[:160]}" if reason else ""
recurrences = ev.payload.get("recurrences") if ev.payload else None
rc = f" (blocked {recurrences}x for the same cause)" if recurrences else ""
msg = (
f"🛑 {n.board_tag}{n.tag}Kanban {n.task_id} routed to TRIAGE"
f" — needs a human decision{rc}{reason}"
)
return msg, None, None
# archived / unblocked are claimed (so the cursor advances past them) but
# intentionally silent (no formatter), and excluded from _WAKE_KINDS so they
# never wake the creator.
_EVENT_FORMATTERS: dict[str, Callable[[Any, "_KanbanNotification"], tuple]] = {
"completed": _fmt_completed,
"blocked": _fmt_blocked,
"gave_up": _fmt_gave_up,
"crashed": _fmt_crashed,
"timed_out": _fmt_timed_out,
"status": _fmt_status,
"blocked": lambda ev, n: (f"⏸ {n.head} blocked{_clip(ev, 'reason', ': {}', 160)}", None, None),
"gave_up": lambda ev, n: (
f"✖ {n.head} gave up after repeated spawn failures{_clip(ev, 'error', _NL, 200)}", None, None,
),
"crashed": lambda ev, n: (f"✖ {n.head} worker crashed (pid gone); dispatcher will retry", None, None),
"timed_out": lambda ev, n: (
f"⏱ {n.head} timed out (max_runtime={int(_payload(ev, 'limit_seconds') or 0)}s); will retry", None, None,
),
"status": lambda ev, n: (f"🔄 {n.head} → {_payload(ev, 'status') or ''}", None, None),
"review_requested": _fmt_review_requested,
"changes_requested": _fmt_changes_requested,
"block_loop_detected": _fmt_block_loop_detected,
# Re-blocked for the same cause past the limit and routed to `triage` for a
# human. It emits no blocked/status event, so ping loudly here.
"block_loop_detected": lambda ev, n: (
f"🛑 {n.head} routed to TRIAGE — needs a human decision"
f"{_clip(ev, 'recurrences', ' (blocked {}x for the same cause)', 200)}{_clip(ev, 'reason', ': {}', 160)}",
None, None,
),
}
# ---------------------------------------------------------------------------
# Delivery of one claimed batch (one subscription, N events)
# ---------------------------------------------------------------------------
# --- Delivery of one claimed batch (one subscription, N events) ---
class _KanbanNotification:
@@ -443,8 +326,8 @@ class _KanbanNotification:
self.title = (task.title if task else sub["task_id"])[:120]
self.board_tag = f"[{self.board_slug}] " if self.board_slug else ""
# Attribute the ping to the worker that did the work.
who = task.assignee if task and task.assignee else None
self.tag = f"@{who} " if who else ""
tag = f"@{task.assignee} " if task and task.assignee else ""
self.head = f"{self.board_tag}{tag}Kanban {self.task_id}"
# The wake self-post path needs the key even when every event was skipped.
self.sub_key = (sub["task_id"], sub["platform"], sub["chat_id"], sub.get("thread_id") or "")
mode = sub.get("delivery_mode") or "notify"
@@ -452,14 +335,11 @@ class _KanbanNotification:
self.send_passive = mode != "wake"
# Worker handoff carried into the synthetic wake turn so the woken
# creator doesn't re-decompose work already on the board.
self.wake_handoff = ""
self.wake_review_detail = ""
self.wake_handoff = self.wake_review_detail = self.session_key = self.synth = ""
self.plat: Any = None
self.adapter: Any = None
self.is_push_adapter = True
self.wake_kinds: set = set()
self.session_key = ""
self.synth = ""
# -- cursor / subscription ops (blocking, run in a fresh-context thread) --
@@ -490,11 +370,8 @@ class _KanbanNotification:
await self.rewind()
async def _wake_failed(self, fmt: str, exc: Exception) -> None:
await self.delivery_failed(
fmt, (self.task_id,),
"kanban notifier: dropping subscription %s on %s after %d consecutive wake failures",
exc, True,
)
drop_fmt = "kanban notifier: dropping subscription %s on %s after %d consecutive wake failures"
await self.delivery_failed(fmt, (self.task_id,), drop_fmt, exc, True)
# -- formatting --
@@ -513,11 +390,7 @@ class _KanbanNotification:
def build_wake_text(self) -> None:
"""Set ``wake_kinds`` / ``session_key`` / ``synth`` for the wake paths."""
task, sub = self.task, self.sub
self.wake_kinds = (
{ev.kind for ev in self.d["events"] if ev.kind in _WAKE_KINDS}
if self.wake_agent
else set()
)
self.wake_kinds = {ev.kind for ev in self.d["events"] if ev.kind in _WAKE_KINDS} if self.wake_agent else set()
if not self.wake_kinds:
return
if self.is_push_adapter:
@@ -532,11 +405,8 @@ class _KanbanNotification:
_status = t("gateway.kanban.wake.status_joiner").join(_parts) or t("gateway.kanban.wake.status_default")
synth = t(
"gateway.kanban.wake.message",
task_id=sub["task_id"],
status=_status,
title=self.title,
assignee=task.assignee if task else "",
board=self.board_slug,
task_id=sub["task_id"], status=_status, title=self.title,
assignee=task.assignee if task else "", board=self.board_slug,
)
# Label as an automatic notification and carry the handoff so the
# creator inspects the board instead of re-decomposing.
@@ -547,36 +417,32 @@ class _KanbanNotification:
self.synth = synth + "\n\n" + t("gateway.kanban.wake.guidance")
def _log_woke(self) -> None:
logger.info(
"kanban notifier: woke agent for %s on %s/%s profile=%s events=%s",
self.task_id, self.platform_str, self.sub["chat_id"], self.sub_profile or "default", self.wake_kinds,
)
logger.info("kanban notifier: woke agent for %s on %s/%s profile=%s events=%s",
self.task_id, self.platform_str, self.sub["chat_id"], self.sub_profile or "default", self.wake_kinds)
async def push_wake(self) -> None:
"""Wake the creator session behind a push adapter; raises on failure."""
from gateway.session import SessionSource
async def wake(self) -> None:
"""Wake the creator session (raises on failure): push adapters get a full SessionSource, non-push a raw self-post."""
from gateway.wake import deliver_wake
sub = self.sub
# Rebuild the creator's real session scope from the persisted
# chat_type: build_session_key() keys DMs differently from
# group/thread, so a hardcoded "group" mis-routed DM/thread creators
# into a fresh session. Legacy rows may carry chat_type in
# delivery_metadata; last resort is "group". A mismatch only degrades
# to a fresh session.
if not self.is_push_adapter:
await deliver_wake(self.adapter, text=self.synth, session_id=self.session_key)
self._log_woke()
return
from gateway.session import SessionSource
# Rebuild the creator's real session scope from the persisted chat_type:
# build_session_key() keys DMs differently from group/thread, so a
# hardcoded "group" mis-routed DM/thread creators into a fresh session.
# Legacy rows may carry chat_type in delivery_metadata; last resort is
# "group". A mismatch only degrades to a fresh session.
_chat_type = str(sub.get("chat_type") or "").strip()
if not _chat_type:
_delivery_meta = sub.get("delivery_metadata")
if isinstance(_delivery_meta, dict):
_chat_type = str(_delivery_meta.get("chat_type") or "").strip()
_source = SessionSource(
platform=self.plat,
chat_id=sub["chat_id"],
chat_type=_chat_type or "group",
thread_id=sub.get("thread_id") or None,
user_id=sub.get("user_id"),
user_id_alt=sub.get("user_id_alt"),
profile=self.sub_profile or None,
scope_id=_wake_scope_id(self.adapter, sub),
platform=self.plat, chat_id=sub["chat_id"], chat_type=_chat_type or "group",
thread_id=sub.get("thread_id") or None, user_id=sub.get("user_id"), user_id_alt=sub.get("user_id_alt"),
profile=self.sub_profile or None, scope_id=_wake_scope_id(self.adapter, sub),
)
await deliver_wake(self.adapter, text=self.synth, session_id=self.session_key, source=_source)
self._log_woke()
@@ -593,30 +459,19 @@ class _KanbanNotification:
# (else the event is lost); None / non-SendResult keeps the
# "no exception == delivered" contract.
if getattr(_send_res, "success", True) is False:
raise RuntimeError(
"adapter send() reported failure: "
f"{getattr(_send_res, 'error', None) or 'unknown error'}"
)
logger.debug(
"kanban notifier: delivered %s event for %s to %s/%s on board %s",
ev.kind, self.task_id, self.platform_str, sub["chat_id"], self.board_slug,
)
raise RuntimeError(f"adapter send() reported failure: {getattr(_send_res, 'error', None) or 'unknown error'}")
logger.debug("kanban notifier: delivered %s event for %s to %s/%s on board %s",
ev.kind, self.task_id, self.platform_str, sub["chat_id"], self.board_slug)
# Upload artifact paths from the completion payload / legacy result as
# native files. Only on ``completed`` so retries never spam attachments.
if ev.kind == "completed":
try:
await self.runner._deliver_kanban_artifacts(
adapter=adapter,
chat_id=sub["chat_id"],
metadata=metadata,
event_payload=getattr(ev, "payload", None),
task=self.task,
adapter=adapter, chat_id=sub["chat_id"], metadata=metadata,
event_payload=getattr(ev, "payload", None), task=self.task,
)
except Exception as art_exc:
logger.debug(
"kanban notifier: artifact delivery for %s failed: %s",
self.task_id, art_exc,
)
logger.debug("kanban notifier: artifact delivery for %s failed: %s", self.task_id, art_exc)
async def _send_pings(self) -> bool:
"""Send every text ping; False when a send failed (claim already rewound/dropped)."""
@@ -630,10 +485,8 @@ class _KanbanNotification:
# IS the delivery and resolves the failure counter.
if not self.is_push_adapter and self.wake_agent:
logger.debug(
"kanban notifier: adapter %s has no push "
"channel; skipping text ping for %s, relying "
"on wake self-post instead",
self.platform_str, self.task_id,
"kanban notifier: adapter %s has no push channel; skipping text ping for %s, relying "
"on wake self-post instead", self.platform_str, self.task_id,
)
continue
if not self.send_passive:
@@ -644,10 +497,8 @@ class _KanbanNotification:
self.clear_failures()
except Exception as exc:
await self.delivery_failed(
"kanban notifier: send failed for %s on %s (attempt %d/%d): %s",
(self.task_id, self.platform_str),
"kanban notifier: dropping subscription %s on %s after %d consecutive send failures",
exc, False,
"kanban notifier: send failed for %s on %s (attempt %d/%d): %s", (self.task_id, self.platform_str),
"kanban notifier: dropping subscription %s on %s after %d consecutive send failures", exc, False,
)
return False
return True
@@ -664,10 +515,8 @@ class _KanbanNotification:
# default) has no adapter.
adapter = self.runner._authorization_adapter(self.plat, self.sub_profile or None)
if adapter is None:
logger.debug(
"kanban notifier: adapter %s disconnected before delivery for %s; rewinding claim",
self.platform_str, self.task_id,
)
logger.debug("kanban notifier: adapter %s disconnected before delivery for %s; rewinding claim",
self.platform_str, self.task_id)
await self.rewind()
return
self.adapter = adapter
@@ -677,29 +526,21 @@ class _KanbanNotification:
if not await self._send_pings():
return
# All text pings delivered (or skipped for non-push / wake-only).
task_terminal = self.task and self.task.status == "archived"
self.build_wake_text()
wake_kinds, is_push = self.wake_kinds, self.is_push_adapter
if not is_push and wake_kinds and self.session_key:
# Self-post IS the delivery: must succeed BEFORE the cursor advances.
from gateway.wake import deliver_wake
# Non-push self-post, or wake-only push sub: the wake IS the delivery
# and must succeed BEFORE the cursor advances.
if wake_kinds and (not self.send_passive if is_push else bool(self.session_key)):
try:
await deliver_wake(adapter, text=self.synth, session_id=self.session_key)
self._log_woke()
await self.wake()
self.clear_failures()
except Exception as _wk_err:
await self._wake_failed("kanban notifier: wake self-post failed for %s (attempt %d/%d): %s", _wk_err)
return
if is_push and not self.send_passive and wake_kinds:
# Wake-only push sub: the wake is the sole delivery and must
# succeed BEFORE the cursor advances.
try:
await self.push_wake()
self.clear_failures()
except Exception as _wk_err:
await self._wake_failed("kanban notifier: wake-only delivery failed for %s (attempt %d/%d): %s", _wk_err)
await self._wake_failed(
"kanban notifier: wake-only delivery failed for %s (attempt %d/%d): %s" if is_push
else "kanban notifier: wake self-post failed for %s (attempt %d/%d): %s",
_wk_err,
)
return
# Delivery complete: advance the cursor (the dedup mechanism).
@@ -711,12 +552,9 @@ class _KanbanNotification:
# advanced; the wake stays best-effort, but log at WARNING so a
# persistently failing wake is visible.
try:
await self.push_wake()
await self.wake()
except Exception as _wk_err:
logger.warning(
"kanban notifier: wakeup injection failed for %s: %s",
self.task_id, _wk_err, exc_info=True,
)
logger.warning("kanban notifier: wakeup injection failed for %s: %s", self.task_id, _wk_err, exc_info=True)
# Unsubscribe only on archive; ``done`` is reversible.
if task_terminal:
if self.task and self.task.status == "archived":
await self.unsub()
+114 -201
View File
@@ -1,24 +1,12 @@
"""Gateway lifecycle ledger — durable termination-reason evidence.
Graceful shutdowns already leave forensics (:mod:`gateway.shutdown_forensics`,
``gateway-exit-diag.log``); an **unclean death** (SIGKILL, kernel OOM kill, VM
death) runs no handler, so the next boot would otherwise have no record of it.
A tiny state machine persisted to ``<HERMES_HOME>/state/gateway.lifecycle.json``
closes that gap:
* :func:`record_startup` reads the previous life's sentinel. ``phase ==
"running"`` means no exit path ran → unclean death. The finding (with the
last heartbeat's memory sample) is appended to ``gateway-exit-diag.log`` as a
``gateway.previous_unclean_exit`` record and logged at WARNING, then the
sentinel is rewritten as ``phase=running`` for the new life.
* :func:`mark_exited` rewrites the sentinel as ``phase=exited`` on every clean
exit path (``_exit_after_graceful_shutdown`` and the watchdog ``os._exit`` sites).
* :func:`sample_memory` is the cheap /proc snapshot the 30s loop heartbeat
embeds, so OOM crash cycles are classifiable from the volume alone.
Everything here is best-effort: a forensics failure must never affect the
gateway lifecycle it is observing.
Graceful shutdowns leave forensics (``gateway-exit-diag.log``); an unclean
death (SIGKILL, kernel OOM, VM death) runs no handler. A sentinel at
``<HERMES_HOME>/state/gateway.lifecycle.json`` closes the gap:
:func:`record_startup` finds ``phase == "running"`` from the previous life →
unclean death, appended to the exit-diag log as ``gateway.previous_unclean_exit``
and logged at WARNING; :func:`mark_exited` rewrites ``phase=exited`` on every
clean exit path. Best-effort: forensics must never affect the lifecycle.
"""
from __future__ import annotations
@@ -28,74 +16,61 @@ import logging
import os
import sqlite3
import time
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_LIFECYCLE_RELATIVE = ("state", "gateway.lifecycle.json")
_EXIT_DIAG_RELATIVE = ("logs", "gateway-exit-diag.log")
_STATE_DB_RELATIVE = ("state.db",)
# Heuristic OOM-suspicion thresholds on the last heartbeat's memory sample.
# Deliberately conservative: only a hint; classification stays with the reader.
_LOW_MEM_AVAILABLE_KIB = 64 * 1024 # < 64 MiB available
_LOW_MEM_AVAILABLE_FRACTION = 0.05 # < 5% of MemTotal available
def _process_hermes_home() -> Path:
"""HERMES_HOME for process-level identity files (ignore task overrides)."""
val = os.environ.get("HERMES_HOME", "").strip()
if val:
return Path(val)
from hermes_constants import get_hermes_home
return get_hermes_home()
val = os.environ.get("HERMES_HOME", "").strip()
return Path(val) if val else get_hermes_home()
def _home_path(home: Optional[Path], relative: tuple) -> Path:
base = home if home is not None else _process_hermes_home()
return base.joinpath(*relative)
def _home_path(home: Optional[Path], *relative: str) -> Path:
return (_process_hermes_home() if home is None else home).joinpath(*relative)
def get_lifecycle_sentinel_path(home: Optional[Path] = None) -> Path:
"""Return ``<HERMES_HOME>/state/gateway.lifecycle.json``."""
return _home_path(home, _LIFECYCLE_RELATIVE)
return _home_path(home, "state", "gateway.lifecycle.json")
def sample_memory() -> Dict[str, Any]:
"""Cheap memory snapshot: own RSS + system availability + swap.
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
Pure ``/proc`` reads, Linux-only (returns ``{}`` elsewhere), never
raises. Values in KiB to match the kernel's units.
"""
sample: Dict[str, Any] = {}
def _proc_fields(path: str, wanted: Dict[str, str]) -> Dict[str, int]:
"""``{dst: int}`` for each ``src: dst`` key found in a ``Key: value`` /proc file."""
found: Dict[str, int] = {}
try:
with open("/proc/self/status", encoding="utf-8") as fh:
for line in fh:
if line.startswith("VmRSS:"):
sample["rss_kib"] = int(line.split()[1])
break
except (OSError, ValueError, IndexError):
pass
try:
meminfo: Dict[str, int] = {}
wanted = {"MemTotal", "MemAvailable", "SwapTotal", "SwapFree"}
with open("/proc/meminfo", encoding="utf-8") as fh:
with open(path, encoding="utf-8") as fh:
for line in fh:
key = line.split(":", 1)[0]
if key in wanted:
meminfo[key] = int(line.split()[1])
if len(meminfo) == len(wanted):
found[wanted[key]] = int(line.split()[1])
if len(found) == len(wanted):
break
for src, dst in (("MemTotal", "mem_total_kib"), ("MemAvailable", "mem_available_kib")):
if src in meminfo:
sample[dst] = meminfo[src]
if "SwapTotal" in meminfo and "SwapFree" in meminfo:
sample["swap_used_kib"] = meminfo["SwapTotal"] - meminfo["SwapFree"]
except (OSError, ValueError, IndexError):
pass
return {}
return found
def sample_memory() -> Dict[str, Any]:
"""Cheap /proc snapshot (KiB): own RSS + MemTotal/MemAvailable + swap used. Linux-only
(``{}`` elsewhere), never raises; the 30s heartbeat embeds it so OOM cycles are classifiable."""
sample = _proc_fields("/proc/self/status", {"VmRSS": "rss_kib"})
mem = _proc_fields("/proc/meminfo", {"MemTotal": "mem_total_kib", "MemAvailable": "mem_available_kib",
"SwapTotal": "SwapTotal", "SwapFree": "SwapFree"})
swap_total, swap_free = mem.pop("SwapTotal", None), mem.pop("SwapFree", None)
sample.update(mem)
if swap_total is not None and swap_free is not None:
sample["swap_used_kib"] = swap_total - swap_free
return sample
@@ -108,10 +83,10 @@ def _read_json(path: Path) -> Optional[Dict[str, Any]]:
def _write_sentinel(payload: Dict[str, Any], home: Optional[Path]) -> None:
path = get_lifecycle_sentinel_path(home)
try:
from utils import atomic_json_write
path = get_lifecycle_sentinel_path(home)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(path, payload, indent=None)
except Exception:
@@ -119,10 +94,9 @@ def _write_sentinel(payload: Dict[str, Any], home: Optional[Path]) -> None:
def _append_exit_diag(record: Dict[str, Any], home: Optional[Path]) -> None:
"""Append a JSON line to gateway-exit-diag.log (same format as the CLI's
``_exit_diag`` records so existing tooling greps both)."""
path = _home_path(home, _EXIT_DIAG_RELATIVE)
"""Append a JSON line to gateway-exit-diag.log (same format as the CLI's ``_exit_diag``)."""
try:
path = _home_path(home, "logs", "gateway-exit-diag.log")
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, default=str) + "\n")
@@ -131,158 +105,118 @@ def _append_exit_diag(record: Dict[str, Any], home: Optional[Path]) -> None:
def _pid_alive_with_start_time(pid: Any, start_time: Any) -> bool:
"""True when ``pid`` is a live process matching ``start_time`` (±2s).
Guards the takeover race: during ``--replace`` the old gateway can still
be mid-teardown when the new one boots — a live matching owner is a
planned handover, not an unclean death.
"""
"""True when ``pid`` is a live process matching ``start_time`` (±2s) — guards the
``--replace`` race: a live matching owner mid-teardown is a handover, not a death."""
try:
pid_int = int(pid)
except (TypeError, ValueError):
return False
if pid_int <= 0:
return False
try:
# NOT os.kill(pid, 0): on Windows that sends CTRL_C_EVENT to the
# target's console group. _pid_exists is the canonical no-kill probe.
from gateway.status import _pid_exists
# NOT os.kill(pid, 0): on Windows that sends CTRL_C_EVENT to the target's console group.
from gateway.status import _pid_exists, get_process_start_time
if not _pid_exists(pid_int):
if pid_int <= 0 or not _pid_exists(pid_int):
return False
except Exception:
return False
if start_time is None:
return True # alive; can't disambiguate PID reuse — err on "alive"
try:
from gateway.status import get_process_start_time
actual = get_process_start_time(pid_int)
return actual is None or abs(float(actual) - float(start_time)) <= 2.0
actual = None if start_time is None else get_process_start_time(pid_int)
return actual is None or abs(float(actual) - float(start_time)) <= 2.0 # None: can't disambiguate PID reuse
except Exception:
return True
def _suspected_oom(mem: Dict[str, Any]) -> bool:
"""Heuristic only (classification stays with the reader); thresholds are
memory_status' "critical" tier so a live warning and a post-mortem verdict agree."""
from gateway.memory_status import _CRITICAL_AVAILABLE_FRACTION, _CRITICAL_AVAILABLE_KIB
total, avail = mem.get("mem_total_kib"), mem.get("mem_available_kib")
if not isinstance(avail, int):
return False
return avail < _CRITICAL_AVAILABLE_KIB or (
isinstance(total, int) and total > 0 and avail / total < _CRITICAL_AVAILABLE_FRACTION
)
def detect_unclean_exit(home: Optional[Path] = None) -> Optional[Dict[str, Any]]:
"""Inspect the previous life's sentinel; return an evidence dict when it
died uncleanly, else ``None``. Read-only — does not rewrite the sentinel.
"""
"""Evidence dict when the previous life died uncleanly, else ``None``. Read-only."""
sentinel = _read_json(get_lifecycle_sentinel_path(home))
if not sentinel or sentinel.get("phase") != "running":
return None
if _pid_alive_with_start_time(sentinel.get("pid"), sentinel.get("start_time")):
return None # live owner — planned takeover in flight, not a death
evidence: Dict[str, Any] = {
"prior_pid": sentinel.get("pid"),
"prior_started_at": sentinel.get("started_at"),
"prior_pid": sentinel.get("pid"), "prior_started_at": sentinel.get("started_at"),
"prior_start_time": sentinel.get("start_time"),
}
# Enrich with the last heartbeat: last proven liveness and memory at that moment.
try:
from gateway.shutdown_watchdog import get_loop_heartbeat_path
hb = _read_json(get_loop_heartbeat_path(home))
hb = _read_json(get_loop_heartbeat_path(home)) or {}
except Exception:
hb = None
hb = {}
if hb:
evidence["last_heartbeat_at"] = hb.get("updated_at")
mem = hb.get("mem")
if isinstance(mem, dict):
evidence["last_heartbeat_mem"] = mem
total = mem.get("mem_total_kib")
avail = mem.get("mem_available_kib")
if isinstance(avail, int) and (
avail < _LOW_MEM_AVAILABLE_KIB
or (
isinstance(total, int)
and total > 0
and avail / total < _LOW_MEM_AVAILABLE_FRACTION
)
):
evidence["suspected_oom"] = True
mem = hb.get("mem")
if isinstance(mem, dict):
evidence["last_heartbeat_mem"] = mem
if _suspected_oom(mem):
evidence["suspected_oom"] = True
return evidence
def check_state_db_integrity(home: Optional[Path] = None) -> str:
"""Return ``"ok"``, ``"absent"``, or the first ``quick_check`` complaint.
"""``"ok"``, ``"absent"``, or the first ``quick_check`` complaint. Never raises.
Called only after an unclean death — a SIGKILL mid-WAL-checkpoint can leave
half-written b-tree pages (see ``_enforce_macos_synchronous_full`` in
:mod:`hermes_state`). ``quick_check(1)`` stops at the first problem (~2s on
a healthy 500MB store): cheap once per unclean boot, too costly every boot.
Opened normally, not read-only: a WAL store needs its -shm sidecar for a
read-only open, and the PRAGMA itself writes nothing. Never raises.
Only after an unclean death — SIGKILL mid-WAL-checkpoint can leave half-written
b-tree pages. ``quick_check(1)`` stops at the first problem (~2s on a healthy
500MB store): cheap once per unclean boot, too costly every boot. Opened
normally: a WAL store needs its -shm sidecar for read-only, and the PRAGMA writes nothing.
"""
path = _home_path(home, _STATE_DB_RELATIVE)
path = _home_path(home, "state.db")
if not path.exists():
return "absent"
try:
conn = sqlite3.connect(str(path))
try:
with closing(sqlite3.connect(str(path))) as conn:
row = conn.execute("PRAGMA quick_check(1)").fetchone()
finally:
conn.close()
except Exception as exc: # sqlite3.Error, OSError, anything
except Exception as exc:
return f"check-failed: {exc}"
if not row or row[0] is None:
return "check-failed: no result"
return str(row[0])
return "check-failed: no result" if not row or row[0] is None else str(row[0])
def _report_unclean_exit(evidence: Dict[str, Any], home: Optional[Path]) -> None:
"""Integrity-check the store, persist the exit-diag record, log at WARNING."""
# The death may have torn the store; this is the only moment we know to look.
verdict = evidence["state_db_integrity"] = check_state_db_integrity(home=home)
if verdict not in ("ok", "absent"):
logger.error(
"state.db FAILED integrity check after an unclean gateway exit: %s — sessions may read as "
"missing until it is repaired. Run `hermes doctor`.",
verdict,
)
_append_exit_diag({"ts": _now_iso(), "tag": "gateway.previous_unclean_exit", "pid": os.getpid(), **evidence}, home)
logger.warning(
"Previous gateway life (pid=%s, started_at=%s) exited UNCLEANLY (no exit path ran — SIGKILL / OOM / "
"VM death). last_heartbeat_at=%s last_mem=%s suspected_oom=%s",
evidence.get("prior_pid"), evidence.get("prior_started_at"), evidence.get("last_heartbeat_at"),
evidence.get("last_heartbeat_mem"), evidence.get("suspected_oom", False),
)
def record_startup(home: Optional[Path] = None) -> Optional[Dict[str, Any]]:
"""Boot-time entry point: report any unclean previous exit, then claim
the sentinel for the current life.
Returns the unclean-exit evidence dict (also persisted to
``gateway-exit-diag.log`` and logged at WARNING) or ``None``. Never
raises.
"""
"""Boot entry point: report any unclean previous exit (evidence dict, also persisted
to ``gateway-exit-diag.log`` and logged at WARNING) then claim the sentinel. Never raises."""
evidence: Optional[Dict[str, Any]] = None
try:
evidence = detect_unclean_exit(home)
if evidence is not None:
# The death may have torn the store; this is the only moment we know to look.
verdict = check_state_db_integrity(home=home)
evidence["state_db_integrity"] = verdict
if verdict not in ("ok", "absent"):
logger.error(
"state.db FAILED integrity check after an unclean gateway "
"exit: %s — sessions may read as missing until it is "
"repaired. Run `hermes doctor`.",
verdict,
)
_append_exit_diag({
"ts": datetime.now(timezone.utc).isoformat(),
"tag": "gateway.previous_unclean_exit",
"pid": os.getpid(),
**evidence,
}, home)
logger.warning(
"Previous gateway life (pid=%s, started_at=%s) exited UNCLEANLY "
"(no exit path ran — SIGKILL / OOM / VM death). "
"last_heartbeat_at=%s last_mem=%s suspected_oom=%s",
evidence.get("prior_pid"),
evidence.get("prior_started_at"),
evidence.get("last_heartbeat_at"),
evidence.get("last_heartbeat_mem"),
evidence.get("suspected_oom", False),
)
_report_unclean_exit(evidence, home)
except Exception:
logger.debug("Unclean-exit detection failed", exc_info=True)
try:
claim: Dict[str, Any] = {
"phase": "running",
"pid": os.getpid(),
"start_time": time.time(),
"started_at": datetime.now(timezone.utc).isoformat(),
}
# Carry the verdict on the PREVIOUS life forward on the new sentinel: it
# is the only machine-readable copy (the exit-diag log is append-only
# prose) and /api/status reads it to report an OOM restart. Scoped to
# this life only — the next clean exit or boot rewrites the sentinel.
claim: Dict[str, Any] = {"phase": "running", "pid": os.getpid(), "start_time": time.time(), "started_at": _now_iso()}
# Carry the verdict on the PREVIOUS life on the new sentinel: it is the only
# machine-readable copy (/api/status reads it to report an OOM restart).
# Scoped to this life — the next clean exit or boot rewrites the sentinel.
if evidence is not None:
claim["prior_unclean_exit"] = True
if evidence.get("suspected_oom"):
@@ -293,49 +227,28 @@ def record_startup(home: Optional[Path] = None) -> Optional[Dict[str, Any]]:
return evidence
def mark_exited(
exit_code: Optional[int] = None,
reason: str = "graceful_shutdown",
home: Optional[Path] = None,
) -> None:
def mark_exited(exit_code: Optional[int] = None, reason: str = "graceful_shutdown", home: Optional[Path] = None) -> None:
"""Mark the current life as cleanly exited. Idempotent, never raises.
Only rewrites the sentinel when it is provably owned by this process:
during a ``--replace`` takeover the replacement claims the sentinel before
the old process finishes teardown, and the old life must not clobber the
new owner's ``running`` phase. A sentinel with ``pid=None`` (or malformed)
has unknown ownership and is likewise left alone.
Only rewrites a sentinel provably owned by this process: during ``--replace``
the replacement claims it before the old process finishes teardown and must not
be clobbered. ``pid=None`` / malformed sentinels have unknown ownership → left alone.
"""
try:
sentinel = _read_json(get_lifecycle_sentinel_path(home))
if sentinel is not None and sentinel.get("pid") != os.getpid():
return
_write_sentinel({
"phase": "exited",
"pid": os.getpid(),
"exit_code": exit_code,
"exit_reason": reason,
"exited_at": datetime.now(timezone.utc).isoformat(),
}, home)
_write_sentinel({"phase": "exited", "pid": os.getpid(), "exit_code": exit_code, "exit_reason": reason,
"exited_at": _now_iso()}, home)
except Exception:
logger.debug("Failed to mark lifecycle sentinel exited", exc_info=True)
def read_prior_exit_label(profile_home: Path) -> str:
"""Container-boot helper: ``clean`` / ``unclean`` / ``unknown`` summary of
how the profile's last gateway life ended. Read-only, exception-free;
used by ``hermes_cli.container_boot`` to annotate ``container-boot.log``.
"""
"""``clean``/``unclean``/``unknown`` for the profile's last gateway life; annotates
``container-boot.log``. ``running`` is unclean: the old PID namespace is gone at container boot."""
try:
sentinel = _read_json(get_lifecycle_sentinel_path(profile_home))
if not sentinel:
return "unknown"
phase = sentinel.get("phase")
if phase == "exited":
return "clean"
if phase == "running":
# Old PID namespace is gone at container boot — never exited cleanly.
return "unclean"
phase = (_read_json(get_lifecycle_sentinel_path(profile_home)) or {}).get("phase")
return {"exited": "clean", "running": "unclean"}.get(phase, "unknown")
except Exception:
pass
return "unknown"
return "unknown"
+12 -28
View File
@@ -1,21 +1,12 @@
"""Shared config→env bridge for media-delivery policy.
``validate_media_delivery_path`` (gateway/platforms/base.py) reads its policy
from environment variables:
- ``HERMES_MEDIA_DELIVERY_STRICT`` <- gateway.strict
- ``HERMES_MEDIA_ALLOW_DIRS`` <- gateway.media_delivery_allow_dirs
- ``HERMES_MEDIA_TRUST_RECENT_FILES`` <- gateway.trust_recent_files
The translation used to run only in gateway startup, so standalone delivery
paths (``hermes cron run``, ``hermes send``, a standalone cron tick) filtered
MEDIA paths under a different policy and silently dropped attachments in
strict/allowlisted deployments (text is unaffected -- only media goes through
path validation). ``apply_media_policy_env()`` is the shared,
idempotent helper every delivery entrypoint calls before filtering media paths.
Precedence: an explicitly-set environment variable WINS over config.yaml, so a
shell-exported override (and gateway startup's own earlier run) survives.
``validate_media_delivery_path`` reads ``HERMES_MEDIA_DELIVERY_STRICT`` (gateway.strict),
``HERMES_MEDIA_ALLOW_DIRS`` (gateway.media_delivery_allow_dirs) and
``HERMES_MEDIA_TRUST_RECENT_FILES`` (gateway.trust_recent_files). Every delivery
entrypoint (gateway startup, ``hermes cron run``, ``hermes send``) calls
:func:`apply_media_policy_env` first so standalone paths filter under the gateway's
policy instead of silently dropping attachments in strict/allowlisted deployments.
An explicitly-set env var WINS over config.yaml, so shell overrides survive.
"""
from __future__ import annotations
@@ -26,9 +17,8 @@ from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_STRICT_ENV = "HERMES_MEDIA_DELIVERY_STRICT"
_FLAG_ENVS = (("strict", "HERMES_MEDIA_DELIVERY_STRICT"), ("trust_recent_files", "HERMES_MEDIA_TRUST_RECENT_FILES"))
_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
def _load_gateway_cfg(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -50,25 +40,19 @@ def _set_env_default(env: str, value: str) -> None:
def _allow_dirs_str(allow_dirs: Any) -> str:
if isinstance(allow_dirs, str):
return allow_dirs
if isinstance(allow_dirs, (list, tuple)):
return os.pathsep.join(str(p) for p in allow_dirs if p)
return ""
return allow_dirs if isinstance(allow_dirs, str) else ""
def apply_media_policy_env(config: Optional[Dict[str, Any]] = None) -> None:
"""Bridge gateway media-policy settings from config.yaml into the env.
Idempotent and env-wins: a variable already present is never overwritten.
Never raises — a policy-bridge failure must not break delivery; the
validator falls back to its defaults exactly as before.
"""
"""Bridge gateway media-policy settings from config.yaml into the env. Idempotent,
env-wins, never raises — a bridge failure must not break delivery (validator defaults apply)."""
try:
gateway_cfg = _load_gateway_cfg(config)
if not gateway_cfg:
return
for key, env in (("strict", _STRICT_ENV), ("trust_recent_files", _TRUST_RECENT_ENV)):
for key, env in _FLAG_ENVS:
flag = gateway_cfg.get(key)
if flag is not None:
_set_env_default(env, "1" if flag else "0")
+59 -105
View File
@@ -1,19 +1,11 @@
"""Repair model-mangled ``computer_use`` screenshot paths in final responses.
``computer_use`` persists a screenshot into the Hermes image cache and tells the
model its absolute path. Some models rewrite a Windows path into a POSIX-looking
one (``C:\\Users\\Alice\\...`` -> ``/Users/Alice/...``) inside an explicit
``MEDIA:`` directive, so delivery-path validation rejects it and drops the
attachment.
The repair is deliberately narrow: it only rewrites paths inside a response that
*already* carries an explicit ``MEDIA:`` directive, and only when the directive's
generated ``computer_use_<uuid>`` basename exactly matches a canonical screenshot
path returned by ``computer_use`` in the current turn. It never auto-attaches
captures; normal media path validation still runs afterwards.
Own module (like ``gateway/media_policy.py``) so the gateway turn path, gateway
background tasks and cron delivery share one implementation.
Some models rewrite the Windows path ``computer_use`` reported into a POSIX-looking
one (``C:\\Users\\Alice\\...`` -> ``/Users/Alice/...``) inside an explicit ``MEDIA:``
directive, so delivery-path validation rejects it and drops the attachment.
Deliberately narrow: only rewrites paths in a response that *already* carries a
``MEDIA:`` directive whose ``computer_use_<uuid>`` basename exactly matches a
canonical path returned this turn. Never auto-attaches; validation still runs.
"""
from __future__ import annotations
@@ -29,15 +21,12 @@ logger = logging.getLogger(__name__)
# root, or UNC share. Shared string so the summary regex stays in sync.
_ABS_PATH_PREFIX_PATTERN = r"(?:[A-Za-z]:[/\\]|/|\\\\)"
_ABS_PATH_PREFIX_RE = re.compile(r"^" + _ABS_PATH_PREFIX_PATTERN)
_CAPTURE_BASENAME_PATTERN = r"computer_use_[0-9a-f]{32}\.(?:png|jpe?g)"
_COMPUTER_USE_CAPTURE_BASENAME_RE = re.compile(
r"^computer_use_[0-9a-f]{32}\.(?:png|jpe?g)$",
re.IGNORECASE,
)
_COMPUTER_USE_CAPTURE_BASENAME_RE = re.compile(r"^" + _CAPTURE_BASENAME_PATTERN + r"$", re.IGNORECASE)
_COMPUTER_USE_CAPTURE_SUMMARY_RE = re.compile(
r"\(shareable screenshot saved to "
r"(?P<path>" + _ABS_PATH_PREFIX_PATTERN + r"[^\r\n]*?"
r"computer_use_[0-9a-f]{32}\.(?:png|jpe?g))\)",
r"(?P<path>" + _ABS_PATH_PREFIX_PATTERN + r"[^\r\n]*?" + _CAPTURE_BASENAME_PATTERN + r")\)",
re.IGNORECASE,
)
@@ -50,8 +39,7 @@ def tool_name_by_call_id(messages: List[Dict[str, Any]]) -> Dict[str, str]:
continue
for call in msg.get("tool_calls") or []:
call_id = call.get("id") or call.get("call_id")
fn = call.get("function") or {}
name = str(fn.get("name") or call.get("name") or "")
name = str((call.get("function") or {}).get("name") or call.get("name") or "")
if call_id and name:
mapping[str(call_id)] = name
return mapping
@@ -59,130 +47,96 @@ def tool_name_by_call_id(messages: List[Dict[str, Any]]) -> Dict[str, str]:
def _computer_use_capture_basename(path: Any) -> str:
"""Canonical (lowercased) capture basename for either separator style, or ''."""
value = str(path or "").strip().strip("`\"'")
basename = re.split(r"[/\\]", value)[-1]
basename = re.split(r"[/\\]", str(path or "").strip().strip("`\"'"))[-1]
return basename.lower() if _COMPUTER_USE_CAPTURE_BASENAME_RE.fullmatch(basename) else ""
def _iter_computer_use_capture_paths(content: Any) -> Iterator[str]:
"""Yield persisted screenshot paths from computer_use result content.
The tool can return JSON, a multimodal content list, or a text fallback; the
latter two keep the canonical path in the human-readable summary even though
the multimodal envelope's ``meta`` is not stored in the tool message.
"""
"""Yield persisted screenshot paths from computer_use result content (JSON, a
multimodal list, or text; the latter two keep the canonical path in the summary
line since the envelope's ``meta`` is not stored in the tool message)."""
if isinstance(content, str):
stripped = content.strip()
if stripped.startswith(("{", "[")):
# Parse JSON first, never regex-scan it: JSON escaping doubles
# backslashes, so a raw-text hit would yield a path that exists
# nowhere. Fail closed on unparseable (truncated) JSON.
try:
payload = json.loads(stripped)
except Exception:
return
if isinstance(payload, (dict, list)):
yield from _iter_computer_use_capture_paths(payload)
if not stripped.startswith(("{", "[")):
for match in _COMPUTER_USE_CAPTURE_SUMMARY_RE.finditer(content):
yield match.group("path").strip()
return
for match in _COMPUTER_USE_CAPTURE_SUMMARY_RE.finditer(content):
yield match.group("path").strip()
return
if isinstance(content, list):
# Parse JSON, never regex-scan it: JSON escaping doubles backslashes, so a raw-text
# hit would yield a path that exists nowhere. Fail closed on truncated JSON.
try:
payload = json.loads(stripped)
except Exception:
return
if isinstance(payload, (dict, list)):
yield from _iter_computer_use_capture_paths(payload)
elif isinstance(content, list):
for part in content:
yield from _iter_computer_use_capture_paths(part)
return
if not isinstance(content, dict):
return
screenshot_path = content.get("screenshot_path")
if isinstance(screenshot_path, str):
yield screenshot_path
meta = content.get("meta")
if isinstance(meta, dict) and isinstance(meta.get("screenshot_path"), str):
yield meta["screenshot_path"]
# Producer shapes (tools/computer_use/tool.py::_capture_response):
# content/text = multimodal parts; text_summary/summary = the line carrying
# "(shareable screenshot saved to ...)".
for field in ("content", "text", "text_summary", "summary"):
nested = content.get(field)
if isinstance(nested, (str, dict, list)):
yield from _iter_computer_use_capture_paths(nested)
elif isinstance(content, dict):
meta = content.get("meta")
for holder in (content, meta if isinstance(meta, dict) else {}):
if isinstance(holder.get("screenshot_path"), str):
yield holder["screenshot_path"]
# Producer shapes (computer_use ``_capture_response``): content/text = multimodal
# parts; text_summary/summary = the "(shareable screenshot saved to ...)" line.
for field in ("content", "text", "text_summary", "summary"):
nested = content.get(field)
if isinstance(nested, (str, dict, list)):
yield from _iter_computer_use_capture_paths(nested)
def _current_turn_messages(messages: List[Dict[str, Any]], history_offset: int) -> List[Dict[str, Any]]:
if not history_offset:
return messages
if len(messages) >= history_offset:
if not history_offset or len(messages) >= history_offset:
return messages[history_offset:]
# Compression can invalidate the slice boundary: recover the turn from its
# last user message, fail closed if none remains. Deliberately narrower than
# gateway/run.py::_collect_auto_append_media_tags' scan-everything fallback —
# that decides whether to ATTACH; this only rewrites paths already emitted.
# Compression can invalidate the slice boundary: recover the turn from its last user
# message, fail closed if none remains. Narrower than run.py's scan-everything fallback
# for auto-attach — that decides whether to ATTACH; this only rewrites emitted paths.
for index in range(len(messages) - 1, -1, -1):
if messages[index].get("role") == "user":
return messages[index:]
return []
def repair_explicit_computer_use_media_paths(
response: str,
messages: List[Dict[str, Any]],
history_offset: int = 0,
) -> str:
"""Recover model-mangled paths for explicitly requested screenshots.
Repairs only an already-explicit ``MEDIA:`` directive whose generated
basename case-insensitively matches a canonical screenshot path from this
turn. Fail-open: the repair is cosmetic, so any unexpected error returns
the response unchanged rather than aborting delivery.
"""
def repair_explicit_computer_use_media_paths(response: str, messages: List[Dict[str, Any]], history_offset: int = 0) -> str:
"""Recover model-mangled paths in explicit ``MEDIA:`` directives whose basename
matches (case-insensitively) a canonical screenshot path from this turn.
Fail-open: the repair is cosmetic, so any error returns the response unchanged."""
try:
return _repair_explicit_computer_use_media_paths_inner(
response, messages, history_offset
)
return _repair_explicit_computer_use_media_paths_inner(response, messages, history_offset)
except Exception:
logger.debug("computer_use media path repair failed", exc_info=True)
return response
def _repair_explicit_computer_use_media_paths_inner(
response: str,
messages: List[Dict[str, Any]],
history_offset: int = 0,
) -> str:
if "MEDIA:" not in response:
return response
turn_messages = _current_turn_messages(messages, history_offset)
def _canonical_capture_paths(turn_messages: List[Dict[str, Any]]) -> Dict[str, str]:
"""``{lowercase basename: absolute canonical path}`` from this turn's computer_use results."""
call_id_names = tool_name_by_call_id(turn_messages)
canonical_by_basename: Dict[str, str] = {}
canonical: Dict[str, str] = {}
for msg in turn_messages:
if msg.get("role") not in {"tool", "function"}:
continue
call_id = str(msg.get("tool_call_id") or msg.get("call_id") or "")
tool_name = str(
msg.get("name") or msg.get("tool_name") or call_id_names.get(call_id) or ""
)
if tool_name != "computer_use":
if str(msg.get("name") or msg.get("tool_name") or call_id_names.get(call_id) or "") != "computer_use":
continue
for path in _iter_computer_use_capture_paths(msg.get("content")):
basename = _computer_use_capture_basename(path)
if basename and _ABS_PATH_PREFIX_RE.match(path):
canonical_by_basename[basename] = path
canonical[basename] = path
return canonical
def _repair_explicit_computer_use_media_paths_inner(response: str, messages: List[Dict[str, Any]], history_offset: int = 0) -> str:
if "MEDIA:" not in response:
return response
canonical_by_basename = _canonical_capture_paths(_current_turn_messages(messages, history_offset))
if not canonical_by_basename:
return response
# Lazy: keeps `import gateway.media_repair` cheap for standalone cron
# processes that may never hit a MEDIA: response. No import cycle either way.
# Lazy: keeps the import cheap for standalone cron processes that may never hit MEDIA:.
from gateway.platforms.base import BasePlatformAdapter
media_files, _ = BasePlatformAdapter.extract_media(response)
repaired = response
for emitted_path, _is_voice in media_files:
for emitted_path, _is_voice in BasePlatformAdapter.extract_media(response)[0]:
canonical = canonical_by_basename.get(_computer_use_capture_basename(emitted_path))
if canonical and emitted_path != canonical:
repaired = repaired.replace(emitted_path, canonical)
+21 -51
View File
@@ -1,15 +1,15 @@
"""Periodic process memory usage logging for the gateway.
Ported from cline/cline#10343 (src/standalone/memory-monitor.ts). Emits one
grep-friendly ``[MEMORY] ...`` line every N seconds (default 300) from a daemon
thread so slow leaks in the long-lived gateway show up as an RSS time series in
``agent.log`` / ``gateway.log``. A baseline snapshot is logged on start and a
final one on stop. Uses stdlib ``resource`` first, ``psutil`` as fallback
(Windows); if neither works the monitor warns once and stays disabled.
Emits one grep-friendly ``[MEMORY] ...`` line every N seconds (default 300)
from a daemon thread so slow leaks show up as an RSS time series in the logs.
A baseline snapshot is logged on start and a final one on stop. Uses stdlib
``resource`` first, ``psutil`` as fallback (Windows); if neither works the
monitor warns once and stays disabled.
"""
from __future__ import annotations
import contextlib
import gc
import logging
import os
@@ -17,7 +17,6 @@ import sys
import threading
import time
from typing import Optional
import contextlib
logger = logging.getLogger(__name__)
@@ -26,7 +25,6 @@ _BYTES_TO_MB = 1024 * 1024
_monitor_thread: Optional[threading.Thread] = None
_stop_event: Optional[threading.Event] = None
_start_time: Optional[float] = None
_interval_seconds: float = 300.0
_lock = threading.Lock()
@@ -36,8 +34,7 @@ def _get_rss_mb() -> Optional[int]:
import resource
# ru_maxrss is KB on Linux but bytes on macOS.
maxrss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
return int(maxrss / (_BYTES_TO_MB if sys.platform == "darwin" else 1024))
return int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / (_BYTES_TO_MB if sys.platform == "darwin" else 1024))
except Exception:
pass
try:
@@ -49,25 +46,12 @@ def _get_rss_mb() -> Optional[int]:
def log_memory_usage(prefix: str = "") -> None:
"""Log current memory usage as ``[MEMORY] [<prefix> ]rss=... gc=... threads=... uptime=...``.
Safe to call on-demand from any thread at lifecycle moments.
"""
"""Log ``[MEMORY] [<prefix> ]rss=... gc=... threads=... uptime=...``; safe from any thread."""
rss = _get_rss_mb()
uptime = int(time.monotonic() - _start_time) if _start_time else 0
try:
gc_counts = gc.get_count() # (gen0, gen1, gen2)
except Exception:
gc_counts = (0, 0, 0)
try:
thread_count = threading.active_count()
except Exception:
thread_count = 0
tag = f"{prefix} " if prefix else ""
rss_text = "unavailable" if rss is None else f"{rss}MB"
logger.info(
"[MEMORY] %srss=%s gc=%s threads=%d uptime=%ds", tag, rss_text, gc_counts, thread_count, uptime
"[MEMORY] %srss=%s gc=%s threads=%d uptime=%ds", f"{prefix} " if prefix else "",
"unavailable" if rss is None else f"{rss}MB", gc.get_count(), threading.active_count(),
int(time.monotonic() - _start_time) if _start_time else 0,
)
@@ -76,41 +60,32 @@ def _monitor_loop(stop_event: threading.Event, interval: float) -> None:
while not stop_event.wait(interval):
try:
log_memory_usage()
except Exception as e:
# Never let the monitor crash the gateway.
except Exception as e: # never let the monitor crash the gateway
logger.debug("Memory monitor iteration failed: %s", e)
def start_memory_monitoring(interval_seconds: float = 300.0) -> bool:
"""Start periodic memory logging in a daemon thread (baseline logged immediately).
Returns True if a fresh monitor was started; False if one is already running
or RSS introspection is unavailable (warned once).
"""
global _monitor_thread, _stop_event, _start_time, _interval_seconds
"""Start periodic logging in a daemon thread (baseline logged immediately). False if
already running or RSS introspection is unavailable (warned once)."""
global _monitor_thread, _stop_event, _start_time
with _lock:
if _monitor_thread is not None and _monitor_thread.is_alive():
return False
if _get_rss_mb() is None:
logger.warning(
"[MEMORY] Memory monitoring unavailable: neither resource.getrusage "
"nor psutil could read process RSS — skipping periodic logging.",
"[MEMORY] Memory monitoring unavailable: neither resource.getrusage nor psutil could read process RSS "
"— skipping periodic logging.",
)
return False
_start_time = time.monotonic()
_interval_seconds = float(interval_seconds)
_stop_event = threading.Event()
log_memory_usage(prefix="baseline")
_monitor_thread = threading.Thread(
target=_monitor_loop,
args=(_stop_event, _interval_seconds),
name="gateway-memory-monitor",
daemon=True,
target=_monitor_loop, args=(_stop_event, float(interval_seconds)), name="gateway-memory-monitor", daemon=True
)
_monitor_thread.start()
logger.info("[MEMORY] Periodic memory monitoring started (interval: %ds)", int(_interval_seconds))
logger.info("[MEMORY] Periodic memory monitoring started (interval: %ds)", int(interval_seconds))
return True
@@ -124,14 +99,9 @@ def stop_memory_monitoring(timeout: float = 2.0) -> None:
with contextlib.suppress(Exception):
log_memory_usage(prefix="shutdown")
_stop_event.set()
thread = _monitor_thread
_monitor_thread = None
_stop_event = None
# Join outside the lock so a stuck log call can't deadlock shutdown.
with contextlib.suppress(Exception):
thread, _monitor_thread, _stop_event = _monitor_thread, None, None
with contextlib.suppress(Exception): # join outside the lock so a stuck log call can't deadlock the stop path
thread.join(timeout=timeout)
logger.info("[MEMORY] Periodic memory monitoring stopped")
+39 -84
View File
@@ -1,104 +1,74 @@
"""Memory status rollup for ``/api/status``.
Read side for memory-pressure signals the gateway already persists but only
logs: the 30s ``state/gateway.heartbeat`` (RSS + MemAvailable/MemTotal + swap,
from :func:`gateway.shutdown_watchdog.write_loop_heartbeat`) and the lifecycle
sentinel's ``suspected_oom`` flag (:func:`gateway.lifecycle_ledger.record_startup`).
Distills them into a compact block the dashboard SPA and the NAS availability
sweep consume — no new sampling, no IPC, two small file reads.
Public-safety: ``/api/status`` is unauthenticated (``PUBLIC_API_PATHS``), so this
block carries only coarse numbers (MB granularity), enums, and booleans — the
same disclosure class as ``active_agents`` / ``nous_session_valid``.
Best-effort and read-only: a missing/corrupt file degrades to
Read side for signals the gateway already persists: the 30s ``state/gateway.heartbeat``
(RSS + MemAvailable/MemTotal + swap) and the lifecycle sentinel's ``suspected_oom``
flag — two small file reads, no IPC. ``/api/status`` is unauthenticated, so only
coarse numbers (MB), enums and booleans. A missing/corrupt file degrades to
``pressure="unknown"`` rather than raising into the status endpoint.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Thresholds on system MemAvailable. ``critical`` mirrors the lifecycle ledger's
# OOM-suspicion heuristics (_LOW_MEM_AVAILABLE_KIB / _LOW_MEM_AVAILABLE_FRACTION):
# a level that would make a later unclean death "suspected OOM" should already
# warn while the process is alive.
# Thresholds on system MemAvailable. ``critical`` doubles as the lifecycle
# ledger's OOM-suspicion heuristic: a level that makes a later unclean death
# "suspected OOM" already warns while the process is alive.
_CRITICAL_AVAILABLE_KIB = 64 * 1024 # < 64 MiB available
_CRITICAL_AVAILABLE_FRACTION = 0.05 # < 5% of MemTotal
_ELEVATED_AVAILABLE_KIB = 128 * 1024 # < 128 MiB available
_ELEVATED_AVAILABLE_FRACTION = 0.15 # < 15% of MemTotal
_PRESSURE_TIERS = ( # order-sensitive: worst first
("critical", _CRITICAL_AVAILABLE_KIB, _CRITICAL_AVAILABLE_FRACTION),
("elevated", _ELEVATED_AVAILABLE_KIB, _ELEVATED_AVAILABLE_FRACTION),
)
# Writer cadence is 30s (DEFAULT_HEARTBEAT_INTERVAL_S); 150s tolerates a briefly
# stalled loop without letting a long-dead gateway's last sample pose as current.
# Writer cadence is 30s; 150s tolerates a briefly stalled loop without letting
# a long-dead gateway's last sample pose as current.
_HEARTBEAT_FRESH_TTL_S = 150.0
_KIB_PER_MB = 1024
def _nonneg_int(value: Any) -> Optional[int]:
"""Return *value* if it is a non-negative int (bools rejected), else None."""
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None
return value
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
def _mb(kib: Any) -> Optional[int]:
kib = _nonneg_int(kib)
return None if kib is None else kib // _KIB_PER_MB
return None if _nonneg_int(kib) is None else kib // 1024
def _parse_iso(value: Any) -> Optional[datetime]:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value)
parsed = datetime.fromisoformat(value) if isinstance(value, str) and value else None
except ValueError:
return None
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
return parsed.replace(tzinfo=timezone.utc) if parsed is not None and parsed.tzinfo is None else parsed
def classify_pressure(available_kib: Any, total_kib: Any) -> str:
"""Map a MemAvailable/MemTotal pair to ``ok``/``elevated``/``critical``.
``unknown`` when the sample is missing or malformed — the caller must
not treat "we could not read it" as "memory is fine".
"""
available = _nonneg_int(available_kib)
"""``ok``/``elevated``/``critical`` from MemAvailable/MemTotal; ``unknown`` when the
sample is missing/malformed — "could not read it" must never read as "fine"."""
available, total = _nonneg_int(available_kib), _nonneg_int(total_kib)
if available is None:
return "unknown"
total = _nonneg_int(total_kib)
fraction = available / total if total else None
for level, kib_floor, frac_floor in (
("critical", _CRITICAL_AVAILABLE_KIB, _CRITICAL_AVAILABLE_FRACTION),
("elevated", _ELEVATED_AVAILABLE_KIB, _ELEVATED_AVAILABLE_FRACTION),
):
for level, kib_floor, frac_floor in _PRESSURE_TIERS:
if available < kib_floor or (fraction is not None and fraction < frac_floor):
return level
return "ok"
def _read_heartbeat(home: Optional[Path]) -> Optional[Dict[str, Any]]:
try:
from gateway.lifecycle_ledger import _read_json
from gateway.shutdown_watchdog import get_loop_heartbeat_path
return _read_json(get_loop_heartbeat_path(home))
except Exception:
return None
def _read_sentinel(home: Optional[Path]) -> Optional[Dict[str, Any]]:
def _read_state_files(home: Optional[Path]) -> tuple:
"""``(heartbeat, sentinel)`` dicts, each ``None`` when unreadable."""
try:
from gateway.lifecycle_ledger import _read_json, get_lifecycle_sentinel_path
from gateway.shutdown_watchdog import get_loop_heartbeat_path
return _read_json(get_lifecycle_sentinel_path(home))
return _read_json(get_loop_heartbeat_path(home)), _read_json(get_lifecycle_sentinel_path(home))
except Exception:
return None
return None, None
def collect_memory_status(
@@ -106,38 +76,25 @@ def collect_memory_status(
*,
now: Optional[datetime] = None,
) -> Dict[str, Any]:
"""Build the ``memory`` block for ``/api/status``.
``home`` scopes the read to a profile's HERMES_HOME (``None`` = active
profile); ``now`` is injectable for tests. Always returns a dict and never
raises — a down/never-started gateway or corrupt files yield
``{"pressure": "unknown", ...}`` plus whatever fields could be recovered.
"""
"""``memory`` block for ``/api/status``; ``home`` scopes to a profile (``None`` =
active), ``now`` is injectable. Never raises — a down gateway or corrupt files
yield ``pressure="unknown"`` plus whatever fields could be recovered."""
moment = now or datetime.now(timezone.utc)
status: Dict[str, Any] = {
"pressure": "unknown",
"gateway_rss_mb": None,
"system_total_mb": None,
"system_available_mb": None,
"swap_used_mb": None,
"sampled_at": None,
"last_boot_unclean": False,
"last_boot_suspected_oom": False,
# Identity of the CURRENT gateway life (sentinel started_at) — changes on
# every boot, so the dashboard can key banner dismissal on it and
# acknowledging one OOM restart does not mute the NEXT one.
"pressure": "unknown", "gateway_rss_mb": None, "system_total_mb": None, "system_available_mb": None,
"swap_used_mb": None, "sampled_at": None, "last_boot_unclean": False, "last_boot_suspected_oom": False,
# Identity of the CURRENT life (sentinel started_at): the dashboard keys
# banner dismissal on it so acknowledging one OOM restart does not mute the NEXT.
"boot_id": None,
}
heartbeat = _read_heartbeat(home)
heartbeat, sentinel = _read_state_files(home)
if heartbeat:
sampled_at = _parse_iso(heartbeat.get("updated_at"))
mem = heartbeat.get("mem")
sampled_at, mem = _parse_iso(heartbeat.get("updated_at")), heartbeat.get("mem")
if isinstance(mem, dict):
status["gateway_rss_mb"] = _mb(mem.get("rss_kib"))
status["system_total_mb"] = _mb(mem.get("mem_total_kib"))
status["system_available_mb"] = _mb(mem.get("mem_available_kib"))
status["swap_used_mb"] = _mb(mem.get("swap_used_kib"))
for dst, src in (("gateway_rss_mb", "rss_kib"), ("system_total_mb", "mem_total_kib"),
("system_available_mb", "mem_available_kib"), ("swap_used_mb", "swap_used_kib")):
status[dst] = _mb(mem.get(src))
if sampled_at is not None:
status["sampled_at"] = sampled_at.isoformat()
# Stale sample: numbers still reported (sampled_at says when) but
@@ -146,12 +103,10 @@ def collect_memory_status(
if 0 <= (moment - sampled_at).total_seconds() <= _HEARTBEAT_FRESH_TTL_S:
status["pressure"] = classify_pressure(mem.get("mem_available_kib"), mem.get("mem_total_kib"))
sentinel = _read_sentinel(home)
if sentinel:
status["last_boot_unclean"] = bool(sentinel.get("prior_unclean_exit"))
status["last_boot_suspected_oom"] = bool(sentinel.get("prior_suspected_oom"))
started_at = sentinel.get("started_at")
if isinstance(started_at, str) and started_at:
status["boot_id"] = started_at
status["boot_id"] = started_at if isinstance(started_at, str) and started_at else None
return status
+36 -59
View File
@@ -12,17 +12,19 @@ from datetime import datetime
from typing import Any, Optional, Tuple
# Current gateway format: [Tue 2026-04-28 13:40:53 CEST]
_HUMAN_TIMESTAMP_RE = re.compile(
r"^\[(?P<dow>[A-Z][a-z]{2}) "
# Leading timestamp prefix, either the current human format
# ``[Tue 2026-04-28 13:40:53 CEST]`` or the older ISO one
# ``[2026-04-13T17:02:06+0200]`` / ``[...+02:00]`` (human tried first).
_TIMESTAMP_PREFIX_RE = re.compile(
r"^\[(?:"
r"(?P<dow>[A-Z][a-z]{2}) "
r"(?P<date>\d{4}-\d{2}-\d{2}) "
r"(?P<time>\d{2}:\d{2}:\d{2})"
r"(?: (?P<tz>[A-Za-z0-9_+\-/:]+))?\]\s*"
r"(?: (?P<tz>[A-Za-z0-9_+\-/:]+))?"
r"|(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)"
r")\]\s*"
)
# Older gateway format: [2026-04-13T17:02:06+0200] or [+02:00]
_ISO_TIMESTAMP_RE = re.compile(r"^\[(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*")
def _localize(dt: datetime, tz) -> float:
"""Epoch for ``dt``; naive values take ``tz`` if given, else the local zone."""
@@ -33,18 +35,16 @@ def _localize(dt: datetime, tz) -> float:
def _parse_iso(text: str, tz=None) -> Optional[float]:
"""Parse an ISO-8601 string (incl. ``+0200`` offsets fromisoformat rejects)."""
try:
dt = datetime.fromisoformat(text)
except (TypeError, ValueError):
for parse in (datetime.fromisoformat, lambda t: datetime.strptime(t, "%Y-%m-%dT%H:%M:%S%z")):
try:
dt = datetime.strptime(text, "%Y-%m-%dT%H:%M:%S%z")
return _localize(parse(text), tz)
except (TypeError, ValueError):
return None
return _localize(dt, tz)
continue
return None
def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
if match.groupdict().get("iso"):
if match.group("iso"):
return _parse_iso(match.group("iso"), tz)
try:
dt = datetime.strptime(f"{match.group('date')} {match.group('time')}", "%Y-%m-%d %H:%M:%S")
@@ -53,18 +53,9 @@ def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
return _localize(dt, tz)
def _match_timestamp_prefix(text: str) -> Optional[re.Match]:
return _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
"""Coerce a timestamp-like value to Unix epoch seconds.
Accepts epoch numbers, datetime objects, ISO strings, and the gateway's
bracketed human-readable format. Returns ``None`` when uninterpretable.
"""
if ts_value is None:
return None
"""Epoch seconds from a number, datetime, ISO string, or the gateway's bracketed
format; ``None`` when uninterpretable."""
if isinstance(ts_value, (int, float)):
return float(ts_value)
if hasattr(ts_value, "timestamp"):
@@ -72,21 +63,17 @@ def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
return float(ts_value.timestamp())
except Exception:
return None
if isinstance(ts_value, str):
text = ts_value.strip()
if not text:
return None
match = _match_timestamp_prefix(text)
if match is not None:
parsed = _parse_timestamp_match(match, tz=tz)
if parsed is not None:
return parsed
try:
return float(text)
except (TypeError, ValueError):
pass
text = ts_value.strip() if isinstance(ts_value, str) else ""
if not text:
return None
match = _TIMESTAMP_PREFIX_RE.match(text)
parsed = _parse_timestamp_match(match, tz=tz) if match is not None else None
if parsed is not None:
return parsed
try:
return float(text)
except (TypeError, ValueError):
return _parse_iso(text, tz)
return None
def format_message_timestamp(ts_value: Any, tz=None) -> str:
@@ -95,21 +82,17 @@ def format_message_timestamp(ts_value: Any, tz=None) -> str:
if epoch is None:
return ""
dt = datetime.fromtimestamp(epoch, tz=tz) if tz is not None else datetime.fromtimestamp(epoch).astimezone()
return "[" + dt.strftime("%a %Y-%m-%d %H:%M:%S %Z") + "]"
return f"[{dt.strftime('%a %Y-%m-%d %H:%M:%S %Z')}]"
def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Optional[float]]:
"""Strip one or more leading gateway timestamp prefixes from ``content``.
Returns ``(clean_content, embedded_epoch)``. With multiple prefixes the one
closest to the message text wins, preserving the original platform-send
time for legacy contaminated rows like ``[processing time] [platform time] [sender] message``.
"""
"""Strip leading gateway timestamp prefixes → ``(clean_content, embedded_epoch)``.
With several prefixes the one closest to the text wins, preserving the platform-send
time of legacy rows like ``[processing time] [platform time] [sender] message``."""
if not isinstance(content, str) or not content:
return content, None
text = content
embedded_epoch: Optional[float] = None
while (match := _match_timestamp_prefix(text)) is not None:
text, embedded_epoch = content, None
while (match := _TIMESTAMP_PREFIX_RE.match(text)) is not None:
parsed = _parse_timestamp_match(match, tz=tz)
if parsed is not None:
embedded_epoch = parsed
@@ -118,14 +101,8 @@ def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Option
def render_user_content_with_timestamp(content: str, ts_value: Any = None, tz=None) -> str:
"""Render a user message for LLM context with exactly one timestamp prefix.
An existing leading prefix is stripped and its parsed time wins over
``ts_value``. If no timestamp is available the cleaned content is returned.
"""
"""Render a user message for LLM context with exactly one timestamp prefix; an
existing prefix is stripped and its parsed time wins over ``ts_value``."""
clean_content, embedded_epoch = strip_leading_message_timestamps(content, tz=tz)
effective_ts = embedded_epoch if embedded_epoch is not None else ts_value
prefix = format_message_timestamp(effective_ts, tz=tz)
if not prefix:
return clean_content
return f"{prefix} {clean_content}" if clean_content else prefix
prefix = format_message_timestamp(ts_value if embedded_epoch is None else embedded_epoch, tz=tz)
return f"{prefix} {clean_content}" if prefix and clean_content else (prefix or clean_content)
+41 -75
View File
@@ -1,10 +1,8 @@
"""
Session mirroring for cross-platform message delivery.
"""Session mirroring for cross-platform message delivery.
When a message is sent to a platform (send_message or cron delivery), append
a "delivery-mirror" record to the target session's transcript so the
receiving-side agent knows what was sent. Standalone: works from CLI, cron
and gateway contexts without the full SessionStore machinery.
When a message is sent to a platform (send_message or cron delivery), append a
"delivery-mirror" record to the target session's transcript so the receiving-side
agent knows what was sent. Standalone: works from CLI, cron and gateway contexts.
"""
import json
@@ -25,26 +23,19 @@ def _origin_user_id(entry: dict) -> str:
def mirror_to_session(
platform: str, chat_id: str, message_text: str, source_label: str = "cli",
thread_id: Optional[str] = None, user_id: Optional[str] = None,
role: str = "assistant", session_id: Optional[str] = None,
platform: str, chat_id: str, message_text: str, source_label: str = "cli", thread_id: Optional[str] = None,
user_id: Optional[str] = None, role: str = "assistant", session_id: Optional[str] = None,
) -> bool:
"""Append a delivery-mirror message to the target session's SQLite transcript.
``session_id``: pass it when the caller already holds the exact session
(e.g. the cron in_channel seed that just created the row) to skip the
origin scan. ``_find_session_id`` refuses to guess on a populated chat
(flat session + N thread sessions sharing one chat_id) and would silently
drop the mirror.
``role`` defaults to ``"assistant"``, correct when the mirrored text is
the agent's own outgoing reply. Text that is NOT the agent speaking (e.g.
a cron brief) must pass ``role="user"``: ``mirror``/``mirror_source``
metadata is dropped at the SQLite boundary, so an assistant-role mirror
replays as a real assistant turn and produces assistant→assistant pairs
that break strict-alternation providers; a user-role mirror collapses
safely via ``repair_message_sequence``'s consecutive-user merge.
Pass ``session_id`` when the caller already holds the exact session (e.g. the
cron in_channel seed) to skip the origin scan, which refuses to guess on a
populated chat (flat + N thread sessions per chat_id) and would drop the mirror.
Text that is NOT the agent speaking (e.g. a cron brief) must pass
``role="user"``: ``mirror`` metadata is dropped at the SQLite boundary, so an
assistant-role mirror replays as a real turn and yields assistant→assistant
pairs that break strict-alternation providers, while a user-role mirror
collapses safely via the consecutive-user merge.
Returns True if mirrored, False if no matching session or error. Never raises.
"""
try:
@@ -52,52 +43,38 @@ def mirror_to_session(
session_id = _find_session_id(platform, str(chat_id), thread_id=thread_id, user_id=user_id)
if not session_id:
logger.warning(
"Mirror: no session found for %s:%s thread=%s user=%s "
"(explicit_id=none, origin-scan bailed)",
"Mirror: no session found for %s:%s thread=%s user=%s (explicit_id=none, origin-scan bailed)",
platform, chat_id, thread_id, user_id,
)
return False
_append_to_sqlite(session_id, {
"role": role,
"content": message_text,
"timestamp": datetime.now().isoformat(),
"mirror": True,
"mirror_source": source_label,
"role": role, "content": message_text, "timestamp": datetime.now().isoformat(),
"mirror": True, "mirror_source": source_label,
})
logger.debug("Mirror: wrote to session %s (from %s)", session_id, source_label)
return True
except Exception as e:
# WARNING, not debug: a silent mirror drop is the cron continuation-amnesia bug.
logger.warning(
"Mirror failed for %s:%s thread=%s user=%s session=%s: %s",
platform, chat_id, thread_id, user_id, session_id, e,
)
logger.warning("Mirror failed for %s:%s thread=%s user=%s session=%s: %s", platform, chat_id, thread_id, user_id, session_id, e)
return False
def _find_session_id(
platform: str, chat_id: str, thread_id: Optional[str] = None, user_id: Optional[str] = None,
) -> Optional[str]:
"""Find the active session_id for a platform + chat_id pair.
def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = None, user_id: Optional[str] = None) -> Optional[str]:
"""Active session_id for a platform + chat_id pair.
state.db gateway session rows are primary; sessions.json is the fallback
for pre-migration databases. DM session keys don't embed the chat_id
(e.g. "agent:main:telegram:dm"), so matching is on the persisted origin.
With *user_id*, exact sender matches win. If several same-chat candidates
exist and none matches the user, return None rather than guess and
contaminate another participant's session.
state.db is primary; sessions.json is the pre-migration fallback. DM keys
don't embed the chat_id ("agent:main:telegram:dm"), so match on the persisted
origin. With *user_id*, exact sender matches win; several same-chat candidates
with no user match → None rather than contaminate another participant's session.
"""
try:
from hermes_state import get_shared_session_db, release_or_close
db = get_shared_session_db()
try:
finder = getattr(db, "find_session_by_origin", None)
if callable(finder):
session_id = finder(platform=platform, chat_id=chat_id, thread_id=thread_id, user_id=user_id)
if session_id:
return str(session_id)
session_id = finder(platform=platform, chat_id=chat_id, thread_id=thread_id, user_id=user_id) if callable(finder) else None
if session_id:
return str(session_id)
finally:
release_or_close(db)
except Exception as e:
@@ -106,26 +83,18 @@ def _find_session_id(
if not _SESSIONS_INDEX.exists():
return None
try:
with open(_SESSIONS_INDEX, encoding="utf-8") as f:
data = json.load(f)
data = json.loads(_SESSIONS_INDEX.read_text(encoding="utf-8"))
except Exception:
return None
platform_lower = platform.lower()
candidates = []
for _key, entry in data.items():
# Keys starting with "_" (e.g. the gateway's "_README") are metadata sentinels.
if str(_key).startswith("_") or not isinstance(entry, dict):
continue
def _matches(entry: dict) -> bool:
origin = entry.get("origin") or {}
if (origin.get("platform") or entry.get("platform", "")).lower() != platform_lower:
continue
if str(origin.get("chat_id", "")) != str(chat_id):
continue
if thread_id is not None and str(origin.get("thread_id") or "") != str(thread_id):
continue
candidates.append(entry)
return ((origin.get("platform") or entry.get("platform", "")).lower() == platform.lower()
and str(origin.get("chat_id", "")) == str(chat_id)
and (thread_id is None or str(origin.get("thread_id") or "") == str(thread_id)))
# Keys starting with "_" (e.g. the gateway's "_README") are metadata sentinels.
candidates = [e for k, e in data.items() if not str(k).startswith("_") and isinstance(e, dict) and _matches(e)]
if not candidates:
return None
if user_id:
@@ -134,23 +103,20 @@ def _find_session_id(
candidates = exact_user_matches
elif len(candidates) > 1:
return None
elif len(candidates) > 1:
distinct_user_ids = {uid.strip() for uid in map(_origin_user_id, candidates) if uid.strip()}
if len(distinct_user_ids) > 1:
return None
elif len(candidates) > 1 and len({u.strip() for u in map(_origin_user_id, candidates) if u.strip()}) > 1:
return None
return max(candidates, key=lambda entry: entry.get("updated_at", "")).get("session_id")
def _append_to_sqlite(session_id: str, message: dict) -> None:
"""Append a message to the SQLite session database."""
db = None
try:
from hermes_state import get_shared_session_db, release_or_close
db = get_shared_session_db()
db.append_message(session_id=session_id, role=message.get("role", "assistant"), content=message.get("content"))
try:
db.append_message(session_id=session_id, role=message.get("role", "assistant"), content=message.get("content"))
finally:
release_or_close(db)
except Exception as e:
logger.debug("Mirror SQLite write failed: %s", e)
finally:
if db is not None:
release_or_close(db)
+151 -262
View File
@@ -1,8 +1,6 @@
"""
DM Pairing System
"""DM pairing: code-based approval of new users on messaging platforms.
Code-based approval flow for authorizing new users on messaging platforms:
unknown users receive a one-time pairing code that the bot owner approves via
Unknown users receive a one-time pairing code that the bot owner approves via
the CLI, instead of maintaining static user-ID allowlists.
Security properties (OWASP + NIST SP 800-63-4): 8-char codes from a 32-char
@@ -47,30 +45,23 @@ LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures
MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform
MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
# Default (non-profile-scoped) pairing directory. Deliberately ``None``: the
# long-lived gateway imports this module at boot, and an eagerly computed path
# would freeze whatever HERMES_HOME/profile context existed then, ignoring later
# context-local overrides (hermes_constants.set_hermes_home_override) -- so the
# gateway and the short-lived ``hermes pairing`` CLI ended up writing different
# directories. ``_default_pairing_dir()`` resolves fresh on every call; tests
# patch this attribute to a concrete path (``patch("gateway.pairing.PAIRING_DIR", tmp)``)
# and a non-``None`` value takes precedence.
# Default pairing directory override. Deliberately ``None``: an eagerly computed
# path would freeze the HERMES_HOME/profile context at gateway boot, ignoring later
# context-local overrides, so the gateway and ``hermes pairing`` CLI wrote different
# directories. ``_default_pairing_dir()`` resolves fresh per call; tests patch this.
PAIRING_DIR = None
def _default_pairing_dir() -> Path:
"""Resolve the default pairing directory fresh (see ``PAIRING_DIR``)."""
if PAIRING_DIR is not None:
return PAIRING_DIR
return get_hermes_dir("platforms/pairing", "pairing")
# Platform value -> per-platform allowlist env var. When an operator already
# runs an allowlist for a platform, approving a pairing code also writes the
# user into it (and revoking removes them), so the operator's list stays the
# single visible source of truth instead of drifting from an opaque
# approved.json. Platforms absent here (or with no allowlist configured) keep
# the pairing store as the sole grant record, honored by the authz union.
# Platform value -> allowlist env var. Approving a code also writes the user into
# an already-configured allowlist (revoke removes them) so the operator's list stays
# the visible source of truth. Platforms absent here (or with no allowlist
# configured) keep the pairing store as the sole grant record (authz union).
_PLATFORM_ALLOWLIST_ENV = {
"telegram": "TELEGRAM_ALLOWED_USERS",
"discord": "DISCORD_ALLOWED_USERS",
@@ -99,14 +90,10 @@ def _allowlist_env_for_platform(platform: str) -> Optional[str]:
env_var = _PLATFORM_ALLOWLIST_ENV.get(platform)
if env_var:
return env_var
try:
with contextlib.suppress(Exception):
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform)
if entry and entry.allowed_users_env:
return entry.allowed_users_env
except Exception:
pass
return platform_registry.get(platform).allowed_users_env or None
return None
@@ -128,11 +115,10 @@ def _normalize_user_id(platform: str, user_id: str) -> str:
def _user_id_aliases(platform: str, user_id: str) -> set[str]:
"""Return all known equivalent user IDs for auth / allowlist matching."""
"""All known equivalent user IDs for auth / allowlist matching."""
raw_user_id = str(user_id or "").strip()
if not raw_user_id:
return set()
aliases = {raw_user_id, _normalize_user_id(platform, raw_user_id)}
if _platform_uses_whatsapp_identity(platform):
aliases.update(expand_whatsapp_aliases(raw_user_id))
@@ -141,48 +127,50 @@ def _user_id_aliases(platform: str, user_id: str) -> set[str]:
def _user_ids_match(platform: str, left: str, right: str) -> bool:
"""Return True when two user IDs represent the same principal."""
"""True when two user IDs represent the same principal."""
left_aliases = _user_id_aliases(platform, left)
right_aliases = _user_id_aliases(platform, right)
return bool(left_aliases and right_aliases and (left_aliases & right_aliases))
return bool(left_aliases and left_aliases & _user_id_aliases(platform, right))
def _matching_ids(platform: str, approved: dict, user_id: str) -> list:
return [uid for uid in approved if _user_ids_match(platform, uid, user_id)]
def _read_allowlist_env(env_var: str) -> str:
"""Read a platform allowlist env var through the profile secret scope.
Under multiplexing the process env may hold ANOTHER profile's allowlist
(first-writer-wins YAML→env bridges), so a scoped miss must return empty
rather than borrow the process value. Unscoped callers (single-profile
CLI / admin endpoints) keep the legacy ``os.getenv`` read. Writes go
through ``hermes_cli.config.save_env_value`` / ``remove_env_value``, which
target the active profile's ``.env`` and, under multiplexing, publish into
the installed scope mapping rather than the shared ``os.environ``.
Under multiplexing the process env may hold ANOTHER profile's allowlist, so a
scoped miss must return empty rather than borrow it. Unscoped callers keep the
legacy ``os.getenv`` read. Writes go through ``save_env_value``/``remove_env_value``,
which target the active profile's ``.env`` / installed scope, not ``os.environ``.
"""
try:
with contextlib.suppress(Exception):
from agent.secret_scope import UnscopedSecretError, get_secret
try:
return (get_secret(env_var) or "").strip()
except UnscopedSecretError:
pass
except Exception:
pass
return (os.getenv(env_var) or "").strip()
def _configured_allowlist(platform: str):
"""Return ``(env_var, ids)`` for a platform whose allowlist is configured, else None.
"""``(env_var, ids)`` for a platform whose allowlist is configured, else None.
An unconfigured allowlist means an open gateway: the pairing store stays the
sole grant record and we must never lock the gateway by materializing one.
"""
env_var = _allowlist_env_for_platform(platform)
if not env_var:
return None
current = _read_allowlist_env(env_var)
if not current:
return None
return env_var, _split_allowlist(current)
current = _read_allowlist_env(env_var) if env_var else ""
return (env_var, _split_allowlist(current)) if current else None
def _write_allowlist_env(env_var: str, ids: list) -> None:
"""Best-effort persist (empty list removes the key); the pairing store grant still authorizes via the union."""
with contextlib.suppress(Exception):
from hermes_cli.config import save_env_value, remove_env_value
save_env_value(env_var, ",".join(ids)) if ids else remove_env_value(env_var)
def _sync_allowlist_add(platform: str, user_id: str) -> None:
@@ -192,31 +180,22 @@ def _sync_allowlist_add(platform: str, user_id: str) -> None:
return
env_var, ids = configured
if "*" in ids or str(user_id) in ids:
return # Already covered.
ids.append(str(user_id))
try:
from hermes_cli.config import save_env_value
save_env_value(env_var, ",".join(ids))
except Exception:
# Best-effort: the pairing store grant still authorizes via the union.
pass
return
_write_allowlist_env(env_var, [*ids, str(user_id)])
def _iter_live_gateway_adapters():
"""Yield adapters from the in-process GatewayRunner, if one is running."""
try:
runner = None
with contextlib.suppress(Exception):
from gateway.run import _gateway_runner_ref
runner = _gateway_runner_ref()
except Exception:
return
if runner is None:
return
for adapter in (getattr(runner, "adapters", None) or {}).values():
if adapter is not None:
yield adapter
for mapping in (getattr(runner, "_profile_adapters", None) or {}).values():
mappings = [getattr(runner, "adapters", None) or {}]
mappings.extend((getattr(runner, "_profile_adapters", None) or {}).values())
for mapping in mappings:
for adapter in (mapping or {}).values():
if adapter is not None:
yield adapter
@@ -232,25 +211,20 @@ def _adapter_platform_name(adapter) -> str:
def _purge_allowlist_entries(entries, platform: str, user_id: str):
"""Drop alias-equivalent allowlist entries while preserving ``*``."""
def keep(entry) -> bool:
entry = str(entry)
return entry.strip() == "*" or not _user_ids_match(platform, entry, str(user_id))
return str(entry).strip() == "*" or not _user_ids_match(platform, str(entry), str(user_id))
if isinstance(entries, str):
return ",".join(part for part in _split_allowlist(entries) if keep(part))
return ",".join(filter(keep, _split_allowlist(entries)))
if isinstance(entries, (set, frozenset)):
return {entry for entry in entries if keep(entry)}
return set(filter(keep, entries))
if isinstance(entries, (list, tuple)):
return [entry for entry in entries if keep(entry)]
return list(filter(keep, entries))
return entries
def _sync_live_adapter_allowlist_remove(platform: str, user_id: str) -> None:
"""Clear revoked principals from in-process adapter allowlist snapshots.
Adapters snapshot ``_allow_from`` at construction; when the revoked
principal was the sole entry the env key is removed entirely, so intake
must not keep authorizing from the stale snapshot until restart.
"""
"""Clear revoked principals from in-process adapter ``_allow_from`` snapshots,
so intake does not keep authorizing from a stale snapshot until restart."""
platform_name = (platform or "").strip().lower()
if not platform_name or not str(user_id or "").strip():
return
@@ -273,10 +247,8 @@ def _sync_live_adapter_allowlist_remove(platform: str, user_id: str) -> None:
def _sync_allowlist_remove(platform: str, user_id: str) -> None:
"""Remove ``user_id`` (and WhatsApp alias equivalents) from the allowlist.
Approve mirrors a normalized phone while revoke is often given a JID or
device-suffix form, so matching uses alias rules -- exact-string delete
would leave the sender authorized. Also clears in-process adapter
snapshots so sole-entry revocation works without a gateway restart.
Approve mirrors a normalized phone while revoke is often given a JID/device
form, so matching uses alias rules -- exact delete would leave the sender authorized.
"""
configured = _configured_allowlist(platform)
if configured is None:
@@ -285,63 +257,67 @@ def _sync_allowlist_remove(platform: str, user_id: str) -> None:
remaining = _purge_allowlist_entries(ids, platform, user_id)
if len(remaining) == len(ids):
return # Not present.
try:
from hermes_cli.config import save_env_value, remove_env_value
if remaining:
save_env_value(env_var, ",".join(remaining))
else:
remove_env_value(env_var)
except Exception:
pass
_write_allowlist_env(env_var, remaining)
_sync_live_adapter_allowlist_remove(platform, user_id)
def _load_json_file(path: Path) -> dict:
if path.exists():
"""Read a JSON object; {} when missing, malformed, unreadable, or not a dict.
PermissionError is logged loudly: a 0600 file owned by another uid (Docker:
``docker exec`` as root wrote it, the gosu-dropped gateway can't read it)
would otherwise silently leave the user unauthorized.
"""
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except PermissionError as e:
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
return {}
st = path.stat()
owner_info = f"owner_uid={st.st_uid} mode={oct(st.st_mode)[-4:]}"
except OSError:
owner_info = "<stat failed>"
euid = os.geteuid() if hasattr(os, "geteuid") else "n/a" # no geteuid on Windows
logger.warning(
"Pairing file %s exists but is not readable as uid=%s (%s; %s). "
"If you ran `docker exec <container> hermes pairing approve ...` as root, "
"re-run with `docker exec -u hermes <container> ...` and "
"chown the existing file to the hermes user, or restart the "
"container so the entrypoint can fix ownership.",
path, euid, owner_info, e,
)
return {}
except (json.JSONDecodeError, OSError):
return {}
def _merge_pairing_dir(active_dir: Path, alternate_dir: Path) -> None:
def _save_json_file(path: Path, data: dict) -> None:
_secure_write(path, json.dumps(data, indent=2, ensure_ascii=False))
def _migrate_split_pairing_dirs(*, home: Optional[Path] = None, active: Optional[Path] = None) -> None:
"""Merge split legacy (``pairing``) / new (``platforms/pairing``) data into the active dir.
If both exist, approved users in the inactive location must not be
silently ignored (they would be asked for a fresh code).
If both exist, approved users in the inactive location must not be silently
ignored (they would be asked for a fresh code). Active data wins on key conflict.
"""
if not alternate_dir.exists() or active_dir.resolve() == alternate_dir.resolve():
return
active_dir.mkdir(parents=True, exist_ok=True)
for src in alternate_dir.glob("*.json"):
if not src.is_file():
continue
dest = active_dir / src.name
merged = _load_json_file(src)
if not merged:
continue
current = _load_json_file(dest)
before = dict(current)
# Active data wins on key conflict; otherwise union the inactive data.
merged.update(current)
if merged != before:
_secure_write(dest, json.dumps(merged, indent=2, ensure_ascii=False))
def _migrate_split_pairing_dirs(
*,
home: Optional[Path] = None,
active: Optional[Path] = None,
) -> None:
home = home or get_hermes_home()
old_dir = home / "pairing"
new_dir = home / "platforms" / "pairing"
active = active if active is not None else _default_pairing_dir()
alternate = new_dir if active.resolve() == old_dir.resolve() else old_dir
_merge_pairing_dir(active, alternate)
alternate = home / "platforms" / "pairing" if active.resolve() == old_dir.resolve() else old_dir
if not alternate.exists() or active.resolve() == alternate.resolve():
return
active.mkdir(parents=True, exist_ok=True)
for src in alternate.glob("*.json"):
merged = _load_json_file(src) if src.is_file() else {}
if not merged:
continue
current = _load_json_file(active / src.name)
merged.update(current)
if merged != current:
_save_json_file(active / src.name, merged)
def _secure_write(path: Path, data: str) -> None:
@@ -362,23 +338,20 @@ def _secure_write(path: Path, data: str) -> None:
raise
def _is_hashed_entry(entry) -> bool:
return isinstance(entry, dict) and "salt" in entry and "hash" in entry
class PairingStore:
"""
Manages pairing codes and approved user lists.
"""Pairing codes and approved user lists.
Data files per platform: ``{platform}-pending.json``,
``{platform}-approved.json``, plus shared ``_rate_limits.json``.
With ``profile="<name>"`` storage resolves from that profile's own
HERMES_HOME with the same legacy/consolidated layout rules as
``hermes -p <name> pairing ...``, so multiplex gateways and profile-scoped
CLI approvals share one whitelist. Without a profile, storage is the global
pairing directory for the current HERMES_HOME.
Files per platform: ``{platform}-pending.json``, ``{platform}-approved.json``,
plus shared ``_rate_limits.json``. With ``profile="<name>"`` storage resolves from
that profile's HERMES_HOME exactly as ``hermes -p <name> pairing ...`` does, so
multiplex gateways and profile-scoped CLI approvals share one whitelist.
"""
def __init__(self, profile: Optional[str] = None):
# Resolved lazily: tests use a temp HERMES_HOME and may construct the
# store before the env is set.
if profile:
root = get_default_hermes_root()
profile_home = root if profile == "default" else root / "profiles" / profile
@@ -387,11 +360,9 @@ class PairingStore:
profile_home = None
self._dir = _default_pairing_dir()
self._dir.mkdir(parents=True, exist_ok=True)
# Merge the alternate old/new layout so upgrades cannot split approvals;
# a profile store must resolve exactly as ``hermes -p <profile>`` does.
# Merge the alternate old/new layout so upgrades cannot split approvals.
_migrate_split_pairing_dirs(home=profile_home, active=self._dir)
# Protects all read-modify-write cycles: platform adapters run
# concurrently in threads sharing one PairingStore.
# Adapters run concurrently in threads sharing one store.
self._lock = threading.RLock()
self._profile = profile # for diagnostics / log lines
@@ -409,51 +380,20 @@ class PairingStore:
def _rate_limit_path(self) -> Path:
return self._dir / "_rate_limits.json"
def _load_json(self, path: Path) -> dict:
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except PermissionError as e:
# Loud warning: a 0600 file owned by another uid (Docker:
# `docker exec` as root writes it, the gosu-dropped gateway
# can't read it) would otherwise be swallowed by the OSError
# branch and silently leave the user unauthorized.
try:
st = path.stat()
owner_info = f"owner_uid={st.st_uid} mode={oct(st.st_mode)[-4:]}"
except OSError:
owner_info = "<stat failed>"
euid = os.geteuid() if hasattr(os, "geteuid") else "n/a" # no geteuid on Windows
logger.warning(
"Pairing file %s exists but is not readable as uid=%s (%s; %s). "
"If you ran `docker exec <container> hermes pairing approve ...` as root, "
"re-run with `docker exec -u hermes <container> ...` and "
"chown the existing file to the hermes user, or restart the "
"container so the entrypoint can fix ownership.",
path, euid, owner_info, e,
)
return {}
except (json.JSONDecodeError, OSError):
return {}
return {}
def _save_json(self, path: Path, data: dict) -> None:
_secure_write(path, json.dumps(data, indent=2, ensure_ascii=False))
_load_json = staticmethod(_load_json_file)
_save_json = staticmethod(_save_json_file)
# ----- Approved users -----
def is_approved(self, platform: str, user_id: str) -> bool:
"""Check if a user is approved (paired) on a platform."""
approved = self._load_json(self._approved_path(platform))
return any(_user_ids_match(platform, uid, user_id) for uid in approved)
return bool(_matching_ids(platform, self._load_json(self._approved_path(platform)), user_id))
def list_approved(self, platform: str = None) -> list:
"""List approved users, optionally filtered by platform."""
results = []
platforms = [platform] if platform else self._all_platforms("approved")
for p in platforms:
approved = self._load_json(self._approved_path(p))
for uid, info in approved.items():
for p in [platform] if platform else self._all_platforms("approved"):
for uid, info in self._load_json(self._approved_path(p)).items():
results.append({"platform": p, "user_id": uid, **info})
return results
@@ -461,11 +401,8 @@ class PairingStore:
"""Add a user to the approved list. Must be called under self._lock."""
approved = self._load_json(self._approved_path(platform))
normalized_user_id = _normalize_user_id(platform, user_id)
for approved_user_id in [
uid for uid in approved if _user_ids_match(platform, uid, normalized_user_id)
]:
for approved_user_id in _matching_ids(platform, approved, normalized_user_id):
del approved[approved_user_id]
approved[normalized_user_id] = {
"user_name": user_name,
"approved_at": time.time(),
@@ -479,9 +416,7 @@ class PairingStore:
path = self._approved_path(platform)
with self._lock:
approved = self._load_json(path)
matching_ids = [
uid for uid in approved if _user_ids_match(platform, uid, user_id)
]
matching_ids = _matching_ids(platform, approved, user_id)
if not matching_ids:
return False
for approved_user_id in matching_ids:
@@ -495,34 +430,22 @@ class PairingStore:
@staticmethod
def _hash_code(code: str, salt: bytes) -> str:
"""Hash a pairing code with the given salt using SHA-256."""
return hashlib.sha256(salt + code.encode("utf-8")).hexdigest()
def _finish_approval(
self, platform: str, pending: dict, matched_key: str, matched_entry: dict
) -> dict:
def _finish_approval(self, platform: str, pending: dict, matched_key: str, matched_entry: dict) -> dict:
"""Remove a pending request and approve its user. Must hold self._lock."""
del pending[matched_key]
self._save_json(self._pending_path(platform), pending)
# A successful approval proves the requester is legitimate, so the
# brute-force streak must not carry over: the counter persists in
# _rate_limits.json and would otherwise accumulate isolated typos
# until a single fresh typo trips a spurious lockout.
# A successful approval proves legitimacy, so the persisted brute-force streak
# must not carry over (isolated typos would accumulate into a spurious lockout).
self._reset_failed_attempts(platform)
self._approve_user(
platform, matched_entry["user_id"], matched_entry.get("user_name", "")
)
result = {"user_id": matched_entry["user_id"], "user_name": matched_entry.get("user_name", "")}
self._approve_user(platform, result["user_id"], result["user_name"])
return result
return {
"user_id": matched_entry["user_id"],
"user_name": matched_entry.get("user_name", ""),
}
def generate_code(
self, platform: str, user_id: str, user_name: str = ""
) -> Optional[str]:
def generate_code(self, platform: str, user_id: str, user_name: str = "") -> Optional[str]:
"""Generate a pairing code for a new user.
Returns None if the user is rate-limited, the platform hit
@@ -533,9 +456,7 @@ class PairingStore:
self._cleanup_expired(platform)
normalized_user_id = _normalize_user_id(platform, user_id)
if self._is_locked_out(platform):
return None
if self._is_rate_limited(platform, user_id):
if self._is_locked_out(platform) or self._is_rate_limited(platform, user_id):
return None
pending = self._load_json(self._pending_path(platform))
if len(pending) >= MAX_PENDING_PER_PLATFORM:
@@ -556,30 +477,24 @@ class PairingStore:
return code
def approve_code(self, platform: str, code: str) -> Optional[dict]:
"""Approve a pairing code. Adds the user to the approved list.
"""Approve a pairing code and add its user to the approved list.
Returns ``{user_id, user_name}`` on success, ``None`` if the code is
invalid/expired OR the platform is locked out after
``MAX_FAILED_ATTEMPTS`` failed approvals (disambiguate with
``_is_locked_out``). The code is hashed with each entry's salt and
compared in constant time; legacy plaintext-key entries are ignored
and pruned at TTL by ``_cleanup_expired``.
Returns ``{user_id, user_name}``, or ``None`` if the code is invalid/expired OR
the platform is locked out (disambiguate with ``_is_locked_out``). Constant-time
salted-hash compare; legacy plaintext entries are ignored and pruned at TTL.
"""
with self._lock:
self._cleanup_expired(platform)
code = code.upper().strip()
# Must run before the pending lookup, or an already-issued valid
# code would still be accepted during lockout, nullifying the
# brute-force protection.
# Before the lookup, or an already-issued valid code would bypass lockout.
if self._is_locked_out(platform):
return None
pending = self._load_json(self._pending_path(platform))
# Skip legacy/malformed entries rather than KeyError so an in-place
# upgrade over an existing pending.json doesn't crash.
# Skip legacy/malformed entries so an in-place upgrade doesn't crash.
for entry_id, entry in pending.items():
if not isinstance(entry, dict) or "salt" not in entry or "hash" not in entry:
if not _is_hashed_entry(entry):
continue
try:
salt = bytes.fromhex(entry["salt"])
@@ -593,27 +508,18 @@ class PairingStore:
@staticmethod
def looks_like_request_id(value: str) -> bool:
"""True when ``value`` has the shape of a ``list_pending`` request id.
Request ids are ``secrets.token_hex(8)`` (16 hex chars); pairing codes
are 8 chars from an uppercase alphabet, so the shapes cannot collide
and callers accepting either can dispatch on this.
"""
"""True when ``value`` is shaped like a ``list_pending`` request id (16 hex chars);
pairing codes are 8 uppercase chars, so callers accepting either can dispatch on this."""
value = str(value or "").strip()
return len(value) == 16 and all(c in "0123456789abcdefABCDEF" for c in value)
def approve_request(self, platform: str, request_id: str) -> Optional[dict]:
"""Approve a pending request by its server-side request id.
"""Approve a pending request by its server-side request id (admin surfaces that
must never reveal the DM'd code). Returns ``{user_id, user_name}`` or ``None``.
Grant path for authenticated admin surfaces (``hermes pairing list``,
dashboard/desktop buttons) that must never reveal the DM'd code.
Returns ``{user_id, user_name}`` or ``None`` for an unknown/expired id.
Unlike :meth:`approve_code` this neither counts toward nor is gated by
the brute-force lockout: a request id is only obtainable by an admin
already authenticated to this store, so a stale id means "the row you
clicked expired", not an attack -- and counting it would let a few GUI
clicks lock the operator out of the CLI's code path.
Neither counts toward nor is gated by the brute-force lockout: a request id is only
obtainable by an authenticated admin, so a stale id is "the row expired", not an
attack -- counting it would let a few GUI clicks lock the operator out.
"""
with self._lock:
self._cleanup_expired(platform)
@@ -623,35 +529,24 @@ class PairingStore:
pending = self._load_json(self._pending_path(platform))
for entry_id, entry in pending.items():
if not isinstance(entry, dict) or "salt" not in entry or "hash" not in entry:
continue
if secrets.compare_digest(str(entry_id).lower(), request_id):
if _is_hashed_entry(entry) and secrets.compare_digest(str(entry_id).lower(), request_id):
return self._finish_approval(platform, pending, entry_id, entry)
return None
def list_pending(self, platform: str = None) -> list:
"""List pending pairing requests, optionally filtered by platform.
Codes are hashed and never returned; each entry exposes a server-side
``request_id`` for :meth:`approve_request`. Legacy pre-hash entries
report an empty ``request_id`` and age out at TTL.
"""
"""List pending requests (codes are never returned; each exposes a ``request_id``
for :meth:`approve_request`; legacy pre-hash entries report an empty id)."""
results = []
with self._lock:
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
for p in [platform] if platform else self._all_platforms("pending"):
self._cleanup_expired(p)
pending = self._load_json(self._pending_path(p))
for entry_id, info in pending.items():
for entry_id, info in self._load_json(self._pending_path(p)).items():
if not isinstance(info, dict):
continue
created_at = info.get("created_at")
if not isinstance(created_at, (int, float)):
continue
is_modern = isinstance(info.get("hash"), str) and isinstance(
info.get("salt"), str
)
is_modern = isinstance(info.get("hash"), str) and isinstance(info.get("salt"), str)
results.append({
"platform": p,
"request_id": str(entry_id) if is_modern else "",
@@ -665,17 +560,15 @@ class PairingStore:
"""Clear all pending requests. Returns count removed."""
with self._lock:
count = 0
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
pending = self._load_json(self._pending_path(p))
count += len(pending)
for p in [platform] if platform else self._all_platforms("pending"):
count += len(self._load_json(self._pending_path(p)))
self._save_json(self._pending_path(p), {})
return count
# ----- Rate limiting and lockout -----
def _is_rate_limited(self, platform: str, user_id: str) -> bool:
"""Check if a user (under any alias) has requested a code too recently."""
"""Whether a user (under any alias) has requested a code too recently."""
limits = self._load_json(self._rate_limit_path())
return any(
(time.time() - limits.get(f"{platform}:{alias}", 0)) < RATE_LIMIT_SECONDS
@@ -683,7 +576,6 @@ class PairingStore:
)
def _record_rate_limit(self, platform: str, user_id: str) -> None:
"""Record the time of a pairing request for rate limiting."""
limits = self._load_json(self._rate_limit_path())
now = time.time()
for alias in _user_id_aliases(platform, user_id):
@@ -691,19 +583,18 @@ class PairingStore:
self._save_json(self._rate_limit_path(), limits)
def _is_locked_out(self, platform: str) -> bool:
"""Check if a platform is in lockout due to failed approval attempts."""
limits = self._load_json(self._rate_limit_path())
return time.time() < limits.get(f"_lockout:{platform}", 0)
def _record_failed_attempt(self, platform: str) -> None:
"""Record a failed approval attempt. Triggers lockout after MAX_FAILED_ATTEMPTS."""
"""Record a failed approval attempt; triggers lockout after MAX_FAILED_ATTEMPTS."""
limits = self._load_json(self._rate_limit_path())
fail_key = f"_failures:{platform}"
fails = limits.get(fail_key, 0) + 1
limits[fail_key] = fails
if fails >= MAX_FAILED_ATTEMPTS:
limits[f"_lockout:{platform}"] = time.time() + LOCKOUT_SECONDS
limits[fail_key] = 0 # Reset counter
limits[fail_key] = 0
print(f"[pairing] Platform {platform} locked out for {LOCKOUT_SECONDS}s "
f"after {MAX_FAILED_ATTEMPTS} failed attempts", flush=True)
self._save_json(self._rate_limit_path(), limits)
@@ -730,12 +621,10 @@ class PairingStore:
or (now - info["created_at"]) > CODE_TTL_SECONDS
]
if expired:
for entry_id in expired:
del pending[entry_id]
self._save_json(path, pending)
self._save_json(path, {k: v for k, v in pending.items() if k not in expired})
def _all_platforms(self, suffix: str) -> list:
"""List all platforms that have data files of a given suffix."""
"""Platforms that have a ``-<suffix>.json`` data file (``_``-prefixed files are shared state)."""
tail = f"-{suffix}.json"
platforms = [f.name.replace(tail, "") for f in self._dir.iterdir() if f.name.endswith(tail)]
return [p for p in platforms if not p.startswith("_")]
+68 -168
View File
@@ -1,25 +1,11 @@
"""
Platform Adapter Registry
"""Platform adapter registry.
Platform adapters (built-in and plugin) self-register here so the gateway can
discover and instantiate them without hardcoded if/elif chains. Plugins
discover and instantiate them without hardcoded if/elif chains. Plugins
register via ``PluginContext.register_platform()``; ``GatewayRunner
._create_adapter()`` consults the registry first and falls back to the legacy
built-in path.
Usage (plugin side)::
platform_registry.register(PlatformEntry(
name="irc", label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg),
check_fn=check_requirements,
validate_config=lambda cfg: bool(cfg.extra.get("server")),
required_env=["IRC_SERVER"], install_hint="pip install irc",
))
Usage (gateway side)::
adapter = platform_registry.create_adapter("irc", platform_config)
built-in path. Plugin side: ``platform_registry.register(PlatformEntry(...))``;
gateway side: ``platform_registry.create_adapter("irc", platform_config)``.
"""
import logging
@@ -60,113 +46,53 @@ def _caller_plugin_scope() -> Optional[str]:
class PlatformEntry:
"""Metadata and factory for a single platform adapter."""
# Identifier used in config.yaml (e.g. "irc") and human-readable label.
name: str
label: str
# Receives a PlatformConfig, returns an adapter instance. A factory (not a
# bare class) lets plugins do custom init / wrapping.
adapter_factory: Callable[[Any], Any]
# PASSIVE dependency probe: True when deps are importable RIGHT NOW. Must
# be side-effect free -- it runs from status displays (`hermes setup`,
# `hermes status`, dashboard readiness) and the config enablement pass,
# none of which may pip-install. Install logic belongs in ensure_deps_fn.
name: str # config.yaml identifier (e.g. "irc")
label: str # human-readable
adapter_factory: Callable[[Any], Any] # PlatformConfig -> adapter (factory allows custom init)
# PASSIVE dependency probe (deps importable RIGHT NOW). Must be side-effect free: it
# runs from status displays and the config enablement pass, which may never pip-install.
check_fn: Callable[[], bool]
# Optional config check. None = skip and let connect() fail descriptively.
validate_config: Optional[Callable[[Any], bool]] = None
# ACTIVE dependency installer: make deps importable (pip / lazy_deps),
# returning True on success. Called by ``create_adapter()`` only when
# ``check_fn`` is False -- the one moment the user has the platform
# enabled+configured and the gateway is about to connect it. None = a
# False ``check_fn`` is a hard block (correct for platforms without
# optional deps). The passive/active split exists because a single field
# either pip-installed from every status display or never installed at all.
validate_config: Optional[Callable[[Any], bool]] = None # None = let connect() fail descriptively
# ACTIVE installer, run by ``create_adapter()`` only when ``check_fn`` is False (the
# platform is enabled+configured and about to connect). None = a False check_fn is a
# hard block. The split exists because one field either installed from every status
# display or never at all.
ensure_deps_fn: Optional[Callable[[], bool]] = None
# Is the platform connected/enabled for this PlatformConfig? Used by
# ``GatewayConfig.get_connected_platforms()`` and setup UI status; None
# falls back to ``validate_config`` or ``check_fn``.
# Connected/configured for this PlatformConfig (``get_connected_platforms``, setup UI);
# None falls back to ``validate_config`` or ``check_fn``.
is_connected: Optional[Callable[[Any], bool]] = None
# Env vars this platform needs (``hermes setup`` display) and the hint
# shown when check_fn returns False.
required_env: list = field(default_factory=list)
install_hint: str = ""
# Interactive setup ``() -> None``. None falls back to
# _setup_standard_platform (needs token_var + vars) or a generic env display.
setup_fn: Optional[Callable[[], None]] = None
# "builtin" or "plugin"; plugin_name is the owning manifest (empty for
# built-ins) so ``hermes gateway setup`` can auto-enable the plugin.
source: str = "plugin"
plugin_name: str = ""
# Auth env var names for _is_user_authorized: comma-separated allowed user
# IDs, and a truthy "allow everyone" switch.
allowed_users_env: str = ""
allow_all_env: str = ""
# Max message length for smart-chunking; 0 = no limit.
max_message_length: int = 0
# If True, session descriptions redact PII (phone numbers, etc.).
pii_safe: bool = False
# Emoji for CLI/gateway display.
emoji: str = "🔌"
# Whether /update may be issued from this platform (_UPDATE_ALLOWED_PLATFORMS).
allow_update_command: bool = True
# Platform hint injected into the system prompt; empty = none.
platform_hint: str = ""
# Env-driven auto-configuration ``() -> Optional[dict]``: returns
# ``PlatformConfig.extra`` fields to seed when the platform is auto-enabled.
# Runs during ``_apply_env_overrides`` BEFORE the adapter is constructed so
# ``gateway status`` reflects env-only config. None/empty dict = skip.
required_env: list = field(default_factory=list) # ``hermes setup`` display
install_hint: str = "" # shown when check_fn is False
setup_fn: Optional[Callable[[], None]] = None # None = _setup_standard_platform / env display
source: str = "plugin" # "builtin" or "plugin"
plugin_name: str = "" # owning manifest so ``hermes gateway setup`` can auto-enable it
allowed_users_env: str = "" # comma-separated allowed user IDs (_is_user_authorized)
allow_all_env: str = "" # truthy "allow everyone" switch
max_message_length: int = 0 # smart-chunking cap; 0 = no limit
pii_safe: bool = False # session descriptions redact PII (phone numbers, etc.)
emoji: str = "🔌" # CLI/gateway display
allow_update_command: bool = True # /update may be issued from this platform
platform_hint: str = "" # injected into the system prompt; empty = none
# ``() -> Optional[dict]`` of ``extra`` fields to seed when auto-enabled from env. Runs
# in ``_apply_env_overrides`` BEFORE adapter construction so ``gateway status`` sees it.
env_enablement_fn: Optional[Callable[[], Optional[dict]]] = None
# YAML->env bridge ``(yaml_cfg, platform_cfg) -> Optional[dict]``: lets a
# plugin own its config.yaml translation instead of core gateway/config.py.
# Called from ``load_gateway_config()`` after the generic shared-key loop and
# before ``_apply_env_overrides``. May mutate ``os.environ`` (guard with
# ``not os.getenv(...)`` to keep env > YAML precedence); the returned dict is
# merged into ``PlatformConfig.extra``. Exceptions are logged at debug.
# Full contract: website/docs/developer-guide/adding-platform-adapters.md.
# YAML->env bridge ``(yaml_cfg, platform_cfg) -> Optional[dict]`` merged into ``extra``;
# runs after the shared-key loop, before ``_apply_env_overrides``. May set ``os.environ``
# (guard with ``not os.getenv(...)`` to keep env > YAML). Contract:
# website/docs/developer-guide/adding-platform-adapters.md.
apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None
# Home-channel env var (e.g. "IRC_HOME_CHANNEL"). When set, cron.scheduler
# accepts ``deliver=<name>`` and reads it for the default chat/room ID.
cron_deliver_env_var: str = ""
# Target parsing ``(target_ref) -> Optional[(chat_id, thread_id)]``, run by
# ``tools/send_message_tool._parse_target_ref`` before channel-directory
# fallback so plugins can declare native target syntax
# (e.g. ``fmsg:@alice@example.com``). None result = continue to directory
# resolution; no opaque fallback is applied.
cron_deliver_env_var: str = "" # home-channel env var read for cron ``deliver=<name>``
# ``(target_ref) -> Optional[(chat_id, thread_id)]`` run before channel-directory
# fallback so plugins can declare native target syntax; None = continue resolution.
parse_target_ref_fn: Optional[Callable[[str], Optional[tuple[str, Optional[str]]]]] = None
# Validation after parsing/normalization or directory resolution: True to
# accept, False to reject, or a non-empty string to reject with diagnostic.
# Post-resolution validation: True accept, False reject, non-empty str = reject + diagnostic.
validate_target_ref_fn: Optional[Callable[[str], bool | str]] = None
# Whole-request delivery handler ``(args, normalized_chat_id, platform_name,
# pconfig)``, sync or async. Prefer standalone_sender_fn when the standard
# send contract suffices.
# Whole-request delivery ``(args, normalized_chat_id, platform_name, pconfig)``, sync or
# async. Prefer standalone_sender_fn when the standard send contract suffices.
send_message_handler: Optional[Callable[[dict, str, str, Any], Any]] = None
# Out-of-process sender used by ``_send_via_adapter`` when cron runs apart
# from the gateway and the in-process adapter weakref is None::
# async (pconfig, chat_id, message, *, thread_id=None,
# media_files=None, force_document=False) -> dict
# Returns ``{"success": True, "message_id": ...}`` or ``{"error": str}``.
# Without it, plugin platforms cannot be cron ``deliver=`` targets when the
# gateway is not co-resident.
# Out-of-process sender for cron without a co-resident gateway::
# async (pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False) -> dict
# returning ``{"success": True, "message_id": ...}`` or ``{"error": str}``.
standalone_sender_fn: Optional[Callable[..., Awaitable[dict]]] = None
@@ -184,11 +110,9 @@ class PlatformRegistry:
# Plugin adapters are isolated per resolved HERMES_HOME and overlay the
# process-global entries for lookups in that profile's runtime scope.
self._scoped_entries: dict[str, dict[str, PlatformEntry]] = {}
# Deferred loaders: name -> zero-arg callable that imports the owning
# plugin module (which calls register()). Adapter modules import heavy
# SDKs at module level; eagerly loading ~20 bundled platforms added
# seconds to every `hermes` invocation, so the real import happens only
# when a lookup actually asks for that platform.
# Deferred loaders: name -> callable importing the owning plugin module (which calls
# register()). Eagerly importing ~20 SDK-heavy adapters added seconds to every
# `hermes` invocation, so the import happens only when a lookup asks for it.
self._deferred: dict[str, _Loader] = {}
self._scoped_deferred: dict[str, dict[str, _Loader]] = {}
self._inflight: dict[_LoadKey, threading.Event] = {}
@@ -220,29 +144,21 @@ class PlatformRegistry:
entry = entries.get(name)
loader = deferred.get(name)
if entry is None and loader is None:
loader = self._inflight_loaders.get((scope, name))
if entry is None and loader is None:
loader = self._consumed_loaders.get((scope, name))
loader = self._inflight_loaders.get((scope, name)) or self._consumed_loaders.get((scope, name))
return entry, loader
def _prune_scope(self, scope: Optional[str]) -> None:
if scope is None:
return
if not self._scoped_entries.get(scope):
self._scoped_entries.pop(scope, None)
if not self._scoped_deferred.get(scope):
self._scoped_deferred.pop(scope, None)
for maps in (self._scoped_entries, self._scoped_deferred):
if not maps.get(scope):
maps.pop(scope, None)
# -- deferred loading ----------------------------------------------------
def register_deferred(self, name: str, loader: _Loader, *, scope: Optional[str] = None) -> None:
"""Register a lazy loader for a platform that hasn't been imported yet.
*loader* imports the owning plugin module, which must call
:meth:`register` for *name*. It runs at most once, on first lookup
(or full materialization). A concrete registration takes precedence
and drops the loader.
"""
"""Register a lazy loader (imports the plugin module, which must call :meth:`register`).
Runs at most once, on first lookup; a concrete registration drops it."""
with self._lock:
entries, deferred = self._scope_maps(scope, create=True)
self._consumed_loaders.pop((scope, name), None)
@@ -268,12 +184,8 @@ class PlatformRegistry:
*,
scope: Optional[str] = None,
) -> bool:
"""Restore a registration if its full state is still *current* (CAS).
Identity checks protect a later registration from removal while letting
an unloaded override reveal what it displaced. Deferred loaders are
part of the state because bundled platform plugins load lazily.
"""
"""Restore a registration if its full state is still *current* (CAS): a later
registration is never removed, and deferred loaders are part of the state."""
with self._lock:
entry, loader = self._registration_state(scope, name, create=True)
if entry is not current[0] or loader is not current[1]:
@@ -355,18 +267,13 @@ class PlatformRegistry:
self._resolve(name, active_scope)
def is_deferred_load_cancelled(self, name: str, *, scope: Optional[str] = None) -> bool:
"""Return whether ownership teardown cancelled an in-flight loader."""
"""Whether ownership teardown cancelled an in-flight loader."""
with self._lock:
return (scope, name) in self._cancelled_inflight
def _resolve_all(self) -> None:
"""Run every pending deferred loader.
Only the iterate-all accessors (``all_entries``/``plugin_entries``) call
this, from paths that genuinely need every adapter (gateway startup,
``hermes setup``/``gateway status``, channel directory). CLI chat never
iterates the full set.
"""
"""Run every pending deferred loader (only ``all_entries``/``plugin_entries`` call
this, from paths that need every adapter; CLI chat never iterates the full set)."""
active_scope = self.current_scope_key()
with self._lock:
_entries, scoped_deferred = self._scope_maps(active_scope)
@@ -386,11 +293,11 @@ class PlatformRegistry:
"""Register a platform adapter entry (last writer wins on name clash)."""
with self._lock:
if scope is None and entry.source == "plugin":
scope = _caller_plugin_scope()
if scope is None:
scope = _plugin_scope_from_callable(entry.adapter_factory)
if scope is None:
scope = _plugin_scope_from_callable(entry.check_fn)
scope = (
_caller_plugin_scope()
or _plugin_scope_from_callable(entry.adapter_factory)
or _plugin_scope_from_callable(entry.check_fn)
)
# A concrete registration supersedes any pending deferred loader.
entries, deferred = self._scope_maps(scope, create=True)
self._consumed_loaders.pop((scope, entry.name), None)
@@ -404,7 +311,7 @@ class PlatformRegistry:
logger.debug("Registered platform adapter: %s (%s)", entry.name, entry.source)
def unregister(self, name: str, *, scope: Optional[str] = None) -> bool:
"""Remove a platform entry. Returns True if it existed."""
"""Remove a platform entry. Returns True if it existed."""
with self._lock:
inferred_scope = scope if scope is not None else _caller_plugin_scope()
active_scope = inferred_scope or self.current_scope_key()
@@ -452,12 +359,8 @@ class PlatformRegistry:
return [e for e in self.all_entries() if e.source == "plugin"]
def registered_names(self) -> set[str]:
"""Concrete and deferred platform names without loading adapters.
Same scope semantics as ``is_registered()``: current profile scope AND
process-global names. Plugin platforms register deferred loaders under
a profile scope, so the global maps alone would miss every plugin.
"""
"""Concrete and deferred names (current profile scope AND process-global, like
``is_registered``) without loading adapters."""
with self._lock:
entries, deferred = self._scope_maps(self.current_scope_key())
return entries.keys() | deferred.keys() | self._entries.keys() | self._deferred.keys()
@@ -481,17 +384,14 @@ class PlatformRegistry:
if entry is None:
return None
deps_ok = False
try:
deps_ok = bool(entry.check_fn())
except Exception as e:
logger.warning("Platform '%s' check_fn raised: %s", entry.label, e)
deps_ok = False
if not deps_ok and entry.ensure_deps_fn is not None:
# The ONE place the active installer runs in the adapter path: the
# platform is enabled+configured and about to connect, so an install
# is what the user wants. (An installer inside connect() is never
# reached when check_fn is False -- create_adapter() would have
# already returned None.)
# The ONE place the active installer runs: the platform is enabled+configured
# and about to connect, so an install is what the user wants.
logger.info("Platform '%s' dependencies missing — attempting install...", entry.label)
try:
deps_ok = bool(entry.ensure_deps_fn())
+21 -54
View File
@@ -1,35 +1,9 @@
"""Profile-based routing for the gateway with hierarchical matching.
"""Profile-based routing: route guilds/channels/threads to different profiles.
Lets a single Hermes instance route specific Discord guilds/channels/threads
to different profiles — each with their own model, tools, memory, and persona.
Matching priority (most specific first):
1. platform + chat_id + thread_id (exact thread) — specificity 14
2. platform + chat_id (channel route) — specificity 6
3. platform + guild_id (guild/server route) — specificity 2
4. No match → default profile
Parent-chain matching: for Discord threads and forum posts, ``parent_chat_id``
carries the direct parent, so a route keyed on a channel also matches messages
in any thread/post under it.
Configuration (config.yaml)::
gateway:
profile_routes:
- name: server-default
platform: discord
guild_id: "YOUR_GUILD_ID"
profile: server-profile
- name: thread-route
platform: discord
chat_id: "YOUR_CHANNEL_ID"
thread_id: "YOUR_THREAD_ID"
profile: thread-profile
- name: owner-whatsapp
platform: whatsapp
chat_id: "15551234567" # phone, JID, or LID — all equivalent
profile: owner
Matching priority, most specific first (``gateway.profile_routes`` in config.yaml):
platform + chat_id + thread_id (14) → platform + chat_id (6) → platform + guild_id (2)
→ default profile. For Discord threads/forum posts ``parent_chat_id`` carries the
direct parent, so a channel route also matches any thread/post under it.
"""
from __future__ import annotations
@@ -41,8 +15,7 @@ import logging
logger = logging.getLogger(__name__)
# Baileys and Cloud share phone/JID/LID identity rules. Other platforms keep
# exact string compare so Telegram numeric ids and Discord snowflakes stay unchanged.
# Baileys and Cloud share phone/JID/LID identity rules; other platforms compare exactly.
_WHATSAPP_IDENTITY_PLATFORMS = {"whatsapp", "whatsapp_cloud"}
_WHATSAPP_NON_USER_SUFFIXES = ("@g.us", "@broadcast", "@newsletter")
@@ -59,15 +32,14 @@ def _whatsapp_user_chat_ids_match(platform: str, left: Optional[str], right: Opt
"""True when two WhatsApp *user* chat_ids refer to the same person.
Uses ``expand_whatsapp_aliases`` (same helper as session keys and adapter
allowlists) so a bare number, ``@s.whatsapp.net`` JID and ``@lid`` LID
collapse to one identity. Group/broadcast JIDs are chats, not senders,
and non-WhatsApp platforms always return False (exact match only).
allowlists) so a bare number, JID and LID collapse to one identity.
Group/broadcast JIDs are chats, not senders; non-WhatsApp platforms → False.
"""
if (platform or "").strip().lower() not in _WHATSAPP_IDENTITY_PLATFORMS:
return False
if not left or not right:
return False
if _is_whatsapp_non_user_chat(left) or _is_whatsapp_non_user_chat(right):
if (
(platform or "").strip().lower() not in _WHATSAPP_IDENTITY_PLATFORMS
or not left or not right
or _is_whatsapp_non_user_chat(left) or _is_whatsapp_non_user_chat(right)
):
return False
from gateway.whatsapp_identity import expand_whatsapp_aliases
@@ -104,13 +76,11 @@ class ProfileRoute:
thread_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
) -> bool:
"""Return True if every discriminator the route declares holds (AND).
"""True if every discriminator the route declares holds (AND).
``chat_id`` matches the channel directly or as the parent of a
thread/forum post; a route with both ``guild_id`` and ``chat_id``
requires both. WhatsApp ``chat_id`` also matches across user-identity
forms (number/JID/LID) after the exact check; groups/broadcasts stay
exact-only.
``chat_id`` matches the channel directly or as the parent of a thread/forum
post; WhatsApp ``chat_id`` also matches across number/JID/LID after the
exact check (groups/broadcasts stay exact-only).
"""
if not self.enabled or self.platform != platform:
return False
@@ -129,11 +99,9 @@ class ProfileRoute:
def _coerce_route_id(value: Any) -> Optional[str]:
"""Normalize a route discriminator to str for strict equality matching.
PyYAML loads unquoted numeric IDs as ``int`` while inbound ``SessionSource``
fields are always ``str``, so ints must be coerced or ``matches()`` fails
silently. Only ``int`` (not ``bool``) is coerced; floats and other types
stringify to something (``"123.0"``) that can never match, so they pass
through with a load-time warning instead of being silently "fixed".
PyYAML loads unquoted numeric IDs as ``int`` while ``SessionSource`` fields are
``str``. Only ``int`` (not ``bool``) is coerced; floats stringify to something
(``"123.0"``) that can never match, so they get a load-time warning instead.
"""
if value is None or isinstance(value, str):
return value
@@ -161,8 +129,7 @@ def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRou
if not platform or not profile:
logger.warning("Skipping profile route %s: missing platform or profile", name)
continue
# Validate profile name to prevent path traversal. Lazy import avoids a
# circular dependency at module load time.
# Validate profile name to prevent path traversal (lazy import: cycle).
try:
from hermes_cli.profiles import normalize_profile_name, validate_profile_name
+23 -45
View File
@@ -14,14 +14,11 @@ from hermes_constants import get_hermes_home
_DISK_DEGRADED_PERCENT = 90.0
_CONNECTED_STATES = {"connected", "running", "ok"}
def _check(status: str, detail: str | None = None, **extra: Any) -> dict[str, Any]:
result: dict[str, Any] = {"status": status}
if detail:
result["detail"] = detail
result.update(extra)
return result
return {"status": status, **({"detail": detail} if detail else {}), **extra}
def _probe_state_db(home: Path) -> dict[str, Any]:
@@ -29,12 +26,10 @@ def _probe_state_db(home: Path) -> dict[str, Any]:
if not path.exists():
return _check("ok", "not initialized")
try:
# Read-only schema query: catches unreadable/corrupt DBs without competing
# with state writers. ``closing`` is required — sqlite3's connection
# context manager only commits/rolls back, never closes, so a bare
# ``with connect()`` would leak one connection (and fds) per health poll.
uri = f"file:{path.as_posix()}?mode=ro"
with closing(sqlite3.connect(uri, uri=True, timeout=1.0)) as conn:
# Read-only schema query: catches unreadable/corrupt DBs without competing with
# writers. ``closing`` is required — sqlite3's context manager only commits/rolls
# back, never closes, so a bare ``with connect()`` leaks a connection per poll.
with closing(sqlite3.connect(f"file:{path.as_posix()}?mode=ro", uri=True, timeout=1.0)) as conn:
conn.execute("PRAGMA query_only = ON")
conn.execute("SELECT name FROM sqlite_master LIMIT 1").fetchone()
return _check("ok")
@@ -48,63 +43,48 @@ def _probe_config(home: Path) -> dict[str, Any]:
return _check("ok", "using defaults")
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
if raw is not None and not isinstance(raw, dict):
return _check("degraded", "top level is not a mapping")
return _check("ok")
except Exception as exc:
return _check("degraded", f"invalid config ({type(exc).__name__})")
return _check("ok") if raw is None or isinstance(raw, dict) else _check("degraded", "top level is not a mapping")
def _probe_disk(home: Path) -> dict[str, Any]:
try:
usage = shutil.disk_usage(home)
used_pct = round((usage.used / usage.total) * 100, 1) if usage.total else 0.0
status = "degraded" if used_pct >= _DISK_DEGRADED_PERCENT else "ok"
return _check(status, used_percent=used_pct, free_bytes=usage.free)
except Exception as exc:
return _check("degraded", type(exc).__name__)
used_pct = round((usage.used / usage.total) * 100, 1) if usage.total else 0.0
return _check("degraded" if used_pct >= _DISK_DEGRADED_PERCENT else "ok", used_percent=used_pct, free_bytes=usage.free)
def _probe_gateway(runtime_status: dict[str, Any]) -> dict[str, Any]:
state = str(runtime_status.get("gateway_state") or "unknown")
platforms = runtime_status.get("platforms")
if not isinstance(platforms, dict):
platforms = {}
platforms = platforms if isinstance(platforms, dict) else {}
connected = sum(
1
for value in platforms.values()
if isinstance(value, dict)
and str(value.get("state") or value.get("status") or "").lower()
in {"connected", "running", "ok"}
isinstance(v, dict) and str(v.get("state") or v.get("status") or "").lower() in _CONNECTED_STATES
for v in platforms.values()
)
status = "ok" if state in {"running", "draining"} else "degraded"
return _check(status, state=state, connected_platforms=connected, platforms=len(platforms))
return _check("ok" if state in {"running", "draining"} else "degraded", state=state,
connected_platforms=connected, platforms=len(platforms))
def _probe_session_store(runtime_status: dict[str, Any], state_db_probe: dict[str, Any]) -> dict[str, Any]:
"""Report the running gateway cache state, not an independent reopen."""
runtime_store = runtime_status.get("session_store")
if isinstance(runtime_store, dict):
state = str(runtime_store.get("status") or "unknown")
if state in {"ok", "unavailable", "retrying"}:
return _check(state)
state = str(runtime_store.get("status") or "unknown") if isinstance(runtime_store, dict) else ""
if state in {"ok", "unavailable", "retrying"}:
return _check(state)
# Older gateways publish no cache state: fall back to the state_db probe.
return _check("ok" if state_db_probe.get("status") == "ok" else "unavailable")
def collect_runtime_readiness(
*,
configured_model: str,
runtime_status: dict[str, Any] | None,
active_api_runs: int = 0,
process_completion_queue_depth: int = 0,
active_delegations: int = 0,
*, configured_model: str, runtime_status: dict[str, Any] | None, active_api_runs: int = 0,
process_completion_queue_depth: int = 0, active_delegations: int = 0,
) -> dict[str, Any]:
"""Return bounded readiness diagnostics without mutating runtime state.
Even on the authenticated endpoint, probes expose status and counts only:
never config values, credentials, paths, queue payloads, or exception messages.
"""
"""Bounded readiness diagnostics, no runtime mutation. Even authenticated, probes
expose status and counts only: never config values, credentials, paths, payloads."""
home = get_hermes_home()
runtime = runtime_status if isinstance(runtime_status, dict) else {}
state_db_probe = _probe_state_db(home)
@@ -116,14 +96,12 @@ def collect_runtime_readiness(
"disk": _probe_disk(home),
"gateway": _probe_gateway(runtime),
"background_queues": _check(
"ok",
active_api_runs=max(0, int(active_api_runs)),
"ok", active_api_runs=max(0, int(active_api_runs)),
process_completions=max(0, int(process_completion_queue_depth)),
active_delegations=max(0, int(active_delegations)),
),
}
overall = "ok" if all(item.get("status") == "ok" for item in checks.values()) else "degraded"
return {"status": overall, "checks": checks}
return {"status": "ok" if all(c.get("status") == "ok" for c in checks.values()) else "degraded", "checks": checks}
__all__ = ["collect_runtime_readiness"]
+26 -56
View File
@@ -12,12 +12,7 @@ from typing import Any
# Exact whole-response markers meaning "the agent intentionally chose not to
# reply". Keep small and explicit; arbitrary empty output remains an
# error/empty-response path, not silence.
LIVE_GATEWAY_SILENT_MARKERS = frozenset({
"[SILENT]",
"SILENT",
"NO_REPLY",
"NO REPLY",
})
LIVE_GATEWAY_SILENT_MARKERS = frozenset({"[SILENT]", "SILENT", "NO_REPLY", "NO REPLY"})
# Longer than any marker could plausibly be, even with stray punctuation.
_MARKER_LENGTH_CAP = 64
@@ -42,20 +37,14 @@ def _strip_edge_silence_punctuation(text: str) -> str:
return text[start:end].strip()
def _canonical_silence_candidates(text: str) -> tuple[str, ...]:
exact = _canonical_silence_candidate(text)
stripped = _strip_edge_silence_punctuation(text.strip())
if stripped == text.strip():
return (exact,)
return (exact, _canonical_silence_candidate(stripped))
def _short_stripped(text: Any) -> str:
"""Stripped text if it is a non-empty string within the marker cap, else ''."""
if not isinstance(text, str):
return ""
stripped = text.strip()
return stripped if 0 < len(stripped) <= _MARKER_LENGTH_CAP else ""
def _canonical_silence_candidates(text: Any) -> tuple[str, ...]:
"""Canonical forms of a short marker-sized response; ``()`` when not a candidate at all."""
stripped = text.strip() if isinstance(text, str) else ""
if not 0 < len(stripped) <= _MARKER_LENGTH_CAP:
return ()
depunctuated = _strip_edge_silence_punctuation(stripped)
forms = (stripped,) if depunctuated == stripped else (stripped, depunctuated)
return tuple(_canonical_silence_candidate(f) for f in forms)
def is_intentional_silence_response(response: Any) -> bool:
@@ -64,61 +53,42 @@ def is_intentional_silence_response(response: Any) -> bool:
Prose that merely mentions ``NO_REPLY`` must be delivered normally. A blank
response is not silence either — that is the empty-response failure path.
"""
stripped = _short_stripped(response)
return bool(stripped) and any(
c in LIVE_GATEWAY_SILENT_MARKERS for c in _canonical_silence_candidates(stripped)
)
return any(c in LIVE_GATEWAY_SILENT_MARKERS for c in _canonical_silence_candidates(response))
def is_autonomous_silence_response(response: Any) -> bool:
"""Loose silence matcher for autonomous lanes (cron, webhook).
Autonomous lanes ask for ``[SILENT]`` when a tick produced nothing worth
attention, and models reliably bracket the marker with a short note. Unlike
:func:`is_intentional_silence_response` (interactive rule: EXACTLY a marker),
this suppresses when a marker is the whole response, sits on its own first or
last line, or the bracketed sentinel opens the response (``[SILENT] No
changes detected``). A token buried mid-sentence is still delivered.
Models reliably bracket ``[SILENT]`` with a short note, so unlike the
interactive EXACT rule this also suppresses when a marker sits on its own
first/last line or the bracketed sentinel opens the response (``[SILENT] No
changes detected``). A token buried mid-sentence is still delivered.
Shares :data:`LIVE_GATEWAY_SILENT_MARKERS` so the two sets cannot drift.
"""
if not isinstance(response, str):
return False
stripped = response.strip()
stripped = response.strip() if isinstance(response, str) else ""
if not stripped:
return False
def _is_token(line: str) -> bool:
return _canonical_silence_candidate(line) in LIVE_GATEWAY_SILENT_MARKERS
if _is_token(stripped):
return True
lines = [ln for ln in stripped.splitlines() if ln.strip()]
if lines and (_is_token(lines[0]) or _is_token(lines[-1])):
return True
# Bracketed form only, so a bare "Silent retry succeeded" is NOT swallowed.
return stripped.upper().startswith("[SILENT]")
# Bracketed form only for the prefix rule, so a bare "Silent retry succeeded" is NOT swallowed.
return stripped.upper().startswith("[SILENT]") or any(
_canonical_silence_candidate(c) in LIVE_GATEWAY_SILENT_MARKERS for c in (stripped, lines[0], lines[-1])
)
def is_intentional_silence_agent_result(agent_result: dict | None, response: Any) -> bool:
"""Silence markers suppress delivery only for successful agent turns."""
if not isinstance(agent_result, dict) or agent_result.get("failed"):
return False
return is_intentional_silence_response(response)
return isinstance(agent_result, dict) and not agent_result.get("failed") and is_intentional_silence_response(response)
def is_partial_silence_marker(text: Any) -> bool:
"""True while streamed ``text`` could still resolve to a silence marker.
The streaming path must decide, before the whole response is known, whether
to show its buffer. A buffer whose canonical form is a non-empty *prefix* of
a marker (``"NO"`` on the way to ``"NO_REPLY"``, or an exact marker not yet
terminated by stream-end) is held back so a raw marker is never shown and
then retracted. Anything that has diverged from every marker, or exceeds the
marker cap, returns False so normal streaming resumes. Shares the marker set
and canonicalization with :func:`is_intentional_silence_response`.
A buffer whose canonical form is a non-empty *prefix* of a marker (``"NO"`` on
the way to ``"NO_REPLY"``, or an exact marker not yet terminated by stream-end)
is held back so a raw marker is never shown and then retracted. Divergence
from every marker, or exceeding the cap, resumes normal streaming.
"""
stripped = _short_stripped(text)
return bool(stripped) and any(
return any(
c and any(marker.startswith(c) for marker in LIVE_GATEWAY_SILENT_MARKERS)
for c in _canonical_silence_candidates(stripped)
for c in _canonical_silence_candidates(text)
)
+66 -142
View File
@@ -6,96 +6,63 @@ from collections.abc import Mapping
from hermes_cli.config import DEFAULT_CONFIG
# EX_TEMPFAIL (sysexits.h): ask the service manager to restart the gateway
# after a graceful drain/reload path completes.
# EX_TEMPFAIL (sysexits.h): ask the service manager to restart after a graceful drain/reload.
GATEWAY_SERVICE_RESTART_EXIT_CODE = 75
# EX_CONFIG (sysexits.h): fatal configuration error (token collision, no
# platforms). The s6 finish script translates this into exit 125 (permanent
# failure) so the supervisor stops restarting the gateway.
# EX_CONFIG (sysexits.h): fatal configuration error (token collision, no platforms);
# the s6 finish script maps it to exit 125 so the supervisor stops restarting.
GATEWAY_FATAL_CONFIG_EXIT_CODE = 78
# Set by ``hermes gateway run --external-supervisor``. Unlike systemd's INVOCATION_ID
# and launchd's XPC_SERVICE_NAME, this survives wrappers that replace the child
# environment (e.g. ``sudo env -i``).
EXTERNAL_GATEWAY_SUPERVISOR_ENV = "HERMES_GATEWAY_EXTERNAL_SUPERVISOR"
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT = float(DEFAULT_CONFIG["agent"]["restart_drain_timeout"])
DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT = float(DEFAULT_CONFIG["gateway"]["signal_interrupt_grace_timeout"])
DEFAULT_GATEWAY_POST_INTERRUPT_GRACE_TIMEOUT = 5.0
# In-band restart waits for active turns to finish *before* ``stop()`` begins; distinct from
# ``restart_drain_timeout``, the force-interrupt budget once ``stop()`` runs (short under TimeoutStopSec).
DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT = float(DEFAULT_CONFIG["agent"]["restart_after_turn_timeout"])
# Cron-only floor under the ``stop()`` drain. ``restart_drain_timeout`` defaults to 0 because
# interrupting a *chat* turn is cheap and recoverable (user told, session resume_pending); an
# interrupted *cron* run is a permanent failure in jobs.json — a 0s drain silently destroys work.
DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT = float(DEFAULT_CONFIG["agent"]["cron_drain_timeout"])
# Watchdog leash held back for post-drain work (interrupt agents, kill subprocesses, mark jobs,
# disconnect adapters). Waiting for cron past that trades a job killed *and recorded* for one
# SIGKILLed mid-write and wedged at ``last_status=running`` forever.
CRON_DRAIN_CLEANUP_RESERVE_S = 10.0
# systemd TimeoutStopSec headroom after the stop-path drain budget, and the floor when that
# budget is still the default immediate (0s) chat drain. Keep in lockstep with generate_systemd_unit().
SYSTEMD_STOP_HEADROOM_S = 30.0
SYSTEMD_TIMEOUT_STOP_SEC_FLOOR = 60.0
_TRUTHY = {"1", "true", "yes", "on"}
def is_global_startup_conflict(error_code: str | None) -> bool:
"""True when an adapter's fatal error is a single-writer ownership conflict.
Adapters emit ``{scope}_lock`` with ``retryable=True`` so a *mid-run*
reconnect can recover once the holder exits or a stale record is cleared.
At startup a live foreign
holder is a configuration conflict (two gateways cannot poll one token), so
the startup router must not treat it as a transient blip. Matches by error
CODE only (``lock_conflict`` / ``*_lock``), never by message text.
Adapters emit ``{scope}_lock`` with ``retryable=True`` so a *mid-run* reconnect can
recover; at startup a live foreign holder is a configuration conflict (two gateways
cannot poll one token), not a transient blip. Matches by error CODE only, never text.
"""
code = (error_code or "").strip().lower()
return bool(code) and (code == "lock_conflict" or code.endswith("_lock"))
# Set by ``hermes gateway run --external-supervisor``. Unlike systemd's
# INVOCATION_ID and launchd's XPC_SERVICE_NAME, this survives wrappers that
# replace the child environment (e.g. ``sudo env -i``).
EXTERNAL_GATEWAY_SUPERVISOR_ENV = "HERMES_GATEWAY_EXTERNAL_SUPERVISOR"
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT = float(
DEFAULT_CONFIG["agent"]["restart_drain_timeout"]
)
DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT = float(
DEFAULT_CONFIG["gateway"]["signal_interrupt_grace_timeout"]
)
DEFAULT_GATEWAY_POST_INTERRUPT_GRACE_TIMEOUT = 5.0
# In-band restart (``/restart``, SIGUSR1, self-restart) waits for active turns
# to finish *before* ``stop()`` begins. Distinct from ``restart_drain_timeout``,
# the force-interrupt budget once ``stop()`` is running (must stay short under
# systemd TimeoutStopSec).
DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT = float(
DEFAULT_CONFIG["agent"]["restart_after_turn_timeout"]
)
# Cron-only floor under the ``stop()`` drain. ``restart_drain_timeout`` defaults
# to 0 because interrupting a *chat* turn is cheap and recoverable (user is
# told, session pre-marked resume_pending). An interrupted *cron* run has
# neither property — nobody is waiting on it, it lands in jobs.json as a
# permanent failure and a recurring job just waits for its next schedule — so a
# zero-second drain silently destroys work.
DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT = float(
DEFAULT_CONFIG["agent"]["cron_drain_timeout"]
)
# Seconds of the shutdown watchdog leash held back for post-drain work
# (interrupt agents, kill tool subprocesses, mark jobs interrupted, disconnect
# adapters). Waiting for cron past that trades a job killed *and recorded* for
# one SIGKILLed mid-write and wedged at ``last_status=running`` forever.
CRON_DRAIN_CLEANUP_RESERVE_S = 10.0
# systemd TimeoutStopSec headroom after the stop-path drain budget, and the
# floor when that budget is still the default immediate (0s) chat drain.
# Keep in lockstep with generate_systemd_unit().
SYSTEMD_STOP_HEADROOM_S = 30.0
SYSTEMD_TIMEOUT_STOP_SEC_FLOOR = 60.0
def is_gateway_supervisor_process(
environ: Mapping[str, str] | None = None,
) -> bool:
def is_gateway_supervisor_process(environ: Mapping[str, str] | None = None) -> bool:
"""Return whether this gateway process is owned by a supervisor."""
env = os.environ if environ is None else environ
if env.get("INVOCATION_ID") or env.get("HERMES_S6_SUPERVISED_CHILD"):
return True
xpc_service = env.get("XPC_SERVICE_NAME", "")
if xpc_service and xpc_service != "0":
return True
return str(env.get(EXTERNAL_GATEWAY_SUPERVISOR_ENV, "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
return bool(env.get("INVOCATION_ID") or env.get("HERMES_S6_SUPERVISED_CHILD") or (xpc_service and xpc_service != "0")
or str(env.get(EXTERNAL_GATEWAY_SUPERVISOR_ENV, "")).strip().lower() in _TRUTHY)
def is_container_restart_context() -> bool:
"""Whether the gateway runs in a container (Docker/Podman): the detached
setsid restart path dies with the cgroup, so exit-75 service restart is the
only viable path. Separate function so tests can mock container detection
(a real ``/.dockerenv`` on CI otherwise flips the routing)."""
"""In a container (Docker/Podman) the detached setsid restart path dies with the cgroup,
so exit-75 service restart is the only viable path. Own function so tests can mock it."""
return os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
@@ -107,25 +74,21 @@ def _seconds(value: object, fallback: float = 0.0) -> float:
return fallback
def _parse_timeout_keeping_zero(raw: object, default: float) -> float:
"""Parse a timeout where ``0`` is a deliberate disable (must NOT fall
through to ``default``), unlike None / blank / non-numeric input."""
def _parse_timeout_keeping_zero(raw: object, default: float, *, finite: bool = False) -> float:
"""Parse a timeout where ``0`` is a deliberate disable (must NOT fall through
to ``default``), unlike None / blank / non-numeric (/ non-finite) input."""
if raw is None or (isinstance(raw, str) and not raw.strip()):
return default
try:
value = float(raw)
value = float(raw) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
return max(0.0, value)
return default if finite and not math.isfinite(value) else max(0.0, value)
def parse_restart_drain_timeout(raw: object) -> float:
"""Parse a configured drain timeout, falling back to the shared default."""
try:
value = float(raw) if str(raw or "").strip() else DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
except (TypeError, ValueError):
return DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
return max(0.0, value)
"""Parse a configured drain timeout; falsy (incl. ``0``) falls back to the shared default."""
return _parse_timeout_keeping_zero(raw or None, DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT)
def parse_restart_after_turn_timeout(raw: object) -> float:
@@ -138,83 +101,44 @@ def parse_cron_drain_timeout(raw: object) -> float:
return _parse_timeout_keeping_zero(raw, DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT)
def parse_signal_interrupt_grace_timeout(raw: object) -> float:
"""Parse the unexpected-signal post-interrupt grace timeout."""
return _parse_timeout_keeping_zero(raw, DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT, finite=True)
def resolve_cron_drain_budget(
drain_timeout: float,
cron_drain_timeout: float,
*,
watchdog_delay: float,
elapsed: float = 0.0,
drain_timeout: float, cron_drain_timeout: float, *, watchdog_delay: float, elapsed: float = 0.0,
cleanup_reserve_s: float = CRON_DRAIN_CLEANUP_RESERVE_S,
) -> float:
"""Seconds the shutdown drain may spend waiting on in-flight cron work.
"""Seconds the stop drain may wait on in-flight cron work.
The configured floor is clamped to what this process can honour: the
shutdown watchdog hard-exits at ``watchdog_delay`` (and TimeoutStopSec is
sized from the same budget), so waiting past that leash minus
``cleanup_reserve_s`` swaps a cleanly-interrupted job for a SIGKILL that
leaves it wedged. Never returns less than ``drain_timeout``: the cron floor
only ever extends the wait, so an operator who deliberately configured a
long ``restart_drain_timeout`` keeps it.
Clamped to what this process can honour: the watchdog hard-exits at ``watchdog_delay``,
so waiting past that leash minus ``cleanup_reserve_s`` swaps a cleanly-interrupted job
for a SIGKILL that leaves it wedged. Never less than ``drain_timeout`` (only extends).
"""
drain = _seconds(drain_timeout)
floor = _seconds(cron_drain_timeout)
if floor <= 0.0:
return drain
ceiling = (
_seconds(watchdog_delay)
- _seconds(elapsed)
- _seconds(cleanup_reserve_s, CRON_DRAIN_CLEANUP_RESERVE_S)
)
ceiling = _seconds(watchdog_delay) - _seconds(elapsed) - _seconds(cleanup_reserve_s, CRON_DRAIN_CLEANUP_RESERVE_S)
return max(drain, min(floor, ceiling))
def resolve_systemd_timeout_stop_sec(
drain_timeout: float,
cron_drain_timeout: float = DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT,
*,
cleanup_reserve_s: float = CRON_DRAIN_CLEANUP_RESERVE_S,
headroom_s: float = SYSTEMD_STOP_HEADROOM_S,
drain_timeout: float, cron_drain_timeout: float = DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT, *,
cleanup_reserve_s: float = CRON_DRAIN_CLEANUP_RESERVE_S, headroom_s: float = SYSTEMD_STOP_HEADROOM_S,
floor_s: float = SYSTEMD_TIMEOUT_STOP_SEC_FLOOR,
) -> int:
"""Seconds systemd ``TimeoutStopSec`` must cover the full stop budget.
``restart_drain_timeout`` is only the chat-turn interrupt budget (default 0);
the stop path may first wait ``cron_drain_timeout`` + ``cleanup_reserve_s``
for cron work, so sizing from drain alone lets systemd SIGKILL an in-budget
drain. A zero cron timeout is an opt-out and does not extend the budget.
Non-numeric inputs degrade to 0.
"""
"""Seconds systemd ``TimeoutStopSec`` must cover: the stop path may first wait
``cron_drain_timeout`` + ``cleanup_reserve_s`` for cron work, so sizing from the chat drain
alone lets systemd SIGKILL an in-budget drain. A zero cron timeout is an opt-out."""
drain = _seconds(drain_timeout)
cron = _seconds(cron_drain_timeout)
cron_budget = (cron + _seconds(cleanup_reserve_s)) if cron > 0.0 else 0.0
stop_budget = max(drain, cron_budget)
return int(max(_seconds(floor_s), stop_budget + _seconds(headroom_s)))
return int(max(_seconds(floor_s), max(drain, cron_budget) + _seconds(headroom_s)))
def resolve_restart_exit_wait_budget(
drain_timeout: float,
after_turn_timeout: float,
*,
headroom: float = 15.0,
) -> float:
"""Seconds a CLI should wait for the gateway PID to exit after SIGUSR1.
In-band restart may defer ``stop()`` until turns finish (``after_turn_timeout``)
and then spend ``drain_timeout`` inside ``stop()``; callers that hard-kill on
expiry must cover both phases.
"""
def resolve_restart_exit_wait_budget(drain_timeout: float, after_turn_timeout: float, *, headroom: float = 15.0) -> float:
"""Seconds a CLI should wait for the gateway PID to exit after SIGUSR1: in-band restart may
defer ``stop()`` until turns finish, then spend ``drain_timeout`` inside it — cover both."""
return _seconds(drain_timeout) + _seconds(after_turn_timeout) + _seconds(headroom)
def parse_signal_interrupt_grace_timeout(raw: object) -> float:
"""Parse the unexpected-signal post-interrupt grace timeout."""
try:
if raw is None or (isinstance(raw, str) and not raw.strip()):
value = DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT
else:
value = float(raw)
except (TypeError, ValueError):
return DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT
if not math.isfinite(value):
return DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT
return max(0.0, value)
+36 -78
View File
@@ -1,35 +1,23 @@
"""Auto-resume restart-loop breaker (defense-3).
Defenses 1 and 2 (the ``_HERMES_GATEWAY`` guard on ``hermes gateway
stop|restart`` + ``terminal_tool``, and the cron-creation lifecycle filter)
stop the agent scheduling its own restart via cron/CLI. They do NOT cover
every SIGTERM source (raw ``launchctl kickstart``, a bad external monitor,
any repeated crash): the supervisor respawns, the gateway auto-resumes the
restart-interrupted session, whose next turn re-runs the offending logic.
This module is the last-resort circuit breaker. Each boot with
restart-interrupted sessions pending is timestamped and persisted (each boot
is a fresh process, so in-memory state is useless). Boots CHAIN while
consecutive gaps stay within ``max_gap_seconds``, so slow crash cycles (a
wedged loop killed by the liveness watchdog every ~150s) trip exactly like the
fast ~10s respawn loop. When tripped, the caller SKIPS auto-resume for that
boot — the gateway still serves real inbound messages, it just stops replaying
the session that keeps killing it.
State lives in ``<HERMES_HOME>/gateway/restart_loop.json`` (profile-scoped).
Best-effort: any read/write failure fails OPEN (no false trip) because a
broken breaker must never wedge a healthy gateway.
Defenses 1-2 (``_HERMES_GATEWAY`` guard on ``hermes gateway stop|restart`` /
``terminal_tool``, cron lifecycle filter) stop the agent scheduling its own restart
but not every SIGTERM source: the supervisor respawns, the gateway auto-resumes the
restart-interrupted session, whose next turn re-runs the offending logic. Boots are
persisted to ``<HERMES_HOME>/gateway/restart_loop.json`` and CHAIN while gaps stay
within ``max_gap_seconds`` (a ~150s watchdog-kill cycle trips like a ~10s loop).
Tripped → caller SKIPS auto-resume. Any I/O failure fails OPEN, never wedging.
"""
from __future__ import annotations
import contextlib
import json
import logging
import time
from typing import List, Optional
from hermes_constants import get_hermes_home
import contextlib
logger = logging.getLogger("gateway.run")
@@ -37,16 +25,12 @@ logger = logging.getLogger("gateway.run")
# within a few cycles.
DEFAULT_MAX_RESTARTS = 3
DEFAULT_WINDOW_SECONDS = 60
# Longest gap between consecutive restart-interrupted boots that still counts
# them as the SAME loop. A fixed-window prune only sees cycles faster than the
# window (a slower loop drops its own history every boot and never trips);
# chaining on the inter-boot gap makes the breaker period-agnostic, and a
# single boot followed by real quiet resets the chain.
# Longest gap between consecutive restart-interrupted boots still counted as the
# SAME loop. A fixed-window prune only sees cycles faster than the window (a slower
# loop drops its history every boot and never trips); chaining on the inter-boot
# gap is period-agnostic, and real quiet resets it.
DEFAULT_MAX_GAP_SECONDS = 300
# Cap the persisted chain; only the newest ``max_restarts`` entries can change
# a verdict, the rest are forensics.
# Only the newest ``max_restarts`` entries can change a verdict; the rest are forensics.
_MAX_STORED_BOOTS = 50
@@ -63,57 +47,44 @@ def _load_boots() -> List[float]:
def _save_boots(boots: List[float]) -> None:
try:
with contextlib.suppress(OSError):
path = _state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"boots": boots}), encoding="utf-8")
except OSError:
pass
def _chain_gap(window_seconds: int, max_gap_seconds: int) -> float:
"""Inter-boot gap that still links two boots. Floored by ``window_seconds`` so
"""Inter-boot gap that still links two boots; floored by ``window_seconds`` so
widening the window never makes the breaker *less* sensitive."""
return float(max(1, window_seconds, max_gap_seconds))
def _chain_ending_at(boots: List[float], ts: float, gap: float) -> List[float]:
"""Unbroken chain of boots leading up to ``ts`` (oldest first).
Walks backwards keeping boots while each successive gap stays within
``gap``; the first wider gap ends the chain (older boots belong to an
already-resolved episode). Nothing recent enough -> empty list, which is how
a healthy gateway forgets an old loop.
"""
"""Unbroken chain of boots leading up to ``ts`` (oldest first): walks backwards
while each gap stays within ``gap``; the first wider gap ends the chain (older
boots are a resolved episode). Empty when nothing is recent — how a healthy
gateway forgets a loop."""
chain: List[float] = []
prev = ts
for t in sorted(boots, reverse=True):
if t > ts:
# Clock moved backwards (NTP step, restored state file): treat the
# future entry as adjacent rather than dropping the whole chain.
if t > ts: # clock moved backwards (NTP step, restored file): future entry is adjacent, not a break
chain.append(t)
continue
if prev - t > gap:
break
chain.append(t)
prev = t
chain.reverse()
return chain
return chain[::-1]
def record_restart_interrupted_boot(
window_seconds: int = DEFAULT_WINDOW_SECONDS,
*,
now: Optional[float] = None,
window_seconds: int = DEFAULT_WINDOW_SECONDS, *, now: Optional[float] = None,
max_gap_seconds: int = DEFAULT_MAX_GAP_SECONDS,
) -> List[float]:
"""Record a restart-interrupted boot; return the pruned chain + now (most recent last).
Best-effort — a persistence failure returns the in-memory list without raising.
"""
"""Record a restart-interrupted boot; return the pruned chain + now (most recent
last). A persistence failure returns the in-memory list without raising."""
ts = time.time() if now is None else now
boots = _chain_ending_at(_load_boots(), ts, _chain_gap(window_seconds, max_gap_seconds))
boots.append(ts)
boots = _chain_ending_at(_load_boots(), ts, _chain_gap(window_seconds, max_gap_seconds)) + [ts]
_save_boots(boots[-_MAX_STORED_BOOTS:])
return boots
@@ -125,32 +96,19 @@ def clear() -> None:
def check_and_record(
max_restarts: int = DEFAULT_MAX_RESTARTS,
window_seconds: int = DEFAULT_WINDOW_SECONDS,
*,
now: Optional[float] = None,
max_gap_seconds: int = DEFAULT_MAX_GAP_SECONDS,
max_restarts: int = DEFAULT_MAX_RESTARTS, window_seconds: int = DEFAULT_WINDOW_SECONDS, *,
now: Optional[float] = None, max_gap_seconds: int = DEFAULT_MAX_GAP_SECONDS,
) -> bool:
"""Record this boot and return True when auto-resume should be SKIPPED.
The single entry point the gateway calls: appends the current boot, then
checks whether the updated chain has reached ``max_restarts``.
"""
boots = record_restart_interrupted_boot(
window_seconds, now=now, max_gap_seconds=max_gap_seconds
)
tripped = len(boots) >= max_restarts if max_restarts > 0 else False
"""Gateway entry point: record this boot; True when the chain reached
``max_restarts`` and auto-resume should be SKIPPED."""
boots = record_restart_interrupted_boot(window_seconds, now=now, max_gap_seconds=max_gap_seconds)
tripped = max_restarts > 0 and len(boots) >= max_restarts
if tripped:
logger.warning(
"Restart-loop breaker TRIPPED: %d chained restart-interrupted "
"gateway boots (no gap wider than %ds; threshold %d). Skipping "
"auto-resume to break a suspected SIGTERM-respawn loop (#30719, "
"#81642). Restart-interrupted sessions stay resume-pending and "
"will continue on the next real user message. If this is a false "
"positive, delete %s.",
len(boots),
int(_chain_gap(window_seconds, max_gap_seconds)),
max_restarts,
_state_path(),
"Restart-loop breaker TRIPPED: %d chained restart-interrupted gateway boots (no gap wider than %ds; "
"threshold %d). Skipping auto-resume to break a suspected SIGTERM-respawn loop (#30719, #81642). "
"Restart-interrupted sessions stay resume-pending and will continue on the next real user message. "
"If this is a false positive, delete %s.",
len(boots), int(_chain_gap(window_seconds, max_gap_seconds)), max_restarts, _state_path(),
)
return tripped
+9 -21
View File
@@ -1,13 +1,8 @@
"""Local index of text we've sent via ``sendRichMessage`` (Bot API 10.1).
Telegram does NOT echo a rich message's content back in ``reply_to_message``
(``.text``/``.caption`` empty, ``.api_kwargs`` None), so replies to rich sends
arrive with no quotable text. We remember ``message_id -> text`` at send time
and look it up by ``reply_to_id`` on inbound. This module is the single source
of truth for that index.
Best-effort and dependency-free: every operation swallows errors and degrades
to a no-op / ``None`` so it can never break a send or an inbound message.
"""Local index of text we've sent via ``sendRichMessage`` (Bot API 10.1). Telegram does NOT echo a rich
message's content back in ``reply_to_message`` (``.text``/``.caption`` empty, ``.api_kwargs`` None), so
replies to rich sends arrive with no quotable text; we remember ``message_id -> text`` at send time and
look it up by ``reply_to_id`` on inbound. Best-effort and dependency-free: every operation swallows
errors and degrades to a no-op / ``None`` so it can never break a send or an inbound message.
"""
from __future__ import annotations
@@ -22,16 +17,10 @@ _MAX_TEXT_CHARS = 2000
def _store_path() -> str:
# get_hermes_home() honors the active profile override.
from hermes_constants import get_hermes_home
from hermes_constants import get_hermes_home # honors the active profile override
return os.path.join(str(get_hermes_home()), "state", "rich_sent_index.json")
def _key(chat_id, message_id) -> str:
return f"{chat_id}:{message_id}"
def _load(path: str) -> dict:
try:
with open(path, "r", encoding="utf-8") as fh:
@@ -49,10 +38,9 @@ def record(chat_id, message_id, text: Optional[str]) -> None:
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
data = _load(path)
data[_key(chat_id, message_id)] = {"t": text[:_MAX_TEXT_CHARS], "ts": int(time.time())}
data[f"{chat_id}:{message_id}"] = {"t": text[:_MAX_TEXT_CHARS], "ts": int(time.time())}
if len(data) > _MAX_ENTRIES: # trim oldest by timestamp
by_age = sorted(data.items(), key=lambda kv: kv[1].get("ts", 0))
for k, _ in by_age[: len(data) - _MAX_ENTRIES]:
for k, _ in sorted(data.items(), key=lambda kv: kv[1].get("ts", 0))[: len(data) - _MAX_ENTRIES]:
data.pop(k, None)
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as fh:
@@ -66,5 +54,5 @@ def lookup(chat_id, message_id) -> Optional[str]:
"""Return stored text for ``(chat_id, message_id)`` or ``None``."""
if message_id is None or chat_id is None:
return None
entry = _load(_store_path()).get(_key(chat_id, message_id))
entry = _load(_store_path()).get(f"{chat_id}:{message_id}")
return (entry.get("t") or None) if isinstance(entry, dict) else None
+1555 -3495
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1874,8 +1874,7 @@ class TestHostedRoomRuns:
for target in (
"gateway.platforms.api_server.time.time",
"gateway.hosted_room_peer.time.time",
"gateway.hosted_rooms.time.time",
"gateway.hosted_rooms_common.time.time",
):
monkeypatch.setattr(target, lambda: 200)
claims = {