Compare commits

...

1 Commits

Author SHA1 Message Date
Ben
0a23537829 fix(kanban): discover profiles under HERMES_HOME, not hardcoded ~/.hermes
`list_profiles_on_disk()` in `hermes_cli/kanban_db.py` hardcoded
`Path.home() / ".hermes" / "profiles"` for the profile lookup,
ignoring `HERMES_HOME`. In the shipped Docker image where
`HERMES_HOME` points at the mounted volume (e.g. `/opt/data`) and the
container user's $HOME contains no .hermes directory, this returned
`[]` on every call — even when real profiles existed at
`<HERMES_HOME>/profiles/`.

That empty list cascades into two user-visible failures that both
present as "all kanban tasks stuck unassigned":

1. Dashboard assignee dropdown. `GET /kanban/assignees` calls
   `known_assignees()`, which unions `list_profiles_on_disk()` with
   currently-assigned names on the board. On a fresh Docker install,
   both sets are empty, so the dropdown is empty and the only tasks
   users can create from the web UI have `assignee=None`. The
   dispatcher (`dispatch_once()` in the same file) explicitly skips
   any ready task whose assignee is NULL and records them into
   `DispatchResult.skipped_unassigned` with no error — they sit in
   `ready` forever. Combined with `_default_spawn()` raising
   `ValueError` on missing assignee, unassigned tasks are structurally
   undispatchable.

2. `hermes kanban init` misdirection. The command printed
   "No profiles found under ~/.hermes/profiles/" regardless of the
   actual resolved path, sending Docker users down the wrong debugging
   path when their profiles were fine, just not being found.

Fix: route `list_profiles_on_disk()` through the existing
`kanban_home()` helper (already defined in the same module at line 84,
already correctly handles Docker via `get_default_hermes_root()`).
`_cmd_init` now prints the actually-resolved profiles directory
instead of a hardcoded path.

This matches the canonical pattern used by `hermes_cli/profiles.py`
(`_get_profiles_root` → `get_default_hermes_root`) and the explicit
note in `tests/conftest.py`:

    Any code in the codebase reading `~/.hermes/*` via
    `Path.home() / ".hermes"` instead of `get_hermes_home()` is a bug
    to fix at the callsite.

Tests:
- New `test_list_profiles_on_disk_docker_layout`: sets $HOME to a
  path with no .hermes dir, sets `HERMES_HOME` to a separate tempdir
  (the Docker layout), writes profiles under there, asserts discovery.
  Verified RED against the buggy code, GREEN against the fix.
- Existing `test_list_profiles_on_disk` updated to set `HERMES_HOME`
  explicitly. It was previously passing by accident because conftest's
  hermetic fixture sets `HERMES_HOME` to a different tempdir, and the
  buggy code path happened to align with the `Path.home()` monkeypatch.
  The new assertion is that profiles are discovered under the resolved
  `HERMES_HOME`, not under wherever `Path.home()` happens to point.

No other call sites in the kanban subsystem use `Path.home()` (grep
confirmed), so this is the only cascade point for the Docker symptom.
2026-05-04 16:00:17 +10:00
3 changed files with 66 additions and 8 deletions

View File

@@ -557,7 +557,16 @@ def _cmd_init(args: argparse.Namespace) -> int:
for name in profiles:
print(f" {name}")
else:
print("No profiles found under ~/.hermes/profiles/.")
# Show the actually-resolved path, not a hardcoded ~/.hermes
# Docker / custom deployments have profiles at
# <HERMES_HOME>/profiles/ (e.g. /opt/data/profiles/), and
# printing ~/.hermes there sends users down the wrong path.
try:
profiles_dir = kb.kanban_home() / "profiles"
where = str(profiles_dir)
except Exception:
where = "~/.hermes/profiles/"
print(f"No profiles found under {where}.")
print("Create one with `hermes -p <name> setup` before assigning tasks.")
print()
print("Next step: start the gateway so ready tasks actually get picked up.")

