diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 19311bcb69..d8e2e861ba 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -9,8 +9,20 @@ board as the dispatcher that claimed the task. The same applies to In standard installs ```` is ``~/.hermes``. In Docker / custom deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g. -``/opt/hermes``), ```` is ``HERMES_HOME``. Set ``HERMES_KANBAN_HOME`` -to override the resolution explicitly (tests, unusual deployments). +``/opt/hermes``), ```` 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 @@ -87,7 +99,7 @@ def kanban_home() -> Path: """ override = os.environ.get("HERMES_KANBAN_HOME", "").strip() if override: - return Path(override) + return Path(override).expanduser() from hermes_constants import get_default_hermes_root return get_default_hermes_root() @@ -97,8 +109,13 @@ def kanban_db_path() -> Path: Anchored at :func:`kanban_home`, not the active profile's ``HERMES_HOME``, so profile workers and the dispatcher converge on - the same board. + 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" @@ -107,7 +124,13 @@ def workspaces_root() -> Path: 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" @@ -2111,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 ` + # 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 ` activates the profile, but the env var is # what the tool reads — set it explicitly here so comments are diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 6214ab758a..66992a721c 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -607,3 +607,108 @@ class TestSharedBoardPaths: 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 ` 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" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 955f460014..aa971c7103 100644 --- a/website/docs/reference/environment-variables.md +++ b/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)