hermes-agent/gateway/kanban_watchers.py
Teknium 1f4c5aed6d
fix(kanban): honor kanban.auto_decompose toggle live, without a gateway restart (#50358)
The gateway dispatcher captured kanban.auto_decompose ONCE at boot, so a user
who flipped it to false to STOP auto-decompose had no way to make that take
effect short of restarting the gateway. Reported (#49638): auto-decompose
created and launched tasks the user never intended (while they were still
typing the task description), and 'even Hermes Agent couldn't disable this
feature' — because the live config edit was silently ignored.

Auto-decompose is a safety toggle; turning it off must halt fan-out on the
next tick. The dispatcher now re-reads the flag (and auto_decompose_per_tick)
from config every tick via the extracted _resolve_auto_decompose_settings(),
which fails SAFE (disabled) on a config read error so a transient failure can
never re-enable a feature the user turned off.

Closes #49638.
2026-06-21 12:43:44 -07:00

1185 lines
56 KiB
Python

"""Kanban board watcher methods for GatewayRunner.
Extracted verbatim from ``gateway/run.py`` (god-file decomposition Phase 3).
These are the background-loop methods that subscribe to kanban boards, deliver
notifications/artifacts, and drive the multi-agent dispatcher. They use only
``self`` state, so they live on a mixin that ``GatewayRunner`` inherits — the
``self._kanban_*`` call sites resolve identically via the MRO, making this a
behavior-neutral move that lifts ~1,000 LOC out of run.py.
"""
from __future__ import annotations
import asyncio
import logging
import os
import sqlite3
import time
from pathlib import Path
from typing import Any, Callable, Optional
# Match the logger run.py uses (logging.getLogger(__name__) where __name__ ==
# "gateway.run") so extracted log records keep their original logger name.
logger = logging.getLogger("gateway.run")
def _resolve_auto_decompose_settings(
load_config: Callable[[], Any],
) -> "tuple[bool, int]":
"""Resolve the live (enabled, per_tick) auto-decompose settings.
Read fresh from config on every dispatcher tick (#49638) so that flipping
``kanban.auto_decompose: false`` to STOP runaway fan-out takes effect on the
next tick instead of requiring a gateway restart. Auto-decompose is a
safety toggle — a user who sees it create and launch tasks they didn't
intend reaches for this flag to halt it, and a stale boot-captured value
silently ignoring that change is the bug reported in #49638.
Fails **safe**: if the config read raises, return ``(False, 3)`` — a
transient read error must never re-enable a feature the user turned off,
nor fall back to the burst-prone default-on behaviour. ``per_tick`` is
clamped to ``>= 1``.
"""
try:
cfg = load_config()
except Exception:
return False, 3
kcfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
enabled = bool(kcfg.get("auto_decompose", True))
try:
per_tick = int(kcfg.get("auto_decompose_per_tick", 3) or 3)
except (TypeError, ValueError):
per_tick = 3
if per_tick < 1:
per_tick = 1
return enabled, per_tick
def _acquire_singleton_lock(lock_path) -> "tuple[Optional[object], str]":
"""Take an exclusive, non-blocking advisory lock for the sole dispatcher.
Only one gateway process machine-wide may run the embedded kanban
dispatcher: concurrent dispatchers double the reclaim frequency (each
runs its own ``release_stale_claims`` → promote → dispatch loop), double
claim-attempt events in the event log, and — with ``wal_autocheckpoint=0`` —
concurrent manual WAL checkpoints can corrupt index pages. The
``dispatch_in_gateway`` config flag is the primary control; this lock is the
backstop that survives config drift and same-profile restart races.
Delegates to :func:`gateway.status._try_acquire_file_lock` (``fcntl`` on
POSIX, ``msvcrt`` on Windows) so the guard is cross-platform.
Returns ``(handle, "held")`` on success — the caller keeps the file handle
for the process lifetime and **must** release it via
:func:`_release_singleton_lock` when done. ``(None, "contended")`` when
another process holds the lock (caller must NOT dispatch). ``(None,
"unavailable")`` when locking cannot be performed (non-POSIX filesystem
without flock, or the status.py helpers are unimportable) — caller falls
back to config-only control.
"""
try:
from gateway.status import _try_acquire_file_lock # deferred; same package
except ImportError:
return None, "unavailable"
try:
Path(lock_path).parent.mkdir(parents=True, exist_ok=True)
handle = open(str(lock_path), "a+", encoding="utf-8")
except OSError:
return None, "unavailable"
if not _try_acquire_file_lock(handle):
handle.close()
return None, "contended"
return handle, "held"
def _release_singleton_lock(handle) -> None:
"""Release a dispatcher singleton lock acquired via :func:`_acquire_singleton_lock`."""
if handle is None:
return
try:
from gateway.status import _release_file_lock
_release_file_lock(handle)
except Exception:
pass
try:
handle.close()
except Exception:
pass
class GatewayKanbanWatchersMixin:
"""Kanban watcher / notifier / dispatcher loops for GatewayRunner."""
async def _kanban_notifier_watcher(self, interval: float = 5.0) -> None:
"""Poll ``kanban_notify_subs`` and deliver terminal events to users.
For each subscription row, fetches ``task_events`` newer than the
stored cursor with kind in the terminal set (``completed``,
``blocked``, ``gave_up``, ``crashed``, ``timed_out``). Sends one
message per new event to ``(platform, chat_id, thread_id)``,
then advances the cursor. When a task reaches a terminal state
(``completed`` / ``archived``), the subscription is removed.
Runs in the gateway event loop; all SQLite work is pushed to a
thread via ``asyncio.to_thread`` so the loop never blocks on the
WAL lock. Failures in one tick don't stop subsequent ticks.
**Multi-board:** iterates every board discovered on disk per
tick. Subscriptions live inside each board's own DB and cannot
cross boards, so delivery semantics are unchanged — this is
purely a fan-out of the single-DB poll.
"""
# Gate: only the dispatch-owning gateway opens kanban DBs for notifier polling.
# Non-dispatch gateways have no subscriptions to deliver — all kanban state lives
# in the dispatch owner's per-board DBs. This prevents N-gateway -shm contention.
# TODO: gate per-board when per-board dispatcher_owner tracking lands.
try:
from hermes_cli.config import load_config as _load_config
except Exception:
logger.warning("kanban notifier: config loader unavailable; disabled")
return
env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower()
if env_override in {"0", "false", "no", "off"}:
logger.info("kanban notifier: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env")
return
try:
cfg = _load_config()
except Exception as exc:
logger.warning("kanban notifier: cannot load config (%s); disabled", exc)
return
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
if not kanban_cfg.get("dispatch_in_gateway", True):
logger.info(
"kanban notifier: disabled via config kanban.dispatch_in_gateway=false"
)
return
from gateway.config import Platform as _Platform
try:
from hermes_cli import kanban_db as _kb
except Exception:
logger.warning("kanban notifier: kanban_db not importable; notifier disabled")
return
TERMINAL_KINDS = ("completed", "blocked", "gave_up", "crashed", "timed_out")
# Subscriptions are removed only when the task reaches a truly final
# status (done / archived). We used to also unsub on any terminal
# event kind (gave_up / crashed / timed_out / blocked), but that
# silently dropped the user out of the loop whenever the dispatcher
# respawned the task: a worker that crashes, gets reclaimed, runs
# again, and crashes a second time would only notify on the first
# crash because the subscription was deleted after the first event.
# Same shape as the reblock-after-unblock cycle that PR #22941
# fixed for `blocked`. Keeping the subscription alive until the
# task is genuinely done lets the cursor (advanced atomically by
# claim_unseen_events_for_sub) handle dedup, and any retry-loop
# event reaches the user.
# Per-subscription send-failure counter. Adapter.send raising
# means the chat is dead (deleted, bot kicked, etc.) — after N
# consecutive send failures the sub is dropped so we don't spin
# against a dead chat every 5 seconds forever.
MAX_SEND_FAILURES = 3
sub_fail_counts: dict[tuple, int] = getattr(
self, "_kanban_sub_fail_counts", {}
)
self._kanban_sub_fail_counts = sub_fail_counts
notifier_profile = getattr(self, "_kanban_notifier_profile", None)
if not notifier_profile:
notifier_profile = self._active_profile_name()
self._kanban_notifier_profile = notifier_profile
# Initial delay so the gateway can finish wiring adapters.
await asyncio.sleep(5)
while self._running:
try:
def _collect():
deliveries: list[dict] = []
active_platforms = {
getattr(platform, "value", str(platform)).lower()
for platform in self.adapters.keys()
}
if not active_platforms:
logger.debug("kanban notifier: no connected adapters; skipping tick")
return deliveries
# Enumerate every board on disk, but poll each resolved DB
# path once. Multiple slugs can point at the same DB when
# HERMES_KANBAN_DB pins the board path; without this guard
# one gateway could collect the same subscription/event
# more than once before advancing the cursor.
try:
boards = _kb.list_boards(include_archived=False)
except Exception:
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
seen_db_paths: set[str] = set()
for board_meta in boards:
slug = board_meta.get("slug") or _kb.DEFAULT_BOARD
db_path = board_meta.get("db_path")
try:
resolved_db_path = str(Path(db_path).expanduser().resolve()) if db_path else str(_kb.kanban_db_path(slug).resolve())
except Exception:
resolved_db_path = f"slug:{slug}"
if resolved_db_path in seen_db_paths:
logger.debug(
"kanban notifier: skipping duplicate board slug %s for DB %s",
slug, resolved_db_path,
)
continue
seen_db_paths.add(resolved_db_path)
try:
conn = _kb.connect(board=slug)
except Exception as exc:
logger.debug("kanban notifier: cannot open board %s: %s", slug, exc)
continue
try:
# `connect()` runs the schema + idempotent migration
# on first open per process, so an explicit
# `init_db()` here would be redundant. Worse:
# `init_db()` deliberately busts the per-process
# cache and re-runs the migration on a *second*
# connection, which races the first and used to
# log a benign but noisy `duplicate column name`
# traceback (and intermittent "database is locked"
# — issue #21378) on every gateway start against
# a legacy DB. `_add_column_if_missing` now
# tolerates that race, but we still skip the
# redundant call to avoid the wasted work.
subs = _kb.list_notify_subs(conn)
if not subs:
logger.debug("kanban notifier: board %s has no subscriptions", slug)
for sub in subs:
owner_profile = sub.get("notifier_profile") or None
if owner_profile and owner_profile != notifier_profile:
logger.debug(
"kanban notifier: subscription for %s owned by profile %s; current profile %s skipping",
sub.get("task_id"), owner_profile, notifier_profile,
)
continue
platform = (sub.get("platform") or "").lower()
if platform not in active_platforms:
logger.debug(
"kanban notifier: subscription for %s on %s skipped; adapter not connected",
sub.get("task_id"), platform or "<missing>",
)
continue
old_cursor, cursor, events = _kb.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,
)
if not events:
continue
task = _kb.get_task(conn, sub["task_id"])
logger.debug(
"kanban notifier: claimed %d event(s) for %s on board %s cursor %s%s",
len(events), sub["task_id"], slug, old_cursor, cursor,
)
deliveries.append({
"sub": sub,
"old_cursor": old_cursor,
"cursor": cursor,
"events": events,
"task": task,
"board": slug,
})
finally:
conn.close()
return deliveries
deliveries = await asyncio.to_thread(_collect)
for d in deliveries:
sub = d["sub"]
task = d["task"]
board_slug = d.get("board")
platform_str = (sub["platform"] or "").lower()
try:
plat = _Platform(platform_str)
except ValueError:
# Unknown platform string; skip and advance cursor so
# we don't replay forever.
await asyncio.to_thread(
self._kanban_advance, sub, d["cursor"], board_slug,
)
continue
adapter = self.adapters.get(plat)
if adapter is None:
logger.debug(
"kanban notifier: adapter %s disconnected before delivery for %s; rewinding claim",
platform_str, sub["task_id"],
)
await asyncio.to_thread(
self._kanban_rewind,
sub,
d["cursor"],
d.get("old_cursor", 0),
board_slug,
)
continue
title = (task.title if task else sub["task_id"])[:120]
for ev in d["events"]:
kind = ev.kind
# Identity prefix: attribute terminal pings to the
# worker that did the work. Makes fleets (where one
# chat subscribes to many tasks) legible at a glance.
who = (task.assignee if task and task.assignee else None)
tag = f"@{who} " if who else ""
if kind == "completed":
# Prefer the run's summary (the worker's
# intentional human-facing handoff, carried
# in the event payload), then fall back to
# task.result for legacy rows written before
# runs shipped.
handoff = ""
payload_summary = None
if ev.payload and ev.payload.get("summary"):
payload_summary = str(ev.payload["summary"])
if payload_summary:
lines = payload_summary.strip().splitlines()
h = lines[0][:200] if lines else payload_summary[:200]
handoff = f"\n{h}"
elif task and task.result:
lines = task.result.strip().splitlines()
r = lines[0][:160] if lines else task.result[:160]
handoff = f"\n{r}"
msg = (
f"{tag}Kanban {sub['task_id']} done"
f"{title}{handoff}"
)
elif kind == "blocked":
reason = ""
if ev.payload and ev.payload.get("reason"):
reason = f": {str(ev.payload['reason'])[:160]}"
msg = f"{tag}Kanban {sub['task_id']} blocked{reason}"
elif kind == "gave_up":
err = ""
if ev.payload and ev.payload.get("error"):
err = f"\n{str(ev.payload['error'])[:200]}"
msg = (
f"{tag}Kanban {sub['task_id']} gave up "
f"after repeated spawn failures{err}"
)
elif kind == "crashed":
msg = (
f"{tag}Kanban {sub['task_id']} worker crashed "
f"(pid gone); dispatcher will retry"
)
elif kind == "timed_out":
limit = 0
if ev.payload and ev.payload.get("limit_seconds"):
limit = int(ev.payload["limit_seconds"])
msg = (
f"{tag}Kanban {sub['task_id']} timed out "
f"(max_runtime={limit}s); will retry"
)
else:
continue
metadata: dict[str, Any] = {}
if sub.get("thread_id"):
metadata["thread_id"] = sub["thread_id"]
sub_key = (
sub["task_id"], sub["platform"],
sub["chat_id"], sub.get("thread_id") or "",
)
try:
await adapter.send(
sub["chat_id"], msg, metadata=metadata,
)
logger.debug(
"kanban notifier: delivered %s event for %s to %s/%s on board %s",
kind, sub["task_id"], platform_str, sub["chat_id"], board_slug,
)
# After delivering the text notification, surface
# any artifact paths the worker referenced in
# ``kanban_complete(summary=..., artifacts=[...])``
# (or the legacy ``result`` field) as native
# uploads. ``extract_local_files`` finds bare
# absolute paths in the summary;
# ``send_document`` / ``send_image_file`` uploads
# them. Only fires on the ``completed`` event so
# we never spam attachments on retries.
if kind == "completed":
try:
await self._deliver_kanban_artifacts(
adapter=adapter,
chat_id=sub["chat_id"],
metadata=metadata,
event_payload=getattr(ev, "payload", None),
task=task,
)
except Exception as art_exc:
logger.debug(
"kanban notifier: artifact delivery for %s failed: %s",
sub["task_id"], art_exc,
)
# Reset the failure counter on success.
sub_fail_counts.pop(sub_key, None)
except Exception as exc:
fails = sub_fail_counts.get(sub_key, 0) + 1
sub_fail_counts[sub_key] = fails
logger.warning(
"kanban notifier: send failed for %s on %s "
"(attempt %d/%d): %s",
sub["task_id"], platform_str, fails,
MAX_SEND_FAILURES, exc,
)
if fails >= MAX_SEND_FAILURES:
logger.warning(
"kanban notifier: dropping subscription "
"%s on %s after %d consecutive send failures",
sub["task_id"], platform_str, fails,
)
await asyncio.to_thread(self._kanban_unsub, sub, board_slug)
sub_fail_counts.pop(sub_key, None)
else:
await asyncio.to_thread(
self._kanban_rewind,
sub,
d["cursor"],
d.get("old_cursor", 0),
board_slug,
)
# Rewind the pre-send claim on transient failure so
# a later tick can retry. After too many failures,
# dropping the subscription is the terminal action.
break
else:
# All events delivered; advance cursor. The cursor
# is the dedup mechanism — it prevents re-delivery
# of the same event on subsequent ticks.
await asyncio.to_thread(
self._kanban_advance, sub, d["cursor"], board_slug,
)
# Unsubscribe only when the task has reached a truly
# final status (done / archived). For blocked /
# gave_up / crashed / timed_out the subscription is
# kept alive so the user gets notified again if the
# dispatcher respawns the task and it cycles into the
# same state. See the longer comment on TERMINAL_KINDS
# above for the failure mode this prevents.
task_terminal = task and task.status in {"done", "archived"}
if task_terminal:
await asyncio.to_thread(
self._kanban_unsub, sub, board_slug,
)
except Exception as exc:
logger.warning("kanban notifier tick failed: %s", exc)
# Sleep with cancellation checks.
for _ in range(int(max(1, interval))):
if not self._running:
return
await asyncio.sleep(1)
def _kanban_advance(
self, sub: dict, cursor: int, board: Optional[str] = None,
) -> None:
"""Sync helper: advance a subscription's cursor. Runs in to_thread.
``board`` scopes the DB connection to the board that owns this
subscription. Unsub cursors in one board can't touch another's.
"""
from hermes_cli import kanban_db as _kb
conn = _kb.connect(board=board)
try:
_kb.advance_notify_cursor(
conn,
task_id=sub["task_id"],
platform=sub["platform"],
chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
new_cursor=cursor,
)
finally:
conn.close()
def _kanban_unsub(self, sub: dict, board: Optional[str] = None) -> None:
from hermes_cli import kanban_db as _kb
conn = _kb.connect(board=board)
try:
_kb.remove_notify_sub(
conn,
task_id=sub["task_id"],
platform=sub["platform"],
chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
)
finally:
conn.close()
def _kanban_rewind(
self,
sub: dict,
claimed_cursor: int,
old_cursor: int,
board: Optional[str] = None,
) -> None:
"""Sync helper: undo a claimed notification cursor after send failure."""
from hermes_cli import kanban_db as _kb
conn = _kb.connect(board=board)
try:
_kb.rewind_notify_cursor(
conn,
task_id=sub["task_id"],
platform=sub["platform"],
chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
claimed_cursor=claimed_cursor,
old_cursor=old_cursor,
)
finally:
conn.close()
async def _deliver_kanban_artifacts(
self,
*,
adapter,
chat_id: str,
metadata: dict,
event_payload: Optional[dict],
task,
) -> None:
"""Upload artifact files referenced by a completed kanban task.
Workers passing ``kanban_complete(artifacts=[...])`` ship absolute
file paths through the completion event so downstream humans get
the deliverable as a native upload instead of a path printed in
chat.
Sources scanned, in priority order:
1. ``event_payload['artifacts']`` (explicit list — preferred)
2. ``event_payload['summary']`` (truncated first line)
3. ``task.result`` (legacy fallback)
Files are deduplicated, missing files are silently skipped (the
path may have been mentioned for reference only), and delivery
errors are logged but do not break the notifier loop.
"""
from pathlib import Path as _Path
candidates: list[str] = []
seen: set[str] = set()
def _add(path: str) -> None:
if not path:
return
expanded = os.path.expanduser(path)
if expanded in seen:
return
if not os.path.isfile(expanded):
return
seen.add(expanded)
candidates.append(expanded)
# 1. Explicit artifacts list in payload.
if isinstance(event_payload, dict):
raw = event_payload.get("artifacts")
if isinstance(raw, (list, tuple)):
for item in raw:
if isinstance(item, str):
_add(item)
# 2. Paths embedded in the payload summary.
summary = event_payload.get("summary")
if isinstance(summary, str) and summary:
paths, _ = adapter.extract_local_files(summary)
for p in paths:
_add(p)
# 3. Legacy: paths embedded in task.result.
if task is not None and getattr(task, "result", None):
result_text = str(task.result)
paths, _ = adapter.extract_local_files(result_text)
for p in paths:
_add(p)
if not candidates:
return
from gateway.platforms.base import BasePlatformAdapter
candidates = BasePlatformAdapter.filter_local_delivery_paths(candidates)
if not candidates:
return
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
from urllib.parse import quote as _quote
# Partition images so they ride a single send_multiple_images call
# on platforms that support batch image uploads (Signal/Slack RPCs).
image_paths = [p for p in candidates if _Path(p).suffix.lower() in _IMAGE_EXTS]
other_paths = [p for p in candidates if _Path(p).suffix.lower() not in _IMAGE_EXTS]
if image_paths:
try:
batch = [(f"file://{_quote(p)}", "") for p in image_paths]
await adapter.send_multiple_images(
chat_id=chat_id, images=batch, metadata=metadata,
)
except Exception as exc:
logger.warning(
"kanban notifier: image batch upload failed: %s", exc,
)
for path in other_paths:
ext = _Path(path).suffix.lower()
try:
if ext in _VIDEO_EXTS:
await adapter.send_video(
chat_id=chat_id, video_path=path, metadata=metadata,
)
else:
await adapter.send_document(
chat_id=chat_id, file_path=path, metadata=metadata,
)
except Exception as exc:
logger.warning(
"kanban notifier: artifact upload (%s) failed: %s",
path, exc,
)
async def _kanban_dispatcher_watcher(self) -> None:
"""Embedded kanban dispatcher — one tick every `dispatch_interval_seconds`.
Gated by `kanban.dispatch_in_gateway` in config.yaml (default True).
When true, the gateway hosts the single dispatcher for this profile:
no separate `hermes kanban daemon` process needed. When false, the
loop exits immediately and an external daemon is expected.
Each tick calls :func:`kanban_db.dispatch_once` inside
``asyncio.to_thread`` so the SQLite WAL lock never blocks the
event loop. Failures in one tick don't stop subsequent ticks —
same pattern as `_kanban_notifier_watcher`.
Shutdown: the loop checks ``self._running`` between ticks; gateway
stop() flips it to False and cancels pending tasks, and the
in-flight ``to_thread`` returns on its own after the current
``dispatch_once`` call finishes (typically <1ms on an idle board).
"""
# Read config once at boot. If the user flips the flag later, they
# restart the gateway; same pattern as every other background
# watcher here. Honours HERMES_KANBAN_DISPATCH_IN_GATEWAY env var
# as an escape hatch (false-y value disables without editing YAML).
try:
from hermes_cli.config import load_config as _load_config
except Exception:
logger.warning("kanban dispatcher: config loader unavailable; disabled")
return
env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower()
if env_override in {"0", "false", "no", "off"}:
logger.info("kanban dispatcher: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env")
return
try:
cfg = _load_config()
except Exception as exc:
logger.warning("kanban dispatcher: cannot load config (%s); disabled", exc)
return
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
if not kanban_cfg.get("dispatch_in_gateway", True):
logger.info(
"kanban dispatcher: disabled via config kanban.dispatch_in_gateway=false"
)
return
try:
from hermes_cli import kanban_db as _kb
except Exception:
logger.warning("kanban dispatcher: kanban_db not importable; dispatcher disabled")
return
# Single-dispatcher backstop. dispatch_in_gateway defaults to true, so a
# new profile gateway (or a same-profile restart race) can silently
# start a second dispatcher; concurrent dispatchers double reclaim
# frequency, double claim-attempt events, and — with
# wal_autocheckpoint=0 — concurrent manual WAL checkpoints can corrupt
# index pages. The lock lives at the machine-global kanban root
# (shared across profiles by design), so it serialises ALL gateways.
self._kanban_dispatcher_lock_handle = None
_lock_path = _kb.kanban_home() / "kanban" / ".dispatcher.lock"
_lock_handle, _lock_state = _acquire_singleton_lock(_lock_path)
if _lock_state == "contended":
logger.info(
"kanban dispatcher: another gateway already holds the dispatcher "
"lock (%s); this gateway will NOT dispatch.", _lock_path,
)
return
if _lock_state == "held":
self._kanban_dispatcher_lock_handle = _lock_handle # hold for process lifetime
logger.info("kanban dispatcher: holding singleton dispatcher lock (%s)", _lock_path)
else:
logger.warning(
"kanban dispatcher: advisory lock unavailable at %s; proceeding "
"on config control alone.", _lock_path,
)
try:
interval = float(kanban_cfg.get("dispatch_interval_seconds", 60) or 60)
except (ValueError, TypeError):
logger.warning(
"kanban dispatcher: invalid dispatch_interval_seconds=%r, using default 60",
kanban_cfg.get("dispatch_interval_seconds"),
)
interval = 60.0
interval = max(interval, 1.0) # sanity floor — tighter than this is a footgun
# Read max_spawn config to limit concurrent kanban tasks
max_spawn = kanban_cfg.get("max_spawn", None)
if max_spawn is not None:
logger.info(f"kanban dispatcher: max_spawn={max_spawn}")
# Cap the number of simultaneously running tasks so slow workers
# (local LLMs, resource-constrained hosts) don't pile up and time
# out. When set, the dispatcher skips spawning when the board
# already has this many tasks in 'running' status.
raw_max_in_progress = kanban_cfg.get("max_in_progress", None)
max_in_progress = None
if raw_max_in_progress is not None:
try:
max_in_progress = int(raw_max_in_progress)
except (TypeError, ValueError):
logger.warning(
"kanban dispatcher: invalid kanban.max_in_progress=%r; ignoring",
raw_max_in_progress,
)
max_in_progress = None
else:
if max_in_progress < 1:
logger.warning(
"kanban dispatcher: kanban.max_in_progress=%r is below 1; ignoring",
raw_max_in_progress,
)
max_in_progress = None
else:
logger.info(f"kanban dispatcher: max_in_progress={max_in_progress}")
raw_failure_limit = kanban_cfg.get("failure_limit", _kb.DEFAULT_FAILURE_LIMIT)
try:
failure_limit = int(raw_failure_limit)
except (TypeError, ValueError):
logger.warning(
"kanban dispatcher: invalid kanban.failure_limit=%r; using default %d",
raw_failure_limit,
_kb.DEFAULT_FAILURE_LIMIT,
)
failure_limit = _kb.DEFAULT_FAILURE_LIMIT
if failure_limit < 1:
logger.warning(
"kanban dispatcher: kanban.failure_limit=%r is below 1; using default %d",
raw_failure_limit,
_kb.DEFAULT_FAILURE_LIMIT,
)
failure_limit = _kb.DEFAULT_FAILURE_LIMIT
# Read stale_timeout_seconds — 0 disables stale detection.
raw_stale = kanban_cfg.get("dispatch_stale_timeout_seconds", 0)
try:
stale_timeout_seconds = int(raw_stale or 0)
except (TypeError, ValueError):
logger.warning(
"kanban dispatcher: invalid kanban.dispatch_stale_timeout_seconds=%r; "
"disabling stale detection",
raw_stale,
)
stale_timeout_seconds = 0
# Read kanban.default_assignee — fallback profile for tasks
# created without an explicit assignee (e.g. via the dashboard).
# When set, the dispatcher applies it to unassigned ready tasks
# instead of skipping them indefinitely (#27145). Empty string
# (the schema default) means "no fallback, keep skipping" —
# backward-compatible with existing installs.
default_assignee = (kanban_cfg.get("default_assignee") or "").strip() or None
if default_assignee:
logger.info(
"kanban dispatcher: default_assignee=%r (unassigned ready tasks "
"will route to this profile)",
default_assignee,
)
# Read kanban.max_in_progress_per_profile — per-profile concurrency
# cap (#21582). When set, no single profile gets more than N
# workers running at once, even if the global max_in_progress
# would allow it. Prevents one profile's local model / API quota
# / browser pool from being overwhelmed by a fan-out.
raw_per_profile = kanban_cfg.get("max_in_progress_per_profile", None)
max_in_progress_per_profile = None
if raw_per_profile is not None:
try:
max_in_progress_per_profile = int(raw_per_profile)
except (TypeError, ValueError):
logger.warning(
"kanban dispatcher: invalid kanban.max_in_progress_per_profile=%r; ignoring",
raw_per_profile,
)
max_in_progress_per_profile = None
else:
if max_in_progress_per_profile < 1:
logger.warning(
"kanban dispatcher: kanban.max_in_progress_per_profile=%r is below 1; ignoring",
raw_per_profile,
)
max_in_progress_per_profile = None
else:
logger.info(
"kanban dispatcher: max_in_progress_per_profile=%d",
max_in_progress_per_profile,
)
# Initial delay so the gateway finishes wiring adapters before the
# dispatcher spawns workers (those workers may hit gateway notify
# subscriptions etc.). Matches the notifier watcher's delay.
await asyncio.sleep(5)
# Health telemetry mirrored from `_cmd_daemon`: warn when ready
# queue is non-empty but spawns are 0 for N consecutive ticks —
# usually means broken PATH, missing venv, or credential loss.
HEALTH_WINDOW = 6
bad_ticks = 0
last_warn_at = 0
# Avoid hot-looping corrupt-looking board DBs, but do not suppress
# same-fingerprint retries forever: transient WAL/open races can
# surface as "database disk image is malformed" for one tick.
CORRUPT_BOARD_RETRY_AFTER_SECONDS = 300
disabled_corrupt_boards: dict[
str, tuple[tuple[str, int | None, int | None], float]
] = {}
def _board_db_fingerprint(slug: str) -> tuple[str, int | None, int | None]:
path = _kb.kanban_db_path(slug)
try:
resolved = str(path.expanduser().resolve())
except Exception:
resolved = str(path)
try:
stat = path.stat()
except OSError:
return (resolved, None, None)
return (resolved, stat.st_mtime_ns, stat.st_size)
def _is_corrupt_board_db_error(exc: Exception) -> bool:
corrupt_guard_error = getattr(_kb, "KanbanDbCorruptError", None)
if corrupt_guard_error is not None and isinstance(exc, corrupt_guard_error):
return True
if not isinstance(exc, sqlite3.DatabaseError):
return False
msg = str(exc).lower()
return (
"file is not a database" in msg
or "database disk image is malformed" in msg
)
def _tick_once_for_board(slug: str) -> "Optional[object]":
"""Run one dispatch_once for a specific board.
Runs in a worker thread via `asyncio.to_thread`. `board=slug`
is passed through `dispatch_once` so `resolve_workspace` and
`_default_spawn` see the right paths. The per-board DB is
opened explicitly so concurrent boards never share a
connection handle or accidentally claim across each other.
"""
conn = None
fingerprint = _board_db_fingerprint(slug)
disabled_entry = disabled_corrupt_boards.get(slug)
if disabled_entry is not None:
disabled_fingerprint, disabled_at = disabled_entry
age = time.monotonic() - disabled_at
if (
disabled_fingerprint == fingerprint
and age < CORRUPT_BOARD_RETRY_AFTER_SECONDS
):
return None
if disabled_fingerprint == fingerprint:
logger.info(
"kanban dispatcher: board %s database fingerprint unchanged "
"after %.0fs quarantine; retrying dispatch",
slug,
age,
)
else:
logger.info(
"kanban dispatcher: board %s database changed; retrying dispatch",
slug,
)
disabled_corrupt_boards.pop(slug, None)
try:
conn = _kb.connect(board=slug)
# `connect()` runs the schema + idempotent migration on
# first open per process; the previous explicit
# `init_db()` call here busted the per-process cache and
# re-ran the migration on a second connection, racing
# the first. See the matching comment in
# `_kanban_notifier_watcher` and issue #21378.
return _kb.dispatch_once(
conn,
board=slug,
max_spawn=max_spawn,
max_in_progress=max_in_progress,
failure_limit=failure_limit,
stale_timeout_seconds=stale_timeout_seconds,
default_assignee=default_assignee,
max_in_progress_per_profile=max_in_progress_per_profile,
)
except sqlite3.DatabaseError as exc:
if _is_corrupt_board_db_error(exc):
disabled_corrupt_boards[slug] = (fingerprint, time.monotonic())
logger.error(
"kanban dispatcher: board %s database %s is not a valid "
"SQLite database; pausing dispatch for this board until "
"the file changes, the gateway restarts, or the "
"quarantine timer expires. Move or restore the file, "
"then run `hermes kanban init` if you need a fresh board.",
slug,
fingerprint[0],
)
return None
logger.exception("kanban dispatcher: tick failed on board %s", slug)
return None
except Exception as exc:
if _is_corrupt_board_db_error(exc):
disabled_corrupt_boards[slug] = (fingerprint, time.monotonic())
logger.error(
"kanban dispatcher: board %s database %s is not a valid "
"SQLite database; pausing dispatch for this board until "
"the file changes, the gateway restarts, or the "
"quarantine timer expires. Move or restore the file, "
"then run `hermes kanban init` if you need a fresh board.",
slug,
fingerprint[0],
)
return None
logger.exception("kanban dispatcher: tick failed on board %s", slug)
return None
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
def _tick_once() -> "list[tuple[str, Optional[object]]]":
"""Run one dispatch_once per board. Returns (slug, result) pairs.
Enumerating boards on every tick keeps the dispatcher honest
when users create a new board mid-run: no restart required,
the next tick picks it up automatically.
"""
try:
boards = _kb.list_boards(include_archived=False)
except Exception:
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
out: list[tuple[str, "Optional[object]"]] = []
for b in boards:
slug = b.get("slug") or _kb.DEFAULT_BOARD
out.append((slug, _tick_once_for_board(slug)))
return out
def _ready_nonempty() -> bool:
"""Cheap probe: is there at least one ready+assigned+unclaimed
task on ANY board whose assignee maps to a real Hermes profile
(i.e. one the dispatcher would actually spawn for)?
Tasks assigned to control-plane lanes (e.g. ``orion-cc``,
``orion-research``) are pulled by terminals via
``claim_task`` directly and never spawnable, so a queue full
of those is "correctly idle", not "stuck". Filtering them out
here keeps the stuck-warn fire only on real failures (broken
PATH, missing venv, credential loss for a real Hermes profile).
"""
try:
boards = _kb.list_boards(include_archived=False)
except Exception:
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
for b in boards:
slug = b.get("slug") or _kb.DEFAULT_BOARD
conn = None
try:
conn = _kb.connect(board=slug)
if _kb.has_spawnable_ready(conn):
return True
if _kb.has_spawnable_review(conn):
return True
except Exception:
continue
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
return False
# Auto-decompose: turn fresh triage tasks into ready workgraphs
# before the dispatcher fans out workers. Gated by
# ``kanban.auto_decompose`` (default True). Capped by
# ``kanban.auto_decompose_per_tick`` (default 3) so a bulk-load
# of triage tasks doesn't burst-spend the aux LLM in one tick;
# remainder defers to subsequent ticks.
#
# The flag is re-read from config EVERY tick (#49638) rather than
# captured once at boot. Auto-decompose is a safety toggle: a user who
# sees it fan out and run tasks they didn't intend reaches for
# ``kanban.auto_decompose: false`` to STOP it — and that must take
# effect on the next tick, not require a gateway restart. (Reported:
# auto-decompose created and launched destructive tasks while the user
# was still typing the task description, and the flag "couldn't be
# disabled" because the gateway had captured its boot-time value.)
def _read_auto_decompose_settings() -> tuple[bool, int]:
"""Re-resolve (enabled, per_tick) from current config each tick."""
return _resolve_auto_decompose_settings(_load_config)
def _auto_decompose_tick(auto_decompose_per_tick: int) -> int:
"""Run the auto-decomposer for up to N triage tasks across all
boards. Returns the number of triage tasks that were
successfully decomposed or specified this tick.
"""
try:
from hermes_cli import kanban_decompose as _decomp
except Exception as exc: # pragma: no cover
logger.warning(
"kanban auto-decompose: import failed (%s); skipping", exc,
)
return 0
try:
boards = _kb.list_boards(include_archived=False)
except Exception:
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
attempted = 0
successes = 0
for b in boards:
slug = b.get("slug") or _kb.DEFAULT_BOARD
if attempted >= auto_decompose_per_tick:
break
# Pin this board for the duration of the call — same
# pattern as the dashboard specify endpoint. The
# decomposer module connects with no board kwarg and
# relies on the env var.
prev_env = os.environ.get("HERMES_KANBAN_BOARD")
try:
os.environ["HERMES_KANBAN_BOARD"] = slug
try:
triage_ids = _decomp.list_triage_ids()
except Exception as exc:
logger.debug(
"kanban auto-decompose: list_triage_ids failed on board %s (%s)",
slug, exc,
)
triage_ids = []
for tid in triage_ids:
if attempted >= auto_decompose_per_tick:
break
attempted += 1
try:
outcome = _decomp.decompose_task(
tid, author="auto-decomposer",
)
except Exception:
logger.exception(
"kanban auto-decompose: decompose_task crashed on %s",
tid,
)
continue
if outcome.ok:
successes += 1
if outcome.fanout and outcome.child_ids:
logger.info(
"kanban auto-decompose [%s]: %s%d children",
slug, tid, len(outcome.child_ids),
)
else:
logger.info(
"kanban auto-decompose [%s]: %s → single task (no fanout)",
slug, tid,
)
else:
# Common no-op reasons (no aux client configured) shouldn't
# spam logs every tick. Log at debug.
logger.debug(
"kanban auto-decompose [%s]: %s skipped: %s",
slug, tid, outcome.reason,
)
finally:
if prev_env is None:
os.environ.pop("HERMES_KANBAN_BOARD", None)
else:
os.environ["HERMES_KANBAN_BOARD"] = prev_env
return successes
logger.info(
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
)
while self._running:
try:
# Reap zombie children before per-board work so a board DB
# failure cannot block cleanup of unrelated workers.
pids = await asyncio.to_thread(_kb.reap_worker_zombies)
if pids:
logger.info(
"kanban dispatcher: reaped %d zombie worker(s), pids=%s",
len(pids),
pids,
)
except Exception:
logger.exception("kanban dispatcher: zombie reaper failed")
try:
# Re-read the auto-decompose toggle live each tick so a user
# flipping kanban.auto_decompose=false to STOP runaway fan-out
# takes effect on the next tick, not on gateway restart (#49638).
_ad_enabled, _ad_per_tick = _read_auto_decompose_settings()
if _ad_enabled:
await asyncio.to_thread(_auto_decompose_tick, _ad_per_tick)
results = await asyncio.to_thread(_tick_once)
any_spawned = False
for slug, res in (results or []):
if res is not None and getattr(res, "spawned", None):
any_spawned = True
# Quiet by default — only log when something actually
# happened, so an idle gateway stays silent.
logger.info(
"kanban dispatcher [%s]: spawned=%d reclaimed=%d "
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
slug,
len(res.spawned),
res.reclaimed,
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
len(res.timed_out) if hasattr(res.timed_out, "__len__") else 0,
res.promoted,
len(res.auto_blocked) if hasattr(res.auto_blocked, "__len__") else 0,
)
# Health telemetry (aggregate across boards)
ready_pending = await asyncio.to_thread(_ready_nonempty)
if ready_pending and not any_spawned:
bad_ticks += 1
else:
bad_ticks = 0
if bad_ticks >= HEALTH_WINDOW:
now = int(time.time())
if now - last_warn_at >= 300:
logger.warning(
"kanban dispatcher stuck: ready queue non-empty for "
"%d consecutive ticks but 0 workers spawned. Check "
"profile health (venv, PATH, credentials) and "
"`hermes kanban list --status ready`.",
bad_ticks,
)
last_warn_at = now
except asyncio.CancelledError:
logger.debug("kanban dispatcher: cancelled")
_release_singleton_lock(self._kanban_dispatcher_lock_handle)
self._kanban_dispatcher_lock_handle = None
raise
except Exception:
logger.exception("kanban dispatcher: unexpected watcher error")
# Sleep in 1s slices so shutdown is snappy — otherwise a stop()
# waits up to `interval` seconds for the current sleep to finish.
slept = 0.0
while slept < interval and self._running:
await asyncio.sleep(min(1.0, interval - slept))
slept += 1.0
_release_singleton_lock(self._kanban_dispatcher_lock_handle)
self._kanban_dispatcher_lock_handle = None