From 2710d859144c28fa54c20808fb30ed40178d93c6 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Sat, 12 Sep 2026 03:41:15 +0800 Subject: [PATCH] fix(kanban): gate gateway notifier polling --- gateway/kanban_watchers.py | 12 +++++++ hermes_cli/config_defaults.py | 3 ++ ...t_kanban_notifier_watcher_dispatch_gate.py | 36 +++++++++++++++---- website/docs/user-guide/features/kanban.md | 1 + 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index 96303924ca..eda9756be4 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -68,6 +68,18 @@ class GatewayKanbanWatchersMixin: dispatcher respawned a crashed task). All SQLite work runs in a thread; one tick's failure never stops the next. """ + try: + from hermes_cli.config import load_config as _load_config + + cfg = _load_config() + kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} + except Exception as exc: + logger.warning("kanban notifier: cannot load config (%s); continuing enabled", exc) + kanban_cfg = {} + if not kanban_cfg.get("notify_in_gateway", True): + logger.info("kanban notifier: disabled via config kanban.notify_in_gateway=false") + return + from gateway.config import Platform as _Platform try: from hermes_cli import kanban_db as _kb diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 85033a6c49..e969f97135 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1722,6 +1722,9 @@ DEFAULT_CONFIG = { # kanban_create is called from a session with a persistent delivery channel. Disable for # profiles that prefer explicit kanban_notify-subscribe calls per task. "auto_subscribe_on_create": True, + # Poll and deliver Kanban subscriptions from this gateway. Disable on profiles that do + # not own notification subscriptions to avoid an idle five-second board probe. + "notify_in_gateway": True, # Run the dispatcher inside the gateway process (~300µs per idle tick). False only if you # run it as a separate unit or don't want the gateway spawning workers. "dispatch_in_gateway": True, diff --git a/tests/gateway/test_kanban_notifier_watcher_dispatch_gate.py b/tests/gateway/test_kanban_notifier_watcher_dispatch_gate.py index b3276c9682..874d3c028e 100644 --- a/tests/gateway/test_kanban_notifier_watcher_dispatch_gate.py +++ b/tests/gateway/test_kanban_notifier_watcher_dispatch_gate.py @@ -1,4 +1,4 @@ -"""Notifier polling stays active when another gateway owns dispatching.""" +"""Notifier polling has an independent gateway config gate.""" import asyncio from unittest.mock import MagicMock, patch @@ -15,6 +15,19 @@ def _make_runner(with_adapter=False): return runner +def test_notifier_watcher_skips_when_notifications_disabled(): + runner = _make_runner(with_adapter=True) + + with patch( + "hermes_cli.config.load_config", + return_value={"kanban": {"notify_in_gateway": False}}, + ): + with patch("hermes_cli.kanban_db.list_boards") as list_boards: + asyncio.run(runner._kanban_notifier_watcher()) + + list_boards.assert_not_called() + + def test_notifier_watcher_polls_without_dispatch_ownership(): """A profile gateway still polls its profile-owned subscriptions.""" runner = _make_runner(with_adapter=True) @@ -33,13 +46,22 @@ def test_notifier_watcher_polls_without_dispatch_ownership(): import hermes_cli.kanban_db as _kb - with patch.object( - _kb, "list_boards", - side_effect=lambda *a, **kw: past_gate.append(True) or [], + with patch( + "hermes_cli.config.load_config", + return_value={ + "kanban": { + "dispatch_in_gateway": False, + "notify_in_gateway": True, + } + }, ): - with patch("asyncio.sleep", side_effect=fake_sleep): - with patch("asyncio.to_thread", side_effect=fake_to_thread): - asyncio.run(runner._kanban_notifier_watcher()) + with patch.object( + _kb, "list_boards", + side_effect=lambda *a, **kw: past_gate.append(True) or [], + ): + with patch("asyncio.sleep", side_effect=fake_sleep): + with patch("asyncio.to_thread", side_effect=fake_to_thread): + asyncio.run(runner._kanban_notifier_watcher()) assert past_gate, ( "gateways without the dispatch lock must still poll owned subscriptions" diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 7ee999fa94..0cd901f129 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -700,6 +700,7 @@ Config knobs (all under `kanban:` in `~/.hermes/config.yaml`): | `orchestrator_profile` | `""` | Profile assigned to the root/orchestration task after decomposition. Empty = fall back to active default profile. | | `default_assignee` | `""` | Where a child task lands when the LLM picks an unknown profile. Empty = fall back to active default. | | `auto_subscribe_on_create` | `true` | When `kanban_create` runs inside a persistent gateway/TUI session, terminal events resume that originating agent with a synthetic status turn. Set to `false` for passive completion or to require explicit `kanban_notify-subscribe` calls. Independent of `auto_decompose`. | +| `notify_in_gateway` | `true` | Poll and deliver Kanban subscriptions from this gateway. Set to `false` on profiles that own no notification subscriptions to stop the idle five-second notifier poll. Independent of `dispatch_in_gateway`; non-dispatch gateways may still own profile-specific delivery adapters. | | `done_sub_retention_days` | `30` | Notify subscriptions survive `done` (reopen-safe) and are removed on `archived`. The notifier GC purges subscriptions whose task has been `done` or `blocked` with no new events for this many days, bounding sub-table growth on boards that never archive. `0` disables the sweep. | And the two auxiliary LLM slots: