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"