Files
hermes-agent/tests/hermes_cli/test_kanban_board_project.py
T
Teknium b7bef04861 fix(kanban): an explicit scratch workspace never inherits the board's project
Move the "explicit scratch means no project" decision into the one resolver
every surface funnels through, `kanban_db.create_task`: board-project
inheritance now runs only when the caller left `workspace_kind` open
(`None`), and `workspace_kind` defaults to scratch after that check. The
tool handler keeps the #106347 fix for `project=""` (no `or` collapse) and
`board=` scoping but drops its handler-local sentinel logic, since the
resolver now owns the rule; the `self_task` project inheritance for
dispatcher-owned workers is unchanged.

Sibling surfaces had the same bug through the same line and are fixed by
the same change:
- CLI `hermes kanban create --workspace scratch` on a project-scoped board
  produced a project worktree; `--workspace` no longer defaults in
  argparse so the resolver can tell "omitted" from "scratch".
- Dashboard `POST /tasks` with `workspace_kind: "scratch"` did the same;
  `CreateTaskBody.workspace_kind` defaults to `None` for the same reason.
- `kanban_swarm.create_swarm` threads `None` through for consistency.

Tests: one resolver invariant in test_kanban_board_project.py (explicit
scratch stays scratch, omitted still inherits) and the salvaged tool test
folded into a single parametrized matrix over scoped/unscoped target boards.
2026-09-09 12:18:58 -07:00

110 lines
4.1 KiB
Python

"""Board→project scoping in kanban_db.
A kanban board can be scoped to a first-class Hermes project so every task on
it anchors to that project (deterministic worktree + branch). Covers the
metadata round-trip and the create-time inheritance.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_WORKTREE = Path(__file__).resolve().parents[2]
if str(_WORKTREE) not in sys.path:
sys.path.insert(0, str(_WORKTREE))
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import projects_db as pdb
@pytest.fixture
def fresh_home(tmp_path, monkeypatch):
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_HOME", "HERMES_KANBAN_BOARD"):
monkeypatch.delenv(var, raising=False)
try:
import hermes_constants
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
except Exception:
pass
kb._INITIALIZED_PATHS.clear()
return home
def test_board_metadata_project_id_roundtrip(fresh_home):
assert kb.read_board_metadata("default").get("project_id") is None
kb.write_board_metadata("default", project_id="p_abc123")
assert kb.read_board_metadata("default")["project_id"] == "p_abc123"
# None leaves unchanged; "" clears.
kb.write_board_metadata("default", name="Still Here")
assert kb.read_board_metadata("default")["project_id"] == "p_abc123"
kb.write_board_metadata("default", project_id="")
assert kb.read_board_metadata("default")["project_id"] is None
def test_create_board_accepts_project_id(fresh_home):
meta = kb.create_board("proj-board", name="Proj Board", project_id="p_xyz")
assert meta["project_id"] == "p_xyz"
assert kb.read_board_metadata("proj-board")["project_id"] == "p_xyz"
def test_create_task_inherits_board_project(fresh_home, tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
with pdb.connect_closing() as pconn:
proj_id = pdb.create_project(pconn, name="Widget", primary_path=str(repo))
kb.create_board("scoped", name="Scoped", project_id=proj_id)
conn = kbc.connect(board="scoped")
try:
tid = kb.create_task(conn, title="inherit me", board="scoped")
assert kb.get_task(conn, tid).project_id == proj_id
finally:
conn.close()
def test_create_task_explicit_project_beats_board(fresh_home, tmp_path):
(tmp_path / "a").mkdir()
(tmp_path / "b").mkdir()
with pdb.connect_closing() as pconn:
board_proj = pdb.create_project(pconn, name="BoardProj", primary_path=str(tmp_path / "a"))
task_proj = pdb.create_project(pconn, name="TaskProj", primary_path=str(tmp_path / "b"))
kb.create_board("scoped2", name="Scoped2", project_id=board_proj)
conn = kbc.connect(board="scoped2")
try:
tid = kb.create_task(conn, title="explicit", board="scoped2", project_id=task_proj)
assert kb.get_task(conn, tid).project_id == task_proj
finally:
conn.close()
def test_create_task_explicit_scratch_beats_board(fresh_home, tmp_path):
"""#106342: every surface (CLI --workspace scratch, dashboard workspace_kind,
tool) funnels here; an explicit scratch on a project-scoped board must stay
scratch, while an omitted kind still inherits the project worktree."""
repo = tmp_path / "repo"
repo.mkdir()
with pdb.connect_closing() as pconn:
proj_id = pdb.create_project(pconn, name="Widget", primary_path=str(repo))
kb.create_board("scoped3", name="Scoped3", project_id=proj_id)
conn = kbc.connect(board="scoped3")
try:
scratch = kb.get_task(conn, kb.create_task(
conn, title="explicit scratch", board="scoped3", workspace_kind="scratch"))
assert (scratch.workspace_kind, scratch.project_id) == ("scratch", None)
default = kb.get_task(conn, kb.create_task(conn, title="default", board="scoped3"))
assert (default.workspace_kind, default.project_id) == ("worktree", proj_id)
finally:
conn.close()