From 2658494e815b4644cb2ed47dc6cb6623b6ecf112 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 3 May 2026 15:05:28 -0700 Subject: [PATCH] fix(kanban): add per-path env overrides + dispatcher env injection Layers defense-in-depth on top of the shared-root anchoring (base commit). Changes in hermes_cli/kanban_db.py: - kanban_db_path() now honours HERMES_KANBAN_DB first, then falls through to kanban_home()/kanban.db. - workspaces_root() now honours HERMES_KANBAN_WORKSPACES_ROOT first, then falls through to kanban_home()/kanban/workspaces. - All three overrides (HERMES_KANBAN_HOME, HERMES_KANBAN_DB, HERMES_KANBAN_WORKSPACES_ROOT) now call .expanduser() for consistency. - _default_spawn() injects HERMES_KANBAN_DB and HERMES_KANBAN_WORKSPACES_ROOT into the worker subprocess env. Even when the worker's get_default_hermes_root() resolution somehow disagrees with the dispatcher's (symlinks, unusual Docker layouts), the two processes still open the same SQLite file. Module docstring updated to describe all three overrides and the dispatcher env-injection contract. Tests (tests/hermes_cli/test_kanban_db.py, TestSharedBoardPaths): - test_hermes_kanban_db_pin_beats_kanban_home - test_hermes_kanban_workspaces_root_pin_beats_kanban_home - test_empty_per_path_overrides_fall_through - test_dispatcher_spawn_injects_kanban_db_and_workspaces_root (monkeypatches subprocess.Popen, asserts both env vars reach the child even after HERMES_HOME is rewritten by `hermes -p `.) Docs: website/docs/reference/environment-variables.md gets entries for the three kanban env vars. This fusion is built on the cleanest of the seven competing PRs that targeted issue #18442: * Base commit (from PR #19350 by @GodsBoy): add `kanban_home()` helper anchored at `get_default_hermes_root()`, reroute all 5 kanban path sites through it (including the 3 sibling log-dir sites that the other six PRs missed), 8-test regression class. * Dispatcher env-var injection approach drawn from PRs #18300 (@quocanh261997) and #19100 (@cg2aigc). * Per-path env overrides drawn from PR #19100 (@cg2aigc). * get_default_hermes_root() resolution direction first proposed in PR #18503 (@beibi9966) and PR #18985 (@Gosuj). Closes the duplicate/competing PRs: #18300, #18503, #18670, #18985, #19037, #19056, #19100. Fixes #18442 and #19348. Co-authored-by: quocanh261997 <17986614+quocanh261997@users.noreply.github.com> Co-authored-by: cg2aigc <232694053+cg2aigc@users.noreply.github.com> Co-authored-by: beibi9966 Co-authored-by: Gosuj <123411271+Gosuj@users.noreply.github.com> Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com> --- hermes_cli/kanban_db.py | 39 ++++++- tests/hermes_cli/test_kanban_db.py | 105 ++++++++++++++++++ .../docs/reference/environment-variables.md | 3 + 3 files changed, 143 insertions(+), 4 deletions(-) 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)