View File

@@ -2726,13 +2726,24 @@ def read_worker_log(
def list_profiles_on_disk() -> list[str]:
"""Return the set of named profiles discovered on disk.
Reads ``~/.hermes/profiles/`` directly so this module has no import
dependency on ``hermes_cli.profiles`` (which pulls in a large chunk
of the CLI startup path). Only returns directories that contain a
``config.yaml`` — a bare dir without config isn't a real profile.
Anchored at :func:`kanban_home` so the Docker / custom-deployment
layout (``HERMES_HOME=/opt/data`` with profiles at
``/opt/data/profiles/<name>/``) is discovered correctly — not just
the standard ``~/.hermes/profiles/`` layout. ``kanban_home`` already
routes through ``get_default_hermes_root``, which handles both the
profile-active case (``HERMES_HOME=<root>/profiles/<name>`` → return
``<root>``) and the Docker case (``HERMES_HOME`` outside ``~/.hermes``
→ return ``HERMES_HOME`` as-is).
Only returns directories that contain a ``config.yaml`` — a bare
dir without config isn't a real profile.
Stays import-safe by reusing ``kanban_home`` (already defined in
this module) rather than importing ``hermes_cli.profiles``, which
would pull in a large chunk of the CLI startup path.
"""
try:
home = Path.home() / ".hermes" / "profiles"
home = kanban_home() / "profiles"
except Exception:
return []
if not home.is_dir():

View File

@@ -899,9 +899,13 @@ def test_migration_renames_legacy_event_kinds(tmp_path, monkeypatch):
# ---------------------------------------------------------------------------
def test_list_profiles_on_disk(tmp_path, monkeypatch):
"""list_profiles_on_disk returns directories under ~/.hermes/profiles/
that contain a config.yaml."""
"""list_profiles_on_disk returns directories under
``<HERMES_HOME>/profiles/`` that contain a config.yaml.
Standard (non-Docker) layout: HERMES_HOME is ``~/.hermes``.
"""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
profiles = tmp_path / ".hermes" / "profiles"
profiles.mkdir(parents=True)
(profiles / "researcher").mkdir()
@@ -916,6 +920,40 @@ def test_list_profiles_on_disk(tmp_path, monkeypatch):
assert names == ["researcher", "writer"]
def test_list_profiles_on_disk_docker_layout(tmp_path, monkeypatch):
"""list_profiles_on_disk discovers profiles under HERMES_HOME even when
HERMES_HOME points outside ~/.hermes (standard Docker layout).
In the shipped Docker image, HERMES_HOME is the mounted volume (e.g.
/opt/data) and the user's $HOME inside the container doesn't contain
a .hermes dir. Profiles live at <HERMES_HOME>/profiles/<name>/ and
must still be discoverable by the kanban board — otherwise the
dashboard assignee dropdown is empty and users can only create tasks
with assignee=None, which the dispatcher skips forever. See the
kanban_home() helper in this module for the canonical resolution.
"""
# Simulate the Docker layout: HOME has no .hermes dir at all; the
# real profiles live under a HERMES_HOME that's completely unrelated
# to ~/.hermes.
fake_home = tmp_path / "home" / "hermes"
fake_home.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: fake_home)
docker_root = tmp_path / "opt" / "data"
docker_root.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(docker_root))
profiles = docker_root / "profiles"
profiles.mkdir()
(profiles / "researcher").mkdir()
(profiles / "researcher" / "config.yaml").write_text("model: {}\n")
(profiles / "writer").mkdir()
(profiles / "writer" / "config.yaml").write_text("model: {}\n")
names = kb.list_profiles_on_disk()
assert names == ["researcher", "writer"]
def test_known_assignees_merges_disk_and_board(tmp_path, monkeypatch):
"""known_assignees unions profiles on disk with currently-assigned
names, and reports per-status counts."""