fix(kanban): deliver routed profile notifications on the authorized transport

Authorize route-only profiles using the ordered canonical route matcher and
served-profile set at both claim and delivery. Preserve secondary credential
boundaries, retry denied routes, and keep scope/parent anchors plus transport
provenance on synthetic wakes. Install the destination runtime scope rather
than inheriting the notifier's scope; a removed profile cannot wake as primary.

Slim forward-port of the direction in #101196/#101397 and #93863 (#93851).
Canonical scope_id takes precedence over the guild alias and live chat cache.
Two route invariants and two runtime-scope invariants reproduced red first.

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
This commit is contained in:
Teknium
2026-09-07 13:15:36 -07:00
parent 9ee3d129b4
commit 30b3ca16f4
3 changed files with 267 additions and 20 deletions

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import re
from pathlib import Path
import weakref
from typing import Any, Callable, Optional
from agent.i18n import t
@@ -74,7 +75,7 @@ def _wake_scope_id(adapter: Any, sub: dict) -> Optional[str]:
"""
delivery_meta = sub.get("delivery_metadata")
if isinstance(delivery_meta, dict):
for key in ("scope_id", "slack_team_id", "team_id"):
for key in ("scope_id", "guild_id", "slack_team_id", "team_id"):
value = delivery_meta.get(key)
if value:
return str(value)
@@ -95,6 +96,47 @@ def _platform_names(mapping: Any) -> set[str]:
return {getattr(platform, "value", str(platform)).lower() for platform in mapping}
def _adapter_for_subscription(runner: Any, platform: Any, sub: dict, owner_profile: Optional[str]) -> Any:
"""Resolve a durable route without turning a missing secondary bot into primary authority."""
adapter = runner._authorization_adapter(platform, owner_profile)
config = getattr(runner, "config", None)
if not getattr(config, "multiplex_profiles", False):
return adapter
primary = runner.adapters.get(platform)
if adapter is not None and adapter is not primary:
return adapter
profile = owner_profile or getattr(runner, "_kanban_notifier_profile", None)
primary_profile = getattr(runner, "_primary_profile_name", None) or runner._active_profile_name()
profile = profile or primary_profile
# Empty maps are startup placeholders for route-only profiles; a connected
# secondary on ANY platform establishes an independent credential boundary.
if (getattr(runner, "_profile_adapters", {}) or {}).get(profile):
return None
metadata = sub.get("delivery_metadata") or {}
guild = metadata.get("scope_id") or metadata.get("guild_id")
parent = metadata.get("parent_chat_id")
chat, thread = sub.get("chat_id"), sub.get("thread_id") or None
thread_like = bool(thread) or (sub.get("chat_type") or metadata.get("chat_type")) in {
"thread", "forum", "forum_post", "forum-post", "topic",
}
# Preserve canonical route order, including equal-specificity ties. An older
# row missing an anchor must not skip a potentially winning route. Reuse the
# route's matcher (including platform-specific identity aliases), not a second
# hand-maintained equality implementation.
for route in getattr(config, "profile_routes", None) or []:
if route.matches(platform.value, guild_id=guild, chat_id=chat,
thread_id=thread, parent_chat_id=parent):
if route.profile != profile:
return None
from gateway.run import _multiplex_profile_homes
served = {name for name, _home in _multiplex_profile_homes(config)}
return primary if profile in served else None
if route.matches(platform.value, guild_id=guild or route.guild_id, chat_id=chat,
thread_id=thread, parent_chat_id=parent or (route.chat_id if thread_like else None)):
return None
return primary if profile == primary_profile else None
# --- Collection (runs in a worker thread) ---
@@ -102,6 +144,7 @@ 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.runner = runner
self.kb = kb
self.notifier_profile = notifier_profile
self.gc_due = gc_due
@@ -111,11 +154,15 @@ class _Collector:
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())
config = getattr(runner, "config", None)
if getattr(config, "multiplex_profiles", False):
self.notifier_profiles.update(
route.profile for route in config.profile_routes
if route.enabled and route.platform in _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.
# coarse pre-filter; exact destination authorization runs before claim
# and again at delivery, rewinding if the route or adapter changed.
self.active_platforms = _platform_names(runner.adapters).union(
*(_platform_names(m) for m in self.profile_adapters.values()))
@@ -169,15 +216,14 @@ class _Collector:
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
from gateway.config import Platform
if _adapter_for_subscription(self.runner, Platform(platform), sub, owner_profile or self.notifier_profile) is None:
return None
old_cursor, cursor, events = _kbn().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,
@@ -460,17 +506,22 @@ class _KanbanNotification:
# (#60600 rows) — fall back to that, then to "group" (the historical default that suits the
# dashboard/group flows). handle_message() get_or_create_session's the target, so a mismatch only
# ever degrades to a fresh session, never an exception.
_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()
_delivery_meta = sub.get("delivery_metadata") or {}
_chat_type = str(sub.get("chat_type") or _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),
parent_chat_id=_delivery_meta.get("parent_chat_id"),
)
await deliver_wake(self.adapter, text=self.synth, session_id=self.session_key, source=_source)
_source._transport_adapter_ref = weakref.ref(self.adapter)
from gateway.run import _async_profile_runtime_scope
if self.sub_profile and getattr(getattr(self.runner, "config", None), "multiplex_profiles", False):
from hermes_cli.profiles import profile_exists
if not profile_exists(self.sub_profile):
raise RuntimeError(f"Kanban wake profile {self.sub_profile!r} no longer exists")
async with _async_profile_runtime_scope(self.runner._resolve_profile_home_for_source(_source)):
await deliver_wake(self.adapter, text=self.synth, session_id=self.session_key, source=_source)
self._log_woke()
async def _send_event(self, ev: Any, msg: str) -> None:
@@ -535,11 +586,8 @@ class _KanbanNotification:
except ValueError:
await self.advance()
return
# Same chokepoint as authorization: a stamped profile is served by ITS
# same-platform adapter and never falls back to the default profile's
# bot (cross-profile mis-delivery). None only when the profile (or
# default) has no adapter.
adapter = self.runner._authorization_adapter(self.plat, self.sub_profile or None)
# Recheck the exact route after claiming: config/adapters can change between ticks.
adapter = _adapter_for_subscription(self.runner, self.plat, self.sub, 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)

View File

@@ -0,0 +1,191 @@
"""Persisted notification routes authorize exactly one transport, including route-only profiles."""
import asyncio
from pathlib import Path
from gateway.config import GatewayConfig, Platform
from gateway.kanban_watchers_notifier import _KanbanNotification, _notifier_collect
from gateway.profile_routing import parse_profile_routes
from gateway.run import GatewayRunner
from hermes_cli import kanban_db as kb, kanban_db_connect as kbc, kanban_db_notify as kbn
class RecordingAdapter:
supports_async_delivery = True
def __init__(self):
self.sent = []
self.handled = []
async def send(self, chat_id, text, **kwargs):
self.sent.append((chat_id, text, kwargs))
async def handle_message(self, event):
self.handled.append(event)
def setup_runner(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_KANBAN_DB", str(home / "kanban.db"))
for name in ("yuki", "other"):
profile = home / "profiles" / name
profile.mkdir(parents=True)
(profile / "config.yaml").write_text("{}\n", encoding="utf-8")
runner = GatewayRunner.__new__(GatewayRunner)
runner.adapters = {Platform.DISCORD: RecordingAdapter()}
runner._profile_adapters = {"yuki": {}}
runner._primary_profile_name = "default"
runner._kanban_notifier_profile = "default"
runner._kanban_dispatcher_lock_handle = object()
runner.config = GatewayConfig(multiplex_profiles=True, profile_routes=parse_profile_routes([
dict(platform="discord", guild_id="guild", chat_id="parent", profile="yuki"),
]))
return runner
def completion(*, profile="yuki", metadata=None, chat="post", thread="post", mode="notify+wake"):
with kbc.connect() as conn:
task = kb.create_task(conn, title="route completion", assignee="worker")
kbn.add_notify_sub(conn, task_id=task, platform="discord", chat_id=chat,
thread_id=thread, chat_type="thread", user_id="creator",
notifier_profile=profile, delivery_mode=mode,
delivery_metadata=metadata if metadata is not None else
{"guild_id": "guild", "scope_id": "guild", "parent_chat_id": "parent"})
kb.complete_task(conn, task, result="finished")
return task
def collect(runner):
return _notifier_collect(runner, kb, notifier_profile="default", gc_due=False, gc_retention_days=30)
async def deliver(runner, rows):
for row in rows:
await _KanbanNotification(runner, row, platform_cls=Platform, sub_fail_counts={}).deliver()
def unseen(task):
with kbc.connect() as conn:
return kbn.unseen_events_for_sub(conn, task_id=task, platform="discord", chat_id="post",
thread_id="post", kinds=["completed"])[1]
def test_exact_routed_profile_delivers_once_on_its_authorized_transport(tmp_path, monkeypatch):
runner = setup_runner(tmp_path, monkeypatch)
primary = runner.adapters[Platform.DISCORD]
task = completion(metadata={"scope_id": "guild", "guild_id": "stale-alias", "parent_chat_id": "parent"})
rows = collect(runner)
assert [row["task"].id for row in rows] == [task]
asyncio.run(deliver(runner, rows))
assert len(primary.sent) == len(primary.handled) == 1
source = primary.handled[0].source
assert (source.profile, source.guild_id, source.scope_id, source.parent_chat_id) == (
"yuki", "guild", "guild", "parent")
assert runner._adapter_for_source(source) is primary
assert not collect(runner)
# A connected secondary owns its credential even where the primary route matches.
secondary = RecordingAdapter()
secondary.scope_id_for_chat = lambda chat: "stale-cache"
runner._profile_adapters["yuki"] = {Platform.DISCORD: secondary}
task = completion(metadata={"guild_id": "guild", "parent_chat_id": "parent"})
asyncio.run(deliver(runner, collect(runner)))
assert len(primary.sent) == 1
assert len(secondary.sent) == len(secondary.handled) == 1
assert secondary.handled[0].source.scope_id == "guild"
assert runner._adapter_for_source(secondary.handled[0].source) is secondary
assert not unseen(task)
def test_route_denials_leave_events_retryable_at_claim_and_send(tmp_path, monkeypatch):
runner = setup_runner(tmp_path, monkeypatch)
primary = runner.adapters[Platform.DISCORD]
# Unknown owners, wrong/default owners, incomplete anchors, and partial credentials
# never become primary delivery authority.
tasks = [completion(profile=owner) for owner in ("other", "default", None)]
tasks += [completion(metadata=meta) for meta in (
{"parent_chat_id": "parent"}, {"guild_id": "guild"},
{"guild_id": "wrong", "parent_chat_id": "parent"},
)]
assert not collect(runner)
assert all(unseen(task) for task in tasks)
good = completion()
runner._profile_adapters["yuki"] = {Platform.TELEGRAM: RecordingAdapter()}
assert not collect(runner)
runner._profile_adapters["yuki"] = {}
runner.config.multiplex_profile_allowlist = ["other"]
assert not collect(runner)
runner.config.multiplex_profile_allowlist = ["yuki"]
rows = collect(runner)
assert [row["task"].id for row in rows] == [good]
# Reassignment after the claim must rewind, never send using stale authority.
runner.config.profile_routes = parse_profile_routes([
dict(platform="discord", guild_id="guild", chat_id="parent", profile="other")])
asyncio.run(deliver(runner, rows))
assert primary.sent == primary.handled == []
assert unseen(good)
# Equal-specificity rules retain configuration order: an unknown parent
# cannot skip an earlier rule, but a known conflicting parent rules it out.
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "tied-routes.db"))
runner.config.profile_routes = parse_profile_routes([
dict(platform="discord", guild_id="guild", chat_id="parent", profile="other"),
dict(platform="discord", guild_id="guild", chat_id="post", profile="yuki"),
])
ambiguous = completion(metadata={"scope_id": "guild"})
exact = completion(metadata={"scope_id": "guild", "parent_chat_id": "different-parent"})
rows = collect(runner)
assert [row["task"].id for row in rows] == [exact]
asyncio.run(deliver(runner, rows))
assert len(primary.sent) == len(primary.handled) == 1
assert unseen(ambiguous)
def test_kanban_wakes_install_the_destination_runtime_scope(tmp_path, monkeypatch):
from agent.secret_scope import get_secret
from gateway.run import _profile_runtime_scope
from hermes_constants import get_hermes_home
runner = setup_runner(tmp_path, monkeypatch)
home = tmp_path / ".hermes"
(home / ".env").write_text("KANBAN_TEST_SECRET=primary\n", encoding="utf-8")
observed = []
class ScopedAdapter(RecordingAdapter):
async def handle_message(self, event):
# A real yield catches scopes that mutate process-global state.
await asyncio.sleep(0)
observed.append((event.source.profile, get_secret("KANBAN_TEST_SECRET"), get_hermes_home()))
await super().handle_message(event)
for name in ("yuki", "other"):
(home / "profiles" / name / ".env").write_text(f"KANBAN_TEST_SECRET={name}\n", encoding="utf-8")
runner._profile_adapters[name] = {Platform.DISCORD: ScopedAdapter()}
completion(profile=name)
rows = collect(runner)
assert len(rows) == 2
async def concurrent_wakes():
with _profile_runtime_scope(home):
await asyncio.gather(*(_KanbanNotification(runner, row, platform_cls=Platform,
sub_fail_counts={}).deliver() for row in rows))
assert get_secret("KANBAN_TEST_SECRET") == "primary"
asyncio.run(concurrent_wakes())
assert sorted(observed) == [(name, name, home / "profiles" / name) for name in ("other", "yuki")]
def test_removed_profile_never_wakes_under_the_primary_runtime(tmp_path, monkeypatch):
import shutil
runner = setup_runner(tmp_path, monkeypatch)
secondary = RecordingAdapter()
runner._profile_adapters["yuki"] = {Platform.DISCORD: secondary}
task = completion(mode="wake")
rows = collect(runner)
assert len(rows) == 1
shutil.rmtree(tmp_path / ".hermes" / "profiles" / "yuki")
asyncio.run(deliver(runner, rows))
assert secondary.handled == []
assert unseen(task)

View File

@@ -1118,6 +1118,14 @@ dispatch and delivery have separate owners:
`writer` profile's Telegram gets its `completed`/`blocked` message delivered
by the `writer` gateway, even though the `default` gateway did the
dispatching.
- **Route-only multiplex profiles** can use the primary adapter when the
subscription's persisted platform, chat, thread, scope and parent-channel
anchors resolve to that exact served profile through `gateway.profile_routes`.
A connected secondary adapter remains authoritative; a partial secondary
adapter registry never falls back to the primary bot. Unmatched, reassigned,
disabled or ambiguous routes remain undelivered and retryable. Old rows
missing required routing anchors are not guessed into a profile. Wake turns keep
the destination profile's runtime scope and the authorized transport.
- **Legacy subscriptions** created before profile stamping (no
`notifier_profile` on the row) are delivered only by the gateway that holds
the actual dispatcher singleton lock, so two gateways never race for them.