From e3ab65fe8006fc5afdc9766d8b90ab70751a869f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:48:14 -0700 Subject: [PATCH] =?UTF-8?q?simplify(compat):=20kanban=5Fdb=20=E2=80=94=20d?= =?UTF-8?q?rop=2073=20re-exports/aliases,=20repoint=20794=20callers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/turn_finalizer.py | 6 +- cli.py | 12 +- gateway/kanban_watchers.py | 14 +- gateway/kanban_watchers_dispatcher.py | 27 ++- gateway/kanban_watchers_notifier.py | 20 +- gateway/slash_commands.py | 6 +- hermes_cli/kanban.py | 84 +++---- hermes_cli/kanban_boards.py | 3 +- hermes_cli/kanban_db.py | 81 +------ hermes_cli/kanban_db_connect.py | 16 +- hermes_cli/kanban_db_dispatch.py | 85 +++---- hermes_cli/kanban_db_notify.py | 6 +- hermes_cli/kanban_db_workspace.py | 6 +- hermes_cli/kanban_decompose.py | 7 +- hermes_cli/kanban_ops.py | 39 ++-- hermes_cli/kanban_parser.py | 10 +- hermes_cli/kanban_specify.py | 7 +- hermes_cli/kanban_transfer.py | 3 +- plugins/kanban/dashboard/plugin_api.py | 26 ++- ...est_turn_finalizer_iteration_limit_exit.py | 20 +- tests/conftest.py | 11 +- .../test_kanban_changes_requested_notifier.py | 10 +- tests/gateway/test_kanban_notifier.py | 92 ++++---- .../test_kanban_notifier_apiserver_wake.py | 14 +- ...test_kanban_notifier_wake_only_ordering.py | 14 +- .../test_kanban_notifier_zero_sub_gate.py | 8 +- .../gateway/test_kanban_reconcile_orphans.py | 22 +- tests/gateway/test_kanban_wake_scope.py | 6 +- tests/hermes_cli/test_kanban_block_kinds.py | 5 +- .../hermes_cli/test_kanban_blocked_sticky.py | 5 +- tests/hermes_cli/test_kanban_board_project.py | 5 +- tests/hermes_cli/test_kanban_boards.py | 28 +-- tests/hermes_cli/test_kanban_cli.py | 13 +- .../test_kanban_cli_dispatch_passthrough.py | 6 +- .../hermes_cli/test_kanban_comment_queries.py | 3 +- .../test_kanban_core_functionality.py | 120 +++++----- .../test_kanban_count_notify_subs.py | 42 ++-- tests/hermes_cli/test_kanban_db.py | 114 ++++----- tests/hermes_cli/test_kanban_db_init.py | 30 +-- tests/hermes_cli/test_kanban_db_repair.py | 30 +-- tests/hermes_cli/test_kanban_decompose.py | 11 +- tests/hermes_cli/test_kanban_decompose_db.py | 11 +- .../test_kanban_default_assignee.py | 20 +- tests/hermes_cli/test_kanban_diagnostics.py | 3 +- tests/hermes_cli/test_kanban_dispatch_lock.py | 12 +- .../test_kanban_dispatch_tick_hook.py | 20 +- .../test_kanban_gateway_restart_handoff.py | 15 +- tests/hermes_cli/test_kanban_goal_mode.py | 5 +- tests/hermes_cli/test_kanban_host_cap.py | 58 ++--- .../test_kanban_init_lock_bounded.py | 11 +- .../hermes_cli/test_kanban_lifecycle_hooks.py | 5 +- tests/hermes_cli/test_kanban_memory_guard.py | 88 +++---- tests/hermes_cli/test_kanban_notify.py | 218 +++++++++++------- .../test_kanban_parent_reopen_invalidation.py | 14 +- .../hermes_cli/test_kanban_per_profile_cap.py | 22 +- tests/hermes_cli/test_kanban_project_link.py | 3 +- tests/hermes_cli/test_kanban_promote.py | 7 +- .../test_kanban_reclaim_claim_lock_guard.py | 14 +- .../test_kanban_review_lifecycle.py | 69 +++--- .../test_kanban_review_lifecycle_complete.py | 24 +- .../hermes_cli/test_kanban_review_surfaces.py | 35 +-- tests/hermes_cli/test_kanban_specify.py | 9 +- tests/hermes_cli/test_kanban_specify_db.py | 15 +- tests/hermes_cli/test_kanban_swarm.py | 13 +- .../test_kanban_task_updated_hook.py | 7 +- tests/hermes_cli/test_kanban_transfer.py | 17 +- .../test_kanban_worker_image_extraction.py | 5 +- .../test_kanban_worker_lifecycle_hooks.py | 24 +- .../test_kanban_worker_session_source.py | 5 +- .../test_kanban_worker_spawn_toolsets.py | 13 +- .../test_kanban_worker_terminal_cwd.py | 6 +- .../test_kanban_worktree_isolation.py | 8 +- .../test_kanban_worktree_teardown.py | 26 ++- tests/hermes_cli/test_kanban_write_guard.py | 7 +- .../test_kanban_write_txn_busy_retry.py | 7 +- tests/plugins/test_kanban_attachments.py | 15 +- .../plugins/test_kanban_board_project_api.py | 3 +- tests/plugins/test_kanban_dashboard_plugin.py | 48 ++-- ...test_kanban_dashboard_task_updated_hook.py | 3 +- tests/plugins/test_kanban_model_override.py | 8 +- .../plugins/test_kanban_ws_idle_disconnect.py | 4 +- tests/test_journal_mode_config.py | 2 +- tests/tools/test_kanban_comment_injection.py | 9 +- tests/tools/test_kanban_redaction.py | 18 +- tests/tools/test_kanban_tools.py | 97 +++++--- .../tui_gateway/test_kanban_notify_poller.py | 32 +-- tools/kanban_tools.py | 19 +- tui_gateway/session_notifications.py | 12 +- 88 files changed, 1155 insertions(+), 998 deletions(-) diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index f2ebf62d17..85074870f5 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -54,9 +54,11 @@ def _record_kanban_budget_exhausted( """ try: from hermes_cli import kanban_db as _kb - _conn = _kb.connect() + from hermes_cli import kanban_db_connect as _kbc + from hermes_cli import kanban_db_dispatch as _kbd + _conn = _kbc.connect() try: - _kb._record_task_failure( + _kbd._record_task_failure( _conn, kanban_task, error=( diff --git a/cli.py b/cli.py index 3ea142f6bd..6ee26558a6 100644 --- a/cli.py +++ b/cli.py @@ -590,7 +590,7 @@ _cleanup_all_terminals = _lazy_shim("tools.terminal_tool", "cleanup_all_environm set_sudo_password_callback = _lazy_shim("tools.terminal_tool", "set_sudo_password_callback") set_approval_callback = _lazy_shim("tools.terminal_tool", "set_approval_callback") set_secret_capture_callback = _lazy_shim("tools.skills_tool", "set_secret_capture_callback") -_cleanup_all_browsers = _lazy_shim("tools.browser_tool", "_emergency_cleanup_all_sessions", "_cleanup_all_browsers") +_cleanup_all_browsers = _lazy_shim("tools.browser_tool_lifecycle", "_emergency_cleanup_all_sessions", "_cleanup_all_browsers") _cleanup_done = False # _run_cleanup runs exactly once _cleanup_in_progress = False @@ -4049,10 +4049,11 @@ def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None: logger.warning("invalid HERMES_KANBAN_RUN_ID=%r", raw_run_id) from hermes_cli import kanban_db as _kb + from hermes_cli import kanban_db_connect as _kbc from hermes_cli.goals import run_kanban_goal_loop as _run_loop, DEFAULT_MAX_TURNS as _DEF_TURNS # Goal text = title + body (the acceptance criteria the judge evaluates against). - with _kb.connect_closing() as conn: + with _kbc.connect_closing() as conn: task = _kb.get_task(conn, task_id) if task is None: return @@ -4070,11 +4071,11 @@ def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None: return resp or "" def _task_status() -> "str | None": - with _kb.connect_closing() as c: + with _kbc.connect_closing() as c: return _kb.goal_run_status(c, task_id, worker_run_id) def _block(reason: str) -> None: - with _kb.connect_closing() as c: + with _kbc.connect_closing() as c: _kb.block_task(c, task_id, reason=reason, expected_run_id=worker_run_id) _run_loop( @@ -4189,9 +4190,10 @@ def _collect_kanban_task_images(single_query_images): return single_query_image_urls try: from hermes_cli import kanban_db as _kb + from hermes_cli import kanban_db_connect as _kbc from agent.image_routing import extract_image_refs as _extract_refs - with _kb.connect_closing() as _conn: + with _kbc.connect_closing() as _conn: _task = _kb.get_task(_conn, _kanban_task_id) _body = getattr(_task, "body", "") if _task is not None else "" if _body: diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index a51b296dc7..84967ad559 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -109,11 +109,12 @@ class GatewayKanbanWatchersMixin: await self._sleep_between_ticks(interval) def _kanban_sub_op(self, board: Optional[str], op: str, sub: dict, **extra: Any) -> None: - """Sync helper (runs in to_thread): call ``kanban_db.`` for one subscription on its board.""" - from hermes_cli import kanban_db as _kb - conn = _kb.connect(board=board) + """Sync helper (runs in to_thread): call ``kanban_db_notify.`` for one subscription on its board.""" + from hermes_cli import kanban_db_connect as _kbc + from hermes_cli import kanban_db_notify as _kbn + conn = _kbc.connect(board=board) try: - getattr(_kb, op)( + getattr(_kbn, op)( conn, task_id=sub["task_id"], platform=sub["platform"], chat_id=sub["chat_id"], thread_id=sub.get("thread_id") or "", **extra, ) @@ -234,7 +235,7 @@ class GatewayKanbanWatchersMixin: Gated by `kanban.dispatch_in_gateway` (default True); when false the loop exits and an external `hermes kanban daemon` is expected. Each - tick runs :func:`kanban_db.dispatch_once` in a thread; one tick's + tick runs :func:`kanban_db_dispatch.dispatch_once` in a thread; one tick's failure never stops the next. Shutdown: ``self._running`` is checked between ticks and the in-flight ``to_thread`` returns on its own. """ @@ -260,7 +261,8 @@ class GatewayKanbanWatchersMixin: try: # Reap zombies before per-board work so a board DB failure # cannot block cleanup of unrelated workers. - pids = await _to_thread_process_service(_kb.reap_worker_zombies) + from hermes_cli import kanban_db_dispatch as _kbd + pids = await _to_thread_process_service(_kbd.reap_worker_zombies) if pids: logger.info("kanban dispatcher: reaped %d zombie worker(s), pids=%s", len(pids), pids) except Exception: diff --git a/gateway/kanban_watchers_dispatcher.py b/gateway/kanban_watchers_dispatcher.py index 29b57422bc..03b84166c1 100644 --- a/gateway/kanban_watchers_dispatcher.py +++ b/gateway/kanban_watchers_dispatcher.py @@ -16,6 +16,16 @@ from typing import Any, Optional from gateway.kanban_watchers_common import _board_slugs, _positive_int_setting, logger + +def _kbc(): + from hermes_cli import kanban_db_connect + return kanban_db_connect + + +def _kbd(): + from hermes_cli import kanban_db_dispatch + return kanban_db_dispatch + _CORRUPT_DB_MARKERS = ("file is not a database", "database disk image is malformed") @@ -51,7 +61,7 @@ def _resolve_dispatcher_settings(kanban_cfg: dict, kb: Any) -> _DispatcherSettin # out. Explicit config wins; otherwise a memory-derived default (unbounded # fan-out swap-thrashes small hosts), or None where total memory can't be read. max_in_progress = _positive_int_setting(kanban_cfg, "max_in_progress") - effective_max_in_progress = kb.resolve_max_in_progress(max_in_progress) + effective_max_in_progress = _kbd().resolve_max_in_progress(max_in_progress) if max_in_progress is None and effective_max_in_progress is not None: logger.info( "kanban dispatcher: kanban.max_in_progress unset; using " @@ -139,8 +149,7 @@ class _KanbanDispatcher: return (resolved, stat.st_mtime_ns, stat.st_size) def is_corrupt_board_db_error(self, exc: Exception) -> bool: - corrupt_guard_error = getattr(self.kb, "KanbanDbCorruptError", None) - if corrupt_guard_error is not None and isinstance(exc, corrupt_guard_error): + if isinstance(exc, _kbc().KanbanDbCorruptError): return True return isinstance(exc, sqlite3.DatabaseError) and any(m in str(exc).lower() for m in _CORRUPT_DB_MARKERS) @@ -175,8 +184,8 @@ class _KanbanDispatcher: try: # No explicit init_db(): connect() runs the migration once per # process (see the matching note in the notifier collector). - conn = self.kb.connect(board=slug) - return self.kb.dispatch_once(conn, board=slug, **kwargs) + conn = _kbc().connect(board=slug) + return _kbd().dispatch_once(conn, board=slug, **kwargs) except Exception as exc: if self.is_corrupt_board_db_error(exc): self.disabled_corrupt_boards[slug] = (fingerprint, time.monotonic()) @@ -209,13 +218,13 @@ class _KanbanDispatcher: review dispatch is on (same gate as the dispatcher): a task waiting for a human reviewer is idle, not stuck. """ - kb = self.kb - _review_probe = kb.review_dispatch_enabled() + kbd = _kbd() + _review_probe = kbd.review_dispatch_enabled() for slug in self._board_slugs(): conn = None try: - conn = kb.connect(board=slug) - if kb.has_spawnable_ready(conn) or (_review_probe and kb.has_spawnable_review(conn)): + conn = _kbc().connect(board=slug) + if kbd.has_spawnable_ready(conn) or (_review_probe and kbd.has_spawnable_review(conn)): return True except Exception: continue diff --git a/gateway/kanban_watchers_notifier.py b/gateway/kanban_watchers_notifier.py index 8cf7499b92..39330e401d 100644 --- a/gateway/kanban_watchers_notifier.py +++ b/gateway/kanban_watchers_notifier.py @@ -15,6 +15,16 @@ from agent.i18n import t from gateway.kanban_watchers_common import _list_boards, _to_thread_process_service, logger + +def _kbc(): + from hermes_cli import kanban_db_connect + return kanban_db_connect + + +def _kbn(): + from hermes_cli import kanban_db_notify + return kanban_db_notify + # "status" covers dashboard drag-drop and `_set_status_direct()`. # ``review_requested`` wakes the origin like a block but is not one; # the task is not archived so later review cycles keep notifying. @@ -135,7 +145,7 @@ class _Collector: """Cheap read-only probe before the writable connect() (schema init, WAL sidecars, checkpoints); a probe failure falls back to the writable open.""" try: - count = self.kb.count_notify_subs( + count = _kbn().count_notify_subs( board=slug, notifier_profiles=self.notifier_profiles, include_unowned=self.include_unowned) except Exception as exc: logger.debug("kanban notifier: read-only subscription probe failed " @@ -149,7 +159,7 @@ class _Collector: def _gc_stale_subs(self, conn: Any, slug: str) -> None: """Best-effort stale-sub sweep: a failed sweep never blocks delivery; the next hourly gate retries.""" try: - _purged = self.kb.purge_stale_done_notify_subs(conn, max_age_days=self.gc_retention_days) + _purged = _kbn().purge_stale_done_notify_subs(conn, max_age_days=self.gc_retention_days) if _purged: logger.info("kanban notifier: purged %d stale done/blocked-task subscription(s) on board %s (retention %dd)", _purged, slug, self.gc_retention_days) @@ -168,7 +178,7 @@ class _Collector: logger.debug("kanban notifier: subscription for %s on %s skipped; adapter not connected", sub.get("task_id"), platform or "") return None - old_cursor, cursor, events = self.kb.claim_unseen_events_for_sub( + old_cursor, cursor, events = _kbn().claim_unseen_events_for_sub( conn, task_id=sub["task_id"], platform=sub["platform"], chat_id=sub["chat_id"], thread_id=sub.get("thread_id") or "", kinds=TERMINAL_KINDS, ) @@ -185,7 +195,7 @@ class _Collector: return kb = self.kb try: - conn = kb.connect(board=slug) + conn = _kbc().connect(board=slug) except Exception as exc: logger.debug("kanban notifier: cannot open board %s: %s", slug, exc) return @@ -195,7 +205,7 @@ class _Collector: # No explicit init_db(): connect() already runs the migration once per # process, and init_db() would re-run it on a second connection racing # the first. - subs = kb.list_notify_subs(conn, notifier_profiles=self.notifier_profiles, include_unowned=self.include_unowned) + subs = _kbn().list_notify_subs(conn, notifier_profiles=self.notifier_profiles, include_unowned=self.include_unowned) if not subs: logger.debug("kanban notifier: board %s has no subscriptions", slug) for sub in subs: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 8300f34ae6..bb215473b6 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -386,9 +386,11 @@ class GatewaySlashCommandsMixin( def _sub(): from hermes_cli import kanban_db as _kb - conn = _kb.connect(board=requested_board) + from hermes_cli import kanban_db_connect as _kbc + from hermes_cli import kanban_db_notify as _kbn + conn = _kbc.connect(board=requested_board) try: - _kb.add_notify_sub( + _kbn.add_notify_sub( conn, task_id=task_id, platform=platform_str, chat_id=chat_id, chat_type=chat_type, thread_id=_field("thread_id"), user_id=_field("user_id"), # Also persist the stable alt id (Signal UUID, Feishu union_id): build_session_key diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 758494ba31..d1734d8a65 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -17,6 +17,10 @@ from pathlib import Path from typing import Optional from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd +from hermes_cli import kanban_db_workspace as kbw +from hermes_cli import kanban_db_notify as kbn from hermes_cli import kanban_swarm as ks from hermes_cli.kanban_output import ( _ATTACHMENT_FIELDS, _RUNS_RUN_FIELDS, _SHOW_RUN_FIELDS, _bulk_apply, _err, @@ -313,15 +317,15 @@ def _cmd_init(args: argparse.Namespace) -> int: def _cmd_heartbeat(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: - ok = kb.heartbeat_worker(conn, args.task_id, note=getattr(args, "note", None), + with kbc.connect_closing() as conn: + ok = kbd.heartbeat_worker(conn, args.task_id, note=getattr(args, "note", None), expected_run_id=_worker_run_id_for(args.task_id)) return _ok_or_err(ok, f"cannot heartbeat {args.task_id} (not running?)", f"Heartbeat recorded for {args.task_id}") def _cmd_assignees(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: data = kb.known_assignees(conn) if _json_out(args, data): return 0 @@ -351,7 +355,7 @@ def _cmd_create(args: argparse.Namespace) -> int: if max_retries is not None and max_retries < 1: return _err(f"kanban: --max-retries must be >= 1 (got {max_retries}); " "use 1 to trip on the first failure.", 2) - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: task_id = kb.create_task( conn, title=args.title, body=args.body, assignee=args.assignee, created_by=args.created_by or _profile_author(), @@ -387,7 +391,7 @@ def _cmd_swarm(args: argparse.Namespace) -> int: return _err(f"kanban swarm: {exc}", 2) if not workers: return _err("kanban swarm: at least one --worker is required", 2) - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: created = ks.create_swarm( conn, goal=args.goal, workers=workers, verifier_assignee=args.verifier, synthesizer_assignee=args.synthesizer, tenant=args.tenant, @@ -408,7 +412,7 @@ def _cmd_list(args: argparse.Namespace) -> int: assignee = args.assignee if args.mine and not assignee: assignee = _profile_author() - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: # Cheap mini-dispatch so list reflects dependencies cleared since the last tick. kb.recompute_ready(conn) tasks = kb.list_tasks( @@ -465,7 +469,7 @@ def _cmd_show(args: argparse.Namespace) -> int: return rc graph = None want_json = getattr(args, "json", False) - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: task = kb.get_task(conn, args.task_id) if not task: return _err(f"no such task: {args.task_id}") @@ -560,7 +564,7 @@ def _cmd_show(args: argparse.Namespace) -> int: def _cmd_assign(args: argparse.Namespace) -> int: profile = _none_profile(args.profile) - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: ok = kb.assign_task(conn, args.task_id, profile) return _ok_or_err(ok, f"no such task: {args.task_id}", f"Assigned {args.task_id} to {profile or '(unassigned)'}") @@ -572,7 +576,7 @@ def _cmd_set_model(args: argparse.Namespace) -> int: model = None provider = getattr(args, "provider", None) try: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: ok = kb.set_model_override(conn, args.task_id, model, provider=provider) except (ValueError, RuntimeError) as exc: return _err(f"kanban: {exc}", 2) @@ -587,7 +591,7 @@ def _cmd_set_model(args: argparse.Namespace) -> int: def _cmd_reclaim(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: ok = kb.reclaim_task(conn, args.task_id, reason=getattr(args, "reason", None)) return _ok_or_err(ok, f"cannot reclaim {args.task_id} (not running or unknown id)", f"Reclaimed {args.task_id}") @@ -596,7 +600,7 @@ def _cmd_reclaim(args: argparse.Namespace) -> int: def _cmd_reassign(args: argparse.Namespace) -> int: profile = _none_profile(args.profile) reclaim = bool(getattr(args, "reclaim", False)) - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: ok = kb.reassign_task(conn, args.task_id, profile, reclaim_first=reclaim, reason=getattr(args, "reason", None)) return _ok_or_err( ok, @@ -626,7 +630,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: diag_config = kd.config_from_runtime_config(load_config()) - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: # Either one-task mode or fleet mode. if getattr(args, "task", None): task = kb.get_task(conn, args.task) @@ -686,21 +690,21 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: def _cmd_link(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: kb.link_tasks(conn, args.parent_id, args.child_id) print(f"Linked {args.parent_id} -> {args.child_id}") return 0 def _cmd_unlink(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: ok = kb.unlink_tasks(conn, args.parent_id, args.child_id) return _ok_or_err(ok, f"No such link: {args.parent_id} -> {args.child_id}", f"Unlinked {args.parent_id} -> {args.child_id}") def _cmd_claim(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: task = kb.claim_task(conn, args.task_id, ttl_seconds=args.ttl) if task is None: existing = kb.get_task(conn, args.task_id) @@ -708,8 +712,8 @@ def _cmd_claim(args: argparse.Namespace) -> int: return _err(f"no such task: {args.task_id}") return _err(f"cannot claim {args.task_id}: status={existing.status} " f"lock={existing.claim_lock or '(none)'}") - workspace = kb.resolve_workspace(task) - kb.set_workspace_path(conn, task.id, str(workspace)) + workspace = kbw.resolve_workspace(task) + kbw.set_workspace_path(conn, task.id, str(workspace)) print(f"Claimed {task.id}\nWorkspace: {workspace}") return 0 @@ -723,7 +727,7 @@ def _cmd_comment(args: argparse.Namespace) -> int: suffix = f"\n\n[trimmed to {args.max_len} chars by --max-len]" body = body[: max(0, args.max_len - len(suffix))].rstrip() + suffix author = args.author or _profile_author() - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: kb.add_comment(conn, args.task_id, author, body) print(f"Comment added to {args.task_id}") return 0 @@ -742,7 +746,7 @@ def _cmd_attach(args: argparse.Namespace) -> int: content_type = args.content_type or mimetypes.guess_type(name)[0] uploaded_by = args.author or _profile_author() try: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: att_id = kb.store_attachment_bytes(conn, args.task_id, name, data, content_type=content_type, uploaded_by=uploaded_by) except kb.AttachmentTooLarge as exc: @@ -752,7 +756,7 @@ def _cmd_attach(args: argparse.Namespace) -> int: def _cmd_attachments(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: if kb.get_task(conn, args.task_id) is None: return _err(f"no such task: {args.task_id}") atts = kb.list_attachments(conn, args.task_id) @@ -770,7 +774,7 @@ def _cmd_attachments(args: argparse.Namespace) -> int: def _cmd_attach_rm(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: removed = kb.delete_attachment(conn, args.attachment_id) if removed is None: return _err(f"no such attachment: {args.attachment_id}") @@ -855,7 +859,7 @@ def _cmd_complete(args: argparse.Namespace) -> int: if rc: return rc fail_msg: dict[str, str] = {} - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: def op(tid): gate_err = _goal_gate_error( conn, tid, (summary or args.result or "").strip(), "completion", @@ -875,7 +879,7 @@ def _cmd_edit(args: argparse.Namespace) -> int: metadata, rc = _parse_metadata_flag(getattr(args, "metadata", None)) if rc: return rc - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: ok = kb.edit_completed_task_result(conn, args.task_id, result=args.result, summary=getattr(args, "summary", None), metadata=metadata) return _ok_or_err(ok, f"cannot edit {args.task_id} (unknown id or task is not done)", f"Edited {args.task_id}") @@ -896,7 +900,7 @@ def _cmd_block(args: argparse.Namespace) -> int: author = _profile_author() ids = _bulk_ids(args) suffix = f": {reason}" if reason else "" - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: def ok_msg(tid): # Report where it landed: dependency blocks -> todo, tripped unblock-loop breaker -> triage. landed = kb.get_task(conn, tid) @@ -917,7 +921,7 @@ def _cmd_schedule(args: argparse.Namespace) -> int: author = _profile_author() ids = _bulk_ids(args) suffix = f": {reason}" if reason else "" - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: op = _commented(conn, reason, author, "SCHEDULED", lambda tid: kb.schedule_task( conn, tid, reason=reason, expected_run_id=_worker_run_id_for(tid))) return _bulk_apply(ids, op, lambda tid: f"Scheduled {tid}{suffix}", lambda tid: f"cannot schedule {tid}") @@ -930,7 +934,7 @@ def _cmd_unblock(args: argparse.Namespace) -> int: reason = _stripped_or_none(getattr(args, "reason", None)) author = _profile_author() if reason else None suffix = f": {reason}" if reason else "" - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: op = _commented(conn, reason, author, "UNBLOCK", lambda tid: kb.unblock_task(conn, tid)) return _bulk_apply(ids, op, lambda tid: f"Unblocked {tid}{suffix}", lambda tid: f"cannot unblock {tid} (not blocked/scheduled?)") @@ -942,7 +946,7 @@ def _cmd_request_review(args: argparse.Namespace) -> int: metadata, rc = _parse_metadata_flag(getattr(args, "metadata", None)) if rc: return rc - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: gate_err = _goal_gate_error( conn, tid, summary or "", "review handoff", "Record the block with kanban block instead of requesting review.", @@ -963,7 +967,7 @@ def _cmd_request_review(args: argparse.Namespace) -> int: def _cmd_request_changes(args: argparse.Namespace) -> int: tid = args.task_id reason = " ".join(args.reason).strip() - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: ok, detail = kb.request_changes(conn, tid, reason=reason, expected_run_id=_worker_run_id_for(tid)) if not ok: return _err(f"cannot request changes for {tid}: {detail or 'invalid review state'}") @@ -980,7 +984,7 @@ def _cmd_reopen_review(args: argparse.Namespace) -> int: reason = str(kb.redact_review_value(reason.strip())).strip() or None author = _profile_author() if reason else None suffix = f": {reason}" if reason else "" - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: def op(tid): if not kb.reopen_review_task(conn, tid): return False @@ -1000,7 +1004,7 @@ def _cmd_promote(args: argparse.Namespace) -> int: dry_run, force = bool(args.dry_run), bool(args.force) results: list[dict[str, object]] = [] - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: for tid in ids: ok, err = kb.promote_task(conn, tid, actor=author, reason=reason, force=force, dry_run=dry_run) results.append({"task_id": tid, "promoted": ok, "dry_run": dry_run, "forced": force, @@ -1030,7 +1034,7 @@ def _cmd_archive(args: argparse.Namespace) -> int: return _err("choose either task_ids to archive or --rm archived task_ids") if not ids and not purge_ids: return _err("at least one task_id is required") - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: if purge_ids: return _bulk_apply(purge_ids, lambda tid: kb.delete_archived_task(conn, tid), lambda tid: f"Deleted {tid}", lambda tid: f"cannot delete {tid} (must already be archived)") @@ -1039,7 +1043,7 @@ def _cmd_archive(args: argparse.Namespace) -> int: def _cmd_stats(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: stats = kb.board_stats(conn) if _json_out(args, stats): return 0 @@ -1057,10 +1061,10 @@ def _cmd_stats(args: argparse.Namespace) -> int: def _cmd_notify_subscribe(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: if kb.get_task(conn, args.task_id) is None: return _err(f"no such task: {args.task_id}") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=args.task_id, platform=args.platform, chat_id=args.chat_id, chat_type=args.chat_type, thread_id=args.thread_id, user_id=args.user_id, user_id_alt=getattr(args, "user_id_alt", None), @@ -1073,8 +1077,8 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int: def _cmd_notify_list(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: - subs = kb.list_notify_subs(conn, args.task_id) + with kbc.connect_closing() as conn: + subs = kbn.list_notify_subs(conn, args.task_id) if _json_out(args, subs): return 0 if not subs: @@ -1094,8 +1098,8 @@ def _cmd_notify_list(args: argparse.Namespace) -> int: def _cmd_notify_unsubscribe(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: - ok = kb.remove_notify_sub(conn, task_id=args.task_id, platform=args.platform, chat_id=args.chat_id, + with kbc.connect_closing() as conn: + ok = kbn.remove_notify_sub(conn, task_id=args.task_id, platform=args.platform, chat_id=args.chat_id, thread_id=args.thread_id) return _ok_or_err(ok, "(no such subscription)", f"Unsubscribed from {args.task_id}") @@ -1115,7 +1119,7 @@ def _cmd_runs(args: argparse.Namespace) -> int: rsk, rc = _run_state_kwargs(args, "runs") if rc: return rc - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: runs = kb.list_runs(conn, args.task_id, **rsk) if _json_out(args, [_obj_dict(r, _RUNS_RUN_FIELDS) for r in runs]): return 0 @@ -1138,7 +1142,7 @@ def _cmd_runs(args: argparse.Namespace) -> int: def _cmd_context(args: argparse.Namespace) -> int: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: text = kb.build_worker_context(conn, args.task_id) print(text) return 0 diff --git a/hermes_cli/kanban_boards.py b/hermes_cli/kanban_boards.py index 98b4017b9f..0c6db1801f 100644 --- a/hermes_cli/kanban_boards.py +++ b/hermes_cli/kanban_boards.py @@ -9,6 +9,7 @@ import argparse from typing import Optional from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli.kanban_output import _err, _fmt_counts, _json_out @@ -26,7 +27,7 @@ def _board_task_counts(slug: str) -> dict[str, int]: try: if not kb.kanban_db_path(board=slug).exists(): return {} - with kb.connect_closing(board=slug) as conn: + with kbc.connect_closing(board=slug) as conn: rows = conn.execute("SELECT status, COUNT(*) AS n FROM tasks GROUP BY status").fetchall() return {r["status"]: int(r["n"]) for r in rows} except Exception: diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 8fd8e6e7e9..ca99bd4a0e 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4172,99 +4172,26 @@ def latest_summaries(conn: sqlite3.Connection, task_ids: Iterable[str]) -> dict[ return {r["task_id"]: r["summary"] for r in rows} -# --- Split modules, re-exported so ``kanban_db.`` stays the monkeypatch target --- -from hermes_cli.kanban_db_connect import ( # noqa: E402,F401 - KanbanDbCorruptError, - _BUSY_RETRY_MAX_S, - _BUSY_RETRY_MIN_S, - _CORRUPT_BACKUP_RETENTION, +# --- Split modules (imported at the tail: they import this module as ``_kb``) --- +from hermes_cli.kanban_db_connect import ( # noqa: E402 _INITIALIZED_PATHS, - _INIT_LOCK_TIMEOUT_SECONDS, - _LAST_WAL_CHECKPOINT, - _SQLITE_HEADER, - _WAL_CHECKPOINT_INTERVAL_SECONDS, - _check_file_length_invariant, - _cross_process_init_lock, - _dispatch_tick_lock, - _maybe_checkpoint_wal, - _migrate_add_optional_columns, - _repairable_index_names, - _sqlite_connect, - _unlock, - connect, - connect_closing, init_db, - repair_db, write_txn, ) -from hermes_cli.kanban_db_workspace import ( # noqa: E402,F401 +from hermes_cli.kanban_db_workspace import ( # noqa: E402 _cleanup_workspace, - _cleanup_worktree_workspace, - _ensure_git_worktree, - _git_toplevel, _is_managed_scratch_path, _managed_scratch_path_info, - _maybe_emit_scratch_tip, - _resolve_worktree_workspace, - _scratch_tip_sentinel_path, - _scratch_tip_shown, _scratch_workspace, - resolve_workspace, - set_branch_name, - set_workspace_path, ) -from hermes_cli.kanban_db_dispatch import ( # noqa: E402,F401 +from hermes_cli.kanban_db_dispatch import ( # noqa: E402 DEFAULT_FAILURE_LIMIT, DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS, - DEFAULT_SPAWN_FAILURE_LIMIT, DispatchResult, - _PROTOCOL_VIOLATION_FAILURE_LIMIT, - _classify_worker_exit, _clear_failure_counter, - _default_spawn, _defer_reclaim_for_live_worker, - _looks_like_path, - _memory_pressure_level, _pid_alive, - _positive_int, - _record_spawn_failure, - _record_task_failure, - _record_worker_exit, - _resolve_hermes_argv, - _resolve_worker_cli_toolsets, - _restart_safe_worker_argv, - _retag_legacy_worker_sessions, - _set_worker_pid, - _system_memory_sample, _terminate_reclaimed_worker, _worker_survived_termination, _worker_terminal_timeout_env, - check_respawn_guard, - configured_max_in_progress, - count_running_tasks_other_boards, - derive_default_max_in_progress, - detect_crashed_workers, - detect_stale_running, - dispatch_once, - enforce_max_runtime, - has_spawnable_ready, - has_spawnable_review, - heartbeat_worker, - reap_worker_zombies, - reconcile_orphaned_running, - resolve_max_in_progress, - review_dispatch_enabled, - run_daemon, -) -from hermes_cli.kanban_db_notify import ( # noqa: E402,F401 - _NOTIFY_DELIVERY_MODES, - add_notify_sub, - advance_notify_cursor, - claim_unseen_events_for_sub, - count_notify_subs, - list_notify_subs, - purge_stale_done_notify_subs, - remove_notify_sub, - rewind_notify_cursor, - unseen_events_for_sub, ) diff --git a/hermes_cli/kanban_db_connect.py b/hermes_cli/kanban_db_connect.py index 5162d672e7..9067dfd0e5 100644 --- a/hermes_cli/kanban_db_connect.py +++ b/hermes_cli/kanban_db_connect.py @@ -1,7 +1,7 @@ """SQLite connection lifecycle for the Kanban DB: open/configure, cross-process init and dispatch-tick locks, WAL checkpoints, corruption detection + quarantine + repair, additive migrations and the busy-retrying ``write_txn`` boundary. -Split out of ``hermes_cli.kanban_db``; every name is re-exported there, and -origin-resident helpers are reached late-bound via ``_kb`` so monkeypatching +Split out of ``hermes_cli.kanban_db``; origin-resident helpers are reached +late-bound via ``_kb`` (import-cycle breaking) so monkeypatching ``kanban_db.`` keeps working. """ @@ -131,7 +131,7 @@ def _cross_process_init_lock(path: Path): handle = lock_path.open("a+b") acquired = False try: - deadline = time.monotonic() + _kb._INIT_LOCK_TIMEOUT_SECONDS + deadline = time.monotonic() + _INIT_LOCK_TIMEOUT_SECONDS while True: try: acquired = _try_lock_nb(handle) @@ -146,7 +146,7 @@ def _cross_process_init_lock(path: Path): "without the cross-process lock (in-process lock + idempotent " "init are the correctness backstop). A stuck holder is no longer " "able to block this connect indefinitely (#36644).", - lock_path, _kb._INIT_LOCK_TIMEOUT_SECONDS, + lock_path, _INIT_LOCK_TIMEOUT_SECONDS, ) yield finally: @@ -326,7 +326,7 @@ def _prune_corrupt_backups(parent: Path, base_name: str, keep: Optional[Path] = ] except OSError: return - budget = max(_kb._CORRUPT_BACKUP_RETENTION - (1 if keep is not None else 0), 0) + budget = max(_CORRUPT_BACKUP_RETENTION - (1 if keep is not None else 0), 0) if len(backups) <= budget: return @@ -732,7 +732,7 @@ def connect_closing(db_path: Optional[Path] = None, *, board: Optional[str] = No See #33159 for the production incident. """ - conn = _kb.connect(db_path=db_path, board=board) + conn = connect(db_path=db_path, board=board) try: yield conn finally: @@ -750,7 +750,7 @@ def init_db(db_path: Optional[Path] = None, *, board: Optional[str] = None) -> P # Clear the cache entry so connect() re-runs schema + migrations. with _INIT_LOCK: _INITIALIZED_PATHS.discard(str(path.resolve())) - with contextlib.closing(_kb.connect(path)): + with contextlib.closing(connect(path)): pass return path @@ -1183,7 +1183,7 @@ def write_txn(conn: sqlite3.Connection, *, allow_nested: bool = False): conn.execute("ROLLBACK") raise # Post-commit torn-extend check — raise now rather than silently corrupt. - _kb._check_file_length_invariant(conn) + _check_file_length_invariant(conn) # Late-bound origin namespace (see module docstring); imported LAST so this diff --git a/hermes_cli/kanban_db_dispatch.py b/hermes_cli/kanban_db_dispatch.py index eced13d741..743e91dbb5 100644 --- a/hermes_cli/kanban_db_dispatch.py +++ b/hermes_cli/kanban_db_dispatch.py @@ -1,7 +1,7 @@ """Dispatcher: crash/stale/orphan detection, failure accounting and the respawn circuit breaker, memory-aware concurrency caps, the one-shot ``dispatch_once`` pass, worker spawning (``_default_spawn``), worker-log rotation and the long-lived ``run_daemon`` loop. -Split out of ``hermes_cli.kanban_db``; every name is re-exported there, and -origin-resident helpers are reached late-bound via ``_kb`` so monkeypatching +Split out of ``hermes_cli.kanban_db``; origin-resident helpers are reached +late-bound via ``_kb`` (import-cycle breaking) so monkeypatching ``kanban_db.`` keeps working. """ @@ -31,8 +31,6 @@ if TYPE_CHECKING: # After this many consecutive non-success attempts on a task/profile the # dispatcher parks the task in ``blocked`` with a reason — prevents retry storms. DEFAULT_FAILURE_LIMIT = 2 -# Legacy alias — callers / tests still reference the old name. -DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT # Worker log files larger than this at spawn time are rotated. DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB @@ -492,7 +490,7 @@ def enforce_max_runtime(conn: sqlite3.Connection, *, signal_fn=None) -> list[str # own. If the breaker trips this flips the task to ``blocked`` and emits # ``gave_up`` on top of the ``timed_out`` already emitted. if cur.rowcount == 1: - _kb._record_task_failure( + _record_task_failure( conn, tid, error=error, outcome="timed_out", @@ -754,7 +752,7 @@ class _DeadWorker: def _classify_dead_worker(pid: int, claimer: Optional[str]) -> _DeadWorker: """Map a dead worker's reaped exit status to its reclaim bookkeeping.""" - kind, code = _kb._classify_worker_exit(pid) + kind, code = _classify_worker_exit(pid) if kind == "clean_exit": # rc=0 while still ``running``: usually the work succeeded and only the # paperwork was skipped; the corrective sentence reaches the retry @@ -905,7 +903,7 @@ def _account_crashes(conn: sqlite3.Connection, crash_details: list) -> list[str] continue # ``force_trip``: the decision (incl. per-task ``max_retries``) was # already made against the violation streak above. - tripped = _kb._record_task_failure( + tripped = _record_task_failure( conn, tid, error=error_text, outcome="crashed", @@ -922,7 +920,7 @@ def _account_crashes(conn: sqlite3.Connection, crash_details: list) -> list[str] ) else: is_systemic = fp_counts.get(_error_fingerprint(error_text), 0) >= 3 - tripped = _kb._record_task_failure( + tripped = _record_task_failure( conn, tid, error=error_text, outcome="crashed", @@ -1099,23 +1097,6 @@ def _record_task_failure( return True -# Backward-compat alias; new code should call ``_record_task_failure``. -def _record_spawn_failure( - conn: sqlite3.Connection, - task_id: str, - error: str, - *, - failure_limit: int = None, -) -> bool: - return _kb._record_task_failure( - conn, task_id, error, - outcome="spawn_failed", - failure_limit=failure_limit, - release_claim=True, - end_run=True, - ) - - def _set_worker_pid(conn: sqlite3.Connection, task_id: str, pid: int) -> None: """Record the spawned child's pid + emit a ``spawned`` event carrying it.""" with _kb.write_txn(conn): @@ -1323,7 +1304,7 @@ def derive_default_max_in_progress(sample: Optional[Mapping[str, Any]] = None) - machines are unaffected. """ if sample is None: - sample = _kb._system_memory_sample() + sample = _system_memory_sample() total_kib = sample.get("mem_total_kib") if isinstance(total_kib, bool) or not isinstance(total_kib, int) or total_kib <= 0: return None @@ -1338,7 +1319,7 @@ def resolve_max_in_progress(configured: Optional[int]) -> Optional[int]: """ if configured is not None: return configured - return _kb.derive_default_max_in_progress() + return derive_default_max_in_progress() def configured_max_in_progress() -> Optional[int]: @@ -1404,7 +1385,7 @@ def count_running_tasks_other_boards(board: Optional[str] = None) -> int: continue if not path.exists(): continue - other = _kb.connect(board=slug) + other = _kbc.connect(board=slug) try: total += count_running_tasks(other) finally: @@ -1424,7 +1405,7 @@ def _memory_pressure_level(sample: Optional[Mapping[str, Any]] = None) -> str: where /proc is unavailable. """ if sample is None: - sample = _kb._system_memory_sample() + sample = _system_memory_sample() if not sample: return "unknown" try: @@ -1442,7 +1423,7 @@ def dispatch_once( dry_run: bool = False, max_spawn: Optional[int] = None, max_in_progress: Optional[int] = None, - failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT, + failure_limit: int = DEFAULT_FAILURE_LIMIT, stale_timeout_seconds: int = 0, board: Optional[str] = None, default_assignee: Optional[str] = None, @@ -1480,13 +1461,13 @@ def dispatch_once( result = _locked_tick() _kb._fire_dispatch_tick_hook(result, board=board, dry_run=dry_run) return result - with _kb._dispatch_tick_lock(db_path) as held: + with _kbc._dispatch_tick_lock(db_path) as held: if not held: result = DispatchResult(skipped_locked=True) else: result = _locked_tick() # Still under the dispatch lock: periodic PASSIVE WAL checkpoint. - _kb._maybe_checkpoint_wal(conn, db_path) + _kbc._maybe_checkpoint_wal(conn, db_path) # Lock released. Fire the tick observer strictly OUTSIDE the critical # section: a slow subscriber must never stall a sibling dispatcher's tick. _kb._fire_dispatch_tick_hook(result, board=board, dry_run=dry_run) @@ -1541,7 +1522,7 @@ def _dispatch_lane_task( if current >= per_profile_cap: result.skipped_per_profile_capped.append((task_id, assignee, current)) return False - guard_reason = _kb.check_respawn_guard(conn, task_id, lane=lane) + guard_reason = check_respawn_guard(conn, task_id, lane=lane) if guard_reason is not None: result.respawn_guarded.append((task_id, guard_reason)) # Event so ``hermes kanban tail`` shows why the task looks stuck. @@ -1573,17 +1554,20 @@ def _dispatch_lane_task( try: resolved_branch_name = None if claimed.workspace_kind == "worktree": - workspace, resolved_branch_name = _kb._resolve_worktree_workspace(claimed, board=board) + workspace, resolved_branch_name = _kbw._resolve_worktree_workspace(claimed, board=board) else: - workspace = _kb.resolve_workspace(claimed, board=board) + workspace = _kbw.resolve_workspace(claimed, board=board) except Exception as exc: - if _record_spawn_failure(conn, claimed.id, f"workspace: {exc}", failure_limit=failure_limit): + if _record_task_failure( + conn, claimed.id, f"workspace: {exc}", + outcome="spawn_failed", failure_limit=failure_limit, release_claim=True, end_run=True, + ): result.auto_blocked.append(claimed.id) return False - _kb.set_workspace_path(conn, claimed.id, str(workspace)) + _kbw.set_workspace_path(conn, claimed.id, str(workspace)) if claimed.workspace_kind == "worktree": - _kb.set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}") - _kb._maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind) + _kbw.set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}") + _kbw._maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind) if lane == "review": # Force-load sdlc-review; the kanban lifecycle is already in every # worker's system prompt via KANBAN_GUIDANCE. @@ -1601,7 +1585,10 @@ def _dispatch_lane_task( _count_spawn(claimed.assignee) return True except Exception as exc: - if _record_spawn_failure(conn, claimed.id, str(exc), failure_limit=failure_limit): + if _record_task_failure( + conn, claimed.id, str(exc), + outcome="spawn_failed", failure_limit=failure_limit, release_claim=True, end_run=True, + ): result.auto_blocked.append(claimed.id) return False @@ -1768,7 +1755,7 @@ def _dispatch_once_locked( dry_run: bool = False, max_spawn: Optional[int] = None, max_in_progress: Optional[int] = None, - failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT, + failure_limit: int = DEFAULT_FAILURE_LIMIT, stale_timeout_seconds: int = 0, board: Optional[str] = None, default_assignee: Optional[str] = None, @@ -2099,7 +2086,7 @@ def _retag_legacy_worker_sessions(workspaces_root_path: str) -> None: def _worker_argv(task: Task, profile_arg: str, hermes_home: Optional[str]) -> list[str]: """Build the ``hermes -p --cli ... chat -q ...`` worker command.""" cmd = [ - *_kb._resolve_hermes_argv(), + *_resolve_hermes_argv(), "-p", profile_arg, # A worker must NEVER boot the interactive TUI: its no-TTY bail-out # exits 0 without doing the task → "protocol violation" every attempt. @@ -2252,7 +2239,7 @@ def _default_spawn(task: Task, workspace: str, *, board: Optional[str] = None) - # match after `hermes -p` rewrites HERMES_HOME (symlink / Docker layouts). env["HERMES_KANBAN_DB"] = str(_kb.kanban_db_path(board=board)) env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(_kb.workspaces_root(board=board)) - _kb._retag_legacy_worker_sessions(env["HERMES_KANBAN_WORKSPACES_ROOT"]) + _retag_legacy_worker_sessions(env["HERMES_KANBAN_WORKSPACES_ROOT"]) # Board slug — defense-in-depth pin if a path is resolved without the # DB / workspaces env vars. env["HERMES_KANBAN_BOARD"] = _kb._normalize_board_slug(board) or _kb.get_current_board() @@ -2299,7 +2286,7 @@ def run_daemon( *, interval: float = 60.0, max_spawn: Optional[int] = None, - failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT, + failure_limit: int = DEFAULT_FAILURE_LIMIT, stop_event=None, on_tick=None, ) -> None: @@ -2332,9 +2319,9 @@ def run_daemon( try: # Re-resolved every tick (config load is mtime-cached) so operator # edits apply without a restart. - max_in_progress = resolve_max_in_progress(_kb.configured_max_in_progress()) - with contextlib.closing(_kb.connect()) as conn: - res = _kb.dispatch_once( + max_in_progress = resolve_max_in_progress(configured_max_in_progress()) + with contextlib.closing(_kbc.connect()) as conn: + res = dispatch_once( conn, max_spawn=max_spawn, max_in_progress=max_in_progress, @@ -2351,5 +2338,7 @@ def run_daemon( # Late-bound origin namespace (see module docstring); imported LAST so this -# module is fully populated before ``kanban_db`` re-exports from it. +# module is fully populated before ``kanban_db`` imports from it. from hermes_cli import kanban_db as _kb # noqa: E402 +from hermes_cli import kanban_db_connect as _kbc # noqa: E402 +from hermes_cli import kanban_db_workspace as _kbw # noqa: E402 diff --git a/hermes_cli/kanban_db_notify.py b/hermes_cli/kanban_db_notify.py index 546dbecbfa..12fb49d61c 100644 --- a/hermes_cli/kanban_db_notify.py +++ b/hermes_cli/kanban_db_notify.py @@ -1,7 +1,7 @@ """Notification subscriptions consumed by the gateway kanban-notifier: per-(task, platform, chat, thread) rows with delivery metadata, unseen-event cursors and purge of stale done-task subs. -Split out of ``hermes_cli.kanban_db``; every name is re-exported there, and -origin-resident helpers are reached late-bound via ``_kb`` so monkeypatching +Split out of ``hermes_cli.kanban_db``; origin-resident helpers are reached +late-bound via ``_kb`` (import-cycle breaking) so monkeypatching ``kanban_db.`` keeps working. """ @@ -412,5 +412,5 @@ def rewind_notify_cursor( # Late-bound origin namespace (see module docstring); imported LAST so this -# module is fully populated before ``kanban_db`` re-exports from it. +# module is fully populated before ``kanban_db`` imports from it. from hermes_cli import kanban_db as _kb # noqa: E402 diff --git a/hermes_cli/kanban_db_workspace.py b/hermes_cli/kanban_db_workspace.py index 426b8181c8..aa27c810a0 100644 --- a/hermes_cli/kanban_db_workspace.py +++ b/hermes_cli/kanban_db_workspace.py @@ -1,7 +1,7 @@ """Task workspace lifecycle: scratch/dir/worktree resolution (incl. git worktree creation), post-completion cleanup with containment guards, worker tmux teardown and the first-use scratch-workspace tip. -Split out of ``hermes_cli.kanban_db``; every name is re-exported there, and -origin-resident helpers are reached late-bound via ``_kb`` so monkeypatching +Split out of ``hermes_cli.kanban_db``; origin-resident helpers are reached +late-bound via ``_kb`` (import-cycle breaking) so monkeypatching ``kanban_db.`` keeps working. """ @@ -545,5 +545,5 @@ def set_branch_name(conn: sqlite3.Connection, task_id: str, branch_name: str) -> # Late-bound origin namespace (see module docstring); imported LAST so this -# module is fully populated before ``kanban_db`` re-exports from it. +# module is fully populated before ``kanban_db`` imports from it. from hermes_cli import kanban_db as _kb # noqa: E402 diff --git a/hermes_cli/kanban_decompose.py b/hermes_cli/kanban_decompose.py index 3af8ac9099..ab11cf6dbd 100644 --- a/hermes_cli/kanban_decompose.py +++ b/hermes_cli/kanban_decompose.py @@ -23,6 +23,7 @@ from dataclasses import dataclass from typing import Optional from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli import profiles as profiles_mod from hermes_cli.kanban_specify import ( _call_aux, _extract_json_blob, _load_triage_task, _task_prompt_fields, _title_body, @@ -222,7 +223,7 @@ def _apply_single(task: kb.Task, parsed: dict, routing: _Routing, author: str) - ) if title_val is None and body_val is None: return DecomposeOutcome(task.id, False, "decomposer returned fanout=false with no title/body") - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: ok = kb.specify_triage_task( conn, task.id, title=title_val, body=body_val, assignee=assignee_val, author=author, ) @@ -273,7 +274,7 @@ def _apply_fanout(task_id: str, parsed: dict, routing: _Routing, author: str) -> if reason: return DecomposeOutcome(task_id, False, reason) try: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: child_ids = kb.decompose_triage_task( conn, task_id, @@ -332,6 +333,6 @@ def decompose_task( def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]: """Return task ids currently in the triage column.""" - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: rows = kb.list_tasks(conn, status="triage", tenant=tenant, limit=1000) return [row.id for row in rows] diff --git a/hermes_cli/kanban_ops.py b/hermes_cli/kanban_ops.py index ef65c3aeed..d027bbc1d9 100644 --- a/hermes_cli/kanban_ops.py +++ b/hermes_cli/kanban_ops.py @@ -13,6 +13,9 @@ import time from pathlib import Path from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd +from hermes_cli import kanban_db_workspace as kbw from hermes_cli.kanban_output import _err, _fmt_ts, _print_json @@ -43,7 +46,7 @@ def _cmd_tail(args: argparse.Namespace) -> int: def tick(): nonlocal last_id - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: events = kb.list_events(conn, args.task_id) for e in events: if e.id > last_id: @@ -63,28 +66,28 @@ def _cmd_dispatch(args: argparse.Namespace) -> int: _cfg = load_config() _kanban_cfg = _cfg.get("kanban", {}) if isinstance(_cfg, dict) else {} default_assignee = (_kanban_cfg.get("default_assignee") or "").strip() or None - max_in_progress_per_profile = kb._positive_int( + max_in_progress_per_profile = kbd._positive_int( _kanban_cfg.get("max_in_progress_per_profile"), None ) # Memory-derived default when unset — same fallback the gateway applies. - max_in_progress = kb.resolve_max_in_progress( - kb._positive_int(_kanban_cfg.get("max_in_progress"), None) + max_in_progress = kbd.resolve_max_in_progress( + kbd._positive_int(_kanban_cfg.get("max_in_progress"), None) ) # CLI --max is the more explicit signal, so it wins over kanban.max_spawn. cli_max = getattr(args, "max", None) max_spawn = ( - cli_max if cli_max is not None else kb._positive_int(_kanban_cfg.get("max_spawn"), None) + cli_max if cli_max is not None else kbd._positive_int(_kanban_cfg.get("max_spawn"), None) ) except Exception: default_assignee = max_in_progress_per_profile = max_in_progress = None max_spawn = getattr(args, "max", None) - with kb.connect_closing() as conn: - res = kb.dispatch_once( + with kbc.connect_closing() as conn: + res = kbd.dispatch_once( conn, dry_run=args.dry_run, max_spawn=max_spawn, max_in_progress=max_in_progress, - failure_limit=getattr(args, "failure_limit", kb.DEFAULT_SPAWN_FAILURE_LIMIT), + failure_limit=getattr(args, "failure_limit", kbd.DEFAULT_FAILURE_LIMIT), default_assignee=default_assignee, max_in_progress_per_profile=max_in_progress_per_profile, ) @@ -187,8 +190,8 @@ def _cmd_daemon(args: argparse.Namespace) -> int: """Is there a ready+assigned+unclaimed task the dispatcher would spawn for? Control-plane lanes pulled via ``claim_task`` are correctly idle, not stuck.""" try: - with kb.connect_closing() as conn: - return kb.has_spawnable_ready(conn) + with kbc.connect_closing() as conn: + return kbd.has_spawnable_ready(conn) except Exception: return False @@ -227,10 +230,10 @@ def _cmd_daemon(args: argparse.Namespace) -> int: ) try: - kb.run_daemon( + kbd.run_daemon( interval=args.interval, max_spawn=args.max, - failure_limit=getattr(args, "failure_limit", kb.DEFAULT_SPAWN_FAILURE_LIMIT), + failure_limit=getattr(args, "failure_limit", kbd.DEFAULT_FAILURE_LIMIT), on_tick=_on_tick, ) finally: @@ -248,12 +251,12 @@ def _cmd_watch(args: argparse.Namespace) -> int: kinds = {k.strip() for k in args.kinds.split(",") if k.strip()} if args.kinds else None print("Watching kanban events. Ctrl-C to stop.", flush=True) # Seed cursor at the latest id so we don't replay history. - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: cursor = int(conn.execute("SELECT COALESCE(MAX(id), 0) AS m FROM task_events").fetchone()["m"]) def tick(): nonlocal cursor - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: rows = conn.execute( "SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, t.assignee, " "t.tenant FROM task_events e LEFT JOIN tasks t ON t.id = e.task_id WHERE e.id > ? " @@ -284,7 +287,7 @@ def _cmd_gc(args: argparse.Namespace) -> int: import shutil scratch_root = kb.workspaces_root() removed_ws = 0 - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: rows = conn.execute( "SELECT id, workspace_kind, workspace_path, branch_name FROM tasks " "WHERE status = 'archived'" @@ -295,7 +298,7 @@ def _cmd_gc(args: argparse.Namespace) -> int: # Same safety predicate: only clean, fully-pushed worktrees go. wt_path = row["workspace_path"] if wt_path and Path(wt_path).is_dir(): - kb._cleanup_worktree_workspace(row["id"], wt_path, row["branch_name"]) + kbw._cleanup_worktree_workspace(row["id"], wt_path, row["branch_name"]) if not Path(wt_path).is_dir(): removed_ws += 1 continue @@ -317,7 +320,7 @@ def _cmd_gc(args: argparse.Namespace) -> int: event_days = getattr(args, "event_retention_days", 30) log_days = getattr(args, "log_retention_days", 30) - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: removed_events = kb.gc_events(conn, older_than_seconds=event_days * 24 * 3600) removed_logs = kb.gc_worker_logs(older_than_seconds=log_days * 24 * 3600) print(f"GC complete: {removed_ws} workspace(s), " @@ -330,7 +333,7 @@ def _cmd_repair(args: argparse.Namespace) -> int: the auto ``kb.init_db()`` (init refuses corrupt DBs). Exit 0 = healthy / repaired / no DB file, 1 = still corrupt.""" try: - report = kb.repair_db() + report = kbc.repair_db() except Exception as exc: # locked/busy probe, unexpected I/O return _err(f"kanban repair: {exc}") diff --git a/hermes_cli/kanban_parser.py b/hermes_cli/kanban_parser.py index 520452049f..c7047a0956 100644 --- a/hermes_cli/kanban_parser.py +++ b/hermes_cli/kanban_parser.py @@ -11,6 +11,8 @@ from __future__ import annotations import argparse from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_dispatch as kbd +from hermes_cli import kanban_db_notify as kbn def _arg(*flags: str, **kw): @@ -337,15 +339,15 @@ _SPECS = [ _cmd("dispatch", [ _arg("--dry-run", action="store_true", help="Don't actually spawn processes; just print what would happen"), _arg("--max", type=int, help="Cap number of spawns this pass"), - _arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT, + _arg("--failure-limit", type=int, default=kbd.DEFAULT_FAILURE_LIMIT, help=f"Auto-block a task after this many consecutive non-success attempts " - f"(spawn_failed, timed_out, or crashed; default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})"), + f"(spawn_failed, timed_out, or crashed; default: {kbd.DEFAULT_FAILURE_LIMIT})"), _json_flag(), ], help="One dispatcher pass: reclaim stale, promote ready, spawn workers"), _cmd("daemon", [ _arg("--interval", type=float, default=60.0, help="Seconds between dispatch ticks (default: 60)"), _arg("--max", type=int, help="Cap number of spawns per tick"), - _arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT), + _arg("--failure-limit", type=int, default=kbd.DEFAULT_FAILURE_LIMIT), _arg("--pidfile", help="Write the daemon's PID to this file on start"), _arg("--verbose", "-v", action="store_true", help="Log each tick's outcome to stdout"), # Escape hatch for hosts that truly cannot run the gateway; hidden from @@ -372,7 +374,7 @@ _SPECS = [ _arg("--notifier-profile", help="Profile gateway that owns/delivers this subscription (default: active profile)"), # choices: single source of truth shared with the DB/watcher enum. - _arg("--delivery-mode", choices=kb._NOTIFY_DELIVERY_MODES, + _arg("--delivery-mode", choices=kbn._NOTIFY_DELIVERY_MODES, help="How the kanban-notifier reacts to terminal events for this " "subscription: 'notify' (passive message only; default), " "'notify+wake' (message AND wake the destination gateway agent so " diff --git a/hermes_cli/kanban_specify.py b/hermes_cli/kanban_specify.py index 7ff7176f21..7da474f846 100644 --- a/hermes_cli/kanban_specify.py +++ b/hermes_cli/kanban_specify.py @@ -20,6 +20,7 @@ from dataclasses import dataclass from typing import Optional from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from utils import env_int @@ -124,7 +125,7 @@ def _profile_author(default: str = "specifier") -> str: def _load_triage_task(task_id: str) -> tuple[Optional[kb.Task], str]: """``(task, "")`` when the task exists and is in triage, else ``(None, reason)``.""" - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: task = kb.get_task(conn, task_id) if task is None: return None, "unknown task id" @@ -209,7 +210,7 @@ def specify_task( if new_body is None and new_title is None: return SpecifyOutcome(task_id, False, "LLM response missing title and body") - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: ok = kb.specify_triage_task( conn, task_id, @@ -225,6 +226,6 @@ def specify_task( def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]: """Task ids in the triage column; ``tenant`` narrows the sweep.""" - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: tasks = kb.list_tasks(conn, status="triage", tenant=tenant, include_archived=False) return [t.id for t in tasks] diff --git a/hermes_cli/kanban_transfer.py b/hermes_cli/kanban_transfer.py index 37d9a303a5..80025210cf 100644 --- a/hermes_cli/kanban_transfer.py +++ b/hermes_cli/kanban_transfer.py @@ -32,6 +32,7 @@ from pathlib import Path from typing import Any, Optional from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli.archive_safe import ( archive_root_dirs, copy_regular_files, @@ -346,7 +347,7 @@ def import_board( # relocation pass writes to it. kb.init_db(board=target) - with kb.connect_closing(board=target) as conn: + with kbc.connect_closing(board=target) as conn: stats, warnings = _relocate_imported_rows(conn, target) counts = _count_rows(conn) diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 6ab43fe1c1..e29e5898e5 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -29,6 +29,10 @@ from fastapi.responses import FileResponse from pydantic import BaseModel, Field from hermes_cli import kanban_db +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn +from hermes_cli import kanban_db_dispatch as kbd +from hermes_cli import kanban_db_workspace as kbw from hermes_cli import kanban_diagnostics as kd from hermes_cli.kanban_db import KANBAN_ATTACHMENT_MAX_BYTES, _collision_free_path, _safe_attachment_name @@ -42,11 +46,11 @@ _BOARD_Q = Query(None, description="Kanban board slug (omit for current)") # --- Connection / board helpers --------------------------------------------- def _ws_upgrade_authorized(ws: "WebSocket") -> bool: - """Authorize a WS upgrade via the dashboard's canonical gate (``web_server._ws_auth_ok``: + """Authorize a WS upgrade via the dashboard's canonical gate (``web_server_chat._ws_auth_ok``: ``?token=`` / ``?ticket=`` / ``?internal=``) so this endpoint can never drift from core - auth; accepts when ``web_server`` isn't importable (bare-FastAPI test harness).""" + auth; accepts when the dashboard isn't importable (bare-FastAPI test harness).""" try: - from hermes_cli import web_server as _ws + from hermes_cli import web_server_chat as _ws except Exception: return True return bool(_ws._ws_auth_ok(ws)) @@ -83,7 +87,7 @@ def _conn(board: Optional[str] = None): kanban_db.init_db(board=board) except Exception as exc: log.warning("kanban init_db failed: %s", exc) - return kanban_db.connect(board=board) + return kbc.connect(board=board) @contextmanager @@ -1115,7 +1119,7 @@ def get_home_channels(task_id: Optional[str] = Query(None), board: Optional[str] subscribed_homes: set[tuple[str, str, str]] = set() if task_id: with _board_conn(board) as (board, conn): - subs = kanban_db.list_notify_subs(conn, task_id) + subs = kbn.list_notify_subs(conn, task_id) subscribed_homes = { (str(sub.get("platform") or ""), str(sub.get("chat_id") or ""), str(sub.get("thread_id") or "")) for sub in subs} return {"home_channels": [ @@ -1133,7 +1137,7 @@ def subscribe_home(task_id: str, platform: str, board: Optional[str] = Query(Non f"gateway.platforms.{platform}.home_channel in config.yaml.") with _board_conn(board) as (board, conn): _require_task(conn, task_id) - kanban_db.add_notify_sub( + kbn.add_notify_sub( conn, task_id=task_id, platform=platform, chat_id=home["chat_id"], thread_id=home["thread_id"] or None, notifier_profile=_active_profile_name()) return {"ok": True, "task_id": task_id, "home_channel": home} @@ -1144,7 +1148,7 @@ def unsubscribe_home(task_id: str, platform: str, board: Optional[str] = Query(N """Remove any notify subscription on *task_id* matching *platform*'s home.""" home = _home_for_platform(platform, f"No home channel configured for platform {platform!r}.") with _board_conn(board) as (board, conn): - kanban_db.remove_notify_sub( + kbn.remove_notify_sub( conn, task_id=task_id, platform=platform, chat_id=home["chat_id"], thread_id=home["thread_id"] or None) return {"ok": True, "task_id": task_id, "home_channel": home} @@ -1184,7 +1188,7 @@ def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_ def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max"), board: Optional[str] = Query(None)): """Dispatch nudge so the UI doesn't wait out the 60 s dispatcher tick.""" with _board_conn(board) as (board, conn): - result = kanban_db.dispatch_once(conn, dry_run=dry_run, max_spawn=max_n, board=board) + result = kbd.dispatch_once(conn, dry_run=dry_run, max_spawn=max_n, board=board) try: return asdict(result) # DispatchResult is a dataclass except TypeError: @@ -1285,7 +1289,7 @@ def _board_counts(slug: str) -> dict[str, int]: try: if not kanban_db.kanban_db_path(board=slug).exists(): return {} - with closing(kanban_db.connect(board=slug)) as conn: + with closing(kbc.connect(board=slug)) as conn: rows = conn.execute("SELECT status, COUNT(*) AS n FROM tasks GROUP BY status").fetchall() return {r["status"]: int(r["n"]) for r in rows} except Exception: @@ -1298,7 +1302,7 @@ def _default_workspace_kind(board: dict[str, Any]) -> str: if not workdir: return "scratch" try: - return "worktree" if kanban_db._git_toplevel(Path(workdir)) else "dir" + return "worktree" if kbw._git_toplevel(Path(workdir)) else "dir" except (OSError, ValueError): return "dir" @@ -1630,7 +1634,7 @@ class _EventTail: def _fetch(self, cursor: int) -> tuple[int, list[dict]]: if self._conn is None: - self._conn = kanban_db.connect(board=self._board) + self._conn = kbc.connect(board=self._board) rows = self._conn.execute( "SELECT id, task_id, run_id, kind, payload, created_at " "FROM task_events WHERE id > ? ORDER BY id ASC LIMIT 200", diff --git a/tests/agent/test_turn_finalizer_iteration_limit_exit.py b/tests/agent/test_turn_finalizer_iteration_limit_exit.py index f6bf0519bd..8594afe63e 100644 --- a/tests/agent/test_turn_finalizer_iteration_limit_exit.py +++ b/tests/agent/test_turn_finalizer_iteration_limit_exit.py @@ -170,8 +170,8 @@ def test_pending_response_records_kanban_timeout(monkeypatch): monkeypatch.setenv("HERMES_KANBAN_TASK", "task-123") record = MagicMock(name="record_task_failure") conn = SimpleNamespace(close=lambda: None) - monkeypatch.setattr("hermes_cli.kanban_db.connect", lambda: conn) - monkeypatch.setattr("hermes_cli.kanban_db._record_task_failure", record) + monkeypatch.setattr("hermes_cli.kanban_db_connect.connect", lambda: conn) + monkeypatch.setattr("hermes_cli.kanban_db_dispatch._record_task_failure", record) agent = _LimitAgent() result = _finalize( @@ -244,8 +244,8 @@ def test_bounded_fallback_records_kanban_failure_when_interrupted(monkeypatch): monkeypatch.setenv("HERMES_KANBAN_TASK", "task-456") record = MagicMock(name="record_task_failure") conn = SimpleNamespace(close=lambda: None) - monkeypatch.setattr("hermes_cli.kanban_db.connect", lambda: conn) - monkeypatch.setattr("hermes_cli.kanban_db._record_task_failure", record) + monkeypatch.setattr("hermes_cli.kanban_db_connect.connect", lambda: conn) + monkeypatch.setattr("hermes_cli.kanban_db_dispatch._record_task_failure", record) agent = _LimitAgent() # Budget exhausted (60/60), interrupted, no fallback-eligible exit_reason @@ -285,8 +285,8 @@ def test_bounded_fallback_records_kanban_failure_when_failed(monkeypatch): monkeypatch.setenv("HERMES_KANBAN_TASK", "task-789") record = MagicMock(name="record_task_failure") conn = SimpleNamespace(close=lambda: None) - monkeypatch.setattr("hermes_cli.kanban_db.connect", lambda: conn) - monkeypatch.setattr("hermes_cli.kanban_db._record_task_failure", record) + monkeypatch.setattr("hermes_cli.kanban_db_connect.connect", lambda: conn) + monkeypatch.setattr("hermes_cli.kanban_db_dispatch._record_task_failure", record) agent = _LimitAgent() result = finalize_turn( @@ -318,8 +318,8 @@ def test_bounded_fallback_does_not_fire_without_kanban_task(monkeypatch): monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: []) record = MagicMock(name="record_task_failure") conn = SimpleNamespace(close=lambda: None) - monkeypatch.setattr("hermes_cli.kanban_db.connect", lambda: conn) - monkeypatch.setattr("hermes_cli.kanban_db._record_task_failure", record) + monkeypatch.setattr("hermes_cli.kanban_db_connect.connect", lambda: conn) + monkeypatch.setattr("hermes_cli.kanban_db_dispatch._record_task_failure", record) agent = _LimitAgent() result = finalize_turn( @@ -349,8 +349,8 @@ def test_bounded_fallback_does_not_fire_when_budget_not_exhausted(monkeypatch): monkeypatch.setenv("HERMES_KANBAN_TASK", "task-999") record = MagicMock(name="record_task_failure") conn = SimpleNamespace(close=lambda: None) - monkeypatch.setattr("hermes_cli.kanban_db.connect", lambda: conn) - monkeypatch.setattr("hermes_cli.kanban_db._record_task_failure", record) + monkeypatch.setattr("hermes_cli.kanban_db_connect.connect", lambda: conn) + monkeypatch.setattr("hermes_cli.kanban_db_dispatch._record_task_failure", record) agent = _LimitAgent(budget_remaining=60) # api_call_count=10, max_iterations=60 — budget NOT exhausted diff --git a/tests/conftest.py b/tests/conftest.py index 94d4ce5239..0c0c892618 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -589,10 +589,10 @@ def _neutralize_kanban_memory_guard(request, monkeypatch): if request.node.get_closest_marker("real_memory_guard"): return try: - from hermes_cli import kanban_db as _kb_mod + from hermes_cli import kanban_db_dispatch as _kbd_mod except Exception: return - monkeypatch.setattr(_kb_mod, "_system_memory_sample", lambda: {}, raising=False) + monkeypatch.setattr(_kbd_mod, "_system_memory_sample", lambda: {}, raising=False) @pytest.fixture(autouse=True) @@ -694,15 +694,16 @@ def _kanban_write_guard(_hermetic_environment, monkeypatch): ``~/.hermes`` captured at import time. Hermetic tests that legitimately move HERMES_HOME to sibling tempdirs are unaffected. - Only patches when ``hermes_cli.kanban_db`` is *already imported* — a - ``sys.modules`` probe, not an import — so the guard never drags the + Only patches when ``hermes_cli.kanban_db_connect`` is *already imported* + — a ``sys.modules`` probe, not an import — so the guard never drags the kanban module into unrelated test processes. Uses ``monkeypatch.setattr`` so pytest restores ``connect`` automatically after each test (no stacked wrappers or state leakage across tests). """ _kdb = sys.modules.get("hermes_cli.kanban_db") - if _kdb is None: + _kdbc = sys.modules.get("hermes_cli.kanban_db_connect") + if _kdb is None or _kdbc is None: return # The sys.modules probe can observe the module MID-IMPORT: a fixture diff --git a/tests/gateway/test_kanban_changes_requested_notifier.py b/tests/gateway/test_kanban_changes_requested_notifier.py index a8f657aff6..374b26ad3a 100644 --- a/tests/gateway/test_kanban_changes_requested_notifier.py +++ b/tests/gateway/test_kanban_changes_requested_notifier.py @@ -3,6 +3,8 @@ import asyncio from gateway.config import Platform from gateway.run import GatewayRunner from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn class RecordingAdapter: @@ -43,7 +45,7 @@ def _runner(adapter): def _create_review_block(delivery_mode, *, reason="Tests need updates"): - conn = kb.connect() + conn = kbc.connect() try: task_id = kb.create_task( conn, @@ -51,7 +53,7 @@ def _create_review_block(delivery_mode, *, reason="Tests need updates"): assignee="implementer", session_id="agent:main:telegram:thread:chat-1:topic-7", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=task_id, platform="telegram", @@ -78,9 +80,9 @@ def _create_review_block(delivery_mode, *, reason="Tests need updates"): def _unseen(task_id): - conn = kb.connect() + conn = kbc.connect() try: - _, events = kb.unseen_events_for_sub( + _, events = kbn.unseen_events_for_sub( conn, task_id=task_id, platform="telegram", diff --git a/tests/gateway/test_kanban_notifier.py b/tests/gateway/test_kanban_notifier.py index 8333929d73..01a78de33e 100644 --- a/tests/gateway/test_kanban_notifier.py +++ b/tests/gateway/test_kanban_notifier.py @@ -10,6 +10,8 @@ from gateway.kanban_watchers_common import ( ) from gateway.run import GatewayRunner from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn class RecordingAdapter: @@ -56,10 +58,10 @@ def _make_runner(adapter): def _create_completed_subscription(summary="done once"): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="notify once", assignee="worker") - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") kb.complete_task(conn, tid, summary=summary) return tid finally: @@ -67,9 +69,9 @@ def _create_completed_subscription(summary="done once"): def _unseen_terminal_events(tid): - conn = kb.connect() + conn = kbc.connect() try: - _, events = kb.unseen_events_for_sub( + _, events = kbn.unseen_events_for_sub( conn, task_id=tid, platform="telegram", @@ -86,7 +88,7 @@ def test_kanban_notifier_replays_telegram_dm_topic_delivery_metadata(tmp_path, m monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, @@ -94,7 +96,7 @@ def test_kanban_notifier_replays_telegram_dm_topic_delivery_metadata(tmp_path, m assignee="worker", session_id="agent:main:telegram:dm:chat-1", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", @@ -142,10 +144,10 @@ def test_active_named_profile_subscription_is_delivered(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) kb.init_db() reason = "AGE-39 — https://linear.example/AGE-39 — publishing verified." - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="approval", assignee="publisher") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", @@ -175,12 +177,12 @@ def test_non_dispatch_gateway_claims_only_its_profile_subscriptions( db_path = tmp_path / "cross-profile-notifier.db" monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: foreign_tid = kb.create_task( conn, title="default-owned", assignee="worker", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=foreign_tid, platform="telegram", @@ -192,7 +194,7 @@ def test_non_dispatch_gateway_claims_only_its_profile_subscriptions( owned_tid = kb.create_task( conn, title="writer-owned", assignee="worker", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=owned_tid, platform="telegram", @@ -222,10 +224,10 @@ def test_legacy_subscription_requires_confirmed_dispatcher_lock_owner( db_path = tmp_path / "legacy-lock-owner.db" monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: task_id = kb.create_task(conn, title="legacy", assignee="worker") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=task_id, platform="telegram", @@ -308,10 +310,10 @@ def test_notifier_redelivers_same_kind_on_dispatch_cycle(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="cycle test", assignee="worker") - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") # First crash — fired by the dispatcher when the worker PID dies. kb._append_event(conn, tid, kind="crashed") finally: @@ -327,9 +329,9 @@ def test_notifier_redelivers_same_kind_on_dispatch_cycle(tmp_path, monkeypatch): # Subscription survives — the cursor advanced past event #1, but the # row is still there. - conn = kb.connect() + conn = kbc.connect() try: - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert len(subs) == 1, ( "Subscription must survive a crashed event so a respawn-cycle " "second crash also notifies the user (issue #21398)." @@ -362,7 +364,7 @@ def test_notifier_subscription_survives_done_reopen_until_archive( monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, @@ -370,7 +372,7 @@ def test_notifier_subscription_survives_done_reopen_until_archive( assignee="worker", session_id="origin-session", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", @@ -397,9 +399,9 @@ def test_notifier_subscription_survives_done_reopen_until_archive( assert adapter.handled[0].source.thread_id == "origin-thread" assert adapter.handled[0].source.profile == "reviewer" - conn = kb.connect() + conn = kbc.connect() try: - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert len(subs) == 1, "completion must retain the origin subscription" first_cursor = subs[0]["last_event_id"] finally: @@ -413,7 +415,7 @@ def test_notifier_subscription_survives_done_reopen_until_archive( assert len(adapter.sent) == 1 assert len(adapter.handled) == 1 - conn = kb.connect() + conn = kbc.connect() try: with kb.write_txn(conn): conn.execute("UPDATE tasks SET status = 'ready' WHERE id = ?", (tid,)) @@ -434,9 +436,9 @@ def test_notifier_subscription_survives_done_reopen_until_archive( assert adapter.handled[-1].source.thread_id == "origin-thread" assert adapter.handled[-1].source.profile == "reviewer" - conn = kb.connect() + conn = kbc.connect() try: - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert len(subs) == 1 assert subs[0]["last_event_id"] > first_cursor assert kb.archive_task(conn, tid) @@ -451,9 +453,9 @@ def test_notifier_subscription_survives_done_reopen_until_archive( # removes the subscription so no later historical event can replay. assert len(adapter.sent) == 3 assert len(adapter.handled) == 2 - conn = kb.connect() + conn = kbc.connect() try: - assert kb.list_notify_subs(conn, tid) == [] + assert kbn.list_notify_subs(conn, tid) == [] finally: conn.close() @@ -463,7 +465,7 @@ def test_notifier_wakeup_uses_subscription_chat_type(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, @@ -471,7 +473,7 @@ def test_notifier_wakeup_uses_subscription_chat_type(tmp_path, monkeypatch): assignee="worker", session_id="origin-session", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", @@ -502,9 +504,9 @@ def test_notifier_wakeup_uses_subscription_chat_type(tmp_path, monkeypatch): def _unseen_terminal_events_for(tid, chat_id): - conn = kb.connect() + conn = kbc.connect() try: - _, events = kb.unseen_events_for_sub( + _, events = kbn.unseen_events_for_sub( conn, task_id=tid, platform="telegram", @@ -534,36 +536,36 @@ def test_kanban_notifier_isolates_per_subscription_failure(tmp_path, monkeypatch # per-subscription isolation (the good delivery happens before the tick # aborts). A deterministic-order shim below removes the reliance on the # scan order entirely. - conn = kb.connect() + conn = kbc.connect() try: tid_bad = kb.create_task(conn, title="bad task", assignee="worker") - kb.add_notify_sub(conn, task_id=tid_bad, platform="telegram", chat_id="chat-bad") + kbn.add_notify_sub(conn, task_id=tid_bad, platform="telegram", chat_id="chat-bad") kb.complete_task(conn, tid_bad, summary="done") tid_good = kb.create_task(conn, title="good task", assignee="worker") - kb.add_notify_sub(conn, task_id=tid_good, platform="telegram", chat_id="chat-good") + kbn.add_notify_sub(conn, task_id=tid_good, platform="telegram", chat_id="chat-good") kb.complete_task(conn, tid_good, summary="done") finally: conn.close() - original_claim = kb.claim_unseen_events_for_sub + original_claim = kbn.claim_unseen_events_for_sub def selective_claim(conn, task_id, **kwargs): if task_id == tid_bad: raise RuntimeError("simulated DB corruption for bad task") return original_claim(conn, task_id=task_id, **kwargs) - monkeypatch.setattr(kb, "claim_unseen_events_for_sub", selective_claim) + monkeypatch.setattr(kbn, "claim_unseen_events_for_sub", selective_claim) # Force the failing subscription to be iterated FIRST regardless of the # unordered SELECT's scan order. - original_list = kb.list_notify_subs + original_list = kbn.list_notify_subs def bad_first(conn, task_id=None, **kwargs): subs = original_list(conn, task_id, **kwargs) return sorted(subs, key=lambda s: 0 if s["task_id"] == tid_bad else 1) - monkeypatch.setattr(kb, "list_notify_subs", bad_first) + monkeypatch.setattr(kbn, "list_notify_subs", bad_first) adapter = RecordingAdapter() runner = _make_runner(adapter) @@ -590,10 +592,10 @@ def test_notifier_delivers_block_loop_detected_triage_ping(tmp_path, monkeypatch monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="loops forever", assignee="worker") - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") kb._append_event( conn, tid, "block_loop_detected", {"reason": "needs credentials", "kind": "needs_input", @@ -613,9 +615,9 @@ def test_notifier_delivers_block_loop_detected_triage_ping(tmp_path, monkeypatch assert tid in text assert "needs credentials" in text # Cursor advanced: the event is claimed and not re-delivered. - conn = kb.connect() + conn = kbc.connect() try: - _, remaining = kb.unseen_events_for_sub( + _, remaining = kbn.unseen_events_for_sub( conn, task_id=tid, platform="telegram", chat_id="chat-1", kinds=["block_loop_detected"], ) @@ -645,7 +647,7 @@ def _review_handoff_task( delivery_mode="notify+wake", summary="PR ready: https://example.invalid/pr/7\nfull details below", ): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, @@ -653,7 +655,7 @@ def _review_handoff_task( assignee="worker", session_id="agent:main:telegram:dm:chat-1", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", @@ -697,7 +699,7 @@ def test_block_loop_detected_wakes_the_origin_session(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "triage-wake.db")) kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, @@ -705,7 +707,7 @@ def test_block_loop_detected_wakes_the_origin_session(tmp_path, monkeypatch): assignee="worker", session_id="agent:main:telegram:dm:chat-1", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", diff --git a/tests/gateway/test_kanban_notifier_apiserver_wake.py b/tests/gateway/test_kanban_notifier_apiserver_wake.py index 7b7c39a62f..1c916e66eb 100644 --- a/tests/gateway/test_kanban_notifier_apiserver_wake.py +++ b/tests/gateway/test_kanban_notifier_apiserver_wake.py @@ -14,6 +14,8 @@ from gateway.config import Platform from gateway.platforms.base import SendResult from gateway.run import GatewayRunner from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn class SoftFailAdapter: @@ -73,12 +75,12 @@ def _make_runner(adapters): def _create_completed_subscription(platform, chat_id, session_id=None): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, title="notify once", assignee="worker", session_id=session_id, ) - kb.add_notify_sub(conn, task_id=tid, platform=platform, chat_id=chat_id) + kbn.add_notify_sub(conn, task_id=tid, platform=platform, chat_id=chat_id) kb.complete_task(conn, tid, summary="done once") return tid finally: @@ -86,9 +88,9 @@ def _create_completed_subscription(platform, chat_id, session_id=None): def _unseen_terminal_events(tid, platform, chat_id): - conn = kb.connect() + conn = kbc.connect() try: - _, events = kb.unseen_events_for_sub( + _, events = kbn.unseen_events_for_sub( conn, task_id=tid, platform=platform, @@ -147,7 +149,7 @@ def test_apiserver_subscriptions_have_independent_wake_destinations( ): monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "apiserver-multi.db")) kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, @@ -156,7 +158,7 @@ def test_apiserver_subscriptions_have_independent_wake_destinations( session_id="worker-session", ) for chat_id in ("origin-a", "origin-b"): - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="api_server", diff --git a/tests/gateway/test_kanban_notifier_wake_only_ordering.py b/tests/gateway/test_kanban_notifier_wake_only_ordering.py index a563c4f552..6f78ac31dd 100644 --- a/tests/gateway/test_kanban_notifier_wake_only_ordering.py +++ b/tests/gateway/test_kanban_notifier_wake_only_ordering.py @@ -15,6 +15,8 @@ import asyncio from gateway.config import Platform from gateway.run import GatewayRunner from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn class RecordingAdapter: @@ -62,7 +64,7 @@ def _make_runner(adapter): def _make_completed_task(delivery_mode): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, @@ -70,7 +72,7 @@ def _make_completed_task(delivery_mode): assignee="worker", session_id="agent:main:telegram:dm:chat-1", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", @@ -85,9 +87,9 @@ def _make_completed_task(delivery_mode): def _unseen_terminal_events(tid): - conn = kb.connect() + conn = kbc.connect() try: - _, events = kb.unseen_events_for_sub( + _, events = kbn.unseen_events_for_sub( conn, task_id=tid, platform="telegram", @@ -100,9 +102,9 @@ def _unseen_terminal_events(tid): def _subs(tid): - conn = kb.connect() + conn = kbc.connect() try: - return kb.list_notify_subs(conn, tid) + return kbn.list_notify_subs(conn, tid) finally: conn.close() diff --git a/tests/gateway/test_kanban_notifier_zero_sub_gate.py b/tests/gateway/test_kanban_notifier_zero_sub_gate.py index 0189fa1c1b..25234c8938 100644 --- a/tests/gateway/test_kanban_notifier_zero_sub_gate.py +++ b/tests/gateway/test_kanban_notifier_zero_sub_gate.py @@ -23,6 +23,8 @@ from unittest.mock import patch from gateway.config import Platform from gateway.run import GatewayRunner from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn class RecordingAdapter: @@ -55,11 +57,11 @@ async def _run_one_notifier_tick(monkeypatch, runner): def _create_completed_task(*, subscribe: bool) -> str: - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="owner gate", assignee="worker") if subscribe: - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") kb.complete_task(conn, tid, summary="done") return tid finally: @@ -76,7 +78,7 @@ def test_zero_sub_board_is_never_opened_writable(tmp_path, monkeypatch): adapter = RecordingAdapter() runner = _make_runner(adapter) - with patch.object(kb, "connect", wraps=kb.connect) as spy_connect: + with patch.object(kbc, "connect", wraps=kbc.connect) as spy_connect: asyncio.run(_run_one_notifier_tick(monkeypatch, runner)) spy_connect.assert_not_called() diff --git a/tests/gateway/test_kanban_reconcile_orphans.py b/tests/gateway/test_kanban_reconcile_orphans.py index 27c33c61f8..a252b0925c 100644 --- a/tests/gateway/test_kanban_reconcile_orphans.py +++ b/tests/gateway/test_kanban_reconcile_orphans.py @@ -27,6 +27,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd @pytest.fixture @@ -45,7 +47,7 @@ def kanban_home(tmp_path, monkeypatch): @pytest.fixture def conn(kanban_home): - with kb.connect() as c: + with kbc.connect() as c: yield c @@ -66,7 +68,7 @@ class TestReconcileOrphanedRunning: tid = kb.create_task(conn, title="zombie", assignee="w") _orphan_running(conn, tid) - reconciled = kb.reconcile_orphaned_running(conn) + reconciled = kbd.reconcile_orphaned_running(conn) assert reconciled == [tid] row = conn.execute( @@ -85,7 +87,7 @@ class TestReconcileOrphanedRunning: tid = kb.create_task(conn, title="half-claim", assignee="w") _orphan_running(conn, tid, claim_lock=f"{host}:dead") - reconciled = kb.reconcile_orphaned_running(conn) + reconciled = kbd.reconcile_orphaned_running(conn) assert reconciled == [tid] assert conn.execute( @@ -96,7 +98,7 @@ class TestReconcileOrphanedRunning: tid = kb.create_task(conn, title="zombie", assignee="w") _orphan_running(conn, tid) - kb.reconcile_orphaned_running(conn) + kbd.reconcile_orphaned_running(conn) events = kb.list_events(conn, tid) recon = [e for e in events if e.kind == "reconciled"] @@ -110,7 +112,7 @@ class TestReconcileOrphanedRunning: tid = kb.create_task(conn, title="healthy", assignee="w") kb.claim_task(conn, tid) - assert kb.reconcile_orphaned_running(conn) == [] + assert kbd.reconcile_orphaned_running(conn) == [] assert conn.execute( "SELECT status FROM tasks WHERE id=?", (tid,) ).fetchone()["status"] == "running" @@ -122,7 +124,7 @@ class TestReconcileOrphanedRunning: sleeper = subprocess.Popen(["sleep", "30"]) try: _orphan_running(conn, tid, worker_pid=sleeper.pid) - assert kb.reconcile_orphaned_running(conn) == [] + assert kbd.reconcile_orphaned_running(conn) == [] assert conn.execute( "SELECT status FROM tasks WHERE id=?", (tid,) ).fetchone()["status"] == "running" @@ -137,7 +139,7 @@ class TestReconcileOrphanedRunning: dead.wait() _orphan_running(conn, tid, worker_pid=dead.pid) - assert kb.reconcile_orphaned_running(conn) == [tid] + assert kbd.reconcile_orphaned_running(conn) == [tid] def test_non_running_statuses_ignored(self, conn): for status in ("todo", "ready", "blocked", "done"): @@ -147,7 +149,7 @@ class TestReconcileOrphanedRunning: "claim_expires=NULL WHERE id=?", (status, tid), ) conn.commit() - assert kb.reconcile_orphaned_running(conn) == [] + assert kbd.reconcile_orphaned_running(conn) == [] class TestDispatchOnceReconciles: @@ -155,7 +157,7 @@ class TestDispatchOnceReconciles: tid = kb.create_task(conn, title="zombie", assignee="w") _orphan_running(conn, tid) - result = kb.dispatch_once(conn, spawn_fn=lambda *a, **k: (True, ""), + result = kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: (True, ""), dry_run=True) assert tid in result.reconciled_orphans @@ -169,7 +171,7 @@ class TestDispatchOnceReconciles: tid = kb.create_task(conn, title="zombie", assignee="w") _orphan_running(conn, tid) - result = kb.dispatch_once(conn, spawn_fn=lambda *a, **k: (True, ""), + result = kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: (True, ""), dry_run=True, reconcile_orphans=False) assert result.reconciled_orphans == [] diff --git a/tests/gateway/test_kanban_wake_scope.py b/tests/gateway/test_kanban_wake_scope.py index 8c277451d4..1d8f26707e 100644 --- a/tests/gateway/test_kanban_wake_scope.py +++ b/tests/gateway/test_kanban_wake_scope.py @@ -16,6 +16,8 @@ from gateway.kanban_watchers_notifier import _wake_scope_id from gateway.run import GatewayRunner from gateway.session import build_session_key from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn from plugins.platforms.slack.adapter import SlackAdapter TEAM = "T0B8U2M6NRE" @@ -75,7 +77,7 @@ async def _one_notifier_tick(monkeypatch, runner): def _completed_subscription(**sub_kwargs): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, @@ -87,7 +89,7 @@ def _completed_subscription(**sub_kwargs): # delivery_mode ("notify+wake"/"wake") on current main; the plain # "notify" default would never reach the wake path under test. sub_kwargs.setdefault("delivery_mode", "notify+wake") - kb.add_notify_sub(conn, task_id=tid, **sub_kwargs) + kbn.add_notify_sub(conn, task_id=tid, **sub_kwargs) kb.complete_task(conn, tid, summary="done") return tid finally: diff --git a/tests/hermes_cli/test_kanban_block_kinds.py b/tests/hermes_cli/test_kanban_block_kinds.py index d562d436db..4646efd992 100644 --- a/tests/hermes_cli/test_kanban_block_kinds.py +++ b/tests/hermes_cli/test_kanban_block_kinds.py @@ -22,6 +22,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -64,7 +65,7 @@ def _make_running_again(conn, tid): def test_block_loop_detected_event_emitted(kanban_home: Path) -> None: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: tid = _running_task(conn) kb.block_task(conn, tid, reason="x", kind="capability") kb.unblock_task(conn, tid) @@ -85,7 +86,7 @@ def test_block_loop_detected_event_emitted(kanban_home: Path) -> None: def test_dependency_then_parent_done_promotes(kanban_home: Path) -> None: """A dependency-parked child becomes ready once its parent completes.""" - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: parent = kb.create_task(conn, title="parent", assignee="worker") child = _running_task(conn, title="child") kb.link_tasks(conn, parent_id=parent, child_id=child) diff --git a/tests/hermes_cli/test_kanban_blocked_sticky.py b/tests/hermes_cli/test_kanban_blocked_sticky.py index 9c3e4f4b89..85fe183721 100644 --- a/tests/hermes_cli/test_kanban_blocked_sticky.py +++ b/tests/hermes_cli/test_kanban_blocked_sticky.py @@ -35,6 +35,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -58,7 +59,7 @@ def test_worker_block_is_not_auto_promoted_by_recompute_ready(kanban_home: Path) must stay blocked across an arbitrary number of dispatcher ticks. Before #28712's fix, ``recompute_ready`` would silently flip it back to ``ready`` on the very next tick.""" - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="needs human review") kb.claim_task(conn, tid) assert kb.block_task( @@ -115,7 +116,7 @@ def test_protocol_violation_loop_is_broken(kanban_home: Path) -> None: that *would* have been written and asserts the *next* tick still leaves the task blocked. """ - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="loop reproducer") kb.claim_task(conn, tid) kb.block_task( diff --git a/tests/hermes_cli/test_kanban_board_project.py b/tests/hermes_cli/test_kanban_board_project.py index 57a6a0901f..2830c9238f 100644 --- a/tests/hermes_cli/test_kanban_board_project.py +++ b/tests/hermes_cli/test_kanban_board_project.py @@ -17,6 +17,7 @@ if str(_WORKTREE) not in sys.path: sys.path.insert(0, str(_WORKTREE)) from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli import projects_db as pdb @@ -63,7 +64,7 @@ def test_create_task_inherits_board_project(fresh_home, tmp_path): proj_id = pdb.create_project(pconn, name="Widget", primary_path=str(repo)) kb.create_board("scoped", name="Scoped", project_id=proj_id) - conn = kb.connect(board="scoped") + conn = kbc.connect(board="scoped") try: tid = kb.create_task(conn, title="inherit me", board="scoped") assert kb.get_task(conn, tid).project_id == proj_id @@ -79,7 +80,7 @@ def test_create_task_explicit_project_beats_board(fresh_home, tmp_path): task_proj = pdb.create_project(pconn, name="TaskProj", primary_path=str(tmp_path / "b")) kb.create_board("scoped2", name="Scoped2", project_id=board_proj) - conn = kb.connect(board="scoped2") + conn = kbc.connect(board="scoped2") try: tid = kb.create_task(conn, title="explicit", board="scoped2", project_id=task_proj) assert kb.get_task(conn, tid).project_id == task_proj diff --git a/tests/hermes_cli/test_kanban_boards.py b/tests/hermes_cli/test_kanban_boards.py index 6fa004879f..92215d5cb9 100644 --- a/tests/hermes_cli/test_kanban_boards.py +++ b/tests/hermes_cli/test_kanban_boards.py @@ -29,6 +29,8 @@ if str(_WORKTREE) not in sys.path: sys.path.insert(0, str(_WORKTREE)) from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd # --------------------------------------------------------------------------- @@ -153,7 +155,7 @@ class TestBoardCRUD: # downstream readers hit `no such table: task_events`. kb.create_board("recycle") # First connect populates _INITIALIZED_PATHS for this DB. - with kb.connect(board="recycle") as conn: + with kbc.connect(board="recycle") as conn: kb.create_task(conn, title="t1", assignee="dev") db_path = kb.board_dir("recycle") / "kanban.db" assert str(db_path.resolve()) in kb._INITIALIZED_PATHS @@ -165,7 +167,7 @@ class TestBoardCRUD: # Simulate the event-stream poll: re-open the same slug. connect() # recreates the directory + empty .db; the schema must be re-applied. - with kb.connect(board="recycle") as conn: + with kbc.connect(board="recycle") as conn: tables = { row[0] for row in conn.execute( @@ -192,18 +194,18 @@ class TestConnectionIsolation: kb.create_board("alpha") kb.create_board("beta") - with kb.connect(board="alpha") as conn: + with kbc.connect(board="alpha") as conn: kb.create_task(conn, title="alpha-task-1", assignee="dev") kb.create_task(conn, title="alpha-task-2", assignee="dev") - with kb.connect(board="beta") as conn: + with kbc.connect(board="beta") as conn: kb.create_task(conn, title="beta-only", assignee="dev") - with kb.connect(board="alpha") as conn: + with kbc.connect(board="alpha") as conn: a = kb.list_tasks(conn) - with kb.connect(board="beta") as conn: + with kbc.connect(board="beta") as conn: b = kb.list_tasks(conn) - with kb.connect(board="default") as conn: + with kbc.connect(board="default") as conn: d = kb.list_tasks(conn) assert {t.title for t in a} == {"alpha-task-1", "alpha-task-2"} @@ -213,9 +215,9 @@ class TestConnectionIsolation: def test_connect_without_args_uses_current(self, fresh_home): kb.create_board("curr") kb.set_current_board("curr") - with kb.connect() as conn: + with kbc.connect() as conn: kb.create_task(conn, title="implicit", assignee="x") - with kb.connect(board="curr") as conn: + with kbc.connect(board="curr") as conn: tasks = kb.list_tasks(conn) assert [t.title for t in tasks] == ["implicit"] @@ -224,11 +226,11 @@ class TestConnectionIsolation: kb.create_board("envwin") kb.set_current_board("persist") monkeypatch.setenv("HERMES_KANBAN_BOARD", "envwin") - with kb.connect() as conn: + with kbc.connect() as conn: kb.create_task(conn, title="via-env", assignee="x") - with kb.connect(board="envwin") as conn: + with kbc.connect(board="envwin") as conn: assert [t.title for t in kb.list_tasks(conn)] == ["via-env"] - with kb.connect(board="persist") as conn: + with kbc.connect(board="persist") as conn: assert kb.list_tasks(conn) == [] @@ -275,7 +277,7 @@ class TestWorkerSpawnEnv: tenant=None, ) - kb._default_spawn(task, str(fresh_home / "ws"), board="spawntest") + kbd._default_spawn(task, str(fresh_home / "ws"), board="spawntest") env = captured["env"] assert env["HERMES_KANBAN_BOARD"] == "spawntest" diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py index 658bf2d8c8..376c79f2a9 100644 --- a/tests/hermes_cli/test_kanban_cli.py +++ b/tests/hermes_cli/test_kanban_cli.py @@ -12,6 +12,7 @@ import pytest from hermes_cli import kanban as kc from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -44,7 +45,8 @@ def test_kanban_list_json_includes_session_id(kanban_home): """JSON output exposes `session_id` so external clients (Scarf, web dashboards) don't need a side query to filter by chat session.""" from hermes_cli import kanban_db as kb - with kb.connect() as conn: + from hermes_cli import kanban_db_connect as kbc + with kbc.connect() as conn: kb.create_task( conn, title="acp task", assignee="alice", session_id="acp-x" ) @@ -58,7 +60,7 @@ def test_kanban_list_json_includes_session_id(kanban_home): def test_kanban_show_text_renders_graph_with_open_connection(kanban_home): - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: parent_id = kb.create_task(conn, title="parent task") child_id = kb.create_task(conn, title="child task") kb.link_tasks(conn, parent_id=parent_id, child_id=child_id) @@ -107,9 +109,9 @@ def test_board_override_is_isolated_per_concurrent_call(kanban_home, monkeypatch assert failures == [] - with kb.connect_closing(board="alpha") as conn: + with kbc.connect_closing(board="alpha") as conn: alpha_titles = [row.title for row in kb.list_tasks(conn, limit=100)] - with kb.connect_closing(board="beta") as conn: + with kbc.connect_closing(board="beta") as conn: beta_titles = [row.title for row in kb.list_tasks(conn, limit=100)] assert alpha_titles == ["alpha-task"] @@ -134,6 +136,7 @@ def test_run_slash_reclaim_running_task(kanban_home): import time import secrets from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc out1 = kc.run_slash("create 'stuck worker task' --assignee broken-model") m = re.search(r"(t_[a-f0-9]+)", out1) @@ -141,7 +144,7 @@ def test_run_slash_reclaim_running_task(kanban_home): tid = m.group(1) # Simulate a running claim outside TTL. - conn = kb.connect() + conn = kbc.connect() try: lock = secrets.token_hex(4) conn.execute( diff --git a/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py b/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py index e7c9ba921a..aaf214458f 100644 --- a/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py +++ b/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py @@ -35,6 +35,7 @@ def test_cli_dispatch_passes_max_in_progress_from_config(isolated_kanban_home, m unreachable from the CLI even though it works from the gateway.""" from hermes_cli import kanban as kb_cli from hermes_cli import kanban_db + from hermes_cli import kanban_db_dispatch as kbd # Configure max_in_progress in the loaded config. fake_config = { @@ -55,7 +56,7 @@ def test_cli_dispatch_passes_max_in_progress_from_config(isolated_kanban_home, m captured.update(kwargs) return kanban_db.DispatchResult() - monkeypatch.setattr(kanban_db, "dispatch_once", fake_dispatch_once) + monkeypatch.setattr(kbd, "dispatch_once", fake_dispatch_once) args = argparse.Namespace(dry_run=True, max=None, failure_limit=2, json=False) kb_cli._cmd_dispatch(args) @@ -76,13 +77,14 @@ def test_cli_max_flag_overrides_config_max_spawn(isolated_kanban_home, monkeypat The CLI flag is the explicit operator signal; config is the default.""" from hermes_cli import kanban as kb_cli from hermes_cli import kanban_db + from hermes_cli import kanban_db_dispatch as kbd fake_config = {"kanban": {"max_spawn": 10}} monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_config) captured = {} monkeypatch.setattr( - kanban_db, "dispatch_once", + kbd, "dispatch_once", lambda conn, **kw: (captured.update(kw), kanban_db.DispatchResult())[1], ) diff --git a/tests/hermes_cli/test_kanban_comment_queries.py b/tests/hermes_cli/test_kanban_comment_queries.py index c1206b7706..5a82223215 100644 --- a/tests/hermes_cli/test_kanban_comment_queries.py +++ b/tests/hermes_cli/test_kanban_comment_queries.py @@ -17,6 +17,7 @@ if str(_WORKTREE) not in sys.path: sys.path.insert(0, str(_WORKTREE)) from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -37,7 +38,7 @@ def fresh_home(tmp_path, monkeypatch): def test_list_comments_after_cursor(fresh_home): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="chat") c1 = kb.add_comment(conn, tid, author="alice", body="first") diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 0a47445ec1..2a0cf6d759 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -22,6 +22,10 @@ from types import SimpleNamespace import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn +from hermes_cli import kanban_db_dispatch as kbd +from hermes_cli import kanban_db_workspace as kbw from hermes_cli.kanban import run_slash @@ -104,10 +108,10 @@ def kanban_home(tmp_path, monkeypatch): # --------------------------------------------------------------------------- def test_notify_sub_crud(kanban_home): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="x") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="123", user_id="u1", notifier_profile="default", delivery_metadata={ @@ -115,7 +119,7 @@ def test_notify_sub_crud(kanban_home): "telegram_reply_to_message_id": "42", }, ) - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert len(subs) == 1 assert subs[0]["platform"] == "telegram" assert subs[0]["notifier_profile"] == "default" @@ -124,45 +128,45 @@ def test_notify_sub_crud(kanban_home): "telegram_reply_to_message_id": "42", } # Duplicate add is a no-op. - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="123", delivery_metadata={ "chat_type": "dm", "telegram_reply_to_message_id": "43", }, ) - assert len(kb.list_notify_subs(conn, tid)) == 1 - assert kb.list_notify_subs(conn, tid)[0]["delivery_metadata"][ + assert len(kbn.list_notify_subs(conn, tid)) == 1 + assert kbn.list_notify_subs(conn, tid)[0]["delivery_metadata"][ "telegram_reply_to_message_id" ] == "43" # Distinct thread is a new row. - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="123", thread_id="5", ) - assert len(kb.list_notify_subs(conn, tid)) == 2 + assert len(kbn.list_notify_subs(conn, tid)) == 2 # Remove one. - ok = kb.remove_notify_sub( + ok = kbn.remove_notify_sub( conn, task_id=tid, platform="telegram", chat_id="123", ) assert ok is True - assert len(kb.list_notify_subs(conn, tid)) == 1 + assert len(kbn.list_notify_subs(conn, tid)) == 1 finally: conn.close() def test_notify_claim_is_single_owner_and_rewindable(kanban_home): - conn1 = kb.connect() - conn2 = kb.connect() + conn1 = kbc.connect() + conn2 = kbc.connect() try: tid = kb.create_task(conn1, title="x", assignee="w") - kb.add_notify_sub(conn1, task_id=tid, platform="telegram", chat_id="123") + kbn.add_notify_sub(conn1, task_id=tid, platform="telegram", chat_id="123") # New subs start caught up at the task's current MAX(task_events.id) # (the `created` event) — issue #29905. - initial_cursor = int(kb.list_notify_subs(conn1, tid)[0]["last_event_id"]) + initial_cursor = int(kbn.list_notify_subs(conn1, tid)[0]["last_event_id"]) kb.complete_task(conn1, tid, result="ok") - old_cursor, claimed_cursor, events = kb.claim_unseen_events_for_sub( + old_cursor, claimed_cursor, events = kbn.claim_unseen_events_for_sub( conn1, task_id=tid, platform="telegram", @@ -175,7 +179,7 @@ def test_notify_claim_is_single_owner_and_rewindable(kanban_home): # A concurrent notifier instance sees the advanced cursor and cannot # claim/send the same event range. - _, _, duplicate_events = kb.claim_unseen_events_for_sub( + _, _, duplicate_events = kbn.claim_unseen_events_for_sub( conn2, task_id=tid, platform="telegram", @@ -184,7 +188,7 @@ def test_notify_claim_is_single_owner_and_rewindable(kanban_home): ) assert duplicate_events == [] - assert kb.rewind_notify_cursor( + assert kbn.rewind_notify_cursor( conn1, task_id=tid, platform="telegram", @@ -192,7 +196,7 @@ def test_notify_claim_is_single_owner_and_rewindable(kanban_home): claimed_cursor=claimed_cursor, old_cursor=old_cursor, ) is True - _, retried_events = kb.unseen_events_for_sub( + _, retried_events = kbn.unseen_events_for_sub( conn2, task_id=tid, platform="telegram", @@ -271,7 +275,7 @@ def test_max_runtime_terminates_overrun_worker(kanban_home): _kb._pid_alive = lambda pid: False # pretend SIGTERM worked immediately try: - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, title="long job", assignee="worker", @@ -279,7 +283,7 @@ def test_max_runtime_terminates_overrun_worker(kanban_home): ) # Spawn by hand: claim + set pid + set active run start to the past. kb.claim_task(conn, tid) - kb._set_worker_pid(conn, tid, os.getpid()) # any live pid works + kbd._set_worker_pid(conn, tid, os.getpid()) # any live pid works # Backdate both the task-level first-start timestamp and the active # run timestamp so elapsed > limit under the per-run runtime model. old_started = int(time.time()) - 30 @@ -294,7 +298,7 @@ def test_max_runtime_terminates_overrun_worker(kanban_home): (old_started, tid), ) - timed_out = kb.enforce_max_runtime(conn, signal_fn=_signal_fn) + timed_out = kbd.enforce_max_runtime(conn, signal_fn=_signal_fn) assert tid in timed_out assert killed and killed[0][0] == os.getpid() @@ -345,7 +349,7 @@ def test_migration_renames_legacy_event_kinds(tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) # Init fresh. kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="x") # Inject legacy event kinds directly. @@ -406,28 +410,28 @@ def test_stale_run_cannot_block_or_heartbeat_new_attempt(kanban_home, monkeypatc """Stale retry attempts cannot mutate the active run lifecycle.""" import hermes_cli.kanban_db as _kb - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="retry heartbeat guarded", assignee="worker") kb.claim_task(conn, tid) run1 = kb.latest_run(conn, tid) - kb._set_worker_pid(conn, tid, 98765) + kbd._set_worker_pid(conn, tid, 98765) monkeypatch.setattr(_kb, "_pid_alive", lambda pid: False) - assert kb.detect_crashed_workers(conn) == [tid] + assert kbd.detect_crashed_workers(conn) == [tid] kb.claim_task(conn, tid) run2 = kb.latest_run(conn, tid) assert run2.id != run1.id - assert not kb.heartbeat_worker(conn, tid, note="late", expected_run_id=run1.id) + assert not kbd.heartbeat_worker(conn, tid, note="late", expected_run_id=run1.id) assert not kb.block_task(conn, tid, reason="late block", expected_run_id=run1.id) task = kb.get_task(conn, tid) assert task.status == "running" assert task.current_run_id == run2.id assert task.last_heartbeat_at is None - assert kb.heartbeat_worker(conn, tid, note="current", expected_run_id=run2.id) + assert kbd.heartbeat_worker(conn, tid, note="current", expected_run_id=run2.id) assert kb.block_task(conn, tid, reason="current block", expected_run_id=run2.id) assert kb.get_task(conn, tid).status == "blocked" finally: @@ -463,7 +467,7 @@ def test_migration_backfills_inflight_run_for_legacy_db(kanban_home): """An existing 'running' task from before task_runs existed should get a synthesized run row so subsequent operations (complete, heartbeat) have something to write to.""" - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="pre-migration", assignee="worker") # Simulate legacy: set running + claim_lock directly, leave @@ -482,7 +486,7 @@ def test_migration_backfills_inflight_run_for_legacy_db(kanban_home): # Re-run init_db — migration backfill should kick in. kb.init_db() - conn2 = kb.connect() + conn2 = kbc.connect() try: runs = kb.list_runs(conn2, tid) assert len(runs) == 1 @@ -532,7 +536,7 @@ def test_claim_task_recovers_from_invariant_leak(kanban_home): """Belt-and-suspenders: if a prior run somehow leaked (stranded current_run_id on a ready task), claim_task should recover rather than strand it further.""" - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="invariant test", assignee="worker") # Manually engineer the invariant violation: create a run, then @@ -576,7 +580,7 @@ def test_claim_task_recovers_from_invariant_leak(kanban_home): def test_unblock_invariant_recovery(kanban_home): """unblock_task must leave current_run_id NULL even if some other code path left it dangling. Engineer the leak, verify recovery.""" - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="unblock invariant", assignee="worker") # Start on running, then open a run, then force to 'blocked' but @@ -617,7 +621,7 @@ def test_migration_backfill_idempotent_under_re_run(tmp_path, monkeypatch): # Fresh DB, one task left in 'running' with a claim but no run row. # Simulates a pre-runs-era DB. kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="legacy inflight", assignee="worker") now = int(time.time()) @@ -717,13 +721,13 @@ def test_default_spawn_does_not_auto_load_any_skill(kanban_home, monkeypatch): monkeypatch.setattr("subprocess.Popen", fake_popen) - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="skill-loading test", assignee="some-profile") task = kb.get_task(conn, tid) - workspace = kb.resolve_workspace(task) - pid = kb._default_spawn(task, str(workspace)) + workspace = kbw.resolve_workspace(task) + pid = kbd._default_spawn(task, str(workspace)) assert pid == 99999 finally: conn.close() @@ -792,12 +796,12 @@ def test_legacy_db_without_skills_column_migrates(tmp_path): assert "skills" not in before # Run the migrator directly — the same function connect() calls. - kb._migrate_add_optional_columns(conn) + kbc._migrate_add_optional_columns(conn) after = {r[1] for r in conn.execute("PRAGMA table_info(tasks)")} assert "skills" in after, f"migration did not add skills column: {after}" # Idempotent: running again must not raise. - kb._migrate_add_optional_columns(conn) + kbc._migrate_add_optional_columns(conn) # Legacy row has skills=NULL -> Task.skills=None. row = conn.execute("SELECT * FROM tasks WHERE id = 'legacy'").fetchone() @@ -863,7 +867,7 @@ def test_legacy_spawn_failure_columns_are_copied_not_renamed(tmp_path): ) conn.commit() - kb._migrate_add_optional_columns(conn) + kbc._migrate_add_optional_columns(conn) cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)")} assert "spawn_failures" in cols assert "consecutive_failures" in cols @@ -877,7 +881,7 @@ def test_legacy_spawn_failure_columns_are_copied_not_renamed(tmp_path): assert task.consecutive_failures == 4 assert task.last_failure_error == "missing profile" - kb._migrate_add_optional_columns(conn) + kbc._migrate_add_optional_columns(conn) row_again = conn.execute("SELECT * FROM tasks WHERE id = 'legacy'").fetchone() assert row_again["consecutive_failures"] == 4 assert row_again["last_failure_error"] == "missing profile" @@ -924,7 +928,7 @@ def test_legacy_migration_no_legacy_columns_at_all(tmp_path): conn.commit() # Must not raise (this was the crash before this fix). - kb._migrate_add_optional_columns(conn) + kbc._migrate_add_optional_columns(conn) cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)")} assert "consecutive_failures" in cols, "migration must add consecutive_failures" @@ -936,7 +940,7 @@ def test_legacy_migration_no_legacy_columns_at_all(tmp_path): assert row["last_failure_error"] is None # Idempotent second run must not raise either. - kb._migrate_add_optional_columns(conn) + kbc._migrate_add_optional_columns(conn) row_again = conn.execute("SELECT * FROM tasks WHERE id = 't1'").fetchone() assert row_again["consecutive_failures"] == 0 assert row_again["last_failure_error"] is None @@ -1033,6 +1037,8 @@ def test_gateway_dispatcher_disables_corrupt_board_without_traceback( from gateway.run import GatewayRunner import hermes_cli.config as _cfg_mod import hermes_cli.kanban_db as _kb + from hermes_cli import kanban_db_connect as _kbc + from hermes_cli import kanban_db_dispatch as _kbd runner = object.__new__(GatewayRunner) runner._running = True @@ -1066,7 +1072,7 @@ def test_gateway_dispatcher_disables_corrupt_board_without_traceback( def _connect(*args, **kwargs): calls["connect"] += 1 if corrupt_exc == "guard": - raise _kb.KanbanDbCorruptError( + raise _kbc.KanbanDbCorruptError( corrupt_db, corrupt_db.with_suffix(".db.corrupt.test.bak"), "sqlite refused to open file: database disk image is malformed", @@ -1075,7 +1081,7 @@ def test_gateway_dispatcher_disables_corrupt_board_without_traceback( async def _to_thread(fn, *args, **kwargs): # PR salvage (#32857 commit 7): the dispatcher now reaps zombies at - # the top of each tick via ``asyncio.to_thread(_kb.reap_worker_zombies)`` + # the top of each tick via ``asyncio.to_thread(_kbd.reap_worker_zombies)`` # BEFORE the per-board tick work. Each tick now issues 3 ``to_thread`` # calls (reaper + ``_tick_once`` + ``_ready_nonempty``) instead of 2, # so this counter must reach 6 to allow the same 2 dispatch ticks the @@ -1090,7 +1096,7 @@ def test_gateway_dispatcher_disables_corrupt_board_without_traceback( async def _sleep(_delay): return None - monkeypatch.setattr(_kb, "connect", _connect) + monkeypatch.setattr(_kbc, "connect", _connect) monkeypatch.setattr("gateway.run.asyncio.to_thread", _to_thread) monkeypatch.setattr("gateway.run.asyncio.sleep", _sleep) @@ -1107,7 +1113,7 @@ def test_gateway_dispatcher_disables_corrupt_board_without_traceback( assert not any("tick failed on board" in msg for msg in messages) assert not any(record.exc_info for record in caplog.records) # First tick connect (dispatch) + two probes per `_has_ready_work` call - # (ready then review, both via _kb.connect). The second dispatch tick + # (ready then review, both via _kbc.connect). The second dispatch tick # skips the dispatch connect because the corrupt board fingerprint is # disabled, but the ready/review probes still each connect. PR f55d94a1e # added the review-column probe alongside the existing ready-column @@ -1129,7 +1135,7 @@ def test_complete_can_retry_after_phantom_rejection(kanban_home): hatch). Regression test for #22923, where workers were believed to be unrecoverable after the first rejection. """ - conn = kb.connect() + conn = kbc.connect() try: # Two parallel completing tasks so we can exercise both retry # shapes without status interference. @@ -1205,7 +1211,7 @@ def test_reclaim_task_resets_running_to_ready(kanban_home, monkeypatch): import time import secrets import hermes_cli.kanban_db as _kb - conn = kb.connect() + conn = kbc.connect() try: t = kb.create_task(conn, title="stuck", assignee="broken") # Simulate a live claim (not expired). @@ -1291,15 +1297,16 @@ def _drive_worker_exit(conn, tid, fake_pid, raw_status): a clean-exit protocol violation into a plain crash. """ import hermes_cli.kanban_db as _kb + from hermes_cli import kanban_db_dispatch as _kbd host_prefix = _kb._claimer_id().split(":", 1)[0] claimed = _kb.claim_task(conn, tid, claimer=f"{host_prefix}:mock") assert claimed is not None, "task was not claimable for the next attempt" - _kb._set_worker_pid(conn, tid, fake_pid) - _kb._record_worker_exit(fake_pid, raw_status) + _kbd._set_worker_pid(conn, tid, fake_pid) + _kbd._record_worker_exit(fake_pid, raw_status) original_alive = _kb._pid_alive _kb._pid_alive = lambda p: False try: - return _kb.detect_crashed_workers(conn) + return _kbd.detect_crashed_workers(conn) finally: _kb._pid_alive = original_alive @@ -1331,7 +1338,8 @@ def test_protocol_violation_budget_not_consumed_by_other_failures(kanban_home): untouched (so the two budgets stay independent). """ import hermes_cli.kanban_db as _kb - conn = kb.connect() + from hermes_cli import kanban_db_dispatch as _kbd + conn = kbc.connect() try: tid = kb.create_task(conn, title="mixed", assignee="worker") @@ -1363,7 +1371,7 @@ def test_protocol_violation_budget_not_consumed_by_other_failures(kanban_home): gave_up = [e for e in kb.list_events(conn, tid) if e.kind == "gave_up"] assert len(gave_up) == 1 assert (gave_up[0].payload or {}).get("protocol_violations") == \ - _kb._PROTOCOL_VIOLATION_FAILURE_LIMIT + _kbd._PROTOCOL_VIOLATION_FAILURE_LIMIT finally: conn.close() @@ -1388,18 +1396,18 @@ def test_notify_sub_starts_caught_up_on_active_task(kanban_home): snaps to the task's MAX(task_events.id) at creation: only events that occur AFTER subscribing are delivered. """ - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="old task", assignee="w") # Historical terminal activity BEFORE anyone subscribes. kb.complete_task(conn, tid, result="done long ago") - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="123") - sub = kb.list_notify_subs(conn, tid)[0] + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="123") + sub = kbn.list_notify_subs(conn, tid)[0] assert int(sub["last_event_id"]) > 0, ( "cursor must snap to MAX(task_events.id) at subscription time" ) - _, events = kb.unseen_events_for_sub( + _, events = kbn.unseen_events_for_sub( conn, task_id=tid, platform="telegram", chat_id="123", kinds=["completed", "blocked", "gave_up", "crashed", "timed_out"], ) diff --git a/tests/hermes_cli/test_kanban_count_notify_subs.py b/tests/hermes_cli/test_kanban_count_notify_subs.py index f55c358045..10ea8c1f94 100644 --- a/tests/hermes_cli/test_kanban_count_notify_subs.py +++ b/tests/hermes_cli/test_kanban_count_notify_subs.py @@ -16,6 +16,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_notify as kbn +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -31,48 +33,48 @@ def kanban_home(tmp_path, monkeypatch): def test_missing_db_counts_zero_and_creates_nothing(kanban_home): db_path = kb.kanban_db_path(board="default") assert not db_path.exists() - assert kb.count_notify_subs(board="default") == 0 - assert kb.count_notify_subs( + assert kbn.count_notify_subs(board="default") == 0 + assert kbn.count_notify_subs( board="default", platform="tui", chat_id="session-1" ) == 0 assert not db_path.exists(), "read-only probe must not create the DB" def test_optional_filters_narrow_count_without_changing_unfiltered_count(kanban_home): - conn = kb.connect(board="default") + conn = kbc.connect(board="default") try: tid = kb.create_task(conn, title="t", assignee="w") - kb.add_notify_sub(conn, task_id=tid, platform="tui", chat_id="session-1") - kb.add_notify_sub( + kbn.add_notify_sub(conn, task_id=tid, platform="tui", chat_id="session-1") + kbn.add_notify_sub( conn, task_id=tid, platform="TUI", chat_id="session-2", thread_id="thread-2", ) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="session-1" ) finally: conn.close() - assert kb.count_notify_subs(board="default") == 3 - assert kb.count_notify_subs(board="default", platform="tui") == 2 - assert kb.count_notify_subs(board="default", chat_id="session-1") == 2 - assert kb.count_notify_subs(board="default", thread_id="thread-2") == 1 - assert kb.count_notify_subs( + assert kbn.count_notify_subs(board="default") == 3 + assert kbn.count_notify_subs(board="default", platform="tui") == 2 + assert kbn.count_notify_subs(board="default", chat_id="session-1") == 2 + assert kbn.count_notify_subs(board="default", thread_id="thread-2") == 1 + assert kbn.count_notify_subs( board="default", platform="tui", chat_id="session-1" ) == 1 - assert kb.count_notify_subs( + assert kbn.count_notify_subs( board="default", platform="tui", chat_id="session-1", thread_id="" ) == 1 - assert kb.count_notify_subs( + assert kbn.count_notify_subs( board="default", platform="tui", chat_id="session-2", thread_id="thread-2", ) == 1 - assert kb.count_notify_subs( + assert kbn.count_notify_subs( board="default", platform="tui", chat_id="other-session" ) == 0 @@ -85,8 +87,8 @@ def test_legacy_db_without_subs_table_counts_zero_and_stays_unmigrated(tmp_path) conn.commit() finally: conn.close() - assert kb.count_notify_subs(db_path=legacy) == 0 - assert kb.count_notify_subs( + assert kbn.count_notify_subs(db_path=legacy) == 0 + assert kbn.count_notify_subs( db_path=legacy, platform="tui", chat_id="session-1" ) == 0 # The probe must not have run schema init on the foreign/legacy DB. @@ -107,11 +109,11 @@ def test_legacy_db_without_subs_table_counts_zero_and_stays_unmigrated(tmp_path) def test_count_notify_subs_filters_profile_owners(tmp_path): db_path = tmp_path / "owners.db" kb.init_db(db_path) - conn = kb.connect(db_path) + conn = kbc.connect(db_path) try: for profile in ("default", "writer", None): task_id = kb.create_task(conn, title=f"owned by {profile}") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=task_id, platform="telegram", @@ -121,10 +123,10 @@ def test_count_notify_subs_filters_profile_owners(tmp_path): finally: conn.close() - assert kb.count_notify_subs( + assert kbn.count_notify_subs( db_path=db_path, notifier_profiles={"writer"}, ) == 1 - assert kb.count_notify_subs( + assert kbn.count_notify_subs( db_path=db_path, notifier_profiles={"default"}, include_unowned=True, diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 06d437a826..8a0b1976e8 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -17,6 +17,9 @@ import pytest import hermes_state import hermes_state_wal from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd +from hermes_cli import kanban_db_workspace as kbw @pytest.fixture @@ -140,7 +143,7 @@ def test_connect_migrates_legacy_db_before_optional_column_indexes(tmp_path): conn.commit() conn.close() - with kb.connect(db_path) as migrated: + with kbc.connect(db_path) as migrated: task_columns = { row["name"] for row in migrated.execute("PRAGMA table_info(tasks)") } @@ -190,7 +193,7 @@ def test_connect_migrates_legacy_db_before_optional_column_indexes(tmp_path): def test_schedule_task_parks_time_delay_without_dispatching(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: t = kb.create_task(conn, title="delayed recheck", assignee="ops") assert kb.schedule_task(conn, t, reason="run next week") is True task = kb.get_task(conn, t) @@ -217,11 +220,11 @@ def test_stale_claim_reclaim_event_records_diagnostic_payload( import json import hermes_cli.kanban_db as _kb - with kb.connect() as conn: + with kbc.connect() as conn: t = kb.create_task(conn, title="x", assignee="a") host = _kb._claimer_id().split(":", 1)[0] kb.claim_task(conn, t, claimer=f"{host}:worker") - kb._set_worker_pid(conn, t, 12345) + kbd._set_worker_pid(conn, t, 12345) old_expires = int(time.time()) - 3600 hb_at = int(time.time()) - 1800 conn.execute( @@ -272,11 +275,12 @@ def test_rate_limit_exit_requeues_without_counting_failure( ``consecutive_failures`` untouched — the breaker must never trip on a transient throttle, even across many quota-wall hits.""" import hermes_cli.kanban_db as _kb + from hermes_cli import kanban_db_dispatch as _kbd monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False) monkeypatch.setenv("HERMES_KANBAN_CRASH_GRACE_SECONDS", "0") - with kb.connect() as conn: + with kbc.connect() as conn: host = _kb._claimer_id().split(":", 1)[0] tid = kb.create_task(conn, title="rl", assignee="a") @@ -294,14 +298,14 @@ def test_rate_limit_exit_requeues_without_counting_failure( (pid, 0, tid), ) conn.commit() - _kb._record_worker_exit( + _kbd._record_worker_exit( pid, _exited_status(_kb.KANBAN_RATE_LIMIT_EXIT_CODE) ) - crashed = kb.detect_crashed_workers(conn) + crashed = kbd.detect_crashed_workers(conn) # Rate-limited requeues are NOT crashes. assert tid not in crashed - rl = getattr(_kb.detect_crashed_workers, "_last_rate_limited", []) + rl = getattr(_kbd.detect_crashed_workers, "_last_rate_limited", []) assert tid in rl task = kb.get_task(conn, tid) @@ -340,7 +344,7 @@ def test_respawn_guard_defers_rate_limited_within_cooldown( monkeypatch.setenv("HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS", "300") now = 5_000_000 - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="rl-guard", assignee="a") # Seed a rate_limited run that just ended + the stamped error. kb.claim_task(conn, tid) @@ -360,12 +364,12 @@ def test_respawn_guard_defers_rate_limited_within_cooldown( # Inside cooldown → defer with the rate-limit-specific reason. monkeypatch.setattr(_kb.time, "time", lambda: now + 100) - assert kb.check_respawn_guard(conn, tid) == "rate_limit_cooldown" + assert kbd.check_respawn_guard(conn, tid) == "rate_limit_cooldown" # Past cooldown → allowed (None), NOT trapped by blocker_auth even # though last_failure_error contains "rate-limited". monkeypatch.setattr(_kb.time, "time", lambda: now + 400) - assert kb.check_respawn_guard(conn, tid) is None + assert kbd.check_respawn_guard(conn, tid) is None @@ -394,7 +398,7 @@ def test_recompute_ready_honours_dispatcher_failure_limit(kanban_home): breaker — sticking a task prematurely (config limit > default) or letting a tripped task escape (config limit < default). """ - with kb.connect() as conn: + with kbc.connect() as conn: # Config allows MORE retries than the default. A task blocked # with failures below the configured limit must still recover. t = kb.create_task(conn, title="lenient", assignee="a") @@ -451,7 +455,7 @@ def test_recompute_ready_honours_dispatcher_failure_limit(kanban_home): def test_delete_archived_task_removes_related_rows(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: parent = kb.create_task(conn, title="parent") tid = kb.create_task(conn, title="child", parents=[parent], assignee="worker") kb.add_comment(conn, tid, "user", "cleanup me") @@ -475,7 +479,7 @@ def test_delete_archived_task_removes_related_rows(kanban_home): def test_delete_task_removes_task_and_cascades(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: t = kb.create_task(conn, title="to-delete", assignee="alice") kb.add_comment(conn, t, "user", "comment") kb.add_comment(conn, t, "user", "another") @@ -534,7 +538,7 @@ def test_worktree_workspace_explicit_target_materializes_linked_worktree(kanban_ _init_git_repo(repo) target = repo / ".worktrees" / "custom-task" branch = "wt/custom-task" - with kb.connect() as conn: + with kbc.connect() as conn: t = kb.create_task( conn, title="ship", @@ -544,7 +548,7 @@ def test_worktree_workspace_explicit_target_materializes_linked_worktree(kanban_ ) task = kb.get_task(conn, t) assert task is not None - ws = kb.resolve_workspace(task) + ws = kbw.resolve_workspace(task) assert ws == target assert ws.exists() @@ -579,11 +583,11 @@ def test_worktree_workspace_explicit_target_materializes_linked_worktree(kanban_ def test_complete_task_persists_scratch_artifacts_before_cleanup(kanban_home): """Completion artifacts from scratch workspaces survive workspace cleanup.""" - with kb.connect() as conn: + with kbc.connect() as conn: t = kb.create_task(conn, title="render chart") task = kb.get_task(conn, t) - ws = kb.resolve_workspace(task) - kb.set_workspace_path(conn, t, ws) + ws = kbw.resolve_workspace(task) + kbw.set_workspace_path(conn, t, ws) artifact = ws / "chart.png" artifact.write_bytes(b"png-bytes") @@ -606,7 +610,7 @@ def test_complete_task_persists_scratch_artifacts_before_cleanup(kanban_home): assert str(persisted) != str(artifact) assert run is not None assert run.metadata["artifacts"] == [str(persisted)] - with kb.connect() as conn: + with kbc.connect() as conn: attachments = kb.list_attachments(conn, t) assert [(a.filename, a.stored_path) for a in attachments] == [ ("chart.png", str(persisted.resolve())) @@ -631,7 +635,7 @@ def test_dir_child_completion_unblocks_deferred_scratch_parent(kanban_home, tmp_ """ child_dir = tmp_path / "persistent-child" child_dir.mkdir() - with kb.connect() as conn: + with kbc.connect() as conn: parent = kb.create_task(conn, title="scratch parent") child = kb.create_task( conn, title="dir child", workspace_kind="dir", @@ -639,8 +643,8 @@ def test_dir_child_completion_unblocks_deferred_scratch_parent(kanban_home, tmp_ ) kb.link_tasks(conn, parent, child) p_task = kb.get_task(conn, parent) - parent_ws = kb.resolve_workspace(p_task) - kb.set_workspace_path(conn, parent, parent_ws) + parent_ws = kbw.resolve_workspace(p_task) + kbw.set_workspace_path(conn, parent, parent_ws) kb.complete_task(conn, parent, result="handoff") assert parent_ws.exists(), "deferred while dir child active" @@ -783,12 +787,12 @@ class TestSharedBoardPaths: # Dispatcher creates the board and a task. self._set_home(monkeypatch, tmp_path, default_home) kb.init_db() - with kb.connect() as conn: + with kbc.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: + with kbc.connect() as conn: task = kb.get_task(conn, task_id) assert task is not None assert task.title == "cross-profile" @@ -844,7 +848,7 @@ class TestSharedBoardPaths: tenant=None, branch_name="wt/t_dispatch_env", ) - kb._default_spawn(task, str(tmp_path / "ws")) + kbd._default_spawn(task, str(tmp_path / "ws")) env = captured["env"] assert env["HERMES_KANBAN_DB"] == str(default_home / "kanban.db") @@ -934,7 +938,7 @@ def test_connect_falls_back_to_delete_on_locking_protocol(tmp_path, monkeypatch, with _patch("hermes_cli.kanban_db.sqlite3.connect", side_effect=wal_blocking_connect): with caplog.at_level("ERROR", logger="hermes_state"): - conn = kb.connect() + conn = kbc.connect() # One fallback error, naming kanban.db errors = [ @@ -990,7 +994,7 @@ def test_connect_works_when_wal_is_silently_refused(tmp_path, monkeypatch, caplo side_effect=wal_silent_noop_connect, ): with caplog.at_level("ERROR", logger="hermes_state"): - conn = kb.connect() + conn = kbc.connect() assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "delete" t = kb.create_task(conn, title="post-silent-fallback task") @@ -1051,7 +1055,7 @@ def test_unlink_tasks_triggers_recompute_ready(kanban_home): Before the fix, child stayed 'todo' indefinitely after unlink; only the next dispatcher tick or a manual 'hermes kanban recompute' would promote it. """ - with kb.connect() as conn: + with kbc.connect() as conn: # A is done. a = kb.create_task(conn, title="parent-done") kb.complete_task(conn, a) @@ -1161,7 +1165,7 @@ def test_migrate_add_optional_columns_tolerates_concurrent_migration(kanban_home ) # Running migration on an already-migrated schema must not raise. - kb._migrate_add_optional_columns(conn) + kbc._migrate_add_optional_columns(conn) conn.close() @@ -1189,10 +1193,11 @@ def test_resolve_hermes_argv_falls_back_to_module_form_when_no_path_shim(monkeyp import shutil import sys import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_dispatch as kbd monkeypatch.delenv("HERMES_BIN", raising=False) monkeypatch.setattr(shutil, "which", lambda name: None) - argv = kb._resolve_hermes_argv() + argv = kbd._resolve_hermes_argv() assert argv == [sys.executable, "-m", "hermes_cli.main"] @@ -1207,13 +1212,14 @@ def test_resolve_hermes_argv_module_actually_runs(): """ import subprocess import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_dispatch as kbd import shutil import unittest.mock as mock with mock.patch.dict(os.environ, {}, clear=False): os.environ.pop("HERMES_BIN", None) with mock.patch.object(shutil, "which", return_value=None): - argv = kb._resolve_hermes_argv() + argv = kbd._resolve_hermes_argv() r = subprocess.run(argv + ["--version"], capture_output=True, text=True, timeout=30) assert r.returncode == 0, ( f"`{' '.join(argv)} --version` failed (rc={r.returncode}); " @@ -1292,12 +1298,12 @@ def test_dispatch_max_in_progress_blocks_review_when_at_limit( spawns.append(task.id) return 42 - with kb.connect() as conn: + with kbc.connect() as conn: running = kb.create_task(conn, title="running", assignee="alice") kb.claim_task(conn, running) review = kb.create_task(conn, title="review", assignee="bob") _set_task_status(conn, review, "review") - res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_in_progress=1) + res = kbd.dispatch_once(conn, spawn_fn=fake_spawn, max_in_progress=1) review_task = kb.get_task(conn, review) assert not res.spawned @@ -1369,8 +1375,8 @@ def test_repeated_corrupt_open_reuses_single_backup(tmp_path): backups: set[Path] = set() for _ in range(10): kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) - with pytest.raises(kb.KanbanDbCorruptError) as excinfo: - kb.connect(db_path=db_path) + with pytest.raises(kbc.KanbanDbCorruptError) as excinfo: + kbc.connect(db_path=db_path) assert excinfo.value.backup_path is not None backups.add(excinfo.value.backup_path) @@ -1384,8 +1390,8 @@ def test_repeated_corrupt_open_reuses_single_backup(tmp_path): f.seek(4096) f.write(b"\xAB" * 64) kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) - with pytest.raises(kb.KanbanDbCorruptError) as excinfo2: - kb.connect(db_path=db_path) + with pytest.raises(kbc.KanbanDbCorruptError) as excinfo2: + kbc.connect(db_path=db_path) second_backup = excinfo2.value.backup_path assert second_backup is not None assert second_backup != backup @@ -1409,7 +1415,7 @@ def test_locked_healthy_db_does_not_classify_as_corrupt(tmp_path, monkeypatch): monkeypatch.setattr(kb.sqlite3, "connect", flaky_connect) with pytest.raises(sqlite3.OperationalError): - kb.connect(db_path=db_path) + kbc.connect(db_path=db_path) # No .corrupt backup may be produced for a healthy-but-locked DB. backups = list(tmp_path.glob("*.corrupt.*")) @@ -1417,7 +1423,7 @@ def test_locked_healthy_db_does_not_classify_as_corrupt(tmp_path, monkeypatch): # And once the lock clears, normal access still works. monkeypatch.setattr(kb.sqlite3, "connect", real_connect) - with kb.connect(db_path=db_path) as conn: + with kbc.connect(db_path=db_path) as conn: kb.create_task(conn, title="still here") titles = [t.title for t in kb.list_tasks(conn)] assert "still here" in titles @@ -1437,20 +1443,20 @@ def test_maybe_emit_scratch_tip_fires_once_per_install(kanban_home, caplog): """ import logging - with kb.connect() as conn: + with kbc.connect() as conn: t1 = kb.create_task(conn, title="first scratch") t2 = kb.create_task(conn, title="second scratch") # Sentinel must not exist yet on a fresh install. - assert not kb._scratch_tip_shown() + assert not kbw._scratch_tip_shown() with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"): - with kb.connect() as conn: - kb._maybe_emit_scratch_tip(conn, t1, "scratch") + with kbc.connect() as conn: + kbw._maybe_emit_scratch_tip(conn, t1, "scratch") # Sentinel is now set. - assert kb._scratch_tip_shown() - assert kb._scratch_tip_sentinel_path().exists() + assert kbw._scratch_tip_shown() + assert kbw._scratch_tip_sentinel_path().exists() # Warning was logged exactly once. tip_records = [ @@ -1463,7 +1469,7 @@ def test_maybe_emit_scratch_tip_fires_once_per_install(kanban_home, caplog): ) # An event row was appended on the first task. - with kb.connect() as conn: + with kbc.connect() as conn: events = conn.execute( "SELECT kind FROM task_events WHERE task_id = ? ORDER BY id", (t1,), @@ -1477,8 +1483,8 @@ def test_maybe_emit_scratch_tip_fires_once_per_install(kanban_home, caplog): # Second scratch materialization on the same install stays silent. caplog.clear() with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"): - with kb.connect() as conn: - kb._maybe_emit_scratch_tip(conn, t2, "scratch") + with kbc.connect() as conn: + kbw._maybe_emit_scratch_tip(conn, t2, "scratch") tip_records2 = [ r for r in caplog.records if "scratch workspaces are ephemeral" in r.getMessage() @@ -1487,7 +1493,7 @@ def test_maybe_emit_scratch_tip_fires_once_per_install(kanban_home, caplog): f"Tip should not re-fire after sentinel is set; got " f"{[r.getMessage() for r in tip_records2]!r}" ) - with kb.connect() as conn: + with kbc.connect() as conn: events2 = conn.execute( "SELECT kind FROM task_events WHERE task_id = ? ORDER BY id", (t2,), @@ -1508,7 +1514,7 @@ def test_connect_sets_secure_delete_on(tmp_path): """secure_delete=ON must be active on every new connection.""" db_path = tmp_path / "kanban.db" kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) - with kb.connect(db_path=db_path) as conn: + with kbc.connect(db_path=db_path) as conn: row = conn.execute("PRAGMA secure_delete").fetchone() assert row[0] == 1, f"expected secure_delete=1, got {row[0]}" @@ -1558,7 +1564,7 @@ def test_write_txn_preserves_original_exception_when_rollback_fails(kanban_home) def __getattr__(self, name): return getattr(self._real, name) - with kb.connect() as conn: + with kbc.connect() as conn: wrapper = FailingConnWrapper(conn) with pytest.raises(sqlite3.OperationalError) as excinfo: with kb.write_txn(wrapper): @@ -1586,7 +1592,7 @@ def test_write_txn_check_reads_correct_header_fields(tmp_path): way the file must never come back clean. """ import struct - from hermes_cli.kanban_db import connect + from hermes_cli.kanban_db_connect import connect from hermes_cli.sqlite_safe_read import file_length_matches_header db = tmp_path / "synthetic.db" @@ -1645,7 +1651,7 @@ def test_bare_connect_does_not_close_on_context_exit(tmp_path): """ db_path = tmp_path / "kanban.db" kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) - with kb.connect(db_path=db_path) as conn: + with kbc.connect(db_path=db_path) as conn: pass # Still usable after with-block exit (the leak). conn.execute("SELECT 1").fetchone() diff --git a/tests/hermes_cli/test_kanban_db_init.py b/tests/hermes_cli/test_kanban_db_init.py index d5ef1a3f9e..8984994011 100644 --- a/tests/hermes_cli/test_kanban_db_init.py +++ b/tests/hermes_cli/test_kanban_db_init.py @@ -6,6 +6,8 @@ import threading from pathlib import Path from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn def _make_legacy_db(path: Path) -> None: @@ -79,7 +81,7 @@ def test_legacy_text_pk_tables_rebuilt_to_integer_autoincrement(tmp_path, monkey db_path = _setup_home(tmp_path, monkeypatch) _make_legacy_db(db_path) - with kb.connect(db_path) as conn: + with kbc.connect(db_path) as conn: for table in ("task_events", "task_comments", "task_runs"): id_col = {r["name"]: r for r in conn.execute(f"PRAGMA table_info({table})")}["id"] assert id_col["type"].upper() == "INTEGER" and id_col["pk"] == 1 @@ -114,10 +116,10 @@ def test_migration_is_idempotent(tmp_path, monkeypatch): db_path = _setup_home(tmp_path, monkeypatch) _make_legacy_db(db_path) - with kb.connect(db_path): + with kbc.connect(db_path): pass kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) - with kb.connect(db_path) as conn: + with kbc.connect(db_path) as conn: id_col = {r["name"]: r for r in conn.execute("PRAGMA table_info(task_events)")}["id"] assert id_col["type"].upper() == "INTEGER" assert len(conn.execute("SELECT * FROM task_events").fetchall()) == 2 @@ -129,8 +131,8 @@ def test_unseen_events_for_sub_survives_migrated_db(tmp_path, monkeypatch): db_path = _setup_home(tmp_path, monkeypatch) _make_legacy_db(db_path) - with kb.connect(db_path) as conn: - cursor, events = kb.unseen_events_for_sub( + with kbc.connect(db_path) as conn: + cursor, events = kbn.unseen_events_for_sub( conn, task_id="task-1", platform="telegram", chat_id="123" ) assert isinstance(cursor, int) @@ -169,7 +171,7 @@ def test_connect_reinitializes_schema_when_db_file_vanished(tmp_path, monkeypatc """ db_path = _default_board_db(tmp_path, monkeypatch) - with kb.connect_closing(db_path) as conn: + with kbc.connect_closing(db_path) as conn: conn.execute( "INSERT INTO tasks (id, title, status, created_at) VALUES ('t-1', 'T', 'ready', 1000)" ) @@ -181,7 +183,7 @@ def test_connect_reinitializes_schema_when_db_file_vanished(tmp_path, monkeypatc for suffix in ("", "-wal", "-shm"): db_path.with_name(db_path.name + suffix).unlink(missing_ok=True) - with kb.connect_closing(db_path) as conn: + with kbc.connect_closing(db_path) as conn: assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 0 assert "tasks" in _tables(db_path) @@ -191,7 +193,7 @@ def test_connect_reinitializes_schema_when_db_replaced_by_empty_file(tmp_path, m header and the integrity probes, but carries no schema at all.""" db_path = _default_board_db(tmp_path, monkeypatch) - with kb.connect_closing(db_path): + with kbc.connect_closing(db_path): pass for suffix in ("", "-wal", "-shm"): @@ -199,7 +201,7 @@ def test_connect_reinitializes_schema_when_db_replaced_by_empty_file(tmp_path, m sqlite3.connect(str(db_path)).close() assert "tasks" not in _tables(db_path) - with kb.connect_closing(db_path) as conn: + with kbc.connect_closing(db_path) as conn: conn.execute( "INSERT INTO tasks (id, title, status, created_at) VALUES ('t-2', 'T', 'ready', 1000)" ) @@ -213,11 +215,11 @@ def test_healthy_fast_path_stays_lock_free(tmp_path, monkeypatch): the schema is actually gone.""" db_path = _default_board_db(tmp_path, monkeypatch) - with kb.connect_closing(db_path): + with kbc.connect_closing(db_path): pass locks: list[Path] = [] - real_lock = kb._cross_process_init_lock + real_lock = kbc._cross_process_init_lock @contextlib.contextmanager def recording_lock(path): @@ -225,13 +227,13 @@ def test_healthy_fast_path_stays_lock_free(tmp_path, monkeypatch): with real_lock(path): yield - monkeypatch.setattr(kb, "_cross_process_init_lock", recording_lock) + monkeypatch.setattr(kbc, "_cross_process_init_lock", recording_lock) - with kb.connect_closing(db_path): + with kbc.connect_closing(db_path): pass assert locks == [] db_path.unlink() - with kb.connect_closing(db_path): + with kbc.connect_closing(db_path): pass assert len(locks) == 1 diff --git a/tests/hermes_cli/test_kanban_db_repair.py b/tests/hermes_cli/test_kanban_db_repair.py index 11d173adb6..9a515f6376 100644 --- a/tests/hermes_cli/test_kanban_db_repair.py +++ b/tests/hermes_cli/test_kanban_db_repair.py @@ -10,6 +10,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd # --------------------------------------------------------------------------- @@ -20,7 +22,7 @@ def _build_board_db(db_path: Path, tasks: int = 12) -> None: """Create a real board DB with data so indexes have entries.""" kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) kb.init_db(db_path=db_path) - with kb.connect(db_path=db_path) as conn: + with kbc.connect(db_path=db_path) as conn: for i in range(tasks): kb.create_task(conn, title=f"task-{i}") conn.close() @@ -102,10 +104,10 @@ def test_connect_auto_repairs_index_only_corruption(tmp_path, caplog): # Precondition: the fixture really produced the index-scoped class. messages = _integrity_messages(db_path) assert any(m.startswith("wrong # of entries in index") for m in messages) - assert kb._repairable_index_names(messages) == ["idx_tasks_status"] + assert kbc._repairable_index_names(messages) == ["idx_tasks_status"] with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"): - conn = kb.connect(db_path=db_path) + conn = kbc.connect(db_path=db_path) try: # DB is clean again and data survived. row = conn.execute("PRAGMA integrity_check").fetchone() @@ -143,7 +145,7 @@ def test_corrupt_backup_retention_cap_prunes_oldest(tmp_path, monkeypatch): board whose file keeps changing between failures grows one backup per mutation. The cap keeps only the newest ``_CORRUPT_BACKUP_RETENTION``. """ - monkeypatch.setattr(kb, "_CORRUPT_BACKUP_RETENTION", 3) + monkeypatch.setattr(kbc, "_CORRUPT_BACKUP_RETENTION", 3) db_path = tmp_path / "kanban.db" _write_page_corrupt_db(db_path) @@ -154,8 +156,8 @@ def test_corrupt_backup_retention_cap_prunes_oldest(tmp_path, monkeypatch): fh.seek(200) fh.write(bytes([i]) * 16) kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) - with pytest.raises(kb.KanbanDbCorruptError) as excinfo: - kb.connect(db_path=db_path) + with pytest.raises(kbc.KanbanDbCorruptError) as excinfo: + kbc.connect(db_path=db_path) assert excinfo.value.backup_path is not None minted.append(excinfo.value.backup_path) # The just-created backup always survives its own prune pass. @@ -207,25 +209,25 @@ def test_dispatch_tick_runs_wal_checkpoint_at_interval(tmp_path, monkeypatch): _build_board_db(db_path, tasks=1) monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) # Fresh per-path clock so previous tests can't have claimed the slot. - monkeypatch.setattr(kb, "_LAST_WAL_CHECKPOINT", {}) + monkeypatch.setattr(kbc, "_LAST_WAL_CHECKPOINT", {}) executed: list[str] = [] - conn = kb.connect(db_path=db_path) + conn = kbc.connect(db_path=db_path) proxy = _ConnProxy(conn, executed) try: - kb.dispatch_once(proxy, spawn_fn=lambda *a, **k: None, dry_run=True) + kbd.dispatch_once(proxy, spawn_fn=lambda *a, **k: None, dry_run=True) assert len(executed) == 1, "first tick should checkpoint" - kb.dispatch_once(proxy, spawn_fn=lambda *a, **k: None, dry_run=True) - kb.dispatch_once(proxy, spawn_fn=lambda *a, **k: None, dry_run=True) + kbd.dispatch_once(proxy, spawn_fn=lambda *a, **k: None, dry_run=True) + kbd.dispatch_once(proxy, spawn_fn=lambda *a, **k: None, dry_run=True) assert len(executed) == 1, "ticks inside the interval must not checkpoint" # Age the per-path timestamp past the interval → next tick fires. key = str(db_path.resolve()) - kb._LAST_WAL_CHECKPOINT[key] -= ( - kb._WAL_CHECKPOINT_INTERVAL_SECONDS + 1.0 + kbc._LAST_WAL_CHECKPOINT[key] -= ( + kbc._WAL_CHECKPOINT_INTERVAL_SECONDS + 1.0 ) - kb.dispatch_once(proxy, spawn_fn=lambda *a, **k: None, dry_run=True) + kbd.dispatch_once(proxy, spawn_fn=lambda *a, **k: None, dry_run=True) assert len(executed) == 2, "tick after the interval should checkpoint" # PASSIVE, not TRUNCATE: CLI kanban commands in other processes write # to the same board without holding the dispatch flock, so a TRUNCATE diff --git a/tests/hermes_cli/test_kanban_decompose.py b/tests/hermes_cli/test_kanban_decompose.py index 31303b8d52..0b5f57489e 100644 --- a/tests/hermes_cli/test_kanban_decompose.py +++ b/tests/hermes_cli/test_kanban_decompose.py @@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli import kanban_decompose as decomp @@ -76,7 +77,7 @@ def _patch_list_profiles(names: list[str]): def test_decompose_with_fanout_creates_children(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="ship a feature", triage=True) llm_payload = jsonlib.dumps({ @@ -102,7 +103,7 @@ def test_decompose_with_fanout_creates_children(kanban_home): assert outcome.fanout is True assert outcome.child_ids and len(outcome.child_ids) == 2 - with kb.connect() as conn: + with kbc.connect() as conn: root = kb.get_task(conn, tid) c0 = kb.get_task(conn, outcome.child_ids[0]) c1 = kb.get_task(conn, outcome.child_ids[1]) @@ -114,7 +115,7 @@ def test_decompose_with_fanout_creates_children(kanban_home): def test_decompose_fanout_false_invalid_llm_assignee_uses_default(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="route me safely", triage=True) llm_payload = jsonlib.dumps({ @@ -139,14 +140,14 @@ def test_decompose_fanout_false_invalid_llm_assignee_uses_default(kanban_home): p.stop() assert outcome.ok, outcome.reason - with kb.connect() as conn: + with kbc.connect() as conn: task = kb.get_task(conn, tid) assert task is not None assert task.assignee == "fallback" def test_decompose_returns_false_when_task_not_triage(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="x") # ready, not triage patches = _patch_list_profiles(["orchestrator"]) diff --git a/tests/hermes_cli/test_kanban_decompose_db.py b/tests/hermes_cli/test_kanban_decompose_db.py index f902c16d08..73109419e6 100644 --- a/tests/hermes_cli/test_kanban_decompose_db.py +++ b/tests/hermes_cli/test_kanban_decompose_db.py @@ -9,6 +9,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -33,7 +34,7 @@ def _create_triage(conn, title="rough idea", body=None, assignee=None, tenant=No def test_decompose_creates_children_and_promotes_root(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: tid = _create_triage(conn, title="ship a feature") assert kb.get_task(conn, tid).status == "triage" @@ -41,7 +42,7 @@ def test_decompose_creates_children_and_promotes_root(kanban_home): {"title": "research", "body": "look at prior art", "assignee": "researcher", "parents": []}, {"title": "build it", "body": "write code", "assignee": "engineer", "parents": [0]}, ] - with kb.connect() as conn: + with kbc.connect() as conn: child_ids = kb.decompose_triage_task( conn, tid, @@ -52,7 +53,7 @@ def test_decompose_creates_children_and_promotes_root(kanban_home): assert child_ids is not None assert len(child_ids) == 2 - with kb.connect() as conn: + with kbc.connect() as conn: root = kb.get_task(conn, tid) c0 = kb.get_task(conn, child_ids[0]) c1 = kb.get_task(conn, child_ids[1]) @@ -69,7 +70,7 @@ def test_decompose_creates_children_and_promotes_root(kanban_home): def test_decompose_records_audit_comment_and_event(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: tid = _create_triage(conn) child_ids = kb.decompose_triage_task( conn, @@ -80,7 +81,7 @@ def test_decompose_records_audit_comment_and_event(kanban_home): ) assert child_ids is not None - with kb.connect() as conn: + with kbc.connect() as conn: comments = kb.list_comments(conn, tid) events = kb.list_events(conn, tid) diff --git a/tests/hermes_cli/test_kanban_default_assignee.py b/tests/hermes_cli/test_kanban_default_assignee.py index d610f2a42e..a56324c026 100644 --- a/tests/hermes_cli/test_kanban_default_assignee.py +++ b/tests/hermes_cli/test_kanban_default_assignee.py @@ -42,11 +42,13 @@ def test_unassigned_task_auto_assigned_with_default_assignee(isolated_kanban_hom task gets the assignment applied and dispatched on the same tick. The DB row is mutated (assignee column + an 'assigned' event).""" kb, _home = isolated_kanban_home - with kb.connect_closing() as conn: + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_dispatch as kbd + with kbc.connect_closing() as conn: kb.create_board(slug="default", name="Test") task_id = kb.create_task(conn, title="t1", assignee=None) - with kb.connect_closing() as conn: - res = kb.dispatch_once( + with kbc.connect_closing() as conn: + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn, dry_run=False, default_assignee="default", ) @@ -56,12 +58,12 @@ def test_unassigned_task_auto_assigned_with_default_assignee(isolated_kanban_hom assert res.spawned[0][0] == task_id assert res.spawned[0][1] == "default" - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: row = conn.execute("SELECT assignee FROM tasks WHERE id = ?", (task_id,)).fetchone() assert row["assignee"] == "default" # 'assigned' event emitted for the audit trail - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: evs = list(conn.execute( "SELECT kind, payload FROM task_events WHERE task_id = ? AND kind = 'assigned'", (task_id,), @@ -81,11 +83,13 @@ def test_explicitly_assigned_task_untouched_by_default_assignee(isolated_kanban_ default_assignee logic — that fallback only applies to genuinely unassigned rows.""" kb, _home = isolated_kanban_home - with kb.connect_closing() as conn: + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_dispatch as kbd + with kbc.connect_closing() as conn: kb.create_board(slug="default", name="Test") task_id = kb.create_task(conn, title="t1", assignee="default") - with kb.connect_closing() as conn: - res = kb.dispatch_once( + with kbc.connect_closing() as conn: + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn, dry_run=False, default_assignee="someother", ) diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py index 89aaa589e6..af9fc1696b 100644 --- a/tests/hermes_cli/test_kanban_diagnostics.py +++ b/tests/hermes_cli/test_kanban_diagnostics.py @@ -15,6 +15,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli import kanban_diagnostics as kd @@ -138,7 +139,7 @@ def test_engine_works_on_sqlite_row_objects(kanban_home): as well as dataclass Task / plain dict. The API layer passes Row objects directly. """ - conn = kb.connect() + conn = kbc.connect() try: parent = kb.create_task(conn, title="p", assignee="w") real = kb.create_task(conn, title="r", assignee="x", created_by="w") diff --git a/tests/hermes_cli/test_kanban_dispatch_lock.py b/tests/hermes_cli/test_kanban_dispatch_lock.py index cec5a83ea2..b358194c31 100644 --- a/tests/hermes_cli/test_kanban_dispatch_lock.py +++ b/tests/hermes_cli/test_kanban_dispatch_lock.py @@ -17,6 +17,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd @pytest.fixture @@ -34,7 +36,7 @@ def kanban_home(tmp_path, monkeypatch): @pytest.fixture def conn(kanban_home): - with kb.connect() as c: + with kbc.connect() as c: yield c @@ -53,9 +55,9 @@ def test_held_lock_skips_the_tick_without_writes(conn): return 999999 # Hold the lock, then attempt a contended tick. - with kb._dispatch_tick_lock(db_path) as held: + with kbc._dispatch_tick_lock(db_path) as held: assert held is True # we genuinely acquired it - result = kb.dispatch_once(conn, spawn_fn=spy_spawn) + result = kbd.dispatch_once(conn, spawn_fn=spy_spawn) assert result.skipped_locked is True assert result.spawned == [] @@ -71,9 +73,9 @@ def test_lock_is_board_scoped(conn): db_other = db_default.with_name("other-board-kanban.db") # Two different lock files → both acquirable simultaneously. - with kb._dispatch_tick_lock(db_default) as held_a: + with kbc._dispatch_tick_lock(db_default) as held_a: assert held_a is True - with kb._dispatch_tick_lock(db_other) as held_b: + with kbc._dispatch_tick_lock(db_other) as held_b: assert held_b is True, "a lock on a different board must be independent" diff --git a/tests/hermes_cli/test_kanban_dispatch_tick_hook.py b/tests/hermes_cli/test_kanban_dispatch_tick_hook.py index fd34575a02..d02e101ea2 100644 --- a/tests/hermes_cli/test_kanban_dispatch_tick_hook.py +++ b/tests/hermes_cli/test_kanban_dispatch_tick_hook.py @@ -17,6 +17,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd from hermes_cli.plugins import VALID_HOOKS, get_plugin_manager @@ -48,10 +50,10 @@ def test_active_tick_fires_hook_with_outcome_ok( kanban_home, all_assignees_spawnable, captured_ticks, ): """A tick that spawns a worker fires the hook with outcome='ok'.""" - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="alice") - kb.dispatch_once(conn, spawn_fn=lambda *a, **k: 4242) + kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: 4242) finally: conn.close() ok_events = [kw for kw in captured_ticks if kw["outcome"] == "ok"] @@ -73,14 +75,14 @@ def test_tick_hook_fires_after_dispatch_lock_released(kanban_home): acquired: list[bool] = [] def _probe_lock(**kw): - with kb._dispatch_tick_lock(db_path) as held: + with kbc._dispatch_tick_lock(db_path) as held: acquired.append(held) mgr._hooks.setdefault("on_kanban_dispatch_tick", []).append(_probe_lock) try: - conn = kb.connect() + conn = kbc.connect() try: - kb.dispatch_once(conn, spawn_fn=lambda *a, **k: 1) + kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: 1) finally: conn.close() finally: @@ -98,9 +100,9 @@ def test_misbehaving_subscriber_does_not_break_dispatcher(kanban_home): mgr._hooks.setdefault("on_kanban_dispatch_tick", []).append(_boom) try: - conn = kb.connect() + conn = kbc.connect() try: - result = kb.dispatch_once(conn, spawn_fn=lambda *a, **k: 1) + result = kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: 1) assert isinstance(result, kb.DispatchResult) finally: conn.close() @@ -120,9 +122,9 @@ def test_no_subscriber_short_circuits_tick_hook(kanban_home, monkeypatch): return real_invoke(hook_name, **kw) monkeypatch.setattr(lifecycle, "invoke_hook", _spy) - conn = kb.connect() + conn = kbc.connect() try: - kb.dispatch_once(conn, spawn_fn=lambda *a, **k: 1) + kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: 1) finally: conn.close() assert "on_kanban_dispatch_tick" not in invoked diff --git a/tests/hermes_cli/test_kanban_gateway_restart_handoff.py b/tests/hermes_cli/test_kanban_gateway_restart_handoff.py index d0efb6ed89..3b7bd85cfc 100644 --- a/tests/hermes_cli/test_kanban_gateway_restart_handoff.py +++ b/tests/hermes_cli/test_kanban_gateway_restart_handoff.py @@ -11,6 +11,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_dispatch as kbd @pytest.fixture @@ -22,7 +23,7 @@ def worker_setup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, profile.joinpath("config.yaml").write_text("{}\n", encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(root)) monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"]) + monkeypatch.setattr(kbd, "_resolve_hermes_argv", lambda: ["hermes"]) workspace = tmp_path / "candidate-worktree" workspace.mkdir() @@ -76,7 +77,7 @@ def test_managed_gateway_worker_is_spawned_in_restart_safe_scope( monkeypatch.setattr("tools.process_registry._worker_memory_max_bytes", lambda: 536_870_912) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run") - assert kb._default_spawn(task, str(workspace)) == 4242 + assert kbd._default_spawn(task, str(workspace)) == 4242 assert captured_cmd[:4] == ["/usr/bin/systemd-run", "--user", "--scope", "--quiet"] unit_index = captured_cmd.index("--unit") assert captured_cmd[unit_index + 1] == "hermes-worker-kanban-t_candidate_restart-run-23" @@ -101,7 +102,7 @@ def test_managed_gateway_worker_spawn_fails_closed_without_scope( monkeypatch.setattr("tools.process_registry._systemd_run_user_scope_available", lambda: False) with pytest.raises(RuntimeError, match="restart-safe systemd scope"): - kb._default_spawn(task, str(workspace)) + kbd._default_spawn(task, str(workspace)) assert popen_calls == [] @@ -117,7 +118,7 @@ def test_managed_gateway_scope_builder_fails_closed_if_binary_disappears( monkeypatch.setattr(subprocess, "Popen", lambda *_args, **_kwargs: pytest.fail("unsafe direct spawn")) with pytest.raises(RuntimeError, match="restart-safe systemd scope"): - kb._default_spawn(task, str(workspace)) + kbd._default_spawn(task, str(workspace)) def test_standalone_dispatcher_keeps_direct_worker_spawn( @@ -136,7 +137,7 @@ def test_standalone_dispatcher_keeps_direct_worker_spawn( lambda: pytest.fail("scope probe must not run outside managed gateway"), ) - assert kb._default_spawn(task, str(workspace)) == 4243 + assert kbd._default_spawn(task, str(workspace)) == 4243 assert captured_cmd[:3] == ["hermes", "-p", "coder"] @@ -159,11 +160,11 @@ def test_real_user_systemd_scope_preserves_worker_context( "'run': os.environ.get('HERMES_KANBAN_RUN_ID'), " "'cgroup': pathlib.Path('/proc/self/cgroup').read_text()})); time.sleep(0.5)" ) - monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: [sys.executable, "-c", script, str(receipt)]) + monkeypatch.setattr(kbd, "_resolve_hermes_argv", lambda: [sys.executable, "-c", script, str(receipt)]) monkeypatch.setenv("INVOCATION_ID", "managed-gateway-test") monkeypatch.setattr(process_registry, "_is_supervised_gateway_process", lambda: True) - pid = kb._default_spawn(task, str(workspace)) + pid = kbd._default_spawn(task, str(workspace)) deadline = time.monotonic() + 5 while not receipt.exists() and time.monotonic() < deadline: time.sleep(0.05) diff --git a/tests/hermes_cli/test_kanban_goal_mode.py b/tests/hermes_cli/test_kanban_goal_mode.py index 00616bfd7d..14a713e3f3 100644 --- a/tests/hermes_cli/test_kanban_goal_mode.py +++ b/tests/hermes_cli/test_kanban_goal_mode.py @@ -18,6 +18,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli import goals @@ -79,7 +80,7 @@ def test_legacy_db_migrates_goal_columns(tmp_path, monkeypatch): # init_db runs the additive migration. kb.init_db() - with kb.connect() as conn: + with kbc.connect() as conn: cols = {r["name"] for r in conn.execute("PRAGMA table_info(tasks)")} assert "goal_mode" in cols assert "goal_max_turns" in cols @@ -172,7 +173,7 @@ class TestCLIJudgeGate: monkeypatch.setattr("hermes_cli.kanban.kb.get_task", lambda conn, tid: fake_task) monkeypatch.setattr("hermes_cli.kanban.kb.complete_task", fake_complete_task) - monkeypatch.setattr("hermes_cli.kanban.kb.connect_closing", fake_connect_closing) + monkeypatch.setattr("hermes_cli.kanban.kbc.connect_closing", fake_connect_closing) monkeypatch.setattr("hermes_cli.kanban._worker_run_id_for", lambda _: None) _aux_client = (object(), "judge-model") if judge_available else (None, None) diff --git a/tests/hermes_cli/test_kanban_host_cap.py b/tests/hermes_cli/test_kanban_host_cap.py index 61de23da9e..ad0633bea0 100644 --- a/tests/hermes_cli/test_kanban_host_cap.py +++ b/tests/hermes_cli/test_kanban_host_cap.py @@ -3,7 +3,7 @@ Three gaps found in review of the original memory-guard PR: 1. The standalone daemon path (``hermes kanban daemon --force`` / - :func:`hermes_cli.kanban_db.run_daemon`) never resolved + :func:`hermes_cli.kanban_db_dispatch.run_daemon`) never resolved ``kanban.max_in_progress`` at all — the one shipped entry point that could still fan out an entire backlog in a single tick. 2. ``max_in_progress`` was enforced per-board while the gateway dispatcher @@ -22,6 +22,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_dispatch as kbd +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -69,15 +71,15 @@ def test_run_daemon_resolves_and_passes_max_in_progress( captured.update(kwargs) return kb.DispatchResult() - monkeypatch.setattr(kb, "dispatch_once", fake_dispatch_once) + monkeypatch.setattr(kbd, "dispatch_once", fake_dispatch_once) # No explicit config → the derived default must flow through. - monkeypatch.setattr(kb, "configured_max_in_progress", lambda: None) - monkeypatch.setattr(kb, "derive_default_max_in_progress", lambda sample=None: 3) + monkeypatch.setattr(kbd, "configured_max_in_progress", lambda: None) + monkeypatch.setattr(kbd, "derive_default_max_in_progress", lambda sample=None: 3) def on_tick(res): stop.set() - kb.run_daemon(interval=0.01, stop_event=stop, on_tick=on_tick) + kbd.run_daemon(interval=0.01, stop_event=stop, on_tick=on_tick) assert captured.get("max_in_progress") == 3 @@ -90,17 +92,17 @@ def test_run_daemon_explicit_config_wins(kanban_home, monkeypatch): captured.update(kwargs) return kb.DispatchResult() - monkeypatch.setattr(kb, "dispatch_once", fake_dispatch_once) - monkeypatch.setattr(kb, "configured_max_in_progress", lambda: 7) + monkeypatch.setattr(kbd, "dispatch_once", fake_dispatch_once) + monkeypatch.setattr(kbd, "configured_max_in_progress", lambda: 7) monkeypatch.setattr( - kb, "derive_default_max_in_progress", + kbd, "derive_default_max_in_progress", lambda sample=None: pytest.fail("derived default must not be consulted"), ) def on_tick(res): stop.set() - kb.run_daemon(interval=0.01, stop_event=stop, on_tick=on_tick) + kbd.run_daemon(interval=0.01, stop_event=stop, on_tick=on_tick) assert captured.get("max_in_progress") == 7 @@ -121,7 +123,7 @@ def test_configured_max_in_progress_parsing(monkeypatch): monkeypatch.setattr( cfgmod, "load_config_readonly", lambda c=config: c ) - assert kb.configured_max_in_progress() == expected, config + assert kbd.configured_max_in_progress() == expected, config # --------------------------------------------------------------------------- @@ -136,15 +138,15 @@ def test_max_in_progress_counts_other_boards( kb.create_board("second") # Two workers already running on the second board. - with kb.connect(board="second") as conn: + with kbc.connect(board="second") as conn: for title in ("busy-1", "busy-2"): tid = kb.create_task(conn, title=title, assignee="alice") assert kb.claim_task(conn, tid) is not None spawns: list = [] - with kb.connect() as conn: + with kbc.connect() as conn: kb.create_task(conn, title="wants-to-run", assignee="alice") - res = kb.dispatch_once( + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn_factory(spawns), max_in_progress=2, ) @@ -158,15 +160,15 @@ def test_max_in_progress_partial_budget_across_boards( ): kb.create_board("second") - with kb.connect(board="second") as conn: + with kbc.connect(board="second") as conn: tid = kb.create_task(conn, title="busy", assignee="alice") assert kb.claim_task(conn, tid) is not None spawns: list = [] - with kb.connect() as conn: + with kbc.connect() as conn: for title in ("a", "b", "c"): kb.create_task(conn, title=title, assignee="alice") - res = kb.dispatch_once( + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn_factory(spawns), max_in_progress=2, ) @@ -183,20 +185,20 @@ def test_count_running_tasks_other_boards_fails_open( kb, "list_boards", lambda **k: (_ for _ in ()).throw(RuntimeError("boom")), ) - assert kb.count_running_tasks_other_boards() == 0 + assert kbd.count_running_tasks_other_boards() == 0 def test_max_spawn_stays_per_board(kanban_home, all_assignees_spawnable): """``max_spawn`` keeps its historical per-board semantics.""" kb.create_board("second") - with kb.connect(board="second") as conn: + with kbc.connect(board="second") as conn: tid = kb.create_task(conn, title="busy", assignee="alice") assert kb.claim_task(conn, tid) is not None spawns: list = [] - with kb.connect() as conn: + with kbc.connect() as conn: kb.create_task(conn, title="a", assignee="alice") - res = kb.dispatch_once( + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn_factory(spawns), max_spawn=1, ) @@ -226,11 +228,11 @@ def test_review_lane_gets_reserved_slot_under_ready_backlog( ) spawns: list = [] - with kb.connect() as conn: + with kbc.connect() as conn: for title in ("ready-1", "ready-2", "ready-3"): kb.create_task(conn, title=title, assignee="alice") review_id = _park_in_review(conn, "review-me", "reviewer") - res = kb.dispatch_once( + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn_factory(spawns), max_in_progress=2, ) @@ -250,10 +252,10 @@ def test_review_reservation_released_when_no_review_work( ) spawns: list = [] - with kb.connect() as conn: + with kbc.connect() as conn: for title in ("ready-1", "ready-2", "ready-3"): kb.create_task(conn, title=title, assignee="alice") - res = kb.dispatch_once( + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn_factory(spawns), max_in_progress=2, ) @@ -278,11 +280,11 @@ def test_nonspawnable_review_does_not_tax_ready_budget( ) spawns: list = [] - with kb.connect() as conn: + with kbc.connect() as conn: for title in ("ready-1", "ready-2"): kb.create_task(conn, title=title, assignee="alice") _park_in_review(conn, "human-review", "some-human") - res = kb.dispatch_once( + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn_factory(spawns), max_in_progress=2, ) @@ -301,11 +303,11 @@ def test_review_budget_still_bounded_by_shared_cap( ) spawns: list = [] - with kb.connect() as conn: + with kbc.connect() as conn: kb.create_task(conn, title="ready-1", assignee="alice") for i in range(3): _park_in_review(conn, f"review-{i}", "reviewer") - res = kb.dispatch_once( + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn_factory(spawns), max_in_progress=2, ) diff --git a/tests/hermes_cli/test_kanban_init_lock_bounded.py b/tests/hermes_cli/test_kanban_init_lock_bounded.py index 38c5782713..ac7814707a 100644 --- a/tests/hermes_cli/test_kanban_init_lock_bounded.py +++ b/tests/hermes_cli/test_kanban_init_lock_bounded.py @@ -23,6 +23,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -43,7 +44,7 @@ def _hold_init_lock(db_path: Path): release = threading.Event() def _holder(): - with kb._cross_process_init_lock(db_path): + with kbc._cross_process_init_lock(db_path): holding.set() release.wait(timeout=10) @@ -57,14 +58,14 @@ def test_initialized_path_connect_skips_init_lock(kanban_home): """A connect to an already-initialized path must not block on the init lock.""" db_path = kb.kanban_db_path(board="default") # Initialize once. - kb.connect().close() + kbc.connect().close() assert str(db_path.resolve()) in kb._INITIALIZED_PATHS # Hold the init lock; a fast-path connect must return promptly anyway. release, t = _hold_init_lock(db_path) try: start = time.monotonic() - kb.connect().close() + kbc.connect().close() elapsed = time.monotonic() - start assert elapsed < 5.0, f"fast-path connect blocked on the init lock ({elapsed:.2f}s)" finally: @@ -75,13 +76,13 @@ def test_initialized_path_connect_skips_init_lock(kanban_home): def test_first_init_connect_is_bounded_when_lock_held(kanban_home, monkeypatch): """First-init connect must time out the cross-process lock and proceed, not hang forever, when another holder owns it.""" - monkeypatch.setattr(kb, "_INIT_LOCK_TIMEOUT_SECONDS", 0.6) + monkeypatch.setattr(kbc, "_INIT_LOCK_TIMEOUT_SECONDS", 0.6) db_path = kb.kanban_db_path(board="default") release, t = _hold_init_lock(db_path) try: start = time.monotonic() - conn = kb.connect() # path NOT yet initialized — must take the bounded path + conn = kbc.connect() # path NOT yet initialized — must take the bounded path conn.close() elapsed = time.monotonic() - start # Proceeded within roughly the timeout window (not unbounded). diff --git a/tests/hermes_cli/test_kanban_lifecycle_hooks.py b/tests/hermes_cli/test_kanban_lifecycle_hooks.py index d21ba16b6a..da8dbecb5d 100644 --- a/tests/hermes_cli/test_kanban_lifecycle_hooks.py +++ b/tests/hermes_cli/test_kanban_lifecycle_hooks.py @@ -13,6 +13,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli.plugins import VALID_HOOKS, get_plugin_manager @@ -49,7 +50,7 @@ def captured_hooks(monkeypatch): def test_claim_fires_hook(kanban_home, captured_hooks): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="worker") claimed = kb.claim_task(conn, tid) @@ -77,7 +78,7 @@ def test_misbehaving_hook_does_not_break_transition(kanban_home, monkeypatch): mgr._hooks.setdefault("kanban_task_completed", []).append(_boom) try: - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="worker") kb.claim_task(conn, tid) diff --git a/tests/hermes_cli/test_kanban_memory_guard.py b/tests/hermes_cli/test_kanban_memory_guard.py index ae145e96b6..7ced3ec927 100644 --- a/tests/hermes_cli/test_kanban_memory_guard.py +++ b/tests/hermes_cli/test_kanban_memory_guard.py @@ -7,8 +7,8 @@ and the whole machine (dashboard included) became unreachable. Covers the two safeguards added in response: -1. :func:`hermes_cli.kanban_db.derive_default_max_in_progress` / - :func:`hermes_cli.kanban_db.resolve_max_in_progress` — memory-derived +1. :func:`hermes_cli.kanban_db_dispatch.derive_default_max_in_progress` / + :func:`hermes_cli.kanban_db_dispatch.resolve_max_in_progress` — memory-derived default global concurrency cap when the operator never set one. 2. The live memory-pressure guard inside ``dispatch_once`` — critical pressure spawns nothing; elevated pressure spawns at most one; unknown @@ -22,6 +22,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_dispatch as kbd +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -45,45 +47,45 @@ GIB = 1024 * 1024 # KiB per GiB def test_derived_cap_small_vm_floors_at_two(): # 1 GiB VM (the incident shape): 1024 // 512 = 2 workers. - assert kb.derive_default_max_in_progress({"mem_total_kib": 1 * GIB}) == 2 + assert kbd.derive_default_max_in_progress({"mem_total_kib": 1 * GIB}) == 2 # Even tiny VMs keep a floor of 2 so boards still make progress. - assert kb.derive_default_max_in_progress({"mem_total_kib": GIB // 4}) == 2 + assert kbd.derive_default_max_in_progress({"mem_total_kib": GIB // 4}) == 2 def test_derived_cap_scales_with_memory_and_ceilings(): - assert kb.derive_default_max_in_progress({"mem_total_kib": 2 * GIB}) == 4 - assert kb.derive_default_max_in_progress({"mem_total_kib": 4 * GIB}) == 8 + assert kbd.derive_default_max_in_progress({"mem_total_kib": 2 * GIB}) == 4 + assert kbd.derive_default_max_in_progress({"mem_total_kib": 4 * GIB}) == 8 # Big iron clamps at the ceiling — explicit config is the escape hatch. - assert kb.derive_default_max_in_progress({"mem_total_kib": 64 * GIB}) == 8 + assert kbd.derive_default_max_in_progress({"mem_total_kib": 64 * GIB}) == 8 def test_derived_cap_fails_open_without_memtotal(): - assert kb.derive_default_max_in_progress({}) is None - assert kb.derive_default_max_in_progress({"mem_total_kib": 0}) is None - assert kb.derive_default_max_in_progress({"mem_total_kib": -5}) is None - assert kb.derive_default_max_in_progress({"mem_total_kib": True}) is None - assert kb.derive_default_max_in_progress({"mem_total_kib": "1048576"}) is None + assert kbd.derive_default_max_in_progress({}) is None + assert kbd.derive_default_max_in_progress({"mem_total_kib": 0}) is None + assert kbd.derive_default_max_in_progress({"mem_total_kib": -5}) is None + assert kbd.derive_default_max_in_progress({"mem_total_kib": True}) is None + assert kbd.derive_default_max_in_progress({"mem_total_kib": "1048576"}) is None def test_resolve_max_in_progress_explicit_config_wins(monkeypatch): monkeypatch.setattr( - kb, "_system_memory_sample", lambda: {"mem_total_kib": 1 * GIB} + kbd, "_system_memory_sample", lambda: {"mem_total_kib": 1 * GIB} ) # Operator said 6 on a 1 GiB box — their call, even above the derived 2. - assert kb.resolve_max_in_progress(6) == 6 - assert kb.resolve_max_in_progress(1) == 1 + assert kbd.resolve_max_in_progress(6) == 6 + assert kbd.resolve_max_in_progress(1) == 1 def test_resolve_max_in_progress_derives_when_unset(monkeypatch): monkeypatch.setattr( - kb, "_system_memory_sample", lambda: {"mem_total_kib": 1 * GIB} + kbd, "_system_memory_sample", lambda: {"mem_total_kib": 1 * GIB} ) - assert kb.resolve_max_in_progress(None) == 2 + assert kbd.resolve_max_in_progress(None) == 2 def test_resolve_max_in_progress_unset_and_unknown_memory_is_uncapped(monkeypatch): - monkeypatch.setattr(kb, "_system_memory_sample", lambda: {}) - assert kb.resolve_max_in_progress(None) is None + monkeypatch.setattr(kbd, "_system_memory_sample", lambda: {}) + assert kbd.resolve_max_in_progress(None) is None # --------------------------------------------------------------------------- @@ -92,17 +94,17 @@ def test_resolve_max_in_progress_unset_and_unknown_memory_is_uncapped(monkeypatc def test_pressure_level_unknown_on_empty_sample(monkeypatch): - monkeypatch.setattr(kb, "_system_memory_sample", lambda: {}) - assert kb._memory_pressure_level() == "unknown" + monkeypatch.setattr(kbd, "_system_memory_sample", lambda: {}) + assert kbd._memory_pressure_level() == "unknown" def test_pressure_level_classifies_via_gateway_thresholds(): ok = {"mem_available_kib": GIB // 2, "mem_total_kib": 1 * GIB} critical = {"mem_available_kib": 32 * 1024, "mem_total_kib": 1 * GIB} elevated = {"mem_available_kib": 100 * 1024, "mem_total_kib": 1 * GIB} - assert kb._memory_pressure_level(ok) == "ok" - assert kb._memory_pressure_level(critical) == "critical" - assert kb._memory_pressure_level(elevated) == "elevated" + assert kbd._memory_pressure_level(ok) == "ok" + assert kbd._memory_pressure_level(critical) == "critical" + assert kbd._memory_pressure_level(elevated) == "elevated" # --------------------------------------------------------------------------- @@ -123,7 +125,7 @@ def test_dispatch_spawns_nothing_under_critical_pressure( kanban_home, all_assignees_spawnable, monkeypatch, ): monkeypatch.setattr( - kb, "_system_memory_sample", lambda: _pressure_sample("critical") + kbd, "_system_memory_sample", lambda: _pressure_sample("critical") ) spawns = [] @@ -131,10 +133,10 @@ def test_dispatch_spawns_nothing_under_critical_pressure( spawns.append(task.id) return 42 - with kb.connect() as conn: + with kbc.connect() as conn: for title in ("a", "b", "c"): kb.create_task(conn, title=title, assignee="alice") - res = kb.dispatch_once(conn, spawn_fn=fake_spawn) + res = kbd.dispatch_once(conn, spawn_fn=fake_spawn) assert not spawns assert not res.spawned @@ -146,22 +148,22 @@ def test_dispatch_critical_pressure_defers_not_drops( ): """Tasks skipped under pressure stay 'ready' and spawn once memory clears.""" sample = {"value": _pressure_sample("critical")} - monkeypatch.setattr(kb, "_system_memory_sample", lambda: sample["value"]) + monkeypatch.setattr(kbd, "_system_memory_sample", lambda: sample["value"]) spawns = [] def fake_spawn(task, workspace, board=None): spawns.append(task.id) return 42 - with kb.connect() as conn: + with kbc.connect() as conn: task = kb.create_task(conn, title="a", assignee="alice") - kb.dispatch_once(conn, spawn_fn=fake_spawn) + kbd.dispatch_once(conn, spawn_fn=fake_spawn) assert not spawns row = kb.get_task(conn, task) assert row is not None and row.status == "ready" sample["value"] = _pressure_sample("ok") - res = kb.dispatch_once(conn, spawn_fn=fake_spawn) + res = kbd.dispatch_once(conn, spawn_fn=fake_spawn) assert spawns == [task] assert res.memory_pressure is None @@ -171,7 +173,7 @@ def test_dispatch_elevated_pressure_spawns_at_most_one( kanban_home, all_assignees_spawnable, monkeypatch, ): monkeypatch.setattr( - kb, "_system_memory_sample", lambda: _pressure_sample("elevated") + kbd, "_system_memory_sample", lambda: _pressure_sample("elevated") ) spawns = [] @@ -179,10 +181,10 @@ def test_dispatch_elevated_pressure_spawns_at_most_one( spawns.append(task.id) return 42 - with kb.connect() as conn: + with kbc.connect() as conn: for title in ("a", "b", "c"): kb.create_task(conn, title=title, assignee="alice") - res = kb.dispatch_once(conn, spawn_fn=fake_spawn) + res = kbd.dispatch_once(conn, spawn_fn=fake_spawn) assert len(spawns) == 1 assert res.memory_pressure == "elevated" @@ -193,7 +195,7 @@ def test_dispatch_elevated_pressure_does_not_widen_tighter_budget( ): """A caller cap already at 0 remaining must not be widened to 1.""" monkeypatch.setattr( - kb, "_system_memory_sample", lambda: _pressure_sample("elevated") + kbd, "_system_memory_sample", lambda: _pressure_sample("elevated") ) spawns = [] @@ -201,11 +203,11 @@ def test_dispatch_elevated_pressure_does_not_widen_tighter_budget( spawns.append(task.id) return 42 - with kb.connect() as conn: + with kbc.connect() as conn: running = kb.create_task(conn, title="running", assignee="alice") kb.claim_task(conn, running) kb.create_task(conn, title="ready", assignee="bob") - res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_in_progress=1) + res = kbd.dispatch_once(conn, spawn_fn=fake_spawn, max_in_progress=1) assert not spawns assert not res.spawned @@ -214,17 +216,17 @@ def test_dispatch_elevated_pressure_does_not_widen_tighter_budget( def test_dispatch_unknown_pressure_imposes_no_restriction( kanban_home, all_assignees_spawnable, monkeypatch, ): - monkeypatch.setattr(kb, "_system_memory_sample", lambda: {}) + monkeypatch.setattr(kbd, "_system_memory_sample", lambda: {}) spawns = [] def fake_spawn(task, workspace, board=None): spawns.append(task.id) return 42 - with kb.connect() as conn: + with kbc.connect() as conn: for title in ("a", "b", "c"): kb.create_task(conn, title=title, assignee="alice") - res = kb.dispatch_once(conn, spawn_fn=fake_spawn) + res = kbd.dispatch_once(conn, spawn_fn=fake_spawn) assert len(spawns) == 3 assert res.memory_pressure is None @@ -235,15 +237,15 @@ def test_dispatch_critical_pressure_still_runs_reclaim_bookkeeping( ): """The guard must only stop NEW spawns — reclaim/promotion still run.""" monkeypatch.setattr( - kb, "_system_memory_sample", lambda: _pressure_sample("critical") + kbd, "_system_memory_sample", lambda: _pressure_sample("critical") ) - with kb.connect() as conn: + with kbc.connect() as conn: parent = kb.create_task(conn, title="parent", assignee="alice") child = kb.create_task( conn, title="child", assignee="alice", parents=[parent], ) conn.execute("UPDATE tasks SET status = 'done' WHERE id = ?", (parent,)) - res = kb.dispatch_once(conn, spawn_fn=lambda *a, **k: 42) + res = kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: 42) row = kb.get_task(conn, child) # Promotion (todo -> ready once parents are done) happened despite the diff --git a/tests/hermes_cli/test_kanban_notify.py b/tests/hermes_cli/test_kanban_notify.py index 3eea0a1920..9650b18ce7 100644 --- a/tests/hermes_cli/test_kanban_notify.py +++ b/tests/hermes_cli/test_kanban_notify.py @@ -4,6 +4,8 @@ import pytest from pathlib import Path from types import SimpleNamespace from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn from unittest.mock import AsyncMock, MagicMock, patch @@ -40,42 +42,44 @@ def test_notify_sub_delivery_mode_persists_and_last_write_wins(kanban_home): ``None`` re-subscribe leaves the existing mode untouched, an unknown value is ignored, and none of this clobbers the notifier_profile owner.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="mode sub task", assignee="worker1") # Fresh sub without a mode -> defaults to "notify". - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="chat1", notifier_profile="owner-a", ) - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert len(subs) == 1 assert subs[0]["delivery_mode"] == "notify" assert subs[0]["notifier_profile"] == "owner-a" # Explicit re-subscribe changes the mode (last-write-wins) and must NOT # overwrite the existing owner (owner self-heals only when unset). - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="chat1", notifier_profile="owner-b", delivery_mode="wake", ) - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert len(subs) == 1 assert subs[0]["delivery_mode"] == "wake" assert subs[0]["notifier_profile"] == "owner-a" # A None re-subscribe leaves the existing mode untouched. - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") - subs = kb.list_notify_subs(conn, tid) + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") + subs = kbn.list_notify_subs(conn, tid) assert subs[0]["delivery_mode"] == "wake" # An unknown mode is ignored (treated like None: no clobber). - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="chat1", delivery_mode="bogus", ) - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert subs[0]["delivery_mode"] == "wake" finally: conn.close() @@ -84,11 +88,13 @@ def test_notify_sub_delivery_mode_persists_and_last_write_wins(kanban_home): def test_child_task_inherits_parent_delivery_mode(kanban_home): """Graph children inherit the parent's ACK edge AND its delivery_mode.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: parent = kb.create_task(conn, title="root", assignee=None) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=parent, platform="telegram", chat_id="chat1", thread_id="42", user_id="u1", user_id_alt="alt-u1", notifier_profile="default", delivery_mode="notify+wake", @@ -96,7 +102,7 @@ def test_child_task_inherits_parent_delivery_mode(kanban_home): child = kb.create_task( conn, title="review child", assignee="ccreviewer", parents=[parent], ) - subs = kb.list_notify_subs(conn, child) + subs = kbn.list_notify_subs(conn, child) finally: conn.close() @@ -116,30 +122,32 @@ def test_notify_sub_chat_type_persists_and_last_write_wins(kanban_home): active-wake path replays this field so the woken turn keys to the operator's real channel.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="chat_type sub", assignee="worker1") # Fresh sub without chat_type -> defaults to "dm". - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") - subs = kb.list_notify_subs(conn, tid) + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") + subs = kbn.list_notify_subs(conn, tid) assert subs[0]["chat_type"] == "dm" # Explicit re-subscribe corrects the recorded chat_type (last-write-wins). - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="chat1", chat_type="group", ) - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert subs[0]["chat_type"] == "group" # A None re-subscribe (here changing only the mode) must NOT clobber # the recorded chat_type. - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="chat1", delivery_mode="wake", ) - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert subs[0]["chat_type"] == "group" assert subs[0]["delivery_mode"] == "wake" finally: @@ -150,23 +158,25 @@ def test_notify_sub_user_id_alt_persists_and_backfills_legacy_rows(kanban_home): """user_id_alt is persisted with the notify subscription routing tuple and can backfill a pre-existing row created before the alt id was known.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="alt sub", assignee="worker1") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="chat1", user_id="open-id", ) - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) assert subs[0]["user_id"] == "open-id" assert subs[0]["user_id_alt"] is None - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="chat1", user_id="open-id", user_id_alt="union-id", ) - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) finally: conn.close() @@ -179,11 +189,13 @@ def test_child_task_inherits_parent_chat_type(kanban_home): delivery_mode, so a woken child notification keys to the same session as the parent's originating channel.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: parent = kb.create_task(conn, title="root", assignee=None) - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=parent, platform="telegram", chat_id="chat1", user_id="u1", user_id_alt="alt-u1", chat_type="group", delivery_mode="notify+wake", @@ -191,7 +203,7 @@ def test_child_task_inherits_parent_chat_type(kanban_home): child = kb.create_task( conn, title="impl child", assignee="coder", parents=[parent], ) - subs = kb.list_notify_subs(conn, child) + subs = kbn.list_notify_subs(conn, child) finally: conn.close() @@ -207,15 +219,17 @@ async def test_notifier_notify_plus_wake_sends_and_wakes(kanban_home): """notify+wake delivers the passive message AND wakes the agent; a plain notify sub only sends. The agent is woken only for the notify+wake sub.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn from gateway.run import GatewayRunner from gateway.config import Platform - conn = kb.connect() + conn = kbc.connect() try: passive_tid = kb.create_task(conn, title="passive task", assignee="worker1") active_tid = kb.create_task(conn, title="active task", assignee="worker1") - kb.add_notify_sub(conn, task_id=passive_tid, platform="telegram", chat_id="chat1") - kb.add_notify_sub( + kbn.add_notify_sub(conn, task_id=passive_tid, platform="telegram", chat_id="chat1") + kbn.add_notify_sub( conn, task_id=active_tid, platform="telegram", chat_id="chat1", delivery_mode="notify+wake", ) @@ -271,10 +285,12 @@ async def test_notifier_plain_notify_never_wakes_even_with_session_id(kanban_hom creator session_id. This guards against the older unconditional wake path that forged adapter.handle_message events after every terminal delivery.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn from gateway.run import GatewayRunner from gateway.config import Platform - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task( conn, @@ -282,7 +298,7 @@ async def test_notifier_plain_notify_never_wakes_even_with_session_id(kanban_hom assignee="worker1", session_id="origin-session-id", ) - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") kb.block_task(conn, tid, reason="plain notify block") finally: conn.close() @@ -323,13 +339,15 @@ async def test_notifier_plain_notify_never_wakes_even_with_session_id(kanban_hom async def test_notifier_notify_wake_does_not_wake_on_status_event(kanban_home): """notify+wake wakes on terminal outcomes, not on dashboard status churn.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn from gateway.run import GatewayRunner from gateway.config import Platform - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="status-only task", assignee="worker1") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="chat1", delivery_mode="notify+wake", ) @@ -373,13 +391,15 @@ async def test_notifier_wake_forwards_persisted_chat_type_and_user_id(kanban_hom user_id so ``deliver_wake`` resolves the operator's real (e.g. group) session instead of a hardcoded one.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn from gateway.run import GatewayRunner from gateway.config import Platform - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="group wake", assignee="worker1") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="grp1", user_id="op-42", chat_type="group", delivery_mode="wake", notifier_profile="owner-profile", @@ -428,13 +448,15 @@ async def test_notifier_wake_only_skips_send_and_advances_cursor(kanban_home): """wake-only: NO passive send, the agent is woken exactly once, and the cursor advances so repeated ticks do not re-wake.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn from gateway.run import GatewayRunner from gateway.config import Platform - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="wake only task", assignee="worker1") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="chat1", delivery_mode="wake", ) @@ -478,9 +500,9 @@ async def test_notifier_wake_only_skips_send_and_advances_cursor(kanban_home): # The subscription survives (blocked is non-terminal) but its cursor moved # past the blocked event. - conn = kb.connect() + conn = kbc.connect() try: - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) finally: conn.close() assert len(subs) == 1 @@ -500,14 +522,16 @@ async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home): TERMINAL_KINDS in gateway/run.py and PR #21398. """ import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn from gateway.run import GatewayRunner from gateway.config import Platform - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title=f"test {kind} task", assignee="worker1") - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") kb._append_event(conn, tid, kind=kind) finally: conn.close() @@ -543,9 +567,9 @@ async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home): # ...but the subscription survives so a respawn-then-same-event cycle # reaches the user too. The cursor (last_event_id) advanced inside # the same write txn as the claim, so the same event won't re-fire. - conn = kb.connect() + conn = kbc.connect() try: - subs = kb.list_notify_subs(conn, tid) + subs = kbn.list_notify_subs(conn, tid) finally: conn.close() assert len(subs) == 1, ( @@ -592,9 +616,9 @@ async def test_notifier_wakes_origin_for_review_and_keeps_subscription(kanban_ho from gateway.config import Platform from gateway.run import GatewayRunner - with kb.connect() as conn: + with kbc.connect() as conn: task_id = kb.create_task(conn, title="review handoff", assignee="builder") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=task_id, platform="telegram", @@ -641,8 +665,8 @@ async def test_notifier_wakes_origin_for_review_and_keeps_subscription(kanban_ho assert any("ready for review" in message for message in delivered) assert any("Implementation and tests ready" in message for message in delivered) - with kb.connect() as conn: - assert kb.list_notify_subs(conn), "review is non-final; subscription must survive" + with kbc.connect() as conn: + assert kbn.list_notify_subs(conn), "review is non-final; subscription must survive" @pytest.mark.asyncio @@ -679,9 +703,9 @@ async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home): assert "subscribed" in out.lower() - conn = kb.connect(board="projx") + conn = kbc.connect(board="projx") try: - subs = kb.list_notify_subs(conn) + subs = kbn.list_notify_subs(conn) tasks = kb.list_tasks(conn) finally: conn.close() @@ -698,9 +722,9 @@ async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home): "thread_id": "20197", } - conn = kb.connect(board="default") + conn = kbc.connect(board="default") try: - assert kb.list_notify_subs(conn) == [] + assert kbn.list_notify_subs(conn) == [] finally: conn.close() @@ -755,9 +779,9 @@ async def test_gateway_autosubscribe_roundtrips_user_id_alt_for_session_key( out = await GatewayRunner._handle_kanban_command(runner, event) assert "subscribed" in out.lower() - conn = kb.connect() + conn = kbc.connect() try: - subs = kb.list_notify_subs(conn) + subs = kbn.list_notify_subs(conn) finally: conn.close() assert len(subs) == 1 @@ -802,6 +826,8 @@ async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_p referenced by name only. The notifier must not crash and must still deliver any artifacts that do exist.""" import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn from gateway.run import GatewayRunner from gateway.config import Platform from tools import kanban_tools as kt @@ -813,10 +839,10 @@ async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_p real_pdf = tmp_path / "real.pdf" real_pdf.write_bytes(b"%PDF-fake") - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="worker1") - kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") + kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") finally: conn.close() @@ -883,9 +909,9 @@ async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_p def test_migration_backfills_legacy_gateway_subs_to_notify_wake(kanban_home): - from hermes_cli.kanban_db import _migrate_add_optional_columns + from hermes_cli.kanban_db_connect import _migrate_add_optional_columns - with kb.connect() as conn: + with kbc.connect() as conn: task_id = kb.create_task(conn, title="legacy sub upgrade") # Simulate a pre-delivery_mode database: drop the column entirely, # then insert legacy-shaped rows (one gateway, one tui). @@ -920,11 +946,11 @@ def test_migration_backfills_legacy_gateway_subs_to_notify_wake(kanban_home): def test_migration_backfill_runs_only_on_first_add(kanban_home): - from hermes_cli.kanban_db import _migrate_add_optional_columns + from hermes_cli.kanban_db_connect import _migrate_add_optional_columns - with kb.connect() as conn: + with kbc.connect() as conn: task_id = kb.create_task(conn, title="explicit downgrade survives") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=task_id, platform="telegram", @@ -952,7 +978,7 @@ def test_migration_backfill_runs_only_on_first_add(kanban_home): def _add_full_parent_sub(kb, conn, parent): - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=parent, platform="telegram", chat_id="chat1", thread_id="topic1", user_id="user1", user_id_alt="alt-1", chat_type="dm", notifier_profile="default", @@ -982,8 +1008,10 @@ def _assert_full_inherited_sub(subs): def test_link_tasks_inherits_all_routing_columns(kanban_home): import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: parent = kb.create_task(conn, title="root", assignee=None) _add_full_parent_sub(kb, conn, parent) @@ -991,7 +1019,7 @@ def test_link_tasks_inherits_all_routing_columns(kanban_home): # _inherit_notify_subs directly (not the create_task parents path). child = kb.create_task(conn, title="existing child", assignee="w1") kb.link_tasks(conn, parent, child) - subs = kb.list_notify_subs(conn, child) + subs = kbn.list_notify_subs(conn, child) finally: conn.close() _assert_full_inherited_sub(subs) @@ -999,15 +1027,17 @@ def test_link_tasks_inherits_all_routing_columns(kanban_home): def test_create_with_parents_inherits_delivery_metadata(kanban_home): import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: parent = kb.create_task(conn, title="root", assignee=None) _add_full_parent_sub(kb, conn, parent) child = kb.create_task( conn, title="graph child", assignee="w1", parents=[parent], ) - subs = kb.list_notify_subs(conn, child) + subs = kbn.list_notify_subs(conn, child) finally: conn.close() _assert_full_inherited_sub(subs) @@ -1019,7 +1049,7 @@ def test_create_with_parents_inherits_delivery_metadata(kanban_home): def _make_done_task_with_sub(kb, conn, *, title, chat_id): tid = kb.create_task(conn, title=title, assignee="worker1") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id=chat_id, notifier_profile="default", ) @@ -1043,26 +1073,30 @@ def _backdate_task(kb, conn, tid, *, days): def test_gc_purges_stale_done_sub_keeps_fresh_one(kanban_home): import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: stale = _make_done_task_with_sub(kb, conn, title="old done", chat_id="c-stale") fresh = _make_done_task_with_sub(kb, conn, title="new done", chat_id="c-fresh") _backdate_task(kb, conn, stale, days=45) - purged = kb.purge_stale_done_notify_subs(conn, max_age_days=30) + purged = kbn.purge_stale_done_notify_subs(conn, max_age_days=30) assert purged == 1 - assert kb.list_notify_subs(conn, stale) == [] + assert kbn.list_notify_subs(conn, stale) == [] # A done task inside the retention window keeps its subscription — # it may still be reopened for review corrections. - assert len(kb.list_notify_subs(conn, fresh)) == 1 + assert len(kbn.list_notify_subs(conn, fresh)) == 1 finally: conn.close() def test_gc_honors_configured_retention_days(kanban_home): import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn from hermes_cli.config_defaults import DEFAULT_CONFIG # The watcher reads kanban.done_sub_retention_days from config; the @@ -1070,32 +1104,34 @@ def test_gc_honors_configured_retention_days(kanban_home): default_days = DEFAULT_CONFIG["kanban"]["done_sub_retention_days"] assert isinstance(default_days, int) and default_days > 0 - conn = kb.connect() + conn = kbc.connect() try: tid = _make_done_task_with_sub(kb, conn, title="ten days old", chat_id="c-10d") _backdate_task(kb, conn, tid, days=10) # Under the shipped default (>= 30d) a 10-day-old done task is fresh. - assert kb.purge_stale_done_notify_subs(conn, max_age_days=default_days) == 0 - assert len(kb.list_notify_subs(conn, tid)) == 1 + assert kbn.purge_stale_done_notify_subs(conn, max_age_days=default_days) == 0 + assert len(kbn.list_notify_subs(conn, tid)) == 1 # A tighter user-configured retention purges the same row. - assert kb.purge_stale_done_notify_subs(conn, max_age_days=7) == 1 - assert kb.list_notify_subs(conn, tid) == [] + assert kbn.purge_stale_done_notify_subs(conn, max_age_days=7) == 1 + assert kbn.list_notify_subs(conn, tid) == [] # Zero (and below) disables the sweep entirely. tid2 = _make_done_task_with_sub(kb, conn, title="ancient", chat_id="c-anc") _backdate_task(kb, conn, tid2, days=3650) - assert kb.purge_stale_done_notify_subs(conn, max_age_days=0) == 0 - assert len(kb.list_notify_subs(conn, tid2)) == 1 + assert kbn.purge_stale_done_notify_subs(conn, max_age_days=0) == 0 + assert len(kbn.list_notify_subs(conn, tid2)) == 1 finally: conn.close() def test_gc_spares_reopened_task_even_when_old(kanban_home): import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: tid = _make_done_task_with_sub(kb, conn, title="reopened", chat_id="c-reopen") _backdate_task(kb, conn, tid, days=90) @@ -1107,8 +1143,8 @@ def test_gc_spares_reopened_task_even_when_old(kanban_home): # Backdate the reopen event too — status alone must protect it. _backdate_task(kb, conn, tid, days=90) - assert kb.purge_stale_done_notify_subs(conn, max_age_days=30) == 0 - assert len(kb.list_notify_subs(conn, tid)) == 1 + assert kbn.purge_stale_done_notify_subs(conn, max_age_days=30) == 0 + assert len(kbn.list_notify_subs(conn, tid)) == 1 finally: conn.close() @@ -1122,39 +1158,43 @@ def _set_task_status(kb, conn, tid, status): def test_gc_purges_blocked_task_that_never_done(kanban_home): import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="stuck blocked", assignee="worker1") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="c-blocked", notifier_profile="default", ) _set_task_status(kb, conn, tid, "blocked") _backdate_task(kb, conn, tid, days=45) - purged = kb.purge_stale_done_notify_subs(conn, max_age_days=30) + purged = kbn.purge_stale_done_notify_subs(conn, max_age_days=30) assert purged == 1 - assert kb.list_notify_subs(conn, tid) == [] + assert kbn.list_notify_subs(conn, tid) == [] finally: conn.close() def test_gc_archived_rows_already_removed_by_unsub(kanban_home): import hermes_cli.kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn - conn = kb.connect() + conn = kbc.connect() try: tid = _make_done_task_with_sub(kb, conn, title="archived", chat_id="c-arch") assert kb.archive_task(conn, tid) # The notifier removes the sub at archive time; the GC targets only # ``done`` tasks, so an archived task contributes nothing to purge. - kb.remove_notify_sub( + kbn.remove_notify_sub( conn, task_id=tid, platform="telegram", chat_id="c-arch", ) _backdate_task(kb, conn, tid, days=365) - assert kb.purge_stale_done_notify_subs(conn, max_age_days=30) == 0 - assert kb.list_notify_subs(conn, tid) == [] + assert kbn.purge_stale_done_notify_subs(conn, max_age_days=30) == 0 + assert kbn.list_notify_subs(conn, tid) == [] finally: conn.close() diff --git a/tests/hermes_cli/test_kanban_parent_reopen_invalidation.py b/tests/hermes_cli/test_kanban_parent_reopen_invalidation.py index 3429a633b9..b040b66f00 100644 --- a/tests/hermes_cli/test_kanban_parent_reopen_invalidation.py +++ b/tests/hermes_cli/test_kanban_parent_reopen_invalidation.py @@ -22,11 +22,13 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd @pytest.fixture def conn(tmp_path: Path): - db = kb.connect(tmp_path / "kanban.db") + db = kbc.connect(tmp_path / "kanban.db") try: yield db finally: @@ -99,14 +101,14 @@ def test_running_descendant_event_precedes_termination_via_reclaim_helper( ) claimed = kb.claim_task(conn, child_id) assert claimed is not None and claimed.status == "running" - kb._set_worker_pid(conn, child_id, 424242) + kbd._set_worker_pid(conn, child_id, 424242) kills: list[tuple] = [] def fake_terminate(pid, claim_lock, **kwargs): # The audit trail must already be durable when the kill fires: # standalone calls commit before terminating. - side = kb.connect(tmp_path / "kanban.db") + side = kbc.connect(tmp_path / "kanban.db") try: kinds = [e.kind for e in kb.list_events(side, child_id)] finally: @@ -176,7 +178,7 @@ def test_dashboard_and_db_paths_produce_identical_outcomes(tmp_path, monkeypatch client = TestClient(app) def build_graph(tag: str): - with kb.connect() as c: + with kbc.connect() as c: parent = kb.create_task(c, title=f"{tag}-parent", assignee="planner") assert kb.complete_task(c, parent) child = kb.create_task( @@ -195,7 +197,7 @@ def test_dashboard_and_db_paths_produce_identical_outcomes(tmp_path, monkeypatch assert r.status_code == 200, r.text # Surface 2: DB function directly (the single domain implementation). - with kb.connect() as c: + with kbc.connect() as c: with kb.write_txn(c): c.execute( "UPDATE tasks SET status = 'todo', completed_at = NULL " @@ -206,7 +208,7 @@ def test_dashboard_and_db_paths_produce_identical_outcomes(tmp_path, monkeypatch c, db_parent, author="dashboard", ) - with kb.connect() as c: + with kbc.connect() as c: def snapshot(tid: str): t = kb.get_task(c, tid) assert t is not None diff --git a/tests/hermes_cli/test_kanban_per_profile_cap.py b/tests/hermes_cli/test_kanban_per_profile_cap.py index b77bd3a30b..b1a73c7ace 100644 --- a/tests/hermes_cli/test_kanban_per_profile_cap.py +++ b/tests/hermes_cli/test_kanban_per_profile_cap.py @@ -38,14 +38,16 @@ def test_cap_2_balances_two_profiles(isolated_kanban_home_with_profiles): """With cap=2: 2 alpha + 2 beta dispatched; remaining 3 alpha + 1 beta deferred to skipped_per_profile_capped.""" kb = isolated_kanban_home_with_profiles - with kb.connect_closing() as conn: + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_dispatch as kbd + with kbc.connect_closing() as conn: kb.create_board(slug="default", name="Test") for i in range(5): kb.create_task(conn, title=f"a{i}", assignee="alpha") for i in range(3): kb.create_task(conn, title=f"b{i}", assignee="beta") - with kb.connect_closing() as conn: - res = kb.dispatch_once( + with kbc.connect_closing() as conn: + res = kbd.dispatch_once( conn, spawn_fn=_fake_spawn, dry_run=True, max_in_progress_per_profile=2, ) @@ -64,13 +66,15 @@ def test_capped_tasks_dispatched_on_subsequent_tick(isolated_kanban_home_with_pr eligible for dispatch on the next tick (after running tasks complete). This verifies the cap is per-tick state, not a permanent block.""" kb = isolated_kanban_home_with_profiles - with kb.connect_closing() as conn: + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_dispatch as kbd + with kbc.connect_closing() as conn: kb.create_board(slug="default", name="Test") ids = [kb.create_task(conn, title=f"a{i}", assignee="alpha") for i in range(3)] # First tick: cap=1, only 1 alpha dispatched - with kb.connect_closing() as conn: - res1 = kb.dispatch_once( + with kbc.connect_closing() as conn: + res1 = kbd.dispatch_once( conn, spawn_fn=_fake_spawn, dry_run=False, max_in_progress_per_profile=1, ) @@ -80,7 +84,7 @@ def test_capped_tasks_dispatched_on_subsequent_tick(isolated_kanban_home_with_pr # Simulate the running task completing — set it back to done so the # 'running' count drops spawned_id = res1.spawned[0][0] - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: with kb.write_txn(conn): conn.execute( "UPDATE tasks SET status = 'done', claim_lock = NULL WHERE id = ?", @@ -88,8 +92,8 @@ def test_capped_tasks_dispatched_on_subsequent_tick(isolated_kanban_home_with_pr ) # Second tick: 1 more alpha should now dispatch - with kb.connect_closing() as conn: - res2 = kb.dispatch_once( + with kbc.connect_closing() as conn: + res2 = kbd.dispatch_once( conn, spawn_fn=_fake_spawn, dry_run=False, max_in_progress_per_profile=1, ) diff --git a/tests/hermes_cli/test_kanban_project_link.py b/tests/hermes_cli/test_kanban_project_link.py index 1994d7d6d0..a54055790e 100644 --- a/tests/hermes_cli/test_kanban_project_link.py +++ b/tests/hermes_cli/test_kanban_project_link.py @@ -8,12 +8,13 @@ import os import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli import projects_db as pdb @pytest.fixture def kanban_conn(tmp_path): - c = kb.connect(db_path=tmp_path / "kanban.db") + c = kbc.connect(db_path=tmp_path / "kanban.db") try: yield c finally: diff --git a/tests/hermes_cli/test_kanban_promote.py b/tests/hermes_cli/test_kanban_promote.py index c0d7cfb8fc..7c32dfb5ff 100644 --- a/tests/hermes_cli/test_kanban_promote.py +++ b/tests/hermes_cli/test_kanban_promote.py @@ -16,6 +16,7 @@ import pytest from hermes_cli import kanban as kb_cli from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -32,7 +33,7 @@ def kanban_home(tmp_path, monkeypatch): @pytest.fixture def conn(kanban_home): - with kb.connect() as c: + with kbc.connect() as c: yield c @@ -88,7 +89,7 @@ def _promote_ns(task_id, *, ids=None, reason=None, force=False, def test_cli_promote_bulk_ids_promotes_all(kanban_home, capsys): - with kb.connect() as conn: + with kbc.connect() as conn: parent = kb.create_task(conn, title="parent") children = [ kb.create_task(conn, title=f"c{i}", parents=[parent]) @@ -100,7 +101,7 @@ def test_cli_promote_bulk_ids_promotes_all(kanban_home, capsys): out = capsys.readouterr().out for c in children: assert c in out - with kb.connect() as conn: + with kbc.connect() as conn: for c in children: assert kb.get_task(conn, c).status == "ready" diff --git a/tests/hermes_cli/test_kanban_reclaim_claim_lock_guard.py b/tests/hermes_cli/test_kanban_reclaim_claim_lock_guard.py index 40ca86a741..3037a6cb1f 100644 --- a/tests/hermes_cli/test_kanban_reclaim_claim_lock_guard.py +++ b/tests/hermes_cli/test_kanban_reclaim_claim_lock_guard.py @@ -21,6 +21,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd @pytest.fixture @@ -39,7 +41,7 @@ def kanban_home(tmp_path, monkeypatch): @pytest.fixture def conn(kanban_home): - with kb.connect() as c: + with kbc.connect() as c: yield c @@ -53,7 +55,7 @@ def test_stale_crash_reset_rejected_for_reclaimed_task(conn): kb.claim_task(conn, tid, claimer=f"{host}:A") dead = subprocess.Popen(["true"]) dead.wait() - kb._set_worker_pid(conn, tid, dead.pid) + kbd._set_worker_pid(conn, tid, dead.pid) old = conn.execute( "SELECT claim_lock, worker_pid FROM tasks WHERE id=?", (tid,) ).fetchone() @@ -68,7 +70,7 @@ def test_stale_crash_reset_rejected_for_reclaimed_task(conn): kb.claim_task(conn, tid, claimer=f"{host}:B") sleeper = subprocess.Popen(["sleep", "30"]) try: - kb._set_worker_pid(conn, tid, sleeper.pid) + kbd._set_worker_pid(conn, tid, sleeper.pid) # The stale reset for worker A — same shape as the guarded UPDATE in # detect_crashed_workers — must reject (rowcount 0) because B owns it. @@ -98,16 +100,16 @@ def test_genuine_crash_still_reclaims(conn): kb.claim_task(conn, tid, claimer=f"{host}:A") dead = subprocess.Popen(["true"]) dead.wait() - kb._set_worker_pid(conn, tid, dead.pid) + kbd._set_worker_pid(conn, tid, dead.pid) # Rewind started_at so the launch grace window doesn't skip the check. conn.execute("UPDATE tasks SET started_at = started_at - 9999 WHERE id=?", (tid,)) conn.execute( "UPDATE task_runs SET started_at = started_at - 9999 WHERE task_id=?", (tid,) ) conn.commit() - kb._record_worker_exit(dead.pid, 1 << 8) # nonzero exit → crash + kbd._record_worker_exit(dead.pid, 1 << 8) # nonzero exit → crash - crashed = kb.detect_crashed_workers(conn) + crashed = kbd.detect_crashed_workers(conn) assert tid in crashed final = conn.execute("SELECT status FROM tasks WHERE id=?", (tid,)).fetchone() assert final["status"] in ("ready", "blocked", "todo") diff --git a/tests/hermes_cli/test_kanban_review_lifecycle.py b/tests/hermes_cli/test_kanban_review_lifecycle.py index 9e11560323..1222cb77fc 100644 --- a/tests/hermes_cli/test_kanban_review_lifecycle.py +++ b/tests/hermes_cli/test_kanban_review_lifecycle.py @@ -26,6 +26,9 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn +from hermes_cli import kanban_db_dispatch as kbd @pytest.fixture @@ -75,7 +78,7 @@ def _last_run(conn, tid): def test_request_review_transitions_running_to_review(kanban_home: Path) -> None: - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="impl a feature", assignee="worker") kb.claim_task(conn, tid) run_id = kb.get_task(conn, tid).current_run_id @@ -125,7 +128,7 @@ def test_repeated_review_requests_never_triage(kanban_home: Path) -> None: ``kanban_block(review-required:)`` approach the second pass hit ``block_recurrences >= 2`` and was wrongly routed to ``triage`` with a ``block_loop_detected`` event. ``request_review`` must never do that.""" - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="cycle me", assignee="worker") for _ in range(4): @@ -162,7 +165,7 @@ def test_repeated_review_requests_never_triage(kanban_home: Path) -> None: def test_request_review_expected_run_id_mismatch_is_noop(kanban_home: Path) -> None: - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="stale worker", assignee="worker") kb.claim_task(conn, tid) real_run = kb.get_task(conn, tid).current_run_id @@ -178,7 +181,7 @@ def test_request_review_expected_run_id_mismatch_is_noop(kanban_home: Path) -> N def test_request_review_unknown_task_returns_false(kanban_home: Path) -> None: - with kb.connect() as conn: + with kbc.connect() as conn: assert kb.request_review(conn, "t_deadbeefcafe") is False @@ -192,7 +195,7 @@ def test_request_review_refuses_to_clear_live_claim_without_ownership( worker_pid. ``force=True`` (explicit human override) and the worker path (``expected_run_id=``) both still work. """ - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="live claim", assignee="worker") claimed = kb.claim_task(conn, tid) assert claimed is not None @@ -217,7 +220,7 @@ def test_request_review_refuses_to_clear_live_claim_without_ownership( assert kb.get_task(conn, tid).status == "review" # 3) force=True: explicit human override on a fresh live-claimed task. - with kb.connect() as conn: + with kbc.connect() as conn: tid2 = kb.create_task(conn, title="forced", assignee="worker") assert kb.claim_task(conn, tid2) is not None assert kb.request_review(conn, tid2, summary="override", force=True) is True @@ -229,7 +232,7 @@ def test_request_review_malformed_provenance_gets_distinct_reason( ) -> None: """M1 regression: malformed re-review provenance is a named failure, not the generic 'unknown id or not in running/ready'.""" - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="provenance", assignee="builder") claimed = kb.claim_task(conn, tid) assert kb.request_review( @@ -279,7 +282,7 @@ def test_request_review_whitespace_only_summary_does_not_crash( never exposed). The transition must still succeed and the event must carry ``summary=None`` (whitespace collapses to no summary). """ - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="blank summary", assignee="worker") kb.claim_task(conn, tid) run_id = kb.get_task(conn, tid).current_run_id @@ -303,7 +306,7 @@ def test_complete_task_closes_review_to_done(kanban_home: Path) -> None: """A task parked in ``review`` (with no active run — request_review closed it, so ``current_run_id IS NULL``, the #54823 shape) must be completable by a human approval via ``complete_task``.""" - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="approve me", assignee="worker") kb.claim_task(conn, tid) kb.request_review( @@ -332,9 +335,9 @@ def test_review_requested_event_is_claimable_for_wake(kanban_home: Path) -> None now in that set, so a wake subscription must see the event — and the subscription is NOT torn down (task is in ``review``, not done/archived), so later review cycles keep notifying.""" - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="wake me", assignee="worker") - kb.add_notify_sub( + kbn.add_notify_sub( conn, task_id=tid, platform="slack", @@ -352,7 +355,7 @@ def test_review_requested_event_is_claimable_for_wake(kanban_home: Path) -> None "completed", "blocked", "gave_up", "crashed", "timed_out", "review_requested", ) - _old, _new, events = kb.claim_unseen_events_for_sub( + _old, _new, events = kbn.claim_unseen_events_for_sub( conn, task_id=tid, platform="slack", @@ -382,7 +385,7 @@ def test_review_dispatch_gate_prevents_phantom_reviewer( import hermes_cli.config as cfgmod import hermes_cli.profiles as profmod - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="park", assignee="worker") kb.claim_task(conn, tid) kb.request_review( @@ -400,7 +403,7 @@ def test_review_dispatch_gate_prevents_phantom_reviewer( cfgmod, "load_config", lambda *a, **k: {"kanban": {"review_dispatch": False}}, ) - res_off = kb.dispatch_once(conn, dry_run=True) + res_off = kbd.dispatch_once(conn, dry_run=True) assert tid not in [s[0] for s in res_off.spawned] assert kb.get_task(conn, tid).status == "review" @@ -410,7 +413,7 @@ def test_review_dispatch_gate_prevents_phantom_reviewer( cfgmod, "load_config", lambda *a, **k: {"kanban": {"review_dispatch": True}}, ) - res_on = kb.dispatch_once(conn, dry_run=True) + res_on = kbd.dispatch_once(conn, dry_run=True) assert tid in [s[0] for s in res_on.spawned] @@ -435,7 +438,7 @@ def test_active_pr_guard_skipped_for_review_lane_but_defers_ready_lane( ) pr_comment = "Opened https://github.com/example/repo/pull/123 for review." - with kb.connect() as conn: + with kbc.connect() as conn: # Review-lane task with a fresh PR comment. review_id = kb.create_task(conn, title="review me", assignee="reviewer") claimed = kb.claim_task(conn, review_id) @@ -449,10 +452,10 @@ def test_active_pr_guard_skipped_for_review_lane_but_defers_ready_lane( ready_id = kb.create_task(conn, title="already PRed", assignee="worker") kb.add_comment(conn, ready_id, author="worker", body=pr_comment) - assert kb.check_respawn_guard(conn, ready_id) == "active_pr" - assert kb.check_respawn_guard(conn, review_id, lane="review") is None + assert kbd.check_respawn_guard(conn, ready_id) == "active_pr" + assert kbd.check_respawn_guard(conn, review_id, lane="review") is None - res = kb.dispatch_once(conn, dry_run=True) + res = kbd.dispatch_once(conn, dry_run=True) spawned_ids = [s[0] for s in res.spawned] guarded = dict(res.respawn_guarded) assert review_id in spawned_ids @@ -470,7 +473,7 @@ def test_active_pr_guard_skipped_for_review_lane_but_defers_ready_lane( # "latest run" query deterministically picks this one. (review_id, _now, _now + 5), ) - assert kb.check_respawn_guard( + assert kbd.check_respawn_guard( conn, review_id, lane="review" ) == "rate_limit_cooldown" @@ -493,7 +496,7 @@ def test_review_dispatch_preserves_task_skills_and_adds_reviewer_skill( captured.append(list(task.skills or [])) return None - with kb.connect() as conn: + with kbc.connect() as conn: task_id = kb.create_task( conn, title="domain review", @@ -509,19 +512,19 @@ def test_review_dispatch_preserves_task_skills_and_adds_reviewer_skill( expected_run_id=implementation.current_run_id, ) monkeypatch.setattr( - kb, + kbd, "check_respawn_guard", lambda _conn, _task_id, **_kw: "rate_limit_cooldown", ) - guarded = kb.dispatch_once(conn, spawn_fn=spawn) + guarded = kbd.dispatch_once(conn, spawn_fn=spawn) assert guarded.respawn_guarded == [(task_id, "rate_limit_cooldown")] assert not guarded.spawned guarded_task = kb.get_task(conn, task_id) assert guarded_task is not None assert guarded_task.status == "review" - monkeypatch.setattr(kb, "check_respawn_guard", lambda _conn, _task_id, **_kw: None) - result = kb.dispatch_once(conn, spawn_fn=spawn) + monkeypatch.setattr(kbd, "check_respawn_guard", lambda _conn, _task_id, **_kw: None) + result = kbd.dispatch_once(conn, spawn_fn=spawn) assert task_id in [task[0] for task in result.spawned] assert captured == [["domain-specific-review", "sdlc-review"]] @@ -541,7 +544,7 @@ def test_review_dispatch_honors_global_and_per_profile_caps( lambda *args, **kwargs: {"kanban": {"review_dispatch": True}}, ) - with kb.connect() as conn: + with kbc.connect() as conn: running_id = kb.create_task(conn, title="already running", assignee="builder") running = kb.claim_task(conn, running_id) assert running is not None @@ -559,7 +562,7 @@ def test_review_dispatch_honors_global_and_per_profile_caps( ) review_ids.append(task_id) - globally_capped = kb.dispatch_once( + globally_capped = kbd.dispatch_once( conn, dry_run=True, max_in_progress=1, @@ -573,7 +576,7 @@ def test_review_dispatch_honors_global_and_per_profile_caps( running_id, expected_run_id=running.current_run_id, ) - global_dry_run = kb.dispatch_once( + global_dry_run = kbd.dispatch_once( conn, dry_run=True, max_in_progress=1, @@ -582,7 +585,7 @@ def test_review_dispatch_honors_global_and_per_profile_caps( task for task in global_dry_run.spawned if task[0] in review_ids ]) == 1 - per_profile_capped = kb.dispatch_once( + per_profile_capped = kbd.dispatch_once( conn, dry_run=True, max_in_progress=10, @@ -605,7 +608,7 @@ def test_reopen_review_task_returns_to_ready(kanban_home: Path) -> None: """The "changes requested" / follow-up path: a task parked in ``review`` goes back to ``ready`` so the dispatcher re-runs the implementer. It must NOT touch ``block_recurrences`` (review was never a block).""" - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="reopen me", assignee="worker") kb.claim_task(conn, tid) kb.request_review( @@ -636,7 +639,7 @@ def test_review_cycle_end_to_end(kanban_home: Path) -> None: """Full loop: run -> review -> follow-up reopen -> re-run -> review -> approve -> done. Never blocks, never triages, and stays wake-subscribed until done.""" - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="cycle", assignee="worker") # Pass 1: implement -> review. @@ -674,7 +677,7 @@ def test_request_review_on_unclaimed_ready_synthesizes_run(kanban_home: Path) -> """A manual/CLI request-review on a never-claimed ``ready`` task has no active run to close. The handoff summary must still be preserved on a synthesized run so the reviewer keeps the context.""" - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="ready then review", assignee="worker") assert kb.get_task(conn, tid).status == "ready" assert kb.get_task(conn, tid).current_run_id is None @@ -695,7 +698,7 @@ def test_request_review_on_unclaimed_ready_synthesizes_run(kanban_home: Path) -> def test_reviewer_reassigns_for_autonomous_dispatch(kanban_home: Path) -> None: """An explicit reviewer routes the review run while preserving implementer provenance.""" - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="route reviewer", assignee="worker") claimed = kb.claim_task(conn, tid) assert claimed is not None diff --git a/tests/hermes_cli/test_kanban_review_lifecycle_complete.py b/tests/hermes_cli/test_kanban_review_lifecycle_complete.py index 4dc65d0f9c..5397ca73f6 100644 --- a/tests/hermes_cli/test_kanban_review_lifecycle_complete.py +++ b/tests/hermes_cli/test_kanban_review_lifecycle_complete.py @@ -17,12 +17,14 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd from hermes_cli import kanban_diagnostics as kd @pytest.fixture def conn(tmp_path: Path): - db = kb.connect(tmp_path / "kanban.db") + db = kbc.connect(tmp_path / "kanban.db") try: yield db finally: @@ -340,11 +342,14 @@ def test_interrupted_review_runs_retry_in_review_phase( ) if reclaim_kind == "spawn_failure": - assert not kb._record_spawn_failure( + assert not kbd._record_task_failure( conn, task_id, "reviewer process failed to spawn", + outcome="spawn_failed", failure_limit=3, + release_claim=True, + end_run=True, ) elif reclaim_kind == "expired_claim": with kb.write_txn(conn): @@ -367,7 +372,7 @@ def test_interrupted_review_runs_retry_in_review_phase( "UPDATE task_runs SET started_at = ? WHERE id = ?", (old, review.current_run_id), ) - assert kb.detect_stale_running(conn, stale_timeout_seconds=1) == [task_id] + assert kbd.detect_stale_running(conn, stale_timeout_seconds=1) == [task_id] retried = kb.get_task(conn, task_id) assert retried is not None @@ -380,11 +385,14 @@ def test_interrupted_review_runs_retry_in_review_phase( def test_review_retry_still_trips_the_failure_breaker(conn) -> None: task_id, _review = _claimed_review(conn, "Reviewer repeatedly fails") - assert kb._record_spawn_failure( + assert kbd._record_task_failure( conn, task_id, "reviewer cannot start", + outcome="spawn_failed", failure_limit=1, + release_claim=True, + end_run=True, ) blocked = kb.get_task(conn, task_id) assert blocked is not None @@ -459,7 +467,7 @@ def test_crashed_and_timed_out_review_runs_retry_in_review_phase( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(kb, "_pid_alive", lambda _pid: False) - monkeypatch.setattr(kb, "_classify_worker_exit", lambda _pid: ("nonzero_exit", 1)) + monkeypatch.setattr(kbd, "_classify_worker_exit", lambda _pid: ("nonzero_exit", 1)) old = int(time.time()) - 1_000 timed_out_id, timed_out_run = _claimed_review( @@ -476,7 +484,7 @@ def test_crashed_and_timed_out_review_runs_retry_in_review_phase( "UPDATE task_runs SET worker_pid = ?, started_at = ? WHERE id = ?", (999_998, old, timed_out_run.current_run_id), ) - assert timed_out_id in kb.enforce_max_runtime(conn, signal_fn=lambda *_: None) + assert timed_out_id in kbd.enforce_max_runtime(conn, signal_fn=lambda *_: None) timed_out = kb.get_task(conn, timed_out_id) assert timed_out is not None assert timed_out.status == "review" @@ -491,7 +499,7 @@ def test_crashed_and_timed_out_review_runs_retry_in_review_phase( "UPDATE task_runs SET worker_pid = ?, started_at = ? WHERE id = ?", (999_999, old, crashed_run.current_run_id), ) - assert crashed_id in kb.detect_crashed_workers(conn) + assert crashed_id in kbd.detect_crashed_workers(conn) crashed = kb.get_task(conn, crashed_id) assert crashed is not None assert crashed.status == "review" @@ -691,7 +699,7 @@ def test_review_transitions_preserve_consecutive_failures(conn) -> None: # A crash now increments 1 -> 2 and trips a failure_limit=2 breaker — # the counter accumulated across the review cycle instead of being # amnesia-reset back to 0. - tripped = kb._record_task_failure( + tripped = kbd._record_task_failure( conn, task_id, "worker crashed", outcome="crashed", failure_limit=2, ) assert tripped is True diff --git a/tests/hermes_cli/test_kanban_review_surfaces.py b/tests/hermes_cli/test_kanban_review_surfaces.py index 908b6dcd49..01d4a040f4 100644 --- a/tests/hermes_cli/test_kanban_review_surfaces.py +++ b/tests/hermes_cli/test_kanban_review_surfaces.py @@ -9,6 +9,7 @@ import pytest from hermes_cli import kanban as kc from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -21,7 +22,7 @@ def review_worker(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> str: monkeypatch.delenv("HERMES_DELEGATED_CHILD_CONTEXT", raising=False) kb._INITIALIZED_PATHS.clear() kb.init_db() - with kb.connect() as conn: + with kbc.connect() as conn: task_id = kb.create_task(conn, title="Review tool contract", assignee="builder") task = kb.claim_task(conn, task_id, claimer="builder:1") assert task is not None @@ -46,7 +47,7 @@ def test_review_tools_redact_handoff_and_route_changes( ) assert requested["ok"] is True - with kb.connect() as conn: + with kbc.connect() as conn: task = kb.get_task(conn, review_worker) assert task is not None assert task.status == "review" @@ -69,7 +70,7 @@ def test_review_tools_redact_handoff_and_route_changes( assert changed["ok"] is True assert changed["implementer"] == "builder" - with kb.connect() as conn: + with kbc.connect() as conn: task = kb.get_task(conn, review_worker) assert task is not None assert task.status == "ready" @@ -124,7 +125,7 @@ def test_review_cli_round_trip_preserves_handoff( kb._INITIALIZED_PATHS.clear() kb.init_db() - with kb.connect() as conn: + with kbc.connect() as conn: task_id = kb.create_task(conn, title="CLI review", assignee="builder") implementation = kb.claim_task(conn, task_id, claimer="builder:1") assert implementation is not None @@ -137,7 +138,7 @@ def test_review_cli_round_trip_preserves_handoff( ) assert "Requested review" in output - with kb.connect() as conn: + with kbc.connect() as conn: task = kb.get_task(conn, task_id) assert task is not None assert task.assignee == "reviewer" @@ -152,7 +153,7 @@ def test_review_cli_round_trip_preserves_handoff( f"request-changes {task_id} 'cover the malformed payload case'" ) assert "Requested changes" in output - with kb.connect() as conn: + with kbc.connect() as conn: task = kb.get_task(conn, task_id) assert task is not None assert task.status == "ready" @@ -168,7 +169,7 @@ def test_domain_and_cli_review_handoffs_redact_before_persistence( monkeypatch.setenv("HERMES_HOME", str(home)) secret = "ghp_" + "R" * 40 - with kb.connect() as conn: + with kbc.connect() as conn: direct_id = kb.create_task(conn, title="direct redaction", assignee="builder") direct_run = kb.claim_task(conn, direct_id) assert direct_run is not None @@ -213,7 +214,7 @@ def test_domain_and_cli_review_handoffs_redact_before_persistence( ) assert "Requested review" in cli_output assert secret not in cli_output - with kb.connect() as conn: + with kbc.connect() as conn: run = kb.latest_run(conn, cli_id) event = [ item for item in kb.list_events(conn, cli_id) @@ -256,7 +257,7 @@ def test_cli_reopen_review_is_transition_first_and_redacts_reason( home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) secret = "ghp_" + "Q" * 40 - with kb.connect() as conn: + with kbc.connect() as conn: invalid_id = kb.create_task(conn, title="not review", assignee="builder") review_id = kb.create_task(conn, title="review", assignee="builder") assert kb.request_review(conn, review_id, summary="ready") @@ -265,7 +266,7 @@ def test_cli_reopen_review_is_transition_first_and_redacts_reason( f'reopen-review {invalid_id} --reason "invalid {secret}"' ) assert "cannot reopen" in invalid_output - with kb.connect() as conn: + with kbc.connect() as conn: assert kb.list_comments(conn, invalid_id) == [] success_output = kc.run_slash( @@ -273,7 +274,7 @@ def test_cli_reopen_review_is_transition_first_and_redacts_reason( ) assert "Reopened" in success_output assert secret not in success_output - with kb.connect() as conn: + with kbc.connect() as conn: task = kb.get_task(conn, review_id) assert task is not None assert task.status == "ready" @@ -293,7 +294,7 @@ def test_goal_mode_review_handoff_cannot_bypass_judge( kb._INITIALIZED_PATHS.clear() kb.init_db() - with kb.connect() as conn: + with kbc.connect() as conn: tool_task = kb.create_task( conn, title="Goal-mode tool task", @@ -322,13 +323,13 @@ def test_goal_mode_review_handoff_cannot_bypass_judge( rejected = json.loads(tools._handle_request_review({"summary": "Looks ready."})) assert "error" in rejected assert "rejected by judge" in rejected["error"] - with kb.connect() as conn: + with kbc.connect() as conn: tool_after = kb.get_task(conn, tool_task) assert tool_after is not None assert tool_after.status == "running" # The shell/CLI path applies the same gate and must not bypass the tool. - with kb.connect() as conn: + with kbc.connect() as conn: cli_task = kb.create_task( conn, title="Goal-mode CLI task", @@ -355,7 +356,7 @@ def test_goal_mode_review_handoff_cannot_bypass_judge( ) output = kc.run_slash(f"request-review {cli_task} --summary 'Looks ready.'") assert "rejected by judge" in output - with kb.connect() as conn: + with kbc.connect() as conn: cli_after = kb.get_task(conn, cli_task) assert cli_after is not None assert cli_after.status == "running" @@ -396,7 +397,7 @@ def test_cli_and_dashboard_receive_graph_aware_deadlock_diagnostic( kb._INITIALIZED_PATHS.clear() kb.init_db() - with kb.connect() as conn: + with kbc.connect() as conn: parent_id = kb.create_task(conn, title="Implementation", assignee="builder") child_id = kb.create_task( conn, @@ -421,7 +422,7 @@ def test_cli_and_dashboard_receive_graph_aware_deadlock_diagnostic( from plugins.kanban.dashboard.plugin_api import _compute_task_diagnostics - with kb.connect() as conn: + with kbc.connect() as conn: dashboard = _compute_task_diagnostics(conn, task_ids=[parent_id]) assert dashboard[parent_id][0]["kind"] == "review_dependency_deadlock" assert dashboard[parent_id][0]["data"]["waiting_child_ids"] == [child_id] diff --git a/tests/hermes_cli/test_kanban_specify.py b/tests/hermes_cli/test_kanban_specify.py index 48f251d5ba..55f1d33d52 100644 --- a/tests/hermes_cli/test_kanban_specify.py +++ b/tests/hermes_cli/test_kanban_specify.py @@ -16,6 +16,7 @@ import pytest from hermes_cli import kanban as kanban_cli from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli import kanban_specify as spec @@ -69,7 +70,7 @@ def _patch_aux_client(content: str, *, model: str = "test-model"): # --------------------------------------------------------------------------- def test_specify_task_happy_path(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task(conn, title="rough", triage=True) content = jsonlib.dumps({ @@ -84,7 +85,7 @@ def test_specify_task_happy_path(kanban_home): assert outcome.task_id == tid assert outcome.new_title == "Refined rough" - with kb.connect() as conn: + with kbc.connect() as conn: task = kb.get_task(conn, tid) # Parent-free → recompute_ready promotes to ready. assert task.status == "ready" @@ -112,7 +113,7 @@ def _run_cli(*argv: str) -> int: def test_cli_specify_tenant_filter(kanban_home, capsys): - with kb.connect() as conn: + with kbc.connect() as conn: outside = kb.create_task(conn, title="outside", triage=True) inside = kb.create_task( conn, title="inside", triage=True, tenant="proj-a", @@ -132,7 +133,7 @@ def test_cli_specify_tenant_filter(kanban_home, capsys): assert ids == {inside} # The outside task stays in triage. - with kb.connect() as conn: + with kbc.connect() as conn: assert kb.get_task(conn, outside).status == "triage" # The inside task was promoted. assert kb.get_task(conn, inside).status in {"todo", "ready"} diff --git a/tests/hermes_cli/test_kanban_specify_db.py b/tests/hermes_cli/test_kanban_specify_db.py index a13517f3ac..2258a9cf2f 100644 --- a/tests/hermes_cli/test_kanban_specify_db.py +++ b/tests/hermes_cli/test_kanban_specify_db.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc @pytest.fixture @@ -32,10 +33,10 @@ def _create_triage(conn, title="rough idea", body=None, assignee=None): def test_specify_promotes_triage_to_todo(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: tid = _create_triage(conn, title="rough idea") assert kb.get_task(conn, tid).status == "triage" - with kb.connect() as conn: + with kbc.connect() as conn: ok = kb.specify_triage_task( conn, tid, @@ -44,7 +45,7 @@ def test_specify_promotes_triage_to_todo(kanban_home): author="specifier-bot", ) assert ok is True - with kb.connect() as conn: + with kbc.connect() as conn: task = kb.get_task(conn, tid) # No parents → recompute_ready should have flipped it past todo to ready. assert task.status == "ready" @@ -53,15 +54,15 @@ def test_specify_promotes_triage_to_todo(kanban_home): def test_specify_rejects_blank_title(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: tid = _create_triage(conn, title="rough") - with kb.connect() as conn, pytest.raises(ValueError): + with kbc.connect() as conn, pytest.raises(ValueError): kb.specify_triage_task(conn, tid, title=" ", body="ok") def test_specify_records_audit_comment_only_when_author_given(kanban_home): # With author → comment added. - with kb.connect() as conn: + with kbc.connect() as conn: tid1 = _create_triage(conn, title="a") kb.specify_triage_task( conn, tid1, title="A-spec", body="b", author="ace" @@ -72,7 +73,7 @@ def test_specify_records_audit_comment_only_when_author_given(kanban_home): assert comments1[0].author == "ace" # Without author → no comment (silent). - with kb.connect() as conn: + with kbc.connect() as conn: tid2 = _create_triage(conn, title="b") kb.specify_triage_task(conn, tid2, title="B-spec", body="b") comments2 = kb.list_comments(conn, tid2) diff --git a/tests/hermes_cli/test_kanban_swarm.py b/tests/hermes_cli/test_kanban_swarm.py index afe53ede5d..eda1a7848f 100644 --- a/tests/hermes_cli/test_kanban_swarm.py +++ b/tests/hermes_cli/test_kanban_swarm.py @@ -1,6 +1,7 @@ import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli.kanban_swarm import ( SwarmWorkerSpec, create_swarm, @@ -10,7 +11,7 @@ from hermes_cli.kanban_swarm import ( def test_create_swarm_builds_parallel_workers_verifier_and_synthesizer(tmp_path): - conn = kb.connect(tmp_path / "kanban.db") + conn = kbc.connect(tmp_path / "kanban.db") try: created = create_swarm( conn, @@ -52,8 +53,8 @@ def test_create_swarm_graph_is_atomic_and_rolls_back_partial_build( tmp_path, monkeypatch: pytest.MonkeyPatch ): db_path = tmp_path / "kanban.db" - writer = kb.connect(db_path) - reader = kb.connect(db_path) + writer = kbc.connect(db_path) + reader = kbc.connect(db_path) original_create = kb.create_task original_complete = kb.complete_task calls = 0 @@ -137,7 +138,7 @@ def test_plain_write_txn_nesting_raises_and_allow_nested_composes(tmp_path): and an outer rollback discards the inner work without any post-commit side effects having fired (the workspace directory survives). """ - conn = kb.connect(tmp_path / "kanban.db") + conn = kbc.connect(tmp_path / "kanban.db") try: workspace = tmp_path / "scratch-ws" workspace.mkdir() @@ -178,7 +179,7 @@ def test_plain_write_txn_nesting_raises_and_allow_nested_composes(tmp_path): def test_swarm_blackboard_merges_structured_updates(tmp_path): - conn = kb.connect(tmp_path / "kanban.db") + conn = kbc.connect(tmp_path / "kanban.db") try: created = create_swarm( conn, @@ -212,7 +213,7 @@ def test_swarm_blackboard_merges_structured_updates(tmp_path): def test_swarm_verifier_and_synthesis_are_dependency_gated(tmp_path): - conn = kb.connect(tmp_path / "kanban.db") + conn = kbc.connect(tmp_path / "kanban.db") try: created = create_swarm( conn, diff --git a/tests/hermes_cli/test_kanban_task_updated_hook.py b/tests/hermes_cli/test_kanban_task_updated_hook.py index 33f784ee59..4080bd9ea7 100644 --- a/tests/hermes_cli/test_kanban_task_updated_hook.py +++ b/tests/hermes_cli/test_kanban_task_updated_hook.py @@ -14,6 +14,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli.plugins import VALID_HOOKS, get_plugin_manager @@ -59,7 +60,7 @@ def test_assign_fires_updated_with_changed_fields(kanban_home, captured_updates) mgr = get_plugin_manager() mgr._hooks.setdefault("on_kanban_task_updated", []).append(_read_assignee) - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="alice") captured_updates.clear() # create-time bookkeeping is not under test @@ -86,7 +87,7 @@ def test_raising_callback_does_not_break_assign(kanban_home): mgr._hooks.setdefault("on_kanban_task_updated", []).append(_boom) try: - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="alice") assert kb.assign_task(conn, tid, "bob") is True @@ -108,7 +109,7 @@ def test_no_subscriber_short_circuits_task_updated(kanban_home, monkeypatch): return real_invoke(hook_name, **kw) monkeypatch.setattr(lifecycle, "invoke_hook", _spy) - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="alice") assert kb.assign_task(conn, tid, "bob") is True diff --git a/tests/hermes_cli/test_kanban_transfer.py b/tests/hermes_cli/test_kanban_transfer.py index fdf37c582e..c5fb70553f 100644 --- a/tests/hermes_cli/test_kanban_transfer.py +++ b/tests/hermes_cli/test_kanban_transfer.py @@ -30,6 +30,7 @@ if str(_WORKTREE) not in sys.path: sys.path.insert(0, str(_WORKTREE)) from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli import kanban_transfer as kt from hermes_cli.archive_safe import normalize_archive_parts, safe_extract_targz @@ -62,7 +63,7 @@ def _seed_board(slug: str = "alpha") -> dict[str, str]: """Create a board with one task of each interesting shape.""" kb.create_board(slug, name="Alpha Board") ids = {} - with kb.connect_closing(board=slug) as conn: + with kbc.connect_closing(board=slug) as conn: ids["scratch"] = kb.create_task( conn, title="scratch task", body="body", assignee="coder" ) @@ -80,7 +81,7 @@ def _seed_board(slug: str = "alpha") -> dict[str, str]: def _claim(task_id: str, slug: str = "alpha") -> None: """Put a task into the state a live worker would leave behind.""" - with kb.connect_closing(board=slug) as conn: + with kbc.connect_closing(board=slug) as conn: with kb.write_txn(conn): conn.execute( "UPDATE tasks SET status='running', claim_lock='lock-1', " @@ -91,7 +92,7 @@ def _claim(task_id: str, slug: str = "alpha") -> None: def _subscribe(task_id: str, slug: str = "alpha") -> None: - with kb.connect_closing(board=slug) as conn: + with kbc.connect_closing(board=slug) as conn: with kb.write_txn(conn): conn.execute( "INSERT INTO kanban_notify_subs " @@ -102,7 +103,7 @@ def _subscribe(task_id: str, slug: str = "alpha") -> None: def _tasks_by_title(slug: str) -> dict[str, dict]: - with kb.connect_closing(board=slug) as conn: + with kbc.connect_closing(board=slug) as conn: return { row["title"]: dict(row) for row in conn.execute("SELECT * FROM tasks").fetchall() @@ -137,7 +138,7 @@ def test_attachment_blob_travels_and_is_readable(kanban_root, tmp_path): target_root = kanban_root("target") result = kt.import_board(archive) - with kb.connect_closing(board=result["board"]) as conn: + with kbc.connect_closing(board=result["board"]) as conn: row = conn.execute( "SELECT filename, stored_path FROM task_attachments" ).fetchone() @@ -211,7 +212,7 @@ def test_gateway_subscriptions_never_travel(kanban_root, tmp_path): # at all, because the archive is the thing that gets shared. kanban_root("target") result = kt.import_board(archive) - with kb.connect_closing(board=result["board"]) as conn: + with kbc.connect_closing(board=result["board"]) as conn: assert conn.execute( "SELECT COUNT(*) FROM kanban_notify_subs" ).fetchone()[0] == 0 @@ -274,12 +275,12 @@ def test_slug_collision_creates_a_new_board(kanban_root, tmp_path): def test_import_never_targets_the_default_board(kanban_root, tmp_path): - with kb.connect_closing(board="default") as conn: + with kbc.connect_closing(board="default") as conn: kb.create_task(conn, title="exported default task") archive = kt.export_board("default", str(tmp_path / "default"))["archive"] kanban_root("target") - with kb.connect_closing(board="default") as conn: + with kbc.connect_closing(board="default") as conn: kb.create_task(conn, title="local default task") result = kt.import_board(archive) diff --git a/tests/hermes_cli/test_kanban_worker_image_extraction.py b/tests/hermes_cli/test_kanban_worker_image_extraction.py index 00577886ef..8806e60050 100644 --- a/tests/hermes_cli/test_kanban_worker_image_extraction.py +++ b/tests/hermes_cli/test_kanban_worker_image_extraction.py @@ -20,6 +20,7 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from agent.image_routing import ( build_native_content_parts, extract_image_refs, @@ -46,7 +47,7 @@ def kanban_home(tmp_path: Path, monkeypatch): def _add_task_with_body(body: str, *, title: str = "Look at this") -> str: - conn = kb.connect() + conn = kbc.connect() try: task_id = kb.create_task( conn, @@ -61,7 +62,7 @@ def _add_task_with_body(body: str, *, title: str = "Look at this") -> str: def _read_body(task_id: str) -> str: - conn = kb.connect() + conn = kbc.connect() try: task = kb.get_task(conn, task_id) return (task.body if task is not None else "") or "" diff --git a/tests/hermes_cli/test_kanban_worker_lifecycle_hooks.py b/tests/hermes_cli/test_kanban_worker_lifecycle_hooks.py index 233d4cfd8a..6bb8d6cc66 100644 --- a/tests/hermes_cli/test_kanban_worker_lifecycle_hooks.py +++ b/tests/hermes_cli/test_kanban_worker_lifecycle_hooks.py @@ -19,6 +19,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd from hermes_cli.plugins import VALID_HOOKS, get_plugin_manager WORKER_HOOKS = ( @@ -76,10 +78,10 @@ def test_dispatch_spawn_fires_worker_spawned( mgr = get_plugin_manager() mgr._hooks.setdefault("on_kanban_worker_spawned", []).append(_read_pid) - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="alice") - result = kb.dispatch_once(conn, spawn_fn=lambda *a, **k: 4242) + result = kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: 4242) assert any(row[0] == tid for row in result.spawned) finally: conn.close() @@ -98,13 +100,13 @@ def test_dispatch_spawn_fires_worker_spawned( def test_crash_reclaim_fires_worker_exited(kanban_home, captured_hooks, monkeypatch): """A dead-PID reclaim fires the exit observer with the exit facts.""" - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="worker") kb.claim_task(conn, tid) - kb._set_worker_pid(conn, tid, 98765) + kbd._set_worker_pid(conn, tid, 98765) monkeypatch.setattr(kb, "_pid_alive", lambda pid: False) - assert kb.detect_crashed_workers(conn) == [tid] + assert kbd.detect_crashed_workers(conn) == [tid] finally: conn.close() @@ -124,7 +126,7 @@ def test_crash_reclaim_fires_worker_exited(kanban_home, captured_hooks, monkeypa def test_stale_claim_reclaim_fires_hook(kanban_home, captured_hooks): """A TTL-expired reclaim fires the stale-claim observer post-commit.""" - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="worker") kb.claim_task(conn, tid) @@ -162,14 +164,14 @@ def test_raising_callbacks_never_break_worker_lifecycle( for hook in WORKER_HOOKS: mgr._hooks.setdefault(hook, []).append(_boom) try: - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="t", assignee="alice") - result = kb.dispatch_once(conn, spawn_fn=lambda *a, **k: 111) + result = kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: 111) assert any(row[0] == tid for row in result.spawned) monkeypatch.setattr(kb, "_pid_alive", lambda pid: False) - assert kb.detect_crashed_workers(conn) == [tid] + assert kbd.detect_crashed_workers(conn) == [tid] kb.claim_task(conn, tid) conn.execute( @@ -199,10 +201,10 @@ def test_no_subscriber_short_circuits_worker_hooks( return real_invoke(hook_name, **kw) monkeypatch.setattr(lifecycle, "invoke_hook", _spy) - conn = kb.connect() + conn = kbc.connect() try: kb.create_task(conn, title="t", assignee="alice") - kb.dispatch_once(conn, spawn_fn=lambda *a, **k: 222) + kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: 222) finally: conn.close() assert "on_kanban_worker_spawned" not in invoked diff --git a/tests/hermes_cli/test_kanban_worker_session_source.py b/tests/hermes_cli/test_kanban_worker_session_source.py index b35dcc1781..eae44b4615 100644 --- a/tests/hermes_cli/test_kanban_worker_session_source.py +++ b/tests/hermes_cli/test_kanban_worker_session_source.py @@ -23,6 +23,7 @@ def db(tmp_path, monkeypatch): def test_worker_spawn_tags_session_source_kanban(monkeypatch, tmp_path): """The dispatcher tags the worker's env so its session is a `kanban` row.""" from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_dispatch as kbd captured = {} @@ -34,7 +35,7 @@ def test_worker_spawn_tags_session_source_kanban(monkeypatch, tmp_path): return _Proc() monkeypatch.setattr("subprocess.Popen", _fake_popen) - monkeypatch.setattr(kb, "_retag_legacy_worker_sessions", lambda _root: None) + monkeypatch.setattr(kbd, "_retag_legacy_worker_sessions", lambda _root: None) monkeypatch.setattr(kb, "worker_logs_dir", lambda board=None: tmp_path / "logs") task = kb.Task( @@ -57,7 +58,7 @@ def test_worker_spawn_tags_session_source_kanban(monkeypatch, tmp_path): workspace = str(tmp_path / "ws") os.makedirs(workspace, exist_ok=True) - kb._default_spawn(task, workspace) + kbd._default_spawn(task, workspace) assert captured["env"]["HERMES_SESSION_SOURCE"] == "kanban" diff --git a/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py b/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py index 14654b5168..c221d63f96 100644 --- a/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py +++ b/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py @@ -60,8 +60,9 @@ agent: monkeypatch.setenv("HERMES_HOME", str(root)) from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_dispatch as kbd - monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"]) + monkeypatch.setattr(kbd, "_resolve_hermes_argv", lambda: ["hermes"]) captured = {} @@ -78,7 +79,7 @@ agent: workspace = tmp_path / "workspace" workspace.mkdir() - pid = kb._default_spawn(_make_task(kb, assignee="elias"), str(workspace)) + pid = kbd._default_spawn(_make_task(kb, assignee="elias"), str(workspace)) assert pid == 4242 assert captured["env"]["HERMES_HOME"] == str(profile) @@ -102,9 +103,10 @@ def test_default_spawn_model_override_survives_real_cli_parse(monkeypatch, tmp_p monkeypatch.setenv("HERMES_HOME", str(root)) from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_dispatch as kbd from hermes_cli._parser import build_top_level_parser - monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"]) + monkeypatch.setattr(kbd, "_resolve_hermes_argv", lambda: ["hermes"]) captured = {} class FakeProc: @@ -120,7 +122,7 @@ def test_default_spawn_model_override_survives_real_cli_parse(monkeypatch, tmp_p workspace.mkdir() task = _make_task(kb, assignee="elias") task.model_override = "gpt-5.6-sol" - kb._default_spawn(task, str(workspace)) + kbd._default_spawn(task, str(workspace)) parser, _subparsers, _chat_parser = build_top_level_parser() # Profile selection is attached by the outer CLI bootstrap rather than @@ -153,8 +155,9 @@ toolsets: monkeypatch.setenv("HERMES_HOME", str(root)) from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_dispatch as kbd - resolved = kb._resolve_worker_cli_toolsets(str(profile)) + resolved = kbd._resolve_worker_cli_toolsets(str(profile)) assert resolved is not None assert "terminal" in resolved diff --git a/tests/hermes_cli/test_kanban_worker_terminal_cwd.py b/tests/hermes_cli/test_kanban_worker_terminal_cwd.py index b7a7c763e4..f2561ffbde 100644 --- a/tests/hermes_cli/test_kanban_worker_terminal_cwd.py +++ b/tests/hermes_cli/test_kanban_worker_terminal_cwd.py @@ -38,7 +38,9 @@ def _make_task(kb, *, assignee: str = "w"): def _capture_spawn_env(kb, monkeypatch, workspace: str) -> dict: - monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"]) + from hermes_cli import kanban_db_dispatch as kbd + + monkeypatch.setattr(kbd, "_resolve_hermes_argv", lambda: ["hermes"]) captured: dict = {} @@ -52,7 +54,7 @@ def _capture_spawn_env(kb, monkeypatch, workspace: str) -> dict: return FakeProc() monkeypatch.setattr(subprocess, "Popen", fake_popen) - kb._default_spawn(_make_task(kb), workspace) + kbd._default_spawn(_make_task(kb), workspace) return captured diff --git a/tests/hermes_cli/test_kanban_worktree_isolation.py b/tests/hermes_cli/test_kanban_worktree_isolation.py index 02538ae0f3..5932161fb9 100644 --- a/tests/hermes_cli/test_kanban_worktree_isolation.py +++ b/tests/hermes_cli/test_kanban_worktree_isolation.py @@ -22,6 +22,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_workspace as kbw @pytest.fixture @@ -67,7 +69,7 @@ def _add_worktree(repo: Path, target: Path, branch: str) -> Path: def test_decompose_worktree_children_get_own_workspace(kanban_home): - with kb.connect() as conn: + with kbc.connect() as conn: root = kb.create_task(conn, title="build the feature", triage=True) conn.execute( "UPDATE tasks SET workspace_kind='worktree', " @@ -105,7 +107,7 @@ def test_resolve_worktree_falls_back_when_path_occupied(kanban_home, tmp_path): repo = _make_repo(tmp_path) occupied = _add_worktree(repo, repo / ".worktrees" / "sibling", "wt/sibling") - with kb.connect() as conn: + with kbc.connect() as conn: tid = kb.create_task( conn, title="second sibling", @@ -114,7 +116,7 @@ def test_resolve_worktree_falls_back_when_path_occupied(kanban_home, tmp_path): ) task = kb.get_task(conn, tid) - workspace, branch = kb._resolve_worktree_workspace(task) + workspace, branch = kbw._resolve_worktree_workspace(task) assert workspace == (repo / ".worktrees" / tid).resolve() assert branch == f"wt/{tid}" # The sibling's checkout is untouched, still on its own branch. diff --git a/tests/hermes_cli/test_kanban_worktree_teardown.py b/tests/hermes_cli/test_kanban_worktree_teardown.py index 260835ddec..9c5100c880 100644 --- a/tests/hermes_cli/test_kanban_worktree_teardown.py +++ b/tests/hermes_cli/test_kanban_worktree_teardown.py @@ -17,6 +17,8 @@ from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_workspace as kbw +from hermes_cli import kanban_db_connect as kbc def _git(*args: str, cwd: str | None = None) -> str: @@ -66,7 +68,7 @@ def repo(tmp_path: Path) -> Path: def _make_worktree(repo: Path, task_id: str, branch: str | None = None) -> Path: target = repo / ".worktrees" / task_id - kb._ensure_git_worktree(repo, target, branch or f"wt/{task_id}") + kbw._ensure_git_worktree(repo, target, branch or f"wt/{task_id}") return target @@ -82,7 +84,7 @@ def _branch_exists(repo: Path, branch: str) -> bool: def test_clean_pushed_worktree_removed(repo: Path) -> None: wt = _make_worktree(repo, "t_aaaa1111") - kb._cleanup_worktree_workspace("t_aaaa1111", str(wt)) + kbw._cleanup_worktree_workspace("t_aaaa1111", str(wt)) assert not wt.exists() # auto-generated task branch goes with it assert not _branch_exists(repo, "wt/t_aaaa1111") @@ -93,7 +95,7 @@ def test_clean_pushed_worktree_removed(repo: Path) -> None: def test_dirty_worktree_preserved(repo: Path) -> None: wt = _make_worktree(repo, "t_bbbb2222") (wt / "wip.txt").write_text("uncommitted\n", encoding="utf-8") - kb._cleanup_worktree_workspace("t_bbbb2222", str(wt)) + kbw._cleanup_worktree_workspace("t_bbbb2222", str(wt)) assert wt.is_dir() assert (wt / "wip.txt").exists() @@ -103,20 +105,20 @@ def test_unpushed_commits_preserved(repo: Path) -> None: (wt / "work.txt").write_text("committed but not pushed\n", encoding="utf-8") _git("-C", str(wt), "add", "work.txt") _git("-C", str(wt), "commit", "-m", "local work") - kb._cleanup_worktree_workspace("t_cccc3333", str(wt)) + kbw._cleanup_worktree_workspace("t_cccc3333", str(wt)) assert wt.is_dir() def test_custom_branch_survives_worktree_removal(repo: Path) -> None: wt = _make_worktree(repo, "t_dddd4444", branch="feature/custom") - kb._cleanup_worktree_workspace("t_dddd4444", str(wt), "feature/custom") + kbw._cleanup_worktree_workspace("t_dddd4444", str(wt), "feature/custom") assert not wt.exists() # only auto-generated wt/* branches are deleted assert _branch_exists(repo, "feature/custom") def test_main_checkout_never_removed(repo: Path) -> None: - kb._cleanup_worktree_workspace("t_eeee5555", str(repo)) + kbw._cleanup_worktree_workspace("t_eeee5555", str(repo)) assert repo.is_dir() assert (repo / "README.md").exists() @@ -124,7 +126,7 @@ def test_main_checkout_never_removed(repo: Path) -> None: def test_non_git_dir_preserved(tmp_path: Path) -> None: plain = tmp_path / "not-a-worktree" plain.mkdir() - kb._cleanup_worktree_workspace("t_ffff6666", str(plain)) + kbw._cleanup_worktree_workspace("t_ffff6666", str(plain)) assert plain.is_dir() @@ -147,7 +149,7 @@ def test_tree_dirtied_between_check_and_removal_preserved( from hermes_cli import worktree_ops monkeypatch.setattr(worktree_ops, "_worktree_is_dirty", lambda _p: False) - kb._cleanup_worktree_workspace("t_gggg7777", str(wt)) + kbw._cleanup_worktree_workspace("t_gggg7777", str(wt)) assert wt.is_dir() assert (wt / "late-wip.txt").exists() @@ -170,7 +172,7 @@ def _worktree_task(conn, repo: Path, title: str = "wt-task") -> tuple[str, Path] def test_complete_task_reaps_clean_worktree(kanban_home: Path, repo: Path) -> None: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: tid, wt = _worktree_task(conn, repo) with kb.write_txn(conn): conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (tid,)) @@ -181,7 +183,7 @@ def test_complete_task_reaps_clean_worktree(kanban_home: Path, repo: Path) -> No def test_complete_task_preserves_dirty_worktree(kanban_home: Path, repo: Path) -> None: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: tid, wt = _worktree_task(conn, repo) (wt / "wip.txt").write_text("unsaved\n", encoding="utf-8") with kb.write_txn(conn): @@ -193,7 +195,7 @@ def test_complete_task_preserves_dirty_worktree(kanban_home: Path, repo: Path) - def test_archive_task_reaps_clean_worktree(kanban_home: Path, repo: Path) -> None: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: tid, wt = _worktree_task(conn, repo) assert kb.archive_task(conn, tid) assert not wt.exists() @@ -202,7 +204,7 @@ def test_archive_task_reaps_clean_worktree(kanban_home: Path, repo: Path) -> Non def test_parent_worktree_deferred_until_children_done( kanban_home: Path, repo: Path ) -> None: - with kb.connect_closing() as conn: + with kbc.connect_closing() as conn: parent, parent_wt = _worktree_task(conn, repo, title="parent") child = kb.create_task(conn, title="child", assignee="worker") kb.link_tasks(conn, parent, child) diff --git a/tests/hermes_cli/test_kanban_write_guard.py b/tests/hermes_cli/test_kanban_write_guard.py index aae43f5435..d6c1e950d8 100644 --- a/tests/hermes_cli/test_kanban_write_guard.py +++ b/tests/hermes_cli/test_kanban_write_guard.py @@ -5,6 +5,7 @@ from __future__ import annotations import pytest from hermes_cli import kanban_db +from hermes_cli import kanban_db_connect as kbc def test_connect_succeeds_under_test_home(tmp_path, monkeypatch): @@ -12,7 +13,7 @@ def test_connect_succeeds_under_test_home(tmp_path, monkeypatch): home = tmp_path / "hermes_home" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) - conn = kanban_db.connect() + conn = kbc.connect() try: assert str(kanban_db.kanban_db_path()).startswith(str(home)) finally: @@ -32,7 +33,7 @@ def test_connect_raises_when_kanban_home_is_real_root(monkeypatch): lambda board=None: _conftest._REAL_KANBAN_ROOT / "kanban.db", ) with pytest.raises(RuntimeError, match="kanban_write_guard"): - kanban_db.connect() + kbc.connect() def test_connect_raises_for_explicit_db_path_under_real_root(): @@ -40,4 +41,4 @@ def test_connect_raises_for_explicit_db_path_under_real_root(): import tests.conftest as _conftest with pytest.raises(RuntimeError, match="kanban_write_guard"): - kanban_db.connect(_conftest._REAL_KANBAN_ROOT / "kanban.db") + kbc.connect(_conftest._REAL_KANBAN_ROOT / "kanban.db") diff --git a/tests/hermes_cli/test_kanban_write_txn_busy_retry.py b/tests/hermes_cli/test_kanban_write_txn_busy_retry.py index 7b3e4f4a93..1857b54452 100644 --- a/tests/hermes_cli/test_kanban_write_txn_busy_retry.py +++ b/tests/hermes_cli/test_kanban_write_txn_busy_retry.py @@ -12,6 +12,7 @@ import sqlite3 import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc class _FakeConn: @@ -51,7 +52,7 @@ def _other(): @pytest.fixture(autouse=True) def _no_file_check(monkeypatch): # Isolate the boundary behaviour from the post-commit invariant. - monkeypatch.setattr(kb, "_check_file_length_invariant", lambda conn: None) + monkeypatch.setattr(kbc, "_check_file_length_invariant", lambda conn: None) def test_retry_sleep_respects_floor(monkeypatch): @@ -62,8 +63,8 @@ def test_retry_sleep_respects_floor(monkeypatch): with kb.write_txn(conn): pass assert slept - assert all(s >= kb._BUSY_RETRY_MIN_S for s in slept) - assert all(s <= kb._BUSY_RETRY_MAX_S for s in slept) + assert all(s >= kbc._BUSY_RETRY_MIN_S for s in slept) + assert all(s <= kbc._BUSY_RETRY_MAX_S for s in slept) def test_transient_busy_at_begin_is_absorbed(): diff --git a/tests/plugins/test_kanban_attachments.py b/tests/plugins/test_kanban_attachments.py index 5d7c527291..077c18117a 100644 --- a/tests/plugins/test_kanban_attachments.py +++ b/tests/plugins/test_kanban_attachments.py @@ -21,6 +21,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc # --------------------------------------------------------------------------- @@ -69,7 +70,7 @@ def _make_task(conn, title="t") -> str: def test_add_list_get_delete_attachment(kanban_home, tmp_path): - conn = kb.connect() + conn = kbc.connect() try: task_id = _make_task(conn) # Write a real blob under the per-task dir so delete can unlink it. @@ -111,7 +112,7 @@ def test_add_list_get_delete_attachment(kanban_home, tmp_path): def test_delete_attachment_missing_returns_none(kanban_home): - conn = kb.connect() + conn = kbc.connect() try: assert kb.delete_attachment(conn, 999999) is None finally: @@ -134,7 +135,7 @@ def test_attachments_root_is_per_board(kanban_home, monkeypatch): def test_worker_context_lists_attachments_with_absolute_path(kanban_home): - conn = kb.connect() + conn = kbc.connect() try: task_id = _make_task(conn, title="translate PDF") dest_dir = kb.task_attachments_dir(task_id) @@ -231,7 +232,7 @@ def test_download_unknown_attachment_404(client): def test_store_attachment_bytes_roundtrip(kanban_home): - conn = kb.connect() + conn = kbc.connect() try: task_id = _make_task(conn) att_id = kb.store_attachment_bytes( @@ -259,7 +260,7 @@ def test_store_attachment_bytes_roundtrip(kanban_home): def test_cli_attach_attachments_and_rm(kanban_home, tmp_path): from hermes_cli.kanban import run_slash - conn = kb.connect() + conn = kbc.connect() try: task_id = _make_task(conn, title="cli-attach") finally: @@ -271,7 +272,7 @@ def test_cli_attach_attachments_and_rm(kanban_home, tmp_path): out = run_slash(f"attach {task_id} {src}") assert "Attached" in out, out - conn = kb.connect() + conn = kbc.connect() try: atts = kb.list_attachments(conn, task_id) assert len(atts) == 1 @@ -286,7 +287,7 @@ def test_cli_attach_attachments_and_rm(kanban_home, tmp_path): removed = run_slash(f"attach-rm {att_id}") assert "Deleted attachment" in removed - conn = kb.connect() + conn = kbc.connect() try: assert kb.list_attachments(conn, task_id) == [] finally: diff --git a/tests/plugins/test_kanban_board_project_api.py b/tests/plugins/test_kanban_board_project_api.py index 4af38911d8..a6615019e7 100644 --- a/tests/plugins/test_kanban_board_project_api.py +++ b/tests/plugins/test_kanban_board_project_api.py @@ -17,6 +17,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli import projects_db as pdb @@ -112,7 +113,7 @@ def test_task_on_scoped_board_inherits_project(client, project): assert r.status_code == 200, r.text task_id = r.json()["task"]["id"] - conn = kb.connect(board="widget") + conn = kbc.connect(board="widget") try: assert kb.get_task(conn, task_id).project_id == project["id"] finally: diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 4ae35cb946..27d7390c04 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -20,6 +20,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc # --------------------------------------------------------------------------- @@ -146,7 +147,7 @@ def test_scheduled_tasks_have_their_own_column_not_todo(client): json={"title": "wait for indexed data", "assignee": "ops"}, ).json()["task"] - conn = kb.connect() + conn = kbc.connect() try: with kb.write_txn(conn): conn.execute( @@ -242,7 +243,7 @@ def test_patch_review_lifecycle_preserves_handoff_and_reopens(client): ) assert response.status_code == 200, response.text assert response.json()["task"]["status"] == "review" - with kb.connect() as conn: + with kbc.connect() as conn: run = kb.latest_run(conn, task["id"]) assert run is not None assert run.outcome == "review_requested" @@ -266,7 +267,7 @@ def test_patch_review_lifecycle_preserves_handoff_and_reopens(client): assert response.status_code == 200, response.text assert response.json()["task"]["status"] == "ready" assert response.json()["task"]["assignee"] == "builder" - with kb.connect() as conn: + with kbc.connect() as conn: assert any( event.kind == "review_reopened" for event in kb.list_events(conn, task["id"]) @@ -311,7 +312,7 @@ def test_reopening_parent_demotes_ready_child(client): def test_reopening_parent_retracts_review_and_blocks_approval(client): - with kb.connect() as conn: + with kbc.connect() as conn: parent_id = kb.create_task(conn, title="parent", assignee="planner") assert kb.complete_task(conn, parent_id) child_id = kb.create_task( @@ -343,7 +344,7 @@ def test_reopening_parent_retracts_review_and_blocks_approval(client): ) assert response.status_code == 200, response.text - with kb.connect() as conn: + with kbc.connect() as conn: child = kb.get_task(conn, child_id) assert child is not None assert child.status == "todo" @@ -362,7 +363,7 @@ def test_reopening_parent_retracts_review_and_blocks_approval(client): ) assert response.status_code == 200, response.text - with kb.connect() as conn: + with kbc.connect() as conn: child = kb.get_task(conn, child_id) assert child is not None assert child.status == "review" @@ -380,7 +381,7 @@ def test_reopening_parent_retracts_review_and_blocks_approval(client): def test_reopening_parent_recursively_retracts_done_and_running_descendants(client): - with kb.connect() as conn: + with kbc.connect() as conn: parent_id = kb.create_task(conn, title="root", assignee="planner") assert kb.complete_task(conn, parent_id) child_id = kb.create_task( @@ -405,7 +406,7 @@ def test_reopening_parent_recursively_retracts_done_and_running_descendants(clie ) assert response.status_code == 200, response.text - with kb.connect() as conn: + with kbc.connect() as conn: child = kb.get_task(conn, child_id) grandchild = kb.get_task(conn, grandchild_id) assert child is not None and child.status == "todo" @@ -421,7 +422,7 @@ def test_reopening_parent_recursively_retracts_done_and_running_descendants(clie json={"status": "done"}, ) assert response.status_code == 200, response.text - with kb.connect() as conn: + with kbc.connect() as conn: child = kb.get_task(conn, child_id) grandchild = kb.get_task(conn, grandchild_id) assert child is not None and child.status == "ready" @@ -429,7 +430,7 @@ def test_reopening_parent_recursively_retracts_done_and_running_descendants(clie def test_dashboard_reclaim_of_active_review_preserves_review_phase(client): - with kb.connect() as conn: + with kbc.connect() as conn: task_id = kb.create_task(conn, title="active review", assignee="reviewer") implementation = kb.claim_task(conn, task_id) assert implementation is not None @@ -449,7 +450,7 @@ def test_dashboard_reclaim_of_active_review_preserves_review_phase(client): assert response.status_code == 200, response.text assert response.json()["task"]["status"] == "review" assert response.json()["task"]["assignee"] == "reviewer" - with kb.connect() as conn: + with kbc.connect() as conn: run = kb.latest_run(conn, task_id) assert run is not None assert run.outcome == "reclaimed" @@ -538,7 +539,7 @@ def test_dispatch_dry_run(client): def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch): """Loopback mode: a missing or wrong ?token= must be rejected with policy-violation; the correct token is accepted. The kanban WS now - delegates to web_server._ws_auth_ok, so we stub that with the real + delegates to web_server_chat._ws_auth_ok, so we stub that with the real loopback-token semantics (auth_required False → constant-time token compare).""" home = tmp_path / ".hermes" @@ -547,7 +548,7 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) kb.init_db() - # Stub web_server with a loopback-mode _ws_auth_ok (auth_required False → + # Stub web_server_chat with a loopback-mode _ws_auth_ok (auth_required False → # accept only the correct ?token=). Mirrors the real gate's loopback path. import hermes_cli import types @@ -559,8 +560,8 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch): _SESSION_TOKEN="secret-xyz", _ws_auth_ok=_fake_ws_auth_ok, ) - monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub) - monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False) + monkeypatch.setitem(sys.modules, "hermes_cli.web_server_chat", stub) + monkeypatch.setattr(hermes_cli, "web_server_chat", stub, raising=False) app = FastAPI() app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") @@ -640,7 +641,7 @@ def test_bulk_review_assignment_preserves_implementer_provenance(client): ) assert response.status_code == 200, response.text assert all(item["ok"] for item in response.json()["results"]) - with kb.connect() as conn: + with kbc.connect() as conn: for task in tasks: current = kb.get_task(conn, task["id"]) assert current is not None @@ -672,7 +673,7 @@ def test_bulk_status_done_forwards_completion_summary(client): assert r.status_code == 200 assert all(r["ok"] for r in r.json()["results"]) - conn = kb.connect() + conn = kbc.connect() try: for tid in (a["id"], b["id"]): task = kb.get_task(conn, tid) @@ -977,7 +978,8 @@ def test_event_dict_includes_run_id(client): r = client.post("/api/plugins/kanban/tasks", json={"title": "e", "assignee": "worker"}) tid = r.json()["task"]["id"] from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: kb.claim_task(conn, tid) run_id = kb.latest_run(conn, tid).id @@ -1075,7 +1077,7 @@ def test_reclaim_endpoint_releases_running_claim(client): """POST /tasks//reclaim drops the claim, returns ok, and emits a manual reclaimed event.""" import secrets - conn = kb.connect() + conn = kbc.connect() try: t = kb.create_task(conn, title="running", assignee="x") lock = secrets.token_hex(8) @@ -1106,7 +1108,7 @@ def test_reclaim_endpoint_releases_running_claim(client): assert body["task_id"] == t # Confirm the task is back to ready. - conn2 = kb.connect() + conn2 = kbc.connect() try: row = conn2.execute( "SELECT status, claim_lock FROM tasks WHERE id=?", (t,), @@ -1119,7 +1121,7 @@ def test_reclaim_endpoint_releases_running_claim(client): def test_reassign_endpoint_switches_profile(client): """POST /tasks//reassign changes the assignee field.""" - conn = kb.connect() + conn = kbc.connect() try: t = kb.create_task(conn, title="task", assignee="orig") finally: @@ -1132,7 +1134,7 @@ def test_reassign_endpoint_switches_profile(client): assert r.status_code == 200, r.text assert r.json()["assignee"] == "newbie" - conn2 = kb.connect() + conn2 = kbc.connect() try: row = conn2.execute( "SELECT assignee FROM tasks WHERE id=?", (t,), @@ -1148,7 +1150,7 @@ def test_reassign_endpoint_switches_profile(client): def test_diagnostics_endpoint_surfaces_blocked_hallucination(client): - conn = kb.connect() + conn = kbc.connect() try: parent = kb.create_task(conn, title="parent", assignee="alice") real = kb.create_task(conn, title="real", assignee="x", created_by="alice") diff --git a/tests/plugins/test_kanban_dashboard_task_updated_hook.py b/tests/plugins/test_kanban_dashboard_task_updated_hook.py index 6aa452d8dc..2a203fc3a5 100644 --- a/tests/plugins/test_kanban_dashboard_task_updated_hook.py +++ b/tests/plugins/test_kanban_dashboard_task_updated_hook.py @@ -18,6 +18,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc from hermes_cli.plugins import get_plugin_manager @@ -67,7 +68,7 @@ def captured_updates(): def _make_task(title="t"): - conn = kb.connect() + conn = kbc.connect() try: return kb.create_task(conn, title=title, assignee="alice") finally: diff --git a/tests/plugins/test_kanban_model_override.py b/tests/plugins/test_kanban_model_override.py index 22221b5ac0..25904790e6 100644 --- a/tests/plugins/test_kanban_model_override.py +++ b/tests/plugins/test_kanban_model_override.py @@ -18,6 +18,8 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_dispatch as kbd # --------------------------------------------------------------------------- @@ -37,7 +39,7 @@ def kanban_home(tmp_path, monkeypatch): @pytest.fixture def conn(kanban_home): - c = kb.connect() + c = kbc.connect() yield c c.close() @@ -118,7 +120,7 @@ def test_migration_adds_provider_override_column(conn): def _spawn_and_capture(monkeypatch, tmp_path, task): - monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"]) + monkeypatch.setattr(kbd, "_resolve_hermes_argv", lambda: ["hermes"]) captured = {} class FakeProc: @@ -131,7 +133,7 @@ def _spawn_and_capture(monkeypatch, tmp_path, task): monkeypatch.setattr(subprocess, "Popen", fake_popen) workspace = tmp_path / "ws" workspace.mkdir(exist_ok=True) - kb._default_spawn(task, str(workspace)) + kbd._default_spawn(task, str(workspace)) return captured["cmd"] diff --git a/tests/plugins/test_kanban_ws_idle_disconnect.py b/tests/plugins/test_kanban_ws_idle_disconnect.py index 8f53692cc1..5b715a87a7 100644 --- a/tests/plugins/test_kanban_ws_idle_disconnect.py +++ b/tests/plugins/test_kanban_ws_idle_disconnect.py @@ -148,7 +148,7 @@ async def test_stream_events_reuses_connection_and_closes_after_disconnect( connect_threads.append(threading.get_ident()) return conn - monkeypatch.setattr(mod.kanban_db, "connect", _connect) + monkeypatch.setattr(mod.kbc, "connect", _connect) wait_calls = 0 @@ -199,7 +199,7 @@ async def test_stream_events_closes_connection_when_cancelled(monkeypatch): connect_threads.append(threading.get_ident()) return conn - monkeypatch.setattr(mod.kanban_db, "connect", _connect) + monkeypatch.setattr(mod.kbc, "connect", _connect) real_wait_for = asyncio.wait_for wait_calls = 0 diff --git a/tests/test_journal_mode_config.py b/tests/test_journal_mode_config.py index d83bf77e1e..d2fff91c1a 100644 --- a/tests/test_journal_mode_config.py +++ b/tests/test_journal_mode_config.py @@ -287,7 +287,7 @@ def test_real_db_openers_honor_configured_delete(monkeypatch, tmp_path): finally: session_db.close() - kanban_conn = kanban_db.connect(db_path=tmp_path / "kanban.db") + kanban_conn = kbc.connect(db_path=tmp_path / "kanban.db") try: observed["kanban"] = kanban_conn.execute( "PRAGMA journal_mode" diff --git a/tests/tools/test_kanban_comment_injection.py b/tests/tools/test_kanban_comment_injection.py index 89a4617b25..63f0b5a886 100644 --- a/tests/tools/test_kanban_comment_injection.py +++ b/tests/tools/test_kanban_comment_injection.py @@ -21,6 +21,7 @@ if str(_WORKTREE) not in sys.path: sys.path.insert(0, str(_WORKTREE)) from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc import tools.kanban_tools as kt @@ -66,7 +67,7 @@ def test_noop_without_worker_env(worker_home, monkeypatch): def test_seed_then_inject_new_comment(worker_home, monkeypatch): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="live task") kb.add_comment(conn, tid, author="desktop", body="pre-existing note") @@ -82,7 +83,7 @@ def test_seed_then_inject_new_comment(worker_home, monkeypatch): assert kt.inject_new_comments_from_env(agent) is False assert agent.steers == [] - conn = kb.connect() + conn = kbc.connect() try: kb.add_comment(conn, tid, author="desktop", body="actually use the v2 API") finally: @@ -100,7 +101,7 @@ def test_seed_then_inject_new_comment(worker_home, monkeypatch): def test_skips_own_authored_comments(worker_home, monkeypatch): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="echo guard") finally: @@ -113,7 +114,7 @@ def test_skips_own_authored_comments(worker_home, monkeypatch): _unthrottle() kt.inject_new_comments_from_env(agent) # seed - conn = kb.connect() + conn = kbc.connect() try: kb.add_comment(conn, tid, author="worker-bot", body="i did a thing") finally: diff --git a/tests/tools/test_kanban_redaction.py b/tests/tools/test_kanban_redaction.py index 026d4a9b30..62cca581e7 100644 --- a/tests/tools/test_kanban_redaction.py +++ b/tests/tools/test_kanban_redaction.py @@ -28,9 +28,10 @@ def worker_env(monkeypatch, tmp_path): monkeypatch.setattr(_Path, "home", lambda: tmp_path) from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc kb._INITIALIZED_PATHS.clear() kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="worker-test", assignee="test-worker") kb.claim_task(conn, tid) @@ -48,9 +49,10 @@ def test_kanban_comment_body_scrubbed_github_pat(worker_env): """ghp_ PAT in comment body must be masked before DB write.""" from tools import kanban_tools as kt from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc secret = "ghp_" + "A" * 40 kt._handle_comment({"task_id": worker_env, "body": f"token: {secret}"}) - conn = kb.connect() + conn = kbc.connect() try: comments = kb.list_comments(conn, worker_env) finally: @@ -65,6 +67,7 @@ def test_kanban_block_reason_scrubbed_jwt(worker_env): """JWT in block reason must be masked before DB write.""" from tools import kanban_tools as kt from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc # Minimal valid-ish JWT (header.payload.sig) jwt = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" @@ -72,7 +75,7 @@ def test_kanban_block_reason_scrubbed_jwt(worker_env): ".dozjgNryP4J3jVmNHl0w5N_5NjP1-iXkpHgcth826Iw" ) kt._handle_block({"reason": f"Bearer {jwt}"}) - conn = kb.connect() + conn = kbc.connect() try: run = kb.latest_run(conn, worker_env) finally: @@ -91,9 +94,10 @@ def test_kanban_comment_no_secret_passthrough(worker_env): """Plain text without credential patterns must pass through unchanged.""" from tools import kanban_tools as kt from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc plain = "hello from the pipeline — no secrets here" kt._handle_comment({"task_id": worker_env, "body": plain}) - conn = kb.connect() + conn = kbc.connect() try: comments = kb.list_comments(conn, worker_env) finally: @@ -111,9 +115,10 @@ def test_scrub_respects_force_flag_regardless_of_config(worker_env, monkeypatch) monkeypatch.setenv("HERMES_REDACT_SECRETS", "false") from tools import kanban_tools as kt from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc secret = "ghp_" + "C" * 40 kt._handle_comment({"task_id": worker_env, "body": f"token: {secret}"}) - conn = kb.connect() + conn = kbc.connect() try: comments = kb.list_comments(conn, worker_env) finally: @@ -130,9 +135,10 @@ def test_kanban_complete_result_field_scrubbed(worker_env): """Legacy result field must be scrubbed just like summary.""" from tools import kanban_tools as kt from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc secret = "sk-" + "D" * 48 kt._handle_complete({"result": f"finished with key={secret}"}) - conn = kb.connect() + conn = kbc.connect() try: run = kb.latest_run(conn, worker_env) finally: diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index b9d2a52e35..7cb982ceff 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -57,9 +57,10 @@ def worker_env(monkeypatch, tmp_path): monkeypatch.setattr(_Path, "home", lambda: tmp_path) from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc kb._INITIALIZED_PATHS.clear() kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="worker-test", assignee="test-worker") kb.claim_task(conn, tid) @@ -84,7 +85,8 @@ def test_list_filters_tasks(monkeypatch, worker_env): """kanban_list gives orchestrators filtered board discovery.""" monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: a = kb.create_task(conn, title="alpha", assignee="factory", priority=5) b = kb.create_task(conn, title="beta", assignee="reviewer") @@ -122,7 +124,8 @@ def test_complete_happy_path(worker_env): assert d["task_id"] == worker_env # Verify via kernel from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: run = kb.latest_run(conn, worker_env) assert run.outcome == "completed" @@ -137,6 +140,7 @@ def test_complete_retry_with_empty_created_cards_succeeds(worker_env): created_cards=[] (the documented escape hatch) must complete the task. Regression for #22923.""" from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc from tools import kanban_tools as kt # Hit the gate first. @@ -153,7 +157,7 @@ def test_complete_retry_with_empty_created_cards_succeeds(worker_env): })) assert ok.get("ok") is True - conn = kb.connect() + conn = kbc.connect() try: assert kb.get_task(conn, worker_env).status == "done" finally: @@ -165,6 +169,7 @@ def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path): Regression for #38367: workers bypassing the judge via early kanban_complete.""" from pathlib import Path as _Path from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc from tools import kanban_tools as kt # Set up isolated HERMES_HOME @@ -177,7 +182,7 @@ def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path): kb._INITIALIZED_PATHS.clear() kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: goal_task_id = kb.create_task( conn, title="goal-mode-test", assignee="test-worker", @@ -207,7 +212,7 @@ def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path): assert f"parents=[{goal_task_id}]" in d["error"] # Verify the task is NOT completed in the DB - conn2 = kb.connect() + conn2 = kbc.connect() try: task = kb.get_task(conn2, goal_task_id) assert task.status == "running" # Should still be running, not done @@ -221,7 +226,8 @@ def test_block_happy_path(worker_env): d = json.loads(out) assert d["ok"] is True from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: assert kb.get_task(conn, worker_env).status == "blocked" finally: @@ -233,6 +239,7 @@ def _make_goal_mode_worker_env(monkeypatch, tmp_path): matching the pattern used by the kanban_complete judge gate tests.""" from pathlib import Path as _Path from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc home = tmp_path / ".hermes" home.mkdir() @@ -243,7 +250,7 @@ def _make_goal_mode_worker_env(monkeypatch, tmp_path): kb._INITIALIZED_PATHS.clear() kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: goal_task_id = kb.create_task( conn, title="goal-mode-block-test", assignee="test-worker", @@ -262,6 +269,7 @@ def test_block_goal_mode_rejects_missing_kind(monkeypatch, tmp_path): sibling of the kanban_complete judge gate / Issue #38367).""" from tools import kanban_tools as kt from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc tid = _make_goal_mode_worker_env(monkeypatch, tmp_path) out = kt._handle_block({"reason": "giving up"}) @@ -269,7 +277,7 @@ def test_block_goal_mode_rejects_missing_kind(monkeypatch, tmp_path): assert "error" in d assert "goal_mode" in d["error"] - conn = kb.connect() + conn = kbc.connect() try: assert kb.get_task(conn, tid).status == "running" finally: @@ -281,6 +289,7 @@ def test_block_goal_mode_rejects_disallowed_kind(monkeypatch, tmp_path): let a goal_mode worker exit the loop without going through the judge.""" from tools import kanban_tools as kt from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc tid = _make_goal_mode_worker_env(monkeypatch, tmp_path) for kind in ("capability", "transient"): @@ -288,7 +297,7 @@ def test_block_goal_mode_rejects_disallowed_kind(monkeypatch, tmp_path): d = json.loads(out) assert "error" in d, f"kind={kind} should be rejected for goal_mode" - conn = kb.connect() + conn = kbc.connect() try: assert kb.get_task(conn, tid).status == "running" finally: @@ -307,11 +316,12 @@ def test_heartbeat_extends_claim_expires(worker_env): """ import time as _time from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc from tools import kanban_tools as kt # Rewind claim_expires into the past so any forward movement is # unambiguous (avoids time.sleep flakiness). - conn = kb.connect() + conn = kbc.connect() try: conn.execute( "UPDATE tasks SET claim_expires = ? WHERE id = ?", @@ -328,7 +338,7 @@ def test_heartbeat_extends_claim_expires(worker_env): out = kt._handle_heartbeat({"note": "still alive"}) assert json.loads(out).get("ok") is True - conn = kb.connect() + conn = kbc.connect() try: after = conn.execute( "SELECT claim_expires FROM tasks WHERE id = ?", (worker_env,) @@ -360,7 +370,8 @@ def test_comment_happy_path(worker_env): assert d["ok"] is True assert d["comment_id"] from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: comments = kb.list_comments(conn, worker_env) assert len(comments) == 1 @@ -385,7 +396,8 @@ def test_comment_ignores_caller_supplied_author(worker_env): }) assert json.loads(out)["ok"] from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: comments = kb.list_comments(conn, worker_env) # Author comes from HERMES_PROFILE in the fixture, not the @@ -407,7 +419,8 @@ def test_create_happy_path(worker_env): assert d["task_id"] assert d["status"] == "todo" # parent isn't done yet from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: child = kb.get_task(conn, d["task_id"]) assert child.title == "child task" @@ -418,7 +431,8 @@ def test_create_happy_path(worker_env): def test_link_happy_path(worker_env): from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: a = kb.create_task(conn, title="A", assignee="x") b = kb.create_task(conn, title="B", assignee="x") @@ -433,7 +447,8 @@ def test_link_happy_path(worker_env): def test_unblock_happy_path(monkeypatch, worker_env): monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: tid = kb.create_task(conn, title="blocked", assignee="worker") kb.block_task(conn, tid, reason="waiting") @@ -446,7 +461,7 @@ def test_unblock_happy_path(monkeypatch, worker_env): assert d["ok"] is True assert d["status"] == "ready" - conn = kb.connect() + conn = kbc.connect() try: assert kb.get_task(conn, tid).status == "ready" finally: @@ -463,9 +478,10 @@ def test_unblock_with_pending_parents_returns_todo(monkeypatch, tmp_path): monkeypatch.setattr(_Path, "home", lambda: tmp_path) from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc kb._INITIALIZED_PATHS.clear() kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: parent = kb.create_task(conn, title="parent", assignee="worker") child = kb.create_task(conn, title="child", assignee="worker", parents=[parent]) @@ -480,7 +496,7 @@ def test_unblock_with_pending_parents_returns_todo(monkeypatch, tmp_path): assert d["ok"] is True assert d["status"] == "todo" - conn = kb.connect() + conn = kbc.connect() try: assert kb.get_task(conn, child).status == "todo" finally: @@ -523,7 +539,8 @@ def test_worker_lifecycle_through_tools(worker_env): # Verify final state from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: parent = kb.get_task(conn, worker_env) assert parent.status == "done" @@ -599,7 +616,8 @@ def test_kanban_guidance_orchestrator_decision_ownership(): def test_worker_complete_rejects_foreign_task_id(worker_env): """A worker cannot complete a task that isn't its own (#19534).""" from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: other = kb.create_task(conn, title="sibling") conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (other,)) @@ -614,7 +632,7 @@ def test_worker_complete_rejects_foreign_task_id(worker_env): assert "refusing to mutate" in d.get("error", "") # Sibling task must be untouched. - conn = kb.connect() + conn = kbc.connect() try: assert kb.get_task(conn, other).status == "ready" finally: @@ -631,7 +649,8 @@ def test_worker_can_comment_on_foreign_task(worker_env): to ``_handle_comment`` would fail CI immediately. """ from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: other = kb.create_task(conn, title="sibling") finally: @@ -647,7 +666,7 @@ def test_worker_can_comment_on_foreign_task(worker_env): # The comment lands on the foreign task, attributed to the worker's # HERMES_PROFILE — never to a caller-controlled string. - conn = kb.connect() + conn = kbc.connect() try: comments = kb.list_comments(conn, other) assert len(comments) == 1 @@ -666,7 +685,8 @@ def test_worker_unblock_rejects_foreign_task_id(worker_env): pinning is "worker cannot mutate foreign task via kanban_unblock". """ from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect() try: other = kb.create_task(conn, title="blocked sibling", assignee="peer") kb.block_task(conn, other, reason="waiting") @@ -681,7 +701,7 @@ def test_worker_unblock_rejects_foreign_task_id(worker_env): f"expected worker-rejection error, got {err}" ) - conn = kb.connect() + conn = kbc.connect() try: assert kb.get_task(conn, other).status == "blocked" finally: @@ -699,9 +719,10 @@ def test_orchestrator_complete_any_task_allowed(monkeypatch, tmp_path): monkeypatch.setattr(_P, "home", lambda: tmp_path) from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc kb._INITIALIZED_PATHS.clear() kb.init_db() - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="child to close out") conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (tid,)) @@ -749,9 +770,10 @@ def multi_board_env(monkeypatch, tmp_path): monkeypatch.setattr(_Path, "home", lambda: tmp_path) from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc kb._INITIALIZED_PATHS.clear() # Default board — implicit - conn = kb.connect() + conn = kbc.connect() try: seed_default = kb.create_task( conn, title="seed-default", assignee="worker-d" @@ -759,7 +781,7 @@ def multi_board_env(monkeypatch, tmp_path): finally: conn.close() # Alt board — explicit slug routes the connection to a separate DB - conn = kb.connect(board="alt") + conn = kbc.connect(board="alt") try: seed_alt = kb.create_task( conn, title="seed-alt", assignee="worker-a" @@ -811,9 +833,11 @@ def test_board_param_none_falls_back_to_env(worker_env): def _list_subs_for_task(task_id): from hermes_cli import kanban_db as kb - conn = kb.connect() + from hermes_cli import kanban_db_connect as kbc + from hermes_cli import kanban_db_notify as kbn + conn = kbc.connect() try: - return list(kb.list_notify_subs(conn, task_id)) + return list(kbn.list_notify_subs(conn, task_id)) finally: conn.close() @@ -960,11 +984,12 @@ def test_maybe_auto_subscribe_swallows_add_notify_sub_failure(monkeypatch, worke monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "chat-42") from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_notify as kbn def _boom(*a, **kw): raise RuntimeError("simulated DB failure") - monkeypatch.setattr(kb, "add_notify_sub", _boom) + monkeypatch.setattr(kbn, "add_notify_sub", _boom) out = kt._handle_create({ "title": "auto-sub tolerates add_notify_sub failure", @@ -1029,13 +1054,14 @@ def _assert_attach_url_blocked(worker_env, url): """Call kanban_attach_url with ``url`` and assert the SSRF guard fired (clean tool error, no attachment row, no network fetch needed).""" from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc from tools import kanban_tools as kt out = kt._handle_attach_url({"url": url}) d = json.loads(out) assert "error" in d, out assert "SSRF" in d["error"] or "blocked" in d["error"].lower(), out - conn = kb.connect() + conn = kbc.connect() try: assert kb.list_attachments(conn, worker_env) == [] finally: @@ -1105,6 +1131,7 @@ def test_attach_url_happy_path_public_host(worker_env, default_url_guard, monkey import httpx from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc from tools import kanban_tools as kt _fake_public_dns(monkeypatch, {"files.example.com": "93.184.216.34"}) @@ -1126,7 +1153,7 @@ def test_attach_url_happy_path_public_host(worker_env, default_url_guard, monkey assert d.get("ok") is True, out assert d["size"] == len(payload) - conn = kb.connect() + conn = kbc.connect() try: atts = kb.list_attachments(conn, worker_env) assert [a.filename for a in atts] == ["spec.pdf"] diff --git a/tests/tui_gateway/test_kanban_notify_poller.py b/tests/tui_gateway/test_kanban_notify_poller.py index 575e4a0770..7600aa50f3 100644 --- a/tests/tui_gateway/test_kanban_notify_poller.py +++ b/tests/tui_gateway/test_kanban_notify_poller.py @@ -15,6 +15,8 @@ from types import SimpleNamespace from unittest.mock import patch from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc +from hermes_cli import kanban_db_notify as kbn from tui_gateway.server import ( _collect_kanban_notifications, _format_kanban_event_text, @@ -28,17 +30,17 @@ def _session(key: str = SESSION_KEY) -> dict: def _create_subscribed_task(*, chat_id: str = SESSION_KEY, platform: str = "tui"): - conn = kb.connect() + conn = kbc.connect() try: tid = kb.create_task(conn, title="notify tui", assignee="worker") - kb.add_notify_sub(conn, task_id=tid, platform=platform, chat_id=chat_id) + kbn.add_notify_sub(conn, task_id=tid, platform=platform, chat_id=chat_id) return tid finally: conn.close() def _complete(tid: str, summary: str = "all done") -> None: - conn = kb.connect() + conn = kbc.connect() try: kb.complete_task(conn, tid, summary=summary) finally: @@ -46,20 +48,20 @@ def _complete(tid: str, summary: str = "all done") -> None: def _sub_rows(tid: str) -> list: - conn = kb.connect() + conn = kbc.connect() try: - return kb.list_notify_subs(conn, task_id=tid) + return kbn.list_notify_subs(conn, task_id=tid) finally: conn.close() class TestCollectKanbanNotifications: def test_zero_sub_board_is_never_opened_writable(self): - conn = kb.connect() + conn = kbc.connect() conn.close() kb.create_board("second-board") - with patch.object(kb, "connect", wraps=kb.connect) as spy_connect: + with patch.object(kbc, "connect", wraps=kbc.connect) as spy_connect: texts = _collect_kanban_notifications(_session()) assert texts == [] @@ -82,7 +84,7 @@ class TestCollectKanbanNotifications: # The retained subscription must not replay the completed event. assert _collect_kanban_notifications(_session()) == [] - conn = kb.connect() + conn = kbc.connect() try: with kb.write_txn(conn): conn.execute( @@ -104,7 +106,7 @@ class TestCollectKanbanNotifications: assert rows[0]["last_event_id"] > first_cursor assert _collect_kanban_notifications(_session()) == [] - conn = kb.connect() + conn = kbc.connect() try: assert kb.archive_task(conn, tid) finally: @@ -117,13 +119,13 @@ class TestCollectKanbanNotifications: def test_matching_tui_sub_delivers_and_advances_cursor(self): tid = _create_subscribed_task() pre_cursor = _sub_rows(tid)[0]["last_event_id"] - conn = kb.connect() + conn = kbc.connect() try: kb.block_task(conn, tid, reason="waiting on review") finally: conn.close() - with patch.object(kb, "connect", wraps=kb.connect) as spy_connect: + with patch.object(kbc, "connect", wraps=kbc.connect) as spy_connect: first = _collect_kanban_notifications(_session()) second = _collect_kanban_notifications(_session()) @@ -145,7 +147,7 @@ class TestCollectKanbanNotifications: pre_cursor = _sub_rows(tid)[0]["last_event_id"] _complete(tid) - with patch.object(kb, "connect", wraps=kb.connect) as spy_connect: + with patch.object(kbc, "connect", wraps=kbc.connect) as spy_connect: texts = _collect_kanban_notifications(_session()) assert texts == [] @@ -159,7 +161,7 @@ class TestCollectKanbanNotifications: pre_cursor = _sub_rows(tid)[0]["last_event_id"] _complete(tid) - with patch.object(kb, "connect", wraps=kb.connect) as spy_connect: + with patch.object(kbc, "connect", wraps=kbc.connect) as spy_connect: texts = _collect_kanban_notifications(_session()) assert texts == [] @@ -175,8 +177,8 @@ class TestCollectKanbanNotifications: def fail_probe(*args, **kwargs): raise OSError("probe unavailable") - monkeypatch.setattr(kb, "count_notify_subs", fail_probe) - with patch.object(kb, "connect", wraps=kb.connect) as spy_connect: + monkeypatch.setattr(kbn, "count_notify_subs", fail_probe) + with patch.object(kbc, "connect", wraps=kbc.connect) as spy_connect: texts = _collect_kanban_notifications(_session()) assert len(texts) == 1 diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 801025e198..b776958a55 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -202,7 +202,8 @@ def _board(board: Optional[str], *, quiet_close: bool = False): contexts. ``board=None`` keeps the env/symlink resolution chain; an explicit slug overrides it per call. ``quiet_close`` swallows close() errors (best-effort bridges).""" from hermes_cli import kanban_db as kb - conn = kb.connect(board=board) + from hermes_cli import kanban_db_connect as kbc + conn = kbc.connect(board=board) try: yield kb, conn finally: @@ -425,12 +426,14 @@ def heartbeat_current_worker_from_env() -> bool: return False _auto_heartbeat_last_attempt = now try: + from hermes_cli import kanban_db_dispatch as kbd with _board(None, quiet_close=True) as (kb, conn): - ops = (("heartbeat_claim", {"claimer": os.environ.get("HERMES_KANBAN_CLAIM_LOCK")}), - ("heartbeat_worker", {"note": None, "expected_run_id": _worker_run_id(tid)})) - for op, kwargs in ops: + ops = ((kb.heartbeat_claim, {"claimer": os.environ.get("HERMES_KANBAN_CLAIM_LOCK")}), + (kbd.heartbeat_worker, {"note": None, "expected_run_id": _worker_run_id(tid)})) + for fn, kwargs in ops: + op = fn.__name__ try: - getattr(kb, op)(conn, tid, **kwargs) + fn(conn, tid, **kwargs) except Exception: logger.debug("auto-heartbeat: %s failed", op, exc_info=True) return True @@ -664,11 +667,12 @@ def _handle_heartbeat(args: dict, **kw) -> str: Without the claim half, a worker blocked in one long tool call would still be reclaimed by ``release_stale_claims``.""" tid = _worker_guard("kanban_heartbeat", args) + from hermes_cli import kanban_db_dispatch as kbd with _board(args.get("board")) as (kb, conn): # The dispatcher pins HERMES_KANBAN_CLAIM_LOCK at spawn; the default # claimer covers locally-driven workers that bypassed the dispatcher. kb.heartbeat_claim(conn, tid, claimer=os.environ.get("HERMES_KANBAN_CLAIM_LOCK")) - ok = kb.heartbeat_worker( + ok = kbd.heartbeat_worker( conn, tid, note=args.get("note"), expected_run_id=_worker_run_id(tid)) _check(ok, f"could not heartbeat {tid} (unknown id or not running)") return _ok(task_id=tid) @@ -904,7 +908,8 @@ def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool: if target is None: return False # CLI / cron / test — no persistent channel from hermes_cli import kanban_db as _kb - _kb.add_notify_sub(conn, task_id=task_id, **target) + from hermes_cli import kanban_db_notify as _kbn + _kbn.add_notify_sub(conn, task_id=task_id, **target) return True except Exception as _exc: logger.warning( diff --git a/tui_gateway/session_notifications.py b/tui_gateway/session_notifications.py index 6dd31b2910..3457bfa7ed 100644 --- a/tui_gateway/session_notifications.py +++ b/tui_gateway/session_notifications.py @@ -259,17 +259,19 @@ def _kb_poll_board(_kb, slug: str, session_key: str) -> list: """Claim + format this session's unseen events on one board. One poller per live session: the board is not opened writable unless it has a subscription owned by this exact session (a failed read-only probe — locked/corrupt DB — falls through so delivery is preserved).""" + from hermes_cli import kanban_db_connect as _kbc + from hermes_cli import kanban_db_notify as _kbn with contextlib.suppress(Exception): - if _kb.count_notify_subs(board=slug, platform="tui", chat_id=session_key) == 0: + if _kbn.count_notify_subs(board=slug, platform="tui", chat_id=session_key) == 0: return [] try: - conn = _kb.connect(board=slug) + conn = _kbc.connect(board=slug) except Exception: return [] texts: list = [] with contextlib.closing(conn): try: - subs = _kb.list_notify_subs(conn) + subs = _kbn.list_notify_subs(conn) except Exception: return [] for sub in subs: @@ -277,7 +279,7 @@ def _kb_poll_board(_kb, slug: str, session_key: str) -> list: continue sub_ident = dict(task_id=sub["task_id"], platform=sub["platform"], chat_id=sub["chat_id"], thread_id=sub.get("thread_id") or "") - _old, _new, events = _kb.claim_unseen_events_for_sub(conn, kinds=_KANBAN_NOTIFY_KINDS, **sub_ident) + _old, _new, events = _kbn.claim_unseen_events_for_sub(conn, kinds=_KANBAN_NOTIFY_KINDS, **sub_ident) if not events: continue task = _kb.get_task(conn, sub["task_id"]) @@ -286,7 +288,7 @@ def _kb_poll_board(_kb, slug: str, session_key: str) -> list: # later reopen notify the same session. The claimed cursor prevents replay. if task and getattr(task, "status", "") == "archived": with contextlib.suppress(Exception): - _kb.remove_notify_sub(conn, **sub_ident) + _kbn.remove_notify_sub(conn, **sub_ident) return texts