/ tests / hermes_cli / test_kanban_db.py
test_kanban_db.py
  1  """Tests for the Kanban DB layer (hermes_cli.kanban_db)."""
  2  
  3  from __future__ import annotations
  4  
  5  import concurrent.futures
  6  import os
  7  import time
  8  from pathlib import Path
  9  
 10  import pytest
 11  
 12  from hermes_cli import kanban_db as kb
 13  
 14  
 15  @pytest.fixture
 16  def kanban_home(tmp_path, monkeypatch):
 17      """Isolated HERMES_HOME with an empty kanban DB."""
 18      home = tmp_path / ".hermes"
 19      home.mkdir()
 20      monkeypatch.setenv("HERMES_HOME", str(home))
 21      monkeypatch.setattr(Path, "home", lambda: tmp_path)
 22      kb.init_db()
 23      return home
 24  
 25  
 26  # ---------------------------------------------------------------------------
 27  # Schema / init
 28  # ---------------------------------------------------------------------------
 29  
 30  def test_init_db_is_idempotent(kanban_home):
 31      # Second call should not error or drop data.
 32      with kb.connect() as conn:
 33          kb.create_task(conn, title="persisted")
 34      kb.init_db()
 35      with kb.connect() as conn:
 36          tasks = kb.list_tasks(conn)
 37      assert len(tasks) == 1
 38      assert tasks[0].title == "persisted"
 39  
 40  
 41  def test_init_creates_expected_tables(kanban_home):
 42      with kb.connect() as conn:
 43          rows = conn.execute(
 44              "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
 45          ).fetchall()
 46      names = {r["name"] for r in rows}
 47      assert {"tasks", "task_links", "task_comments", "task_events"} <= names
 48  
 49  
 50  # ---------------------------------------------------------------------------
 51  # Task creation + status inference
 52  # ---------------------------------------------------------------------------
 53  
 54  def test_create_task_no_parents_is_ready(kanban_home):
 55      with kb.connect() as conn:
 56          tid = kb.create_task(conn, title="ship it", assignee="alice")
 57          t = kb.get_task(conn, tid)
 58      assert t is not None
 59      assert t.status == "ready"
 60      assert t.assignee == "alice"
 61      assert t.workspace_kind == "scratch"
 62  
 63  
 64  def test_create_task_with_parent_is_todo_until_parent_done(kanban_home):
 65      with kb.connect() as conn:
 66          p = kb.create_task(conn, title="parent")
 67          c = kb.create_task(conn, title="child", parents=[p])
 68          assert kb.get_task(conn, c).status == "todo"
 69          kb.complete_task(conn, p, result="ok")
 70          assert kb.get_task(conn, c).status == "ready"
 71  
 72  
 73  def test_create_task_unknown_parent_errors(kanban_home):
 74      with kb.connect() as conn, pytest.raises(ValueError, match="unknown parent"):
 75          kb.create_task(conn, title="orphan", parents=["t_ghost"])
 76  
 77  
 78  def test_workspace_kind_validation(kanban_home):
 79      with kb.connect() as conn, pytest.raises(ValueError, match="workspace_kind"):
 80          kb.create_task(conn, title="bad ws", workspace_kind="cloud")
 81  
 82  
 83  # ---------------------------------------------------------------------------
 84  # Links + dependency resolution
 85  # ---------------------------------------------------------------------------
 86  
 87  def test_link_demotes_ready_child_to_todo_when_parent_not_done(kanban_home):
 88      with kb.connect() as conn:
 89          a = kb.create_task(conn, title="a")
 90          b = kb.create_task(conn, title="b")
 91          assert kb.get_task(conn, b).status == "ready"
 92          kb.link_tasks(conn, a, b)
 93          assert kb.get_task(conn, b).status == "todo"
 94  
 95  
 96  def test_link_keeps_ready_child_when_parent_already_done(kanban_home):
 97      with kb.connect() as conn:
 98          a = kb.create_task(conn, title="a")
 99          kb.complete_task(conn, a)
