Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
96 changes: 96 additions & 0 deletions gateway/kanban_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,51 @@ def _resolve_auto_decompose_settings(
return enabled, per_tick


def _resolve_default_notify_targets(
load_config: Callable[[], Any],
) -> "list[dict]":
"""Resolve the live board-wide ``kanban.default_notify`` target list.

Each entry fans every ticket's terminal events out to a chat with zero
manual ``notify-subscribe``. Shape in ``config.yaml``::

kanban:
default_notify:
- platform: signal
chat_id: "group:..."
thread_id: "" # optional

Read fresh on every notifier tick (like ``_resolve_auto_decompose_settings``)
so adding/removing a target takes effect on the next tick instead of
requiring a gateway restart. Fails **safe**: any read/parse error returns
``[]`` (no auto-subscribe) rather than raising out of the notifier loop.
Entries missing ``platform`` or ``chat_id`` are dropped. ``thread_id``
defaults to ``""`` to match the subscription primary key.
"""
try:
cfg = load_config()
except Exception:
return []
kcfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
raw = kcfg.get("default_notify") or []
Comment on lines +83 to +89

ghost Jun 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail closed when the kanban section is malformed

If a live config edit leaves kanban as a non-dict value (for example kanban: false or a YAML list), this .get() raises before _collect() runs, and the outer tick handler skips all existing per-task notification delivery every interval. Since this resolver is meant to fail safe to [], guard that kcfg is a dict so malformed default_notify config cannot break unrelated kanban subscriptions.

Useful? React with 👍 / 👎.

if not isinstance(raw, (list, tuple)):
return []
targets: list[dict] = []
for entry in raw:
if not isinstance(entry, dict):
continue
platform = str(entry.get("platform") or "").strip().lower()
chat_id = str(entry.get("chat_id") or "").strip()
if not platform or not chat_id:
continue
targets.append({
"platform": platform,
"chat_id": chat_id,
"thread_id": str(entry.get("thread_id") or "").strip(),
})
return targets


def _acquire_singleton_lock(lock_path) -> "tuple[Optional[object], str]":
"""Take an exclusive, non-blocking advisory lock for the sole dispatcher.

Expand Down Expand Up @@ -192,6 +237,11 @@ async def _kanban_notifier_watcher(self, interval: float = 5.0) -> None:

while self._running:
try:
# Resolve board-wide auto-subscribe targets fresh each tick so
# adding/removing a `kanban.default_notify` entry takes effect
# on the next tick (no gateway restart). Fails safe to [].
default_notify_targets = _resolve_default_notify_targets(_load_config)

def _collect():
deliveries: list[dict] = []
active_platforms = {
Expand Down Expand Up @@ -244,6 +294,52 @@ def _collect():
# a legacy DB. `_add_column_if_missing` now
# tolerates that race, but we still skip the
# redundant call to avoid the wasted work.
#
# Board-wide auto-subscribe: before reading the
# subscription table, ensure every config-listed
# `kanban.default_notify` target is subscribed to
# every active (non-final) task on this board. This
# is what makes terminal events fan out to e.g. the
# "Kanban Workers" Signal group with zero manual
# `notify-subscribe`. `add_notify_sub` is
# INSERT-OR-IGNORE on the (task, platform, chat,
# thread) PK, so this is idempotent and never
# disturbs an existing per-task subscription (the
# cursor / fail-count state is keyed the same way).
# We only auto-subscribe targets whose platform
# adapter is currently connected, mirroring the
# delivery gate below.
if default_notify_targets:
try:
active_tasks = _kb.list_tasks(conn)
except Exception as exc:
active_tasks = []
logger.debug(
"kanban notifier: cannot list tasks on board %s for default-notify: %s",
slug, exc,
)
FINAL_STATUSES = {"done", "archived"}
for t in active_tasks:
if getattr(t, "status", None) in FINAL_STATUSES:
continue

ghost Jun 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not skip fast completions before subscribing

When a task is created and completes before the notifier's next poll (the watcher has an initial 5s delay and then polls on the interval), this final-status guard prevents kanban.default_notify from inserting any subscription row, so the later completed event is never claimed or delivered to the default chat. This drops exactly the short/quick completion notifications the board-wide setting is meant to cover; inserting the default row before excluding already-done tasks (or otherwise processing recent completed tasks) is needed to avoid missing them.

Useful? React with 👍 / 👎.

for tgt in default_notify_targets:
if tgt["platform"] not in active_platforms:
continue
try:
_kb.add_notify_sub(

ghost Jun 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove stale auto-subscriptions when config targets change

This only ever INSERT OR IGNOREs default-notify rows, so after a target has been present for one tick, deleting it from kanban.default_notify does not stop notifications for any already-subscribed tasks; the persisted rows remain indistinguishable from manual subscriptions and will keep delivering until those tasks are finally done/archived. That contradicts the live-edit behavior described for removing targets and can keep sending noisy or unwanted task updates to a chat after it has been removed from config.

Useful? React with 👍 / 👎.

ghost Jun 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve dead-chat suppression for default targets

For a default target whose chat is invalid or whose adapter keeps failing while the task stays non-final (for example blocked after a crash/give-up cycle), the existing send-failure path drops the subscription after MAX_SEND_FAILURES, but the next tick immediately recreates it here because the config still lists the target. That resets the row cursor to 0 and the failure counter was popped when dropping the row, so the same terminal events are retried/replayed forever instead of honoring the dead-chat suppression.

Useful? React with 👍 / 👎.

conn,
task_id=t.id,
platform=tgt["platform"],
chat_id=tgt["chat_id"],
thread_id=tgt["thread_id"],
notifier_profile=notifier_profile,
)
except Exception as exc:
logger.debug(
"kanban notifier: default-notify subscribe failed for %s on %s: %s",
t.id, tgt["platform"], exc,
)

ghost Jun 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Executing _kb.add_notify_sub in a loop for every active task on every notifier tick (which runs every 5 seconds) is highly inefficient. Since add_notify_sub opens and commits an IMMEDIATE write transaction for each task, this approach will result in $O(N)$ write transactions per tick (where $N$ is the number of active tasks). On boards with many active tasks, this will cause severe database write contention, lock issues, and performance degradation.

Instead, we can perform a single bulk INSERT OR IGNORE ... SELECT ... query per target in a single transaction. This reduces the database overhead to $O(1)$ transactions per tick, significantly improving efficiency and scalability.

                            if default_notify_targets:
                                now = int(time.time())
                                for tgt in default_notify_targets:
                                    if tgt["platform"] not in active_platforms:
                                        continue
                                    try:
                                        with _kb.write_txn(conn):
                                            conn.execute(
                                                """
                                                INSERT OR IGNORE INTO kanban_notify_subs (
                                                    task_id, platform, chat_id, thread_id, notifier_profile, created_at
                                                )
                                                SELECT id, ?, ?, ?, ?, ?
                                                FROM tasks
                                                WHERE status NOT IN ('done', 'archived')
                                                """,
                                                (
                                                    tgt["platform"],
                                                    tgt["chat_id"],
                                                    tgt["thread_id"],
                                                    notifier_profile,
                                                    now,
                                                ),
                                            )
                                    except Exception as exc:
                                        logger.debug(
                                            "kanban notifier: default-notify bulk subscribe failed for %s on %s: %s",
                                            tgt["chat_id"], tgt["platform"], exc,
                                        )


subs = _kb.list_notify_subs(conn)
if not subs:
logger.debug("kanban notifier: board %s has no subscriptions", slug)
Expand Down
11 changes: 11 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2444,6 +2444,17 @@ def _ensure_hermes_home_managed(home: Path):
# worker process (if still running host-locally) is terminated
# before the reclaim. 0 disables stale detection entirely.
"dispatch_stale_timeout_seconds": 14400,
# Board-wide auto-subscribe. Each entry fans EVERY ticket's terminal
# events (completed / blocked / gave_up / crashed / timed_out, plus
# completion artifacts) out to a chat with zero manual
# ``hermes kanban notify-subscribe``. The gateway notifier applies
# these to every active task each tick (idempotent INSERT-OR-IGNORE,
# so per-task subscriptions are never disturbed). Read fresh each
# tick — editing this list takes effect on the next tick, no restart.
# Entries are dicts: {platform, chat_id, thread_id?}. ``chat_id`` is
# the adapter's send-format id (e.g. Signal group: "group:<base64>").
# Empty list = feature off (default).
"default_notify": [],
},

# execute_code settings — controls the tool used for programmatic tool calls.
Expand Down
Loading