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
9 changes: 9 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,15 @@ def _cmd_create(args: argparse.Namespace) -> int:
file=sys.stderr,
)
return 2
# Reject an assignee that names a lane that doesn't exist. The dispatcher
# only spawns ready tasks whose assignee is a real profile on disk; a
# hand-typed `--assignee foo` typo would otherwise be accepted onto the
# board and silently never run. Shared validator with the create tool so
# the two surfaces can't drift.
assignee_error = kb.validate_assignee(args.assignee)
if assignee_error:
print(f"kanban: {assignee_error}", file=sys.stderr)
return 2
Comment on lines +1333 to +1336

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 Honor idempotency before validating assignees

When a create request is retried with an --idempotency-key for an existing non-archived task whose assignee profile was later renamed or deleted, this preflight rejects the retry before kb.create_task can reach its existing idempotency lookup and return the original task id. That turns an idempotent retry into a hard failure; the tool path has the same ordering, so webhook/orchestrator retries can break after a profile cleanup even though no new row would be inserted.

Useful? React with 👍 / 👎.

with kb.connect_closing() as conn:
task_id = kb.create_task(
conn,
Expand Down
45 changes: 45 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -8520,6 +8520,51 @@ def list_profiles_on_disk() -> list[str]:
return sorted(names)


# Assignee values that mean "intentionally unassigned" rather than naming a
# profile. Mirrors the tool surface's ``_normalize_profile`` (none/-/null) so a
# deliberately-unassigned or triage card stays legal. Matched case-folded.
_UNASSIGNED_SENTINELS = frozenset({"none", "-", "null"})


def validate_assignee(assignee: Optional[str]) -> Optional[str]:
"""Validate a create-time assignee against the known profile set.

Returns an error string when ``assignee`` names a lane that does not
exist (a typo'd or never-built profile), or ``None`` when it is valid.

The dispatcher only spawns a ready task whose assignee is a real profile
on disk; an unknown assignee is accepted onto the board and then silently
never spawns. Validating here makes a bad route fail loud at create time
so nobody has to pre-probe the profile list.

"Unassigned" is valid: ``None``, empty, and the ``none``/``-``/``null``
sentinels all mean "no owner yet" (a triage/unassigned card) and pass.
Only a *named* assignee that is neither a known profile nor a sentinel
is rejected. Reuses :func:`list_profiles_on_disk` — no new enumeration.
"""
if assignee is None:
return None
text = str(assignee).strip()
if not text or text.casefold() in _UNASSIGNED_SENTINELS:
return None
Comment on lines +8548 to +8549

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 Normalize unassigned sentinels before creating cards

This new validator accepts and the error text recommends none as an unassigned sentinel, but both create callers still pass the original string into create_task. When a user or model follows that hint (--assignee none / assignee: "none"), the row is stored with literal assignee none, so the dispatcher treats it as skipped_nonspawnable instead of unassigned (and kanban.default_assignee will not apply). The sentinel needs to be converted to None before insertion, or not advertised as creating an unassigned card.

Useful? React with 👍 / 👎.

try:
from hermes_cli.profiles import normalize_profile_name

canon = normalize_profile_name(text)
except Exception:
canon = text
known = set(list_profiles_on_disk())
if canon in known:
Comment on lines +8550 to +8557

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To ensure robust and defensive validation, we should address two potential issues here:

  1. Case-sensitivity / Normalization Mismatch: If a profile directory on disk contains uppercase letters or is not fully normalized, but normalize_profile_name normalizes the input assignee, a direct membership check (canon in known) might fail. Normalizing both the input and the disk profile names using the same function guarantees consistent matching.
  2. Robust Exception Handling: If list_profiles_on_disk() raises an exception (e.g., due to permission issues or missing directories on an uninitialized system), the function will crash. Wrapping it in a try-except block ensures defensive fallback to an empty list.
    try:
        raw_known = list_profiles_on_disk()
    except Exception:
        raw_known = []
    try:
        from hermes_cli.profiles import normalize_profile_name

        canon = normalize_profile_name(text)
        known = {normalize_profile_name(p) for p in raw_known}
    except Exception:
        canon = text
        known = set(raw_known)
    if canon in known:

return None
Comment on lines +8556 to +8558

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 non-profile Kanban worker lanes

This makes list_profiles_on_disk() a hard allow-list, but dispatch_once intentionally treats non-profile assignees as control-plane/terminal lanes (for example orion-cc) that are skipped by the dispatcher and claimed directly via claim_task. In those setups, /kanban create --assignee orion-cc and the kanban_create tool now fail before a row is created, breaking the documented external/terminal lane workflow rather than just catching typos. Please keep an explicit path for intentional non-profile lanes instead of rejecting every assignee absent from the profile list.

Useful? React with 👍 / 👎.

valid = ", ".join(sorted(known)) if known else "(none found on disk)"
return (
f"assignee {assignee!r} is not a known profile — the dispatcher only "
f"spawns tasks whose assignee names a profile under "
f"~/.hermes/profiles/, so this card would be accepted but never run. "
f"Valid profiles: {valid}. (Use 'none' to create an unassigned card.)"
)


def known_assignees(conn: sqlite3.Connection) -> list[dict]:
"""Return every assignee name known to the board or on disk.

Expand Down
7 changes: 7 additions & 0 deletions tests/hermes_cli/test_kanban_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ def kanban_home(tmp_path, monkeypatch):
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Seed the profile dirs that CLI create tests route to. `hermes kanban
# create` now validates --assignee against list_profiles_on_disk(), so the
# fake assignees these tests use must exist as profiles on disk.
for _name in ("alice", "bob", "broken-model", "orig", "x"):
_pdir = home / "profiles" / _name
_pdir.mkdir(parents=True, exist_ok=True)
(_pdir / "config.yaml").write_text("model: {}\n")
kb.init_db()
return home

Expand Down
18 changes: 15 additions & 3 deletions tests/hermes_cli/test_kanban_core_functionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ def kanban_home(tmp_path, monkeypatch):
# written against. The grace-period itself is covered by dedicated
# tests in tests/hermes_cli/test_kanban_db.py.
monkeypatch.setenv("HERMES_KANBAN_CRASH_GRACE_SECONDS", "0")
# Seed the profile dirs that CLI create tests route to. `hermes kanban
# create` now validates --assignee against list_profiles_on_disk(), so the
# fake assignees these tests use must exist as profiles on disk.
for _name in ("linguist", "x"):
_pdir = home / "profiles" / _name
_pdir.mkdir(parents=True, exist_ok=True)
(_pdir / "config.yaml").write_text("model: {}\n")
kb.init_db()
return home

Expand Down Expand Up @@ -2199,6 +2206,11 @@ def test_cli_create_on_fresh_home_auto_inits(tmp_path, monkeypatch):
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# kanban create now validates --assignee against the profiles on disk;
# seed the `worker` profile so this auto-init smoke test still routes.
_wp = home / "profiles" / "worker"
_wp.mkdir(parents=True)
(_wp / "config.yaml").write_text("model: {}\n")
# Sanity: kanban.db does NOT exist yet.
import subprocess as _sp
import sys as _sys
Expand Down Expand Up @@ -3424,7 +3436,7 @@ def _raise():
def _make_create_ns(**overrides):
"""Build a Namespace suitable for kb_cli._cmd_create()."""
ns = argparse.Namespace(
title="x", body=None, assignee="worker",
title="x", body=None, assignee="linguist",
created_by="user", workspace="scratch", tenant=None,
priority=0, parent=None, triage=False,
idempotency_key=None, max_runtime=None, skills=None,
Expand All @@ -3443,7 +3455,7 @@ def test_cli_create_warns_when_no_gateway(kanban_home, monkeypatch, capsys):
"hermes_cli.config.load_config",
lambda: {"kanban": {"dispatch_in_gateway": True}},
)
ns = _make_create_ns(title="warn-me", assignee="worker")
ns = _make_create_ns(title="warn-me", assignee="linguist")
assert kb_cli._cmd_create(ns) == 0
captured = capsys.readouterr()
# Stderr has the warning prefix + guidance.
Expand All @@ -3458,7 +3470,7 @@ def test_cli_create_silent_when_gateway_up(kanban_home, monkeypatch, capsys):
"hermes_cli.config.load_config",
lambda: {"kanban": {"dispatch_in_gateway": True}},
)
ns = _make_create_ns(title="silent", assignee="worker")
ns = _make_create_ns(title="silent", assignee="linguist")
assert kb_cli._cmd_create(ns) == 0
captured = capsys.readouterr()
assert "hermes gateway start" not in captured.err
Expand Down
142 changes: 142 additions & 0 deletions tests/hermes_cli/test_kanban_validate_assignee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Tests for create-time assignee validation (issue: silent dispatcher drop).

``kanban_create`` (both the model tool and the ``hermes kanban create`` CLI)
now rejects an assignee that names a lane which doesn't exist, instead of
accepting a card the dispatcher will then silently never spawn. The check is a
single shared validator, ``kanban_db.validate_assignee``, so the two surfaces
can't drift.

Covers:
- validate_assignee() directly: real profile / unknown / sentinels / default.
- tool _handle_create: real assignee OK, unknown rejected (no row created),
'none' sentinel OK.
- CLI _cmd_create / run_slash: unknown --assignee fails loud (no row created).
"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from hermes_cli import kanban as kc
from hermes_cli import kanban_db as kb


def _seed_profiles(home: Path, *names: str) -> None:
for name in names:
pdir = home / "profiles" / name
pdir.mkdir(parents=True, exist_ok=True)
(pdir / "config.yaml").write_text("model: {}\n")


@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with two real profiles on disk (researcher,
writer) plus the implicit ``default``."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
_seed_profiles(home, "researcher", "writer")
kb._INITIALIZED_PATHS.clear()
kb.init_db()
return home


# ---------------------------------------------------------------------------
# validate_assignee() unit cases
# ---------------------------------------------------------------------------

def test_validate_assignee_accepts_real_profile(kanban_home):
assert kb.validate_assignee("researcher") is None
assert kb.validate_assignee("writer") is None


def test_validate_assignee_accepts_default(kanban_home):
# `default` is always a valid profile when the default root exists.
assert kb.validate_assignee("default") is None


def test_validate_assignee_normalizes_case(kanban_home):
# create_task canonicalizes via normalize_profile_name; the validator must
# accept the same case-folded form so a title-cased label still passes.
assert kb.validate_assignee("Researcher") is None


def test_validate_assignee_rejects_unknown(kanban_home):
err = kb.validate_assignee("equity-analyzer")
assert err is not None
assert "equity-analyzer" in err
# Lists the valid profiles so the model can self-correct.
assert "researcher" in err and "writer" in err


@pytest.mark.parametrize("sentinel", [None, "", " ", "none", "None", "-", "null"])
def test_validate_assignee_accepts_unassigned_sentinels(kanban_home, sentinel):
# A deliberately-unassigned / triage card is valid — don't over-tighten.
assert kb.validate_assignee(sentinel) is None


# ---------------------------------------------------------------------------
# Tool surface: _handle_create
# ---------------------------------------------------------------------------

def _board_task_count() -> int:
conn = kb.connect()
try:
row = conn.execute("SELECT COUNT(*) AS n FROM tasks").fetchone()
return int(row["n"])
finally:
conn.close()


def test_tool_create_real_assignee_succeeds(kanban_home):
from tools import kanban_tools as kt

out = json.loads(kt._handle_create({"title": "do work", "assignee": "researcher"}))
assert out["ok"] is True
assert out["task_id"]


def test_tool_create_unknown_assignee_rejected_no_row(kanban_home):
from tools import kanban_tools as kt

before = _board_task_count()
out = json.loads(kt._handle_create({"title": "bad route", "assignee": "equity-analyzer"}))
assert "error" in out
assert "equity-analyzer" in out["error"]
# The board must be unchanged: no row was created for the bad assignee.
assert _board_task_count() == before


def test_tool_create_unassigned_sentinel_succeeds(kanban_home):
"""An explicit 'none' assignee is a recognized unassign sentinel and must
still pass the validator — guards against over-tightening. (The validator
deliberately only gates 'names a lane that doesn't exist'; it does not
coerce how the sentinel is stored — that's out of scope.)"""
from tools import kanban_tools as kt

out = json.loads(kt._handle_create({"title": "park it", "assignee": "none"}))
assert out["ok"] is True
assert out["task_id"]


# ---------------------------------------------------------------------------
# CLI surface: hermes kanban create
# ---------------------------------------------------------------------------

def test_cli_create_real_assignee_succeeds(kanban_home):
out = kc.run_slash("create 'cli ok' --assignee writer")
assert "Created" in out


def test_cli_create_unknown_assignee_rejected_no_row(kanban_home):
before = _board_task_count()
out = kc.run_slash("create 'cli bad' --assignee equity-analyzer")
assert "Created" not in out
assert "equity-analyzer" in out
assert "not a known profile" in out
# No row created for the bad assignee.
assert _board_task_count() == before
24 changes: 24 additions & 0 deletions tests/tools/test_kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,18 @@ def worker_env(monkeypatch, tmp_path):
from pathlib import Path as _Path
monkeypatch.setattr(_Path, "home", lambda: tmp_path)

# Seed the profile dirs that create-path tests route to. ``kanban_create``
# now validates the assignee against ``list_profiles_on_disk()`` (rejecting
# a lane that doesn't exist), so the fake assignees these tests use must be
# real profiles on disk for their happy paths to succeed.
profiles_root = home / "profiles"
for _name in (
"test-worker", "peer", "factory", "qa", "worker", "linguist", "a", "x",
):
_pdir = profiles_root / _name
_pdir.mkdir(parents=True, exist_ok=True)
(_pdir / "config.yaml").write_text("model: {}\n")

from hermes_cli import kanban_db as kb
kb._INITIALIZED_PATHS.clear()
kb.init_db()
Expand Down Expand Up @@ -1513,6 +1525,13 @@ def multi_board_env(monkeypatch, tmp_path):
from pathlib import Path as _Path
monkeypatch.setattr(_Path, "home", lambda: tmp_path)

# kanban_create validates the assignee against list_profiles_on_disk();
# seed the profiles these board-routing create tests assign to.
for _name in ("worker", "linguist"):
_pdir = home / "profiles" / _name
_pdir.mkdir(parents=True, exist_ok=True)
(_pdir / "config.yaml").write_text("model: {}\n")

from hermes_cli import kanban_db as kb
kb._INITIALIZED_PATHS.clear()
# Default board — implicit
Expand Down Expand Up @@ -1952,6 +1971,11 @@ def test_create_respects_auto_subscribe_on_create_false(monkeypatch, worker_env,
(home / "config.yaml").write_text(
"kanban:\n auto_subscribe_on_create: false\n"
)
# kanban_create validates the assignee against the profiles under this
# (fresh) home's default root; seed `peer` so the create still routes.
_pp = home / "profiles" / "peer"
_pp.mkdir(parents=True)
(_pp / "config.yaml").write_text("model: {}\n")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord")
monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "channel-1")
Expand Down
11 changes: 11 additions & 0 deletions tools/kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,17 @@ def _handle_create(args: dict, **kw) -> str:
"assignee is required — name the profile that should execute this "
"task (the dispatcher will only spawn tasks with an assignee)"
)
# Reject an assignee that names a lane that doesn't exist. The dispatcher
# only spawns ready tasks whose assignee is a real profile on disk; an
# unknown assignee is accepted onto the board and then silently never
# spawns. Fail loud here so the model self-corrects instead of filing a
# card that sits forever. Shared with the CLI create path so the two
# surfaces can't drift.
from hermes_cli import kanban_db as _kb_validate

assignee_error = _kb_validate.validate_assignee(assignee)
if assignee_error:
return tool_error(assignee_error)
body = args.get("body")
parents = args.get("parents") or []
tenant = args.get("tenant") or os.environ.get("HERMES_TENANT")
Expand Down
Loading