Skip to content
Merged
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
94 changes: 94 additions & 0 deletions gateway/kanban_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,56 @@ 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 {}
if not isinstance(kcfg, dict):
# A malformed live edit (e.g. ``kanban: false`` or a YAML list) must
# not raise out of the notifier loop — that would skip ALL per-task
# delivery every tick. Fail safe to "no default targets".
return []
raw = kcfg.get("default_notify") or []
Comment on lines +83 to +89

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 +242,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 +299,45 @@ 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:
# One bulk INSERT-OR-IGNORE per connected target
# instead of a per-task write txn each tick: the
# SELECT picks every active (non-final) task in a
# single transaction. Idempotent on the PK, so an
# existing per-task subscription (cursor /
# fail-count) is never disturbed.
for tgt in default_notify_targets:
if tgt["platform"] not in active_platforms:
continue
try:
_kb.add_default_notify_subs(
conn,
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",
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
35 changes: 35 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -7878,6 +7878,41 @@ def list_notify_subs(
return [dict(r) for r in rows]


def add_default_notify_subs(
conn: sqlite3.Connection,
*,
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
notifier_profile: Optional[str] = None,
final_statuses: Iterable[str] = ("done", "archived"),
) -> None:
"""Subscribe one board-wide target to every active (non-final) task in a
single write transaction.

Equivalent to calling :func:`add_notify_sub` once per active task, but
issues one ``INSERT OR IGNORE ... SELECT`` instead of N IMMEDIATE write
transactions per notifier tick — the per-task loop opened a write txn for
every active task every 5s even when the rows already existed. Idempotent
on the (task, platform, chat, thread) PK, so existing per-task or default
subscriptions (and their cursors) are never disturbed.
"""
finals = tuple(final_statuses)
placeholders = ",".join("?" for _ in finals)
now = int(time.time())
with write_txn(conn):
conn.execute(
f"""
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 ({placeholders})
""",
(platform, chat_id, thread_id or "", notifier_profile, now, *finals),
)


def remove_notify_sub(
conn: sqlite3.Connection,
*,
Expand Down
Loading