simplify(compat): kanban_db — drop 73 re-exports/aliases, repoint 794 callers

This commit is contained in:
Teknium
2026-09-03 13:48:14 -07:00
parent 53db597201
commit e3ab65fe80
88 changed files with 1155 additions and 998 deletions
+4 -2
View File
@@ -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=(
+7 -5
View File
@@ -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:
+8 -6
View File
@@ -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.<op>`` 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.<op>`` 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:
+18 -9
View File
@@ -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
+15 -5
View File
@@ -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 "<missing>")
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:
+4 -2
View File
@@ -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
+44 -40
View File
@@ -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
+2 -1
View File
@@ -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:
+4 -77
View File
@@ -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.<name>`` 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,
)
+8 -8
View File
@@ -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.<name>`` 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
+37 -48
View File
@@ -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.<name>`` 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 <profile> --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
+3 -3
View File
@@ -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.<name>`` 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
+3 -3
View File
@@ -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.<name>`` 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
+4 -3
View File
@@ -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]
+21 -18
View File
@@ -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}")
+6 -4
View File
@@ -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 "
+4 -3
View File
@@ -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]
+2 -1
View File
@@ -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)
+15 -11
View File
@@ -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",
@@ -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
+6 -5
View File
@@ -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
@@ -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",
+47 -45
View File
@@ -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",
@@ -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",
@@ -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()
@@ -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()
+12 -10
View File
@@ -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 == []
+4 -2
View File
@@ -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:
+3 -2
View File
@@ -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)
@@ -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(
@@ -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
+15 -13
View File
@@ -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"
+8 -5
View File
@@ -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(
@@ -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],
)
@@ -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")
@@ -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"],
)
@@ -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,
+60 -54
View File
@@ -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()
+16 -14
View File
@@ -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
+16 -14
View File
@@ -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
+6 -5
View File
@@ -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"])
+6 -5
View File
@@ -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)
@@ -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",
)
+2 -1
View File
@@ -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")
@@ -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"
@@ -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
@@ -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)
+3 -2
View File
@@ -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)
+30 -28
View File
@@ -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,
)
@@ -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).
@@ -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)
+45 -43
View File
@@ -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
+129 -89
View File
@@ -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()
@@ -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
@@ -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,
)
+2 -1
View File
@@ -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:
+4 -3
View File
@@ -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"
@@ -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")
@@ -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=<own run>``) 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
@@ -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
+18 -17
View File
@@ -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]
+5 -4
View File
@@ -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"}
+8 -7
View File
@@ -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)
+7 -6
View File
@@ -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,
@@ -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
+9 -8
View File
@@ -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)
@@ -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 ""
@@ -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
@@ -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"
@@ -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
@@ -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
@@ -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.
@@ -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)
+4 -3
View File
@@ -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")
@@ -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():
+8 -7
View File
@@ -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:
@@ -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:
+25 -23
View File
@@ -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/<id>/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/<id>/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")
@@ -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:
+5 -3
View File
@@ -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"]
@@ -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
+1 -1
View File
@@ -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"
+5 -4
View File
@@ -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:
+12 -6
View File
@@ -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:
+62 -35
View File
@@ -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"]
+17 -15
View File
@@ -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
+12 -7
View File
@@ -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(
+7 -5
View File
@@ -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