simplify(compat): kanban_db — drop 73 re-exports/aliases, repoint 794 callers
This commit is contained in:
@@ -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=(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user