fix(kanban): per-home dispatch claim allowlist for shared boards

On a shared kanban.db, every home's profile_exists('default') is
unconditionally True, so any home's dispatcher could claim cards assigned
to 'default'. Wrap the _profile_exists_fn() predicate with an optional
per-home allowlist: kanban.dispatch_profiles (config.yaml, list or
comma-separated string) with a HERMES_KANBAN_DISPATCH_PROFILES env bridge.
Unset preserves upstream behavior; 'none' claims nothing. Foreign
assignees land in the existing skipped_nonspawnable bucket, and the gate
applies to the ready spawn path, _has_spawnable, and review dispatch alike.
Also documents the multi-home default collision in the kanban user guide.

Fixes #110995
This commit is contained in:
Kevin Rajan
2026-09-14 11:13:35 -05:00
committed by Teknium
parent 931387dd42
commit 2d46af3fa2
4 changed files with 167 additions and 3 deletions
+7
View File
@@ -1771,6 +1771,13 @@ DEFAULT_CONFIG = {
# fan-out workflows that would otherwise saturate one profile's local model / API quota / browser
# pool while leaving other profiles idle. See #21582.
"max_in_progress_per_profile": None,
# Per-home claim allowlist for shared boards (#110995): profile names this
# home's dispatcher may claim, as a list or comma-separated string. Unset
# (None) = any existing profile is claimable (upstream behavior);
# ["none"] = claim nothing. On a shared kanban.db, every home's
# profile_exists("default") is True, so without this each home can claim
# default-assigned cards. Runtime override: HERMES_KANBAN_DISPATCH_PROFILES.
"dispatch_profiles": None,
# Auto-run the decomposer on Triage tasks every tick. False = manual via `hermes kanban
# decompose <id>` or the dashboard's Decompose button.
"auto_decompose": True,
+73 -3
View File
@@ -1218,12 +1218,82 @@ def check_respawn_guard(
def _profile_exists_fn() -> Optional[Callable[[str], bool]]:
"""``hermes_cli.profiles.profile_exists``, or ``None`` when it cannot be
imported (local import avoids a cycle; callers fall back to trusting the
assignee)."""
assignee).
When a per-home claim allowlist is configured (``kanban.dispatch_profiles``
or ``HERMES_KANBAN_DISPATCH_PROFILES``, #110995), the returned predicate
additionally requires the assignee to be listed — so a card assigned to
``default`` is only claimable by homes that opted into it. Foreign
assignees land in the existing ``skipped_nonspawnable`` bucket.
"""
try:
from hermes_cli.profiles import profile_exists
from hermes_cli.profiles import normalize_profile_name, profile_exists
except Exception:
return None
return profile_exists
allowlist = _dispatch_profile_allowlist(normalize_profile_name)
if allowlist is None:
return profile_exists
def _gated(name: str) -> bool:
try:
canon = normalize_profile_name(name)
except ValueError:
canon = (name or "").strip().lower()
return canon in allowlist and bool(profile_exists(name))
return _gated
# Env-var bridge for the per-home kanban dispatch claim allowlist (#110995).
# Non-secret behavioral settings live in config.yaml; this is the fleet-friendly
# runtime override (containers set env per home more easily than per-home
# config.yaml edits), mirroring terminal.cwd -> TERMINAL_CWD.
KANBAN_DISPATCH_PROFILES_ENV = "HERMES_KANBAN_DISPATCH_PROFILES"
def _dispatch_profile_allowlist(normalize_profile_name) -> Optional[frozenset]:
"""Per-home claim allowlist for the kanban dispatcher (#110995).
On a shared board (one ``kanban.db`` mounted across several Hermes homes),
every home's ``profile_exists`` returns True for ``default`` — the root
profile every home has — so a card assigned to ``default`` is claimable by
every home's dispatcher. A home opts out of foreign claims by declaring
which assignees it may claim, canonically in config.yaml:
kanban:
dispatch_profiles: ["sage", "researcher"] # or "sage,researcher"
(``HERMES_KANBAN_DISPATCH_PROFILES`` overrides config at runtime.)
Returns ``None`` when neither is set (upstream behavior: any existing
profile is claimable). The special value ``none`` (or an empty value)
yields an empty allowlist — the home claims nothing.
"""
raw = os.environ.get(KANBAN_DISPATCH_PROFILES_ENV)
if raw is None:
try:
from hermes_cli.config import load_config
cfg = load_config()
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
raw = kanban_cfg.get("dispatch_profiles")
except Exception:
return None
if raw is None:
return None
if isinstance(raw, (list, tuple)):
names = [str(n) for n in raw]
else:
names = str(raw).split(",")
names = [n.strip() for n in names if n.strip()]
if not names or all(n.casefold() == "none" for n in names):
return frozenset()
allowed = set()
for n in names:
try:
allowed.add(normalize_profile_name(n))
except ValueError:
allowed.add(n.strip().lower())
return frozenset(allowed)
def _has_spawnable(conn: sqlite3.Connection, status: str) -> bool:
@@ -0,0 +1,69 @@
"""Per-home kanban dispatch claim allowlist.
Regression tests for #110995: on a shared kanban board (one kanban.db mounted
across several Hermes homes) every home's ``profile_exists("default")`` is
unconditionally True, so any home's dispatcher could claim cards assigned to
``default``. ``kanban.dispatch_profiles`` (or the
``HERMES_KANBAN_DISPATCH_PROFILES`` env bridge) declares which assignees this
home may claim; anything else lands in ``skipped_nonspawnable``.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from hermes_cli import kanban_db_dispatch as kbd
@pytest.fixture
def every_home_has_default(monkeypatch):
"""Reproduce the incident premise: ``profile_exists("default")`` is True."""
from hermes_cli import profiles
monkeypatch.setattr(profiles, "profile_exists", lambda name: True)
# Env bridge for the claim allowlist (mirrors
# kanban_db_dispatch.KANBAN_DISPATCH_PROFILES_ENV; spelled out so the tests
# stay red-on-base against code that lacks the constant).
_DISPATCH_PROFILES_ENV = "HERMES_KANBAN_DISPATCH_PROFILES"
@pytest.fixture
def no_env_bridge(monkeypatch):
monkeypatch.delenv(_DISPATCH_PROFILES_ENV, raising=False)
def test_allowlist_env_restricts_default_claim(monkeypatch, every_home_has_default):
monkeypatch.setenv(_DISPATCH_PROFILES_ENV, "sage,researcher")
claim = kbd._profile_exists_fn()
assert claim is not None
assert claim("sage") is True
assert claim("researcher") is True
# The incident: this home must NOT claim another home's "default".
assert claim("default") is False
def test_allowlist_env_none_claims_nothing(monkeypatch, every_home_has_default):
monkeypatch.setenv(_DISPATCH_PROFILES_ENV, "none")
claim = kbd._profile_exists_fn()
assert claim is not None
assert claim("sage") is False
assert claim("default") is False
def test_allowlist_config_key_end_to_end(every_home_has_default, no_env_bridge):
"""Real config.yaml -> real load_config() -> gated predicate."""
home = Path(os.environ["HERMES_HOME"])
(home / "config.yaml").write_text("kanban:\n dispatch_profiles:\n - sage\n")
claim = kbd._profile_exists_fn()
assert claim is not None
assert claim("sage") is True
assert claim("default") is False
def test_unset_allowlist_preserves_upstream(every_home_has_default, no_env_bridge):
claim = kbd._profile_exists_fn()
assert claim is not None
assert claim("default") is True
@@ -282,8 +282,26 @@ kanban:
review_dispatch: true # default: spawn the assigned profile with
# the bundled sdlc-review skill. Set false
# for human-only review boards.
dispatch_profiles: null # default: this home may claim cards for any
# existing profile. Set to a list (or
# comma-separated string) of profile names to
# restrict which assignees this home claims.
# ["none"] claims nothing. Override at
# runtime with HERMES_KANBAN_DISPATCH_PROFILES.
```
### Shared boards across homes
Mounting one `kanban.db` in several Hermes homes (containers, fleet hosts) shares
the board, but profile names are home-local: every home has a root profile named
`default`, and the dispatcher's spawn gate checks `profile_exists(assignee)`
against the *claiming* home. Without further configuration, every home's
dispatcher considers a card assigned to `default` claimable, so the wrong home
can claim and run it. Either give each home unique profile names, or set
`kanban.dispatch_profiles` per home to declare exactly which assignees that home
may claim — anything else lands in the dispatcher's `skipped_nonspawnable`
bucket instead of spawning.
Override the config flag at runtime via `HERMES_KANBAN_DISPATCH_IN_GATEWAY=0`
for debugging. Standard gateway supervision applies: run `hermes gateway
start` directly, or wire the gateway up as a systemd user unit (see the