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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user