100          b = kb.create_task(conn, title="b")
101          assert kb.get_task(conn, b).status == "ready"
102          kb.link_tasks(conn, a, b)
103          assert kb.get_task(conn, b).status == "ready"
104  
105  
106  def test_link_rejects_self_loop(kanban_home):
107      with kb.connect() as conn:
108          a = kb.create_task(conn, title="a")
109          with pytest.raises(ValueError, match="itself"):
110              kb.link_tasks(conn, a, a)
111  
112  
113  def test_link_detects_cycle(kanban_home):
114      with kb.connect() as conn:
115          a = kb.create_task(conn, title="a")
116          b = kb.create_task(conn, title="b", parents=[a])
117          c = kb.create_task(conn, title="c", parents=[b])
118          with pytest.raises(ValueError, match="cycle"):
119              kb.link_tasks(conn, c, a)
120          with pytest.raises(ValueError, match="cycle"):
121              kb.link_tasks(conn, b, a)
122  
123  
124  def test_recompute_ready_cascades_through_chain(kanban_home):
125      with kb.connect() as conn:
126          a = kb.create_task(conn, title="a")
127          b = kb.create_task(conn, title="b", parents=[a])
128          c = kb.create_task(conn, title="c", parents=[b])
129          assert [kb.get_task(conn, x).status for x in (a, b, c)] == \
130                 ["ready", "todo", "todo"]
131          kb.complete_task(conn, a)
132          assert kb.get_task(conn, b).status == "ready"
133          kb.complete_task(conn, b)
134          assert kb.get_task(conn, c).status == "ready"
135  
136  
137  def test_recompute_ready_fan_in_waits_for_all_parents(kanban_home):
138      with kb.connect() as conn:
139          a = kb.create_task(conn, title="a")
140          b = kb.create_task(conn, title="b")
141          c = kb.create_task(conn, title="c", parents=[a, b])
142          kb.complete_task(conn, a)
143          assert kb.get_task(conn, c).status == "todo"
144          kb.complete_task(conn, b)
145          assert kb.get_task(conn, c).status == "ready"
146  
147  
148  # ---------------------------------------------------------------------------
149  # Atomic claim (CAS)
150  # ---------------------------------------------------------------------------
151  
152  def test_claim_once_wins_second_loses(kanban_home):
153      with kb.connect() as conn:
154          t = kb.create_task(conn, title="x", assignee="a")
155          first = kb.claim_task(conn, t, claimer="host:1")
156          assert first is not None and first.status == "running"
157          second = kb.claim_task(conn, t, claimer="host:2")
158          assert second is None
159  
160  
161  def test_claim_fails_on_non_ready(kanban_home):
162      with kb.connect() as conn:
163          t = kb.create_task(conn, title="x")
164          # Move to todo by introducing an unsatisfied parent.
165          p = kb.create_task(conn, title="p")
166          kb.link_tasks(conn, p, t)
167          assert kb.get_task(conn, t).status == "todo"
168          assert kb.claim_task(conn, t) is None
169  
170  
171  def test_stale_claim_reclaimed(kanban_home):
172      with kb.connect() as conn:
173          t = kb.create_task(conn, title="x", assignee="a")
174          kb.claim_task(conn, t)
175          # Rewind claim_expires so it looks stale.
176          conn.execute(
177              "UPDATE tasks SET claim_expires = ? WHERE id = ?",
178              (int(time.time()) - 3600, t),
179          )
180          reclaimed = kb.release_stale_claims(conn)
181          assert reclaimed == 1
182          assert kb.get_task(conn, t).status == "ready"
183  
184  
185  def test_heartbeat_extends_claim(kanban_home):
186      with kb.connect() as conn:
187          t = kb.create_task(conn, title="x", assignee="a")
188          claimer = "host:hb"
189          kb.claim_task(conn, t, claimer=claimer, ttl_seconds=60)
190          original = kb.get_task(conn, t).claim_expires
191          # Rewind then heartbeat.
192          conn.execute("UPDATE tasks SET claim_expires = ? WHERE id = ?", (0, t))
193          ok = kb.heartbeat_claim(conn, t, claimer=claimer, ttl_seconds=3600)
194          assert ok
195          new = kb.get_task(conn, t).claim_expires
196          assert new > int(time.time()) + 3000
197  
198  
199  def test_concurrent_claims_only_one_wins(kanban_home):
200      """Fire N threads claiming the same task; exactly one must win."""
201      with kb.connect() as conn:
202          t = kb.create_task(conn, title="race", assignee="a")
203  
204      def attempt(i):
205          with kb.connect() as c:
206              return kb.claim_task(c, t, claimer=f"host:{i}")
207  
208      n_workers = 8
209      with concurrent.futures.ThreadPoolExecutor(max_workers=n_workers) as ex:
210          results = list(ex.map(attempt, range(n_workers)))
211      winners = [r for r in results if r is not None]
212      assert len(winners) == 1
213      assert winners[0].status == "running"
214  
215  
216  # ---------------------------------------------------------------------------
217  # Complete / block / unblock / archive / assign
218  # ---------------------------------------------------------------------------
219  
220  def test_complete_records_result(kanban_home):
221      with kb.connect() as conn:
222          t = kb.create_task(conn, title="x")
223          assert kb.complete_task(conn, t, result="done and dusted")
224          task = kb.get_task(conn, t)
225      assert task.status == "done"
226      assert task.result == "done and dusted"
227      assert task.completed_at is not None
228  
229  
230  def test_block_then_unblock(kanban_home):
231      with kb.connect() as conn:
232          t = kb.create_task(conn, title="x", assignee="a")
233          kb.claim_task(conn, t)
234          assert kb.block_task(conn, t, reason="need input")
235          assert kb.get_task(conn, t).status == "blocked"
236          assert kb.unblock_task(conn, t)
237          assert kb.get_task(conn, t).status == "ready"
238  
239  
240  def test_assign_refuses_while_running(kanban_home):
241      with kb.connect() as conn:
242          t = kb.create_task(conn, title="x", assignee="a")
243          kb.claim_task(conn, t)
244          with pytest.raises(RuntimeError, match="currently running"):
245              kb.assign_task(conn, t, "b")
246  
247  
248  def test_assign_reassigns_when_not_running(kanban_home):
249      with kb.connect() as conn:
250          t = kb.create_task(conn, title="x", assignee="a")
251          assert kb.assign_task(conn, t, "b")
252          assert kb.get_task(conn, t).assignee == "b"
253  
254  
255  def test_assignee_normalized_to_lowercase_on_create_and_assign(kanban_home):
256      """Dashboard/CLI may pass title-cased profile labels; DB + spawn use canonical id."""
257      with kb.connect() as conn:
258          tid = kb.create_task(conn, title="cased", assignee="Jules")
259          assert kb.get_task(conn, tid).assignee == "jules"
260          assert kb.assign_task(conn, tid, "Librarian")
261          assert kb.get_task(conn, tid).assignee == "librarian"
262  
263  
264  def test_list_tasks_assignee_filter_case_insensitive(kanban_home):
265      with kb.connect() as conn:
266          tid = kb.create_task(conn, title="q", assignee="jules")
267          found = kb.list_tasks(conn, assignee="Jules")
268          assert len(found) == 1 and found[0].id == tid
269  
270  
271  def test_archive_hides_from_default_list(kanban_home):
272      with kb.connect() as conn:
273          t = kb.create_task(conn, title="x")
274          kb.complete_task(conn, t)
275          assert kb.archive_task(conn, t)
276          assert len(kb.list_tasks(conn)) == 0
277          assert len(kb.list_tasks(conn, include_archived=True)) == 1
278  
279  
280  # ---------------------------------------------------------------------------
281  # Comments / events / worker context
282  # ---------------------------------------------------------------------------
283  
284  def test_comments_recorded_in_order(kanban_home):
285      with kb.connect() as conn:
286          t = kb.create_task(conn, title="x")
287          kb.add_comment(conn, t, "user", "first")
288          kb.add_comment(conn, t, "researcher", "second")
289          comments = kb.list_comments(conn, t)
290      assert [c.body for c in comments] == ["first", "second"]
291      assert [c.author for c in comments] == ["user", "researcher"]
292  
293  
294  def test_empty_comment_rejected(kanban_home):
295      with kb.connect() as conn:
296          t = kb.create_task(conn, title="x")
297          with pytest.raises(ValueError, match="body is required"):
298              kb.add_comment(conn, t, "user", "")
299  
300  
301  def test_events_capture_lifecycle(kanban_home):
302      with kb.connect() as conn:
303          t = kb.create_task(conn, title="x", assignee="a")
304          kb.claim_task(conn, t)
305          kb.complete_task(conn, t, result="ok")
306          events = kb.list_events(conn, t)
307      kinds = [e.kind for e in events]
308      assert "created" in kinds
309      assert "claimed" in kinds
310      assert "completed" in kinds
311  
312  
313  def test_worker_context_includes_parent_results_and_comments(kanban_home):
314      with kb.connect() as conn:
315          p = kb.create_task(conn, title="p")
316          kb.complete_task(conn, p, result="PARENT_RESULT_MARKER")
317          c = kb.create_task(conn, title="child", parents=[p])
318          kb.add_comment(conn, c, "user", "CLARIFICATION_MARKER")
319          ctx = kb.build_worker_context(conn, c)
320      assert "PARENT_RESULT_MARKER" in ctx
321      assert "CLARIFICATION_MARKER" in ctx
322      assert c in ctx
323      assert "child" in ctx
324  
325  
326  # ---------------------------------------------------------------------------
327  # Dispatcher
328  # ---------------------------------------------------------------------------
329  
330  def test_dispatch_dry_run_does_not_claim(kanban_home):
331      with kb.connect() as conn:
332          t1 = kb.create_task(conn, title="a", assignee="alice")
333          t2 = kb.create_task(conn, title="b", assignee="bob")
334          res = kb.dispatch_once(conn, dry_run=True)
335      assert {s[0] for s in res.spawned} == {t1, t2}
336      with kb.connect() as conn:
337          # Dry run must NOT mutate status.
338          assert kb.get_task(conn, t1).status == "ready"
339          assert kb.get_task(conn, t2).status == "ready"
340  
341  
342  def test_dispatch_skips_unassigned(kanban_home):
343      with kb.connect() as conn:
344          t = kb.create_task(conn, title="floater")
345          res = kb.dispatch_once(conn, dry_run=True)
346      assert t in res.skipped_unassigned
347      assert not res.spawned
348  
349  
350  def test_dispatch_promotes_ready_and_spawns(kanban_home):
351      spawns = []
352  
353      def fake_spawn(task, workspace):
354          spawns.append((task.id, task.assignee, workspace))
355  
356      with kb.connect() as conn:
357          p = kb.create_task(conn, title="p", assignee="alice")
358          c = kb.create_task(conn, title="c", assignee="bob", parents=[p])
359          # Finish parent outside dispatch; promotion happens inside.
360          kb.complete_task(conn, p)
361          res = kb.dispatch_once(conn, spawn_fn=fake_spawn)
362      # Spawned c (a was already done when dispatch was called).
363      assert len(spawns) == 1
364      assert spawns[0][0] == c
365      assert spawns[0][1] == "bob"
366      # c is now running
367      with kb.connect() as conn:
368          assert kb.get_task(conn, c).status == "running"
369  
370  
371  def test_dispatch_spawn_failure_releases_claim(kanban_home):
372      def boom(task, workspace):
373          raise RuntimeError("spawn failed")
374  
375      with kb.connect() as conn:
376          t = kb.create_task(conn, title="boom", assignee="alice")
377          kb.dispatch_once(conn, spawn_fn=boom)
378          # Must return to ready so the next tick can retry.
379          assert kb.get_task(conn, t).status == "ready"
380          assert kb.get_task(conn, t).claim_lock is None
381  
382  
383  def test_dispatch_reclaims_stale_before_spawning(kanban_home):
384      with kb.connect() as conn:
385          t = kb.create_task(conn, title="x", assignee="alice")
386          kb.claim_task(conn, t)
387          conn.execute(
388              "UPDATE tasks SET claim_expires = ? WHERE id = ?",
389              (int(time.time()) - 1, t),
390          )
391          res = kb.dispatch_once(conn, dry_run=True)
392      assert res.reclaimed == 1
393  
394  
395  # ---------------------------------------------------------------------------
396  # Workspace resolution
397  # ---------------------------------------------------------------------------
398  
399  def test_scratch_workspace_created_under_hermes_home(kanban_home):
400      with kb.connect() as conn:
401          t = kb.create_task(conn, title="x")
402          task = kb.get_task(conn, t)
403          ws = kb.resolve_workspace(task)
404      assert ws.exists()
405      assert ws.is_dir()
406      assert "kanban" in str(ws)
407  
408  
409  def test_dir_workspace_honors_given_path(kanban_home, tmp_path):
410      target = tmp_path / "my-vault"
411      with kb.connect() as conn:
412          t = kb.create_task(
413              conn, title="biz", workspace_kind="dir", workspace_path=str(target)
414          )
415          task = kb.get_task(conn, t)
416          ws = kb.resolve_workspace(task)
417      assert ws == target
418      assert ws.exists()
419  
420  
421  def test_worktree_workspace_returns_intended_path(kanban_home, tmp_path):
422      target = str(tmp_path / ".worktrees" / "my-task")
423      with kb.connect() as conn:
424          t = kb.create_task(
425              conn, title="ship", workspace_kind="worktree", workspace_path=target
426          )
427          task = kb.get_task(conn, t)
428          ws = kb.resolve_workspace(task)
429      # We do NOT auto-create worktrees; the worker's skill handles that.
430      assert str(ws) == target
431  
432  
433  # ---------------------------------------------------------------------------
434  # Tenancy
435  # ---------------------------------------------------------------------------
436  
437  def test_tenant_column_filters_listings(kanban_home):
438      with kb.connect() as conn:
439          kb.create_task(conn, title="a1", tenant="biz-a")
440          kb.create_task(conn, title="b1", tenant="biz-b")
441          kb.create_task(conn, title="shared")  # no tenant
442          biz_a = kb.list_tasks(conn, tenant="biz-a")
443          biz_b = kb.list_tasks(conn, tenant="biz-b")
444      assert [t.title for t in biz_a] == ["a1"]
445      assert [t.title for t in biz_b] == ["b1"]
446  
447  
448  def test_tenant_propagates_to_events(kanban_home):
449      with kb.connect() as conn:
450          t = kb.create_task(conn, title="tenant-task", tenant="biz-a")
451          events = kb.list_events(conn, t)
452      # The "created" event should have tenant in its payload.
453      created = [e for e in events if e.kind == "created"]
454      assert created and created[0].payload.get("tenant") == "biz-a"
455  
456  
457  # ---------------------------------------------------------------------------
458  # Shared-board path resolution (issue #19348)
459  #
460  # The kanban board is a cross-profile coordination primitive: a worker
461  # spawned with `hermes -p <profile>` must read/write the same kanban.db
462  # as the dispatcher that claimed the task. These tests exercise the
463  # path-resolution layer directly and would have caught the regression
464  # where `kanban_db_path()` resolved to the active profile's HERMES_HOME.
465  # ---------------------------------------------------------------------------
466  
467  class TestSharedBoardPaths:
468      """`kanban_home`/`kanban_db_path`/`workspaces_root`/`worker_log_path`
469      must anchor at the **shared root**, not the active profile's HERMES_HOME."""
470  
471      def _set_home(self, monkeypatch, tmp_path, hermes_home):
472          monkeypatch.setattr(Path, "home", lambda: tmp_path)
473          monkeypatch.setenv("HERMES_HOME", str(hermes_home))
474          monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False)
475  
476      def test_default_install_anchors_at_home_dot_hermes(
477          self, tmp_path, monkeypatch
478      ):
479          # Standard install: HERMES_HOME == ~/.hermes, no profile active.
480          default_home = tmp_path / ".hermes"
481          default_home.mkdir()
482          self._set_home(monkeypatch, tmp_path, default_home)
483  
484          assert kb.kanban_home() == default_home
485          assert kb.kanban_db_path() == default_home / "kanban.db"
486          assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
487          assert (
488              kb.worker_log_path("t_demo")
489              == default_home / "kanban" / "logs" / "t_demo.log"
490          )
491  
492      def test_profile_worker_resolves_to_shared_root(
493          self, tmp_path, monkeypatch
494      ):
495          # Reproduces the bug: dispatcher uses ~/.hermes/kanban.db,
496          # worker spawned with -p <profile> previously resolved to
497          # ~/.hermes/profiles/<profile>/kanban.db. After the fix both
498          # converge on ~/.hermes/kanban.db.
499          default_home = tmp_path / ".hermes"
500          default_home.mkdir()
501          profile_home = default_home / "profiles" / "nehemiahkanban"
502          profile_home.mkdir(parents=True)
503          self._set_home(monkeypatch, tmp_path, profile_home)
504  
505          # All four resolvers must anchor at the shared root, not the
506          # profile-local HERMES_HOME.
507          assert kb.kanban_home() == default_home
508          assert kb.kanban_db_path() == default_home / "kanban.db"
509          assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
510          assert (
511              kb.worker_log_path("t_0d214f19")
512              == default_home / "kanban" / "logs" / "t_0d214f19.log"
513          )
514  
515          # Sanity: the profile-local path that used to be returned is
516          # explicitly NOT what we resolve to anymore.
517          assert kb.kanban_db_path() != profile_home / "kanban.db"
518  
519      def test_dispatcher_and_profile_worker_converge(
520          self, tmp_path, monkeypatch
521      ):
522          # End-to-end convergence: resolve the path under each side's
523          # HERMES_HOME and confirm equality. This is the property the
524          # dispatcher/worker handoff actually depends on.
525          default_home = tmp_path / ".hermes"
526          default_home.mkdir()
527          profile_home = default_home / "profiles" / "coder"
528          profile_home.mkdir(parents=True)
529  
530          # Dispatcher's perspective.
531          self._set_home(monkeypatch, tmp_path, default_home)
532          dispatcher_db = kb.kanban_db_path()
533          dispatcher_ws = kb.workspaces_root()
534          dispatcher_log = kb.worker_log_path("t_handoff")
535  
536          # Worker's perspective (profile activated by `hermes -p coder`).
537          monkeypatch.setenv("HERMES_HOME", str(profile_home))
538          worker_db = kb.kanban_db_path()
539          worker_ws = kb.workspaces_root()
540          worker_log = kb.worker_log_path("t_handoff")
541  
542          assert dispatcher_db == worker_db
543          assert dispatcher_ws == worker_ws
544          assert dispatcher_log == worker_log
545  
546      def test_docker_custom_hermes_home_uses_env_path_directly(
547          self, tmp_path, monkeypatch
548      ):
549          # Docker / custom deployment: HERMES_HOME points outside ~/.hermes.
550          # `get_default_hermes_root()` returns env_home directly when it
551          # is not a `<root>/profiles/<name>` shape and not under
552          # `Path.home() / ".hermes"`.
553          custom_root = tmp_path / "opt" / "hermes"
554          custom_root.mkdir(parents=True)
555          self._set_home(monkeypatch, tmp_path, custom_root)
556  
557          assert kb.kanban_home() == custom_root
558          assert kb.kanban_db_path() == custom_root / "kanban.db"
559  
560      def test_docker_profile_layout_uses_grandparent(
561          self, tmp_path, monkeypatch
562      ):
563          # Docker profile shape: HERMES_HOME=/opt/hermes/profiles/coder;
564          # `get_default_hermes_root()` walks up to /opt/hermes because
565          # the immediate parent dir is named "profiles".
566          custom_root = tmp_path / "opt" / "hermes"
567          profile = custom_root / "profiles" / "coder"
568          profile.mkdir(parents=True)
569          self._set_home(monkeypatch, tmp_path, profile)
570  
571          assert kb.kanban_home() == custom_root
572          assert kb.kanban_db_path() == custom_root / "kanban.db"
573  
574      def test_explicit_override_via_hermes_kanban_home(
575          self, tmp_path, monkeypatch
576      ):
577          # Explicit override: HERMES_KANBAN_HOME beats every other
578          # resolution rule.
579          default_home = tmp_path / ".hermes"
580          profile_home = default_home / "profiles" / "any"
581          profile_home.mkdir(parents=True)
582          override = tmp_path / "shared-board"
583          override.mkdir()
584  
585          monkeypatch.setattr(Path, "home", lambda: tmp_path)
586          monkeypatch.setenv("HERMES_HOME", str(profile_home))
587          monkeypatch.setenv("HERMES_KANBAN_HOME", str(override))
588  
589          assert kb.kanban_home() == override
590          assert kb.kanban_db_path() == override / "kanban.db"
591          assert kb.workspaces_root() == override / "kanban" / "workspaces"
592  
593      def test_empty_override_falls_through(self, tmp_path, monkeypatch):
594          # Empty/whitespace override is treated as unset.
595          default_home = tmp_path / ".hermes"
596          default_home.mkdir()
597          monkeypatch.setattr(Path, "home", lambda: tmp_path)
598          monkeypatch.setenv("HERMES_HOME", str(default_home))
599          monkeypatch.setenv("HERMES_KANBAN_HOME", "   ")
600  
601          assert kb.kanban_home() == default_home
602  
603      def test_dispatcher_and_worker_share_a_real_database(
604          self, tmp_path, monkeypatch
605      ):
606          # Belt-and-suspenders: round-trip a task across the two
607          # HERMES_HOME perspectives via a real SQLite file. Without the
608          # fix the worker would open a different file and see no rows.
609          default_home = tmp_path / ".hermes"
610          default_home.mkdir()
611          profile_home = default_home / "profiles" / "nehemiahkanban"
612          profile_home.mkdir(parents=True)
613  
614          # Dispatcher creates the board and a task.
615          self._set_home(monkeypatch, tmp_path, default_home)
616          kb.init_db()
617          with kb.connect() as conn:
618              task_id = kb.create_task(conn, title="cross-profile")
619  
620          # Worker switches to the profile HERMES_HOME and reads.
621          monkeypatch.setenv("HERMES_HOME", str(profile_home))
622          with kb.connect() as conn:
623              task = kb.get_task(conn, task_id)
624          assert task is not None
625          assert task.title == "cross-profile"
626  
627      def test_hermes_kanban_db_pin_beats_kanban_home(
628          self, tmp_path, monkeypatch
629      ):
630          # HERMES_KANBAN_DB pins the file path directly and beats both
631          # HERMES_KANBAN_HOME and the `get_default_hermes_root()` path.
632          # This is the env the dispatcher injects into workers.
633          default_home = tmp_path / ".hermes"
634          default_home.mkdir()
635          umbrella = tmp_path / "umbrella"
636          umbrella.mkdir()
637          pinned_db = tmp_path / "pinned" / "board.db"
638          pinned_db.parent.mkdir()
639  
640          monkeypatch.setattr(Path, "home", lambda: tmp_path)
641          monkeypatch.setenv("HERMES_HOME", str(default_home))
642          monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella))
643          monkeypatch.setenv("HERMES_KANBAN_DB", str(pinned_db))
644  
645          assert kb.kanban_db_path() == pinned_db
646          # workspaces_root still follows HERMES_KANBAN_HOME -- the pins
647          # are independent.
648          assert kb.workspaces_root() == umbrella / "kanban" / "workspaces"
649  
650      def test_hermes_kanban_workspaces_root_pin_beats_kanban_home(
651          self, tmp_path, monkeypatch
652      ):
653          # HERMES_KANBAN_WORKSPACES_ROOT pins the workspaces root directly.
654          default_home = tmp_path / ".hermes"
655          default_home.mkdir()
656          umbrella = tmp_path / "umbrella"
657          umbrella.mkdir()
658          pinned_ws = tmp_path / "pinned-workspaces"
659          pinned_ws.mkdir()
660  
661          monkeypatch.setattr(Path, "home", lambda: tmp_path)
662          monkeypatch.setenv("HERMES_HOME", str(default_home))
663          monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella))
664          monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(pinned_ws))
665  
666          assert kb.workspaces_root() == pinned_ws
667          # kanban_db_path still follows HERMES_KANBAN_HOME.
668          assert kb.kanban_db_path() == umbrella / "kanban.db"
669  
670      def test_empty_per_path_overrides_fall_through(
671          self, tmp_path, monkeypatch
672      ):
673          # Empty/whitespace pins are treated as unset, same as
674          # HERMES_KANBAN_HOME.
675          default_home = tmp_path / ".hermes"
676          default_home.mkdir()
677          monkeypatch.setattr(Path, "home", lambda: tmp_path)
678          monkeypatch.setenv("HERMES_HOME", str(default_home))
679          monkeypatch.setenv("HERMES_KANBAN_DB", "   ")
680          monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", "")
681  
682          assert kb.kanban_db_path() == default_home / "kanban.db"
683          assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
684  
685      def test_dispatcher_spawn_injects_kanban_db_and_workspaces_root(
686          self, tmp_path, monkeypatch
687      ):
688          # The dispatcher's `_default_spawn` must inject HERMES_KANBAN_DB
689          # and HERMES_KANBAN_WORKSPACES_ROOT into the worker env so the
690          # worker converges on the dispatcher's paths even when the
691          # `-p <profile>` flag rewrites HERMES_HOME.
692          default_home = tmp_path / ".hermes"
693          default_home.mkdir()
694          self._set_home(monkeypatch, tmp_path, default_home)
695  
696          captured = {}
697  
698          class _FakePopen:
699              def __init__(self, cmd, **kwargs):
700                  captured["cmd"] = cmd
701                  captured["env"] = kwargs.get("env", {})
702                  self.pid = 4242
703  
704          monkeypatch.setattr("subprocess.Popen", _FakePopen)
705  
706          task = kb.Task(
707              id="t_dispatch_env",
708              title="x",
709              body=None,
710              assignee="coder",
711              status="ready",
712              priority=0,
713              created_by=None,
714              created_at=0,
715              started_at=None,
716              completed_at=None,
717              workspace_kind="scratch",
718              workspace_path=None,
719              claim_lock=None,
720              claim_expires=None,
721              tenant=None,
722          )
723          kb._default_spawn(task, str(tmp_path / "ws"))
724  
725          env = captured["env"]
726          assert env["HERMES_KANBAN_DB"] == str(default_home / "kanban.db")
727          assert env["HERMES_KANBAN_WORKSPACES_ROOT"] == str(
728              default_home / "kanban" / "workspaces"
729          )
730          assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env"