Kanban DB scoped per-profile breaks multi-agent sharing
NousResearch/hermes-agent · cluster 18442
Data as of
Nothing open on GitHub. Fix merged in #19378. Check it covers your case.
The short answer
- Known?
- Yes. 9 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #19378. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #19378. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A cluster of bug reports and fixes addressing that the Kanban database and workspace paths resolve relative to the active profile, preventing workers spawned under custom profiles from accessing the shared board used by orchestrators. The work centers on anchoring these paths to a single default root regardless of which profile is active.
Open issues0
Open PRs0
Fixes on record
-
#19378 fix(kanban): share board, workspaces, and worker logs across profiles
files:
hermes_cli/kanban.py,hermes_cli/kanban_db.py,tests/hermes_cli/test_kanban_db.py,website/docs/reference/environment-variables.mdShow the diff
-
hermes_cli/kanban.py@@ -366,7 +366,7 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu # --- log --- p_log = sub.add_parser( "log", - help="Print the worker log for a task (from $HERMES_HOME/kanban/logs/)", + help="Print the worker log for a task (from <kanban-root>/kanban/logs/)", ) p_log.add_argument("task_id") p_log.add_argument("--tail", type=int, default=None, -
hermes_cli/kanban_db.py@@ -1,8 +1,28 @@ """SQLite-backed Kanban board for multi-profile collaboration. -The board lives at ``$HERMES_HOME/kanban.db`` (profile-agnostic on purpose: -multiple profiles on the same machine all see the same board, which IS the -coordination primitive). +The board lives at ``<root>/kanban.db`` where ``<root>`` is the **shared +Hermes root** (the parent of any active profile). Profiles intentionally +collapse onto a single board: it IS the cross-profile coordination +primitive. A worker spawned with ``hermes -p <profile>`` joins the same +board as the dispatcher that claimed the task. The same applies to +``<root>/kanban/workspaces/`` and ``<root>/kanban/logs/``. + +In standard installs ``<root>`` is ``~/.hermes``. In Docker / custom +deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g. +``/opt/hermes``), ``<root>`` is ``HERMES_HOME``. Three env-var overrides +are available (highest precedence first, all optional): + +* ``HERMES_KANBAN_DB`` — pin the database file path directly. +* ``HERMES_KANBAN_WORKSPACES_ROOT`` — pin the workspaces root directly. +* ``HERMES_KANBAN_HOME`` — pin the umbrella root that anchors all three + kanban paths (db + workspaces + logs). Useful for tests and unusual + deployments where a single override is enough. + +The dispatcher injects ``HERMES_KANBAN_DB`` and +``HERMES_KANBAN_WORKSPACES_ROOT`` into the worker subprocess env as a +defense-in-depth measure: even if the worker's ``get_default_hermes_root()`` +resolution somehow disagrees with the dispatcher's (unusual symlink or +Docker layout), the two processes still converge on the same files. Schema is intentionally small: tasks, task_links, task_comments, task_events. The ``workspace_kind`` field decouples coordination from git @@ -61,16 +81,57 @@ # Paths # --------------------------------------------------------------------------- +def kanban_home() -> Path: + """Return the shared Hermes root that anchors the kanban board. + + Resolution order: + + 1. ``HERMES_KANBAN_HOME`` env var when set and non-empty (explicit + override for tests and unusual deployments). + 2. ``get_default_hermes_root()``, which already returns ``<root>`` + when ``HERMES_HOME`` is ``<root>/profiles/<name>``, and returns + ``HERMES_HOME`` directly for Docker / custom deployments. + + The kanban board is shared across profiles **by design** (see the + module docstring). Resolving the kanban paths through the active + profile's ``HERMES_HOME`` would silently fork the board per profile, + which breaks the dispatcher / worker handoff. + """ + override = os.environ.get("HERMES_KANBAN_HOME", "").strip() + if override: + return Path(override).expanduser() + from hermes_constants import get_default_hermes_root + return get_default_hermes_root() + + def kanban_db_path() -> Path: - """Return the path to ``kanban.db`` inside the active HERMES_HOME.""" - from hermes_constants import get_hermes_home - return get_hermes_home() / "kanban.db" + """Return the path to the shared ``kanban.db``. + + Anchored at :func:`kanban_home`, not the active profile's + ``HERMES_HOME``, so profile workers and the dispatcher converge on + the same board. ``HERMES_KANBAN_DB`` pins the path directly (highest + precedence) — the dispatcher injects this into worker subprocess env + as defense-in-depth. + """ + override = os.environ.get("HERMES_KANBAN_DB", "").strip() + if override: + return Path(override).expanduser() + return kanban_home() / "kanban.db" def workspaces_root() -> Path: - """Return the directory under which ``scratch`` workspaces are created.""" - from hermes_constants import get_hermes_home - return get_hermes_home() / "kanban" / "workspaces" + """Return the directory under which ``scratch`` workspaces are created. + + Anchored at :func:`kanban_home` so workspace paths are stable across + profile workers spawned by the dispatcher. + ``HERMES_KANBAN_WORKSPACES_ROOT`` pins the path directly (highest + precedence) — the dispatcher injects this into worker subprocess env + as defense-in-depth. + """ + override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip() + if override: + return Path(override).expanduser() + return kanban_home() / "kanban" / "workspaces" # --------------------------------------------------------------------------- @@ -1516,12 +1577,15 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: def resolve_workspace(task: Task) -> Path: """Resolve (and create if needed) the workspace for a task. - - ``scratch``: a fresh dir under ``$HERMES_HOME/kanban/workspaces/<id>/``. + - ``scratch``: a fresh dir under ``<kanban-root>/kanban/workspaces/<id>/``, + where ``<kanban-root>`` is the shared Hermes root (see + :func:`kanban_home`). The path is the same for the dispatcher and + every profile worker, so handoff is path-stable. - ``dir:<path>``: the path stored in ``workspace_path``. Created if missing. MUST be absolute — relative paths are rejected to prevent confused-deputy traversal where ``../../../tmp/attacker`` resolves against the dispatcher's CWD instead of a meaningful - root. Users who want a HERMES_HOME-relative workspace should + root. Users who want a kanban-root-relative workspace should compute the absolute path themselves. - ``worktree``: a git worktree at ``workspace_path``. Not created automatically in v1 -- the kanban-worker skill documents @@ -2070,6 +2134,14 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]: env["HERMES_TENANT"] = task.tenant env["HERMES_KANBAN_TASK"] = task.id env["HERMES_KANBAN_WORKSPACE"] = workspace + # Pin the shared board + workspaces root the dispatcher resolved, so + # that even when the worker activates a profile (`hermes -p <name>` + # rewrites HERMES_HOME), its kanban paths still match the + # dispatcher's. Belt-and-braces with the `get_default_hermes_root()` + # resolution in `kanban_home()` — symmetric resolution is the norm, + # but unusual symlink / Docker layouts are caught here too. + env["HERMES_KANBAN_DB"] = str(kanban_db_path()) + env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(workspaces_root()) # HERMES_PROFILE is the author the kanban_comment tool defaults to. # `hermes -p <assignee>` activates the profile, but the env var is # what the tool reads — set it explicitly here so comments are @@ -2104,9 +2176,10 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]: "chat", "-q", prompt, ]) - # Redirect output to a per-task log under HERMES_HOME/kanban/logs/. - from hermes_constants import get_hermes_home - log_dir = get_hermes_home() / "kanban" / "logs" + # Redirect output to a per-task log under <kanban-root>/kanban/logs/. + # Anchored at the shared kanban root, not the worker's profile home, + # so `hermes kanban tail` reads the same file the worker writes to. + log_dir = kanban_home() / "kanban" / "logs" log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / f"{task.id}.log" _rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES) @@ -2591,8 +2664,7 @@ def gc_worker_logs( """Delete worker log files older than ``older_than_seconds``. Returns the number of files removed. Kept separate from ``gc_events`` because log files live on disk, not in SQLite.""" - from hermes_constants import get_hermes_home - log_dir = get_hermes_home() / "kanban" / "logs" + log_dir = kanban_home() / "kanban" / "logs" if not log_dir.exists(): return 0 cutoff = time.time() - older_than_seconds @@ -2614,8 +2686,7 @@ def gc_worker_logs( def worker_log_path(task_id: str) -> Path: """Return the path to a worker's log file. The file may not exist (task never spawned, or log already GC'd).""" - from hermes_constants import get_hermes_home - return get_hermes_home() / "kanban" / "logs" / f"{task_id}.log" + return kanban_home() / "kanban" / "logs" / f"{task_id}.log" def read_worker_log( -
tests/hermes_cli/test_kanban_db.py@@ -436,3 +436,279 @@ def test_tenant_propagates_to_events(kanban_home): # The "created" event should have tenant in its payload. created = [e for e in events if e.kind == "created"] assert created and created[0].payload.get("tenant") == "biz-a" + + +# --------------------------------------------------------------------------- +# Shared-board path resolution (issue #19348) +# +# The kanban board is a cross-profile coordination primitive: a worker +# spawned with `hermes -p <profile>` must read/write the same kanban.db +# as the dispatcher that claimed the task. These tests exercise the +# path-resolution layer directly and would have caught the regression +# where `kanban_db_path()` resolved to the active profile's HERMES_HOME. +# --------------------------------------------------------------------------- + +class TestSharedBoardPaths: + """`kanban_home`/`kanban_db_path`/`workspaces_root`/`worker_log_path` + must anchor at the **shared root**, not the active profile's HERMES_HOME.""" + + def _set_home(self, monkeypatch, tmp_path, hermes_home): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False) + + def test_default_install_anchors_at_home_dot_hermes( + self, tmp_path, monkeypatch + ): + # Standard install: HERMES_HOME == ~/.hermes, no profile active. + default_home = tmp_path / ".hermes" + default_home.mkdir() + self._set_home(monkeypatch, tmp_path, default_home) + + assert kb.kanban_home() == default_home + assert kb.kanban_db_path() == default_home / "kanban.db" + assert kb.workspaces_root() == default_home / "kanban" / "workspaces" + assert ( + kb.worker_log_path("t_demo") + == default_home / "kanban" / "logs" / "t_demo.log" + ) + + def test_profile_worker_resolves_to_shared_root( + self, tmp_path, monkeypatch + ): + # Reproduces the bug: dispatcher uses ~/.hermes/kanban.db, + # worker spawned with -p <profile> previously resolved to + # ~/.hermes/profiles/<profile>/kanban.db. After the fix both + # converge on ~/.hermes/kanban.db. + default_home = tmp_path / ".hermes" + default_home.mkdir() + profile_home = default_home / "profiles" / "nehemiahkanban" + profile_home.mkdir(parents=True) + self._set_home(monkeypatch, tmp_path, profile_home) + + # All four resolvers must anchor at the shared root, not the + # profile-local HERMES_HOME. + assert kb.kanban_home() == default_home + assert kb.kanban_db_path() == default_home / "kanban.db" + assert kb.workspaces_root() == default_home / "kanban" / "workspaces" + assert ( + kb.worker_log_path("t_0d214f19") + == default_home / "kanban" / "logs" / "t_0d214f19.log" + ) + + # Sanity: the profile-local path that used to be returned is + # explicitly NOT what we resolve to anymore. + assert kb.kanban_db_path() != profile_home / "kanban.db" + + def test_dispatcher_and_profile_worker_converge( + self, tmp_path, monkeypatch + ): + # End-to-end convergence: resolve the path under each side's + # HERMES_HOME and confirm equality. This is the property the + # dispatcher/worker handoff actually depends on. + default_home = tmp_path / ".hermes" + default_home.mkdir() + profile_home = default_home / "profiles" / "coder" + profile_home.mkdir(parents=True) + + # Dispatcher's perspective. + self._set_home(monkeypatch, tmp_path, default_home) + dispatcher_db = kb.kanban_db_path() + dispatcher_ws = kb.workspaces_root() + dispatcher_log = kb.worker_log_path("t_handoff") + + # Worker's perspective (profile activated by `hermes -p coder`). + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + worker_db = kb.kanban_db_path() + worker_ws = kb.workspaces_root() + worker_log = kb.worker_log_path("t_handoff") + + assert dispatcher_db == worker_db + assert dispatcher_ws == worker_ws + assert dispatcher_log == worker_log + + def test_docker_custom_hermes_home_uses_env_path_directly( + self, tmp_path, monkeypatch + ): + # Docker / custom deployment: HERMES_HOME points outside ~/.hermes. + # `get_default_hermes_root()` returns env_home directly when it + # is not a `<root>/profiles/<name>` shape and not under + # `Path.home() / ".hermes"`. + custom_root = tmp_path / "opt" / "hermes" + custom_root.mkdir(parents=True) + self._set_home(monkeypatch, tmp_path, custom_root) + + assert kb.kanban_home() == custom_root + assert kb.kanban_db_path() == custom_root / "kanban.db" + + def test_docker_profile_layout_uses_grandparent( + self, tmp_path, monkeypatch + ): + # Docker profile shape: HERMES_HOME=/opt/hermes/profiles/coder; + # `get_default_hermes_root()` walks up to /opt/hermes because + # the immediate parent dir is named "profiles". + custom_root = tmp_path / "opt" / "hermes" + profile = custom_root / "profiles" / "coder" + profile.mkdir(parents=True) + self._set_home(monkeypatch, tmp_path, profile) + + assert kb.kanban_home() == custom_root + assert kb.kanban_db_path() == custom_root / "kanban.db" + + def test_explicit_override_via_hermes_kanban_home( + self, tmp_path, monkeypatch + ): + # Explicit override: HERMES_KANBAN_HOME beats every other + # resolution rule. + default_home = tmp_path / ".hermes" + profile_home = default_home / "profiles" / "any" + profile_home.mkdir(parents=True) + override = tmp_path / "shared-board" + override.mkdir() + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.setenv("HERMES_KANBAN_HOME", str(override)) + + assert kb.kanban_home() == override + assert kb.kanban_db_path() == override / "kanban.db" + assert kb.workspaces_root() == override / "kanban" / "workspaces" + + def test_empty_override_falls_through(self, tmp_path, monkeypatch): + # Empty/whitespace override is treated as unset. + default_home = tmp_path / ".hermes" + default_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + monkeypatch.setenv("HERMES_KANBAN_HOME", " ") + + assert kb.kanban_home() == default_home + + def test_dispatcher_and_worker_share_a_real_database( + self, tmp_path, monkeypatch + ): + # Belt-and-suspenders: round-trip a task across the two + # HERMES_HOME perspectives via a real SQLite file. Without the + # fix the worker would open a different file and see no rows. + default_home = tmp_path / ".hermes" + default_home.mkdir() + profile_home = default_home / "profiles" / "nehemiahkanban" + profile_home.mkdir(parents=True) + + # Dispatcher creates the board and a task. + self._set_home(monkeypatch, tmp_path, default_home) + kb.init_db() + with kb.connect() as conn: + task_id = kb.create_task(conn, title="cross-profile") + + # Worker switches to the profile HERMES_HOME and reads. + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + with kb.connect() as conn: + task = kb.get_task(conn, task_id) + assert task is not None + assert task.title == "cross-profile" + + def test_hermes_kanban_db_pin_beats_kanban_home( + self, tmp_path, monkeypatch + ): + # HERMES_KANBAN_DB pins the file path directly and beats both + # HERMES_KANBAN_HOME and the `get_default_hermes_root()` path. + # This is the env the dispatcher injects into workers. + default_home = tmp_path / ".hermes" + default_home.mkdir() + umbrella = tmp_path / "umbrella" + umbrella.mkdir() + pinned_db = tmp_path / "pinned" / "board.db" + pinned_db.parent.mkdir() + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella)) + monkeypatch.setenv("HERMES_KANBAN_DB", str(pinned_db)) + + assert kb.kanban_db_path() == pinned_db + # workspaces_root still follows HERMES_KANBAN_HOME -- the pins + # are independent. + assert kb.workspaces_root() == umbrella / "kanban" / "workspaces" + + def test_hermes_kanban_workspaces_root_pin_beats_kanban_home( + self, tmp_path, monkeypatch + ): + # HERMES_KANBAN_WORKSPACES_ROOT pins the workspaces root directly. + default_home = tmp_path / ".hermes" + default_home.mkdir() + umbrella = tmp_path / "umbrella" + umbrella.mkdir() + pinned_ws = tmp_path / "pinned-workspaces" + pinned_ws.mkdir() + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella)) + monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(pinned_ws)) + + assert kb.workspaces_root() == pinned_ws + # kanban_db_path still follows HERMES_KANBAN_HOME. + assert kb.kanban_db_path() == umbrella / "kanban.db" + + def test_empty_per_path_overrides_fall_through( + self, tmp_path, monkeypatch + ): + # Empty/whitespace pins are treated as unset, same as + # HERMES_KANBAN_HOME. + default_home = tmp_path / ".hermes" + default_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + monkeypatch.setenv("HERMES_KANBAN_DB", " ") + monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", "") + + assert kb.kanban_db_path() == default_home / "kanban.db" + assert kb.workspaces_root() == default_home / "kanban" / "workspaces" + + def test_dispatcher_spawn_injects_kanban_db_and_workspaces_root( + self, tmp_path, monkeypatch + ): + # The dispatcher's `_default_spawn` must inject HERMES_KANBAN_DB + # and HERMES_KANBAN_WORKSPACES_ROOT into the worker env so the + # worker converges on the dispatcher's paths even when the + # `-p <profile>` flag rewrites HERMES_HOME. + default_home = tmp_path / ".hermes" + default_home.mkdir() + self._set_home(monkeypatch, tmp_path, default_home) + + captured = {} + + class _FakePopen: + def __init__(self, cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs.get("env", {}) + self.pid = 4242 + + monkeypatch.setattr("subprocess.Popen", _FakePopen) + + task = kb.Task( + id="t_dispatch_env", + title="x", + body=None, + assignee="coder", + status="ready", + priority=0, + created_by=None, + created_at=0, + started_at=None, + completed_at=None, + workspace_kind="scratch", + workspace_path=None, + claim_lock=None, + claim_expires=None, + tenant=None, + ) + kb._default_spawn(task, str(tmp_path / "ws")) + + env = captured["env"] + assert env["HERMES_KANBAN_DB"] == str(default_home / "kanban.db") + assert env["HERMES_KANBAN_WORKSPACES_ROOT"] == str( + default_home / "kanban" / "workspaces" + ) + assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env" -
website/docs/reference/environment-variables.md@@ -88,6 +88,9 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `HERMES_LOCAL_STT_COMMAND` | Optional local speech-to-text command template. Supports `{input_path}`, `{output_dir}`, `{language}`, and `{model}` placeholders | | `HERMES_LOCAL_STT_LANGUAGE` | Default language passed to `HERMES_LOCAL_STT_COMMAND` or auto-detected local `whisper` CLI fallback (default: `en`) | | `HERMES_HOME` | Override Hermes config directory (default: `~/.hermes`). Also scopes the gateway PID file and systemd service name, so multiple installations can run concurrently | +| `HERMES_KANBAN_HOME` | Override the shared Hermes root that anchors the kanban board (db + workspaces + worker logs). Falls back to `get_default_hermes_root()` (the parent of any active profile). Useful for tests and unusual deployments | +| `HERMES_KANBAN_DB` | Pin the kanban database file path directly (highest precedence; beats `HERMES_KANBAN_HOME`). The dispatcher injects this into worker subprocess env so profile workers converge on the dispatcher's board | +| `HERMES_KANBAN_WORKSPACES_ROOT` | Pin the kanban workspaces root directly (highest precedence for workspaces; beats `HERMES_KANBAN_HOME`). The dispatcher injects this into worker subprocess env | ## Provider Auth (OAuth)
-
4 PRs closed without merging
From the close comments: superseded by another PR 4
-
#18985 fix(kanban): anchor board to default Hermes root, not active profile · superseded by another PR →
#18442 -
#19037 fix: make kanban_db_path and workspaces_root profile-agnostic · superseded by another PR →
#18442 -
#19056 fix(kanban): make kanban_db_path and workspaces_root profile-agnostic (#19036) · superseded by another PR →
#18442 -
#19350 fix(kanban): use shared board path for profile workers · superseded by another PR →
#18442
Closed issues · 4
- Closed#18442[Bug]: Kanban DB is profile-scoped instead of shared across profiles, so workers cannot see root tasks
- Closed#18959kanban: workers spawned under custom profiles read profile-scoped kanban.db, not the host DB → infinite crash loop
- Closed#19036Kanban database is profile-aware, breaking multi-agent orchestrator → worker workflows
- Closed#19348[Bug]: kanban workers spawned with -p <profile> use a profile-local kanban.db instead of the shared board
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.23: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #19378
- Closed issues
- 4
- Attempts
- 4 PRs closed without merging
- Fix link checked
- 3 May 2026