Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions gateway/kanban_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,24 @@ def _collect():
getattr(platform, "value", str(platform)).lower()
for platform in self.adapters.keys()
}
# Widen to every platform any secondary profile has live,
# not just the default profile's. This is only a coarse
# pre-filter to skip claiming events for subs nobody can
# possibly deliver — the precise per-profile check (via
# gateway/authz_mixin.py::_authorization_adapter, which
# forbids default-profile fallback) still runs at delivery
# time below, rewinding the claim if it resolves to None.
# Without this, a subscription owned by a secondary
# profile on a platform the DEFAULT profile never
# connected (e.g. beta owns discord, default doesn't) was
# dropped here before ever being claimed — no rewind
# applies to an unclaimed event, so it silently never
# retries.
for _profile_adapter_map in getattr(self, "_profile_adapters", {}).values():
active_platforms.update(
getattr(platform, "value", str(platform)).lower()
for platform in _profile_adapter_map.keys()
)
if not active_platforms:
logger.debug("kanban notifier: no connected adapters; skipping tick")
return deliveries
Expand Down
49 changes: 49 additions & 0 deletions tests/gateway/test_kanban_notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,55 @@ def test_notifier_owning_profile_adapter_no_default_fallback(tmp_path, monkeypat
assert [ev.kind for ev in _unseen_terminal_events_for(tid, "chat-beta")] == ["completed"]


def test_notifier_claims_platform_only_a_secondary_profile_owns(tmp_path, monkeypatch):
"""A subscription owned by a secondary profile on a platform the DEFAULT
profile never connected must still be claimed and delivered.

Regression: the ``_collect()`` pre-filter built ``active_platforms``
solely from ``self.adapters`` (the default profile). A sub owned by
profile "beta" on "discord", where beta genuinely has a live discord
adapter but the default profile has no discord adapter at all, was
dropped by that pre-filter (``platform not in active_platforms``)
before ``claim_unseen_events_for_sub`` ever ran — unlike the
disconnected-adapter path, an unclaimed event is never rewound, so this
was a permanent, silent notification loss, not a retryable one. This
directly contradicts the feature's own purpose (routing notifications
via the owning profile), and is the same cross-profile-adapter-lookup
class the delivery-side chokepoint in
``test_notifier_owning_profile_adapter_no_default_fallback`` already
guards — just one gate earlier.
"""
db_path = tmp_path / "secondary-only-platform.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()

conn = kb.connect()
try:
tid = kb.create_task(conn, title="owned by beta on discord", assignee="worker")
kb.add_notify_sub(
conn, task_id=tid, platform="discord", chat_id="chat-beta",
notifier_profile="beta",
)
kb.complete_task(conn, tid, summary="done")
finally:
conn.close()

beta_adapter = RecordingAdapter()
runner = GatewayRunner.__new__(GatewayRunner)
runner._running = True
# Default profile has NO discord adapter at all.
runner.adapters = {Platform.TELEGRAM: RecordingAdapter()}
# Secondary profile "beta" has a live discord adapter.
runner._profile_adapters = {"beta": {Platform.DISCORD: beta_adapter}}
runner._kanban_sub_fail_counts = {}

asyncio.run(_run_one_notifier_tick(monkeypatch, runner))

assert len(beta_adapter.sent) == 1, (
f"beta's discord adapter should have received the notification; got {beta_adapter.sent!r}"
)


def _unseen_terminal_events_for(tid, chat_id):
conn = kb.connect()
try:
Expand Down
Loading