From 8869927f2dab8c1ec192f4dc7e795c63db993952 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:51:13 -0700 Subject: [PATCH 01/38] =?UTF-8?q?refactor(cli-misc):=20logs.py=20=E2=80=94?= =?UTF-8?q?=20extract=20size/age=20labels,=20compact=20regex=20and=20docst?= =?UTF-8?q?rings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/logs.py | 121 +++++++++++++++------------------------------ 1 file changed, 40 insertions(+), 81 deletions(-) diff --git a/hermes_cli/logs.py b/hermes_cli/logs.py index 2ed920b19a..8ee79f5cbe 100644 --- a/hermes_cli/logs.py +++ b/hermes_cli/logs.py @@ -1,12 +1,7 @@ """``hermes logs`` — view and filter Hermes log files. -hermes logs # last 50 lines of agent.log hermes logs -f # follow agent.log in real time hermes logs -errors # last 50 lines of errors.log hermes logs gateway -n 100 # last 100 lines of gateway.log -hermes logs gui -f # follow gui.log (dashboard/pty/ws) hermes logs desktop -f # follow desktop.log -(Electron app boot/backend) hermes logs --level WARNING # only WARNING+ lines hermes logs --session -abc123 # filter by session ID substring hermes logs --component tools # only tool-related lines -hermes logs --since 1h # lines from the last hour hermes logs --since 30m -f # follow, starting 30 -min ago +``hermes logs [name] [-n N] [-f] [--level L] [--session S] [--component C] [--since 1h]``; +``hermes logs list`` shows the available files. """ import re @@ -30,33 +25,18 @@ LOG_FILES = { "mcp": "mcp-stderr.log", } -# Log line timestamp regex — matches "2026-04-05 22:35:00,123" or -# "2026-04-05 22:35:00" at the start of a line. +# "2026-04-05 22:35:00[,123]" at the start of a line. _TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})") - -# Level extraction — matches " INFO ", " WARNING ", " ERROR ", " DEBUG ", " CRITICAL " _LEVEL_RE = re.compile(r"\s(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s") - -# Logger name extraction — after level and optional session tag, the next -# non-space token before ":" is the logger name. -# Matches: "INFO gateway.run:" or "INFO [sess_abc] tools.terminal_tool:" -_LOGGER_NAME_RE = re.compile( - r"\s(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)" # level - r"(?:\s+\[.*?\])?" # optional session tag - r"\s+(\S+):" # logger name -) - -# Level ordering for >= filtering +# Logger name: the token before ":" after the level and optional "[session]" tag, +# e.g. "INFO gateway.run:" or "INFO [sess_abc] tools.terminal_tool:". +_LOGGER_NAME_RE = re.compile(r"\s(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)(?:\s+\[.*?\])?\s+(\S+):") _LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4} def _parse_since(since_str: str) -> Optional[datetime]: - """Parse a relative time string like '1h', '30m', '2d' into a datetime cutoff. - - Returns None if the string can't be parsed. - """ - since_str = since_str.strip().lower() - match = re.match(r"^(\d+)\s*([smhd])$", since_str) + """Parse a relative time like '1h', '30m', '2d' into a cutoff; None if unparseable.""" + match = re.match(r"^(\d+)\s*([smhd])$", since_str.strip().lower()) if not match: return None unit = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days"}[match.group(2)] @@ -64,7 +44,6 @@ def _parse_since(since_str: str) -> Optional[datetime]: def _parse_line_timestamp(line: str) -> Optional[datetime]: - """Extract timestamp from a log line. Returns None if not parseable.""" m = _TS_RE.match(line) if not m: return None @@ -75,19 +54,16 @@ def _parse_line_timestamp(line: str) -> Optional[datetime]: def _extract_level(line: str) -> Optional[str]: - """Extract the log level from a line.""" m = _LEVEL_RE.search(line) return m.group(1) if m else None def _extract_logger_name(line: str) -> Optional[str]: - """Extract the logger name from a log line.""" m = _LOGGER_NAME_RE.search(line) return m.group(1) if m else None def _line_matches_component(line: str, prefixes: Sequence[str]) -> bool: - """Check if a log line's logger name starts with any of *prefixes*.""" name = _extract_logger_name(line) return name is not None and name.startswith(tuple(prefixes)) @@ -100,7 +76,7 @@ def _matches_filters( since: Optional[datetime] = None, component_prefixes: Optional[Sequence[str]] = None, ) -> bool: - """Check if a log line passes all active filters.""" + """Whether a line passes all active filters (lines without a timestamp/level pass those).""" if since is not None: ts = _parse_line_timestamp(line) if ts is not None and ts < since: @@ -124,7 +100,7 @@ def tail_log( since: Optional[str] = None, component: Optional[str] = None, ) -> None: - """Read and display log lines, optionally following in real time.""" + """Print the filtered tail of a log, optionally following in real time.""" filename = LOG_FILES.get(log_name) if filename is None: print(f"Unknown log: {log_name!r}. Available: {', '.join(sorted(LOG_FILES))}") @@ -136,7 +112,6 @@ def tail_log( print("(Logs are created when Hermes runs — try 'hermes chat' first)") sys.exit(1) - # Parse --since into a datetime cutoff since_dt = None if since: since_dt = _parse_since(since) @@ -149,7 +124,6 @@ def tail_log( print(f"Invalid --level: {level!r}. Use DEBUG, INFO, WARNING, ERROR, or CRITICAL.") sys.exit(1) - # Resolve component to logger name prefixes component_prefixes = None if component: from hermes_logging import COMPONENT_PREFIXES @@ -164,14 +138,12 @@ def tail_log( since=since_dt, component_prefixes=component_prefixes) has_filters = any(v is not None for v in filters.values()) - # Read and display the tail try: lines = _read_tail(log_path, num_lines, has_filters=has_filters, **filters) except PermissionError: print(f"Permission denied: {log_path}") sys.exit(1) - # Print header filter_parts = [ f"{label}={value}" for label, value in (("level>", min_level), ("session", session), ("component", component), ("since", since)) @@ -186,8 +158,6 @@ def tail_log( if not follow: return - - # Follow mode — poll for new content try: _follow_log(log_path, **filters) except KeyboardInterrupt: @@ -207,8 +177,7 @@ def _read_tail( """Read the last *num_lines* matching lines from a log file.""" if not has_filters: return _read_last_n_lines(path, num_lines) - # Read more lines to ensure we get enough after filtering. - # For large files, read last 10K lines and filter down. + # Over-read so enough lines survive filtering. raw_lines = _read_last_n_lines(path, max(num_lines * 20, 2000)) filtered = [ l for l in raw_lines @@ -224,43 +193,33 @@ def _read_all_lines(path: Path) -> list: def _read_last_n_lines(path: Path, n: int) -> list: - """Efficiently read the last N lines from a file.""" + """Read the last N lines; files over 1MB are read in growing chunks from the end.""" try: size = path.stat().st_size if size == 0: return [] - - # For files up to 1MB, just read the whole thing — simple and correct. if size <= 1_048_576: return _read_all_lines(path)[-n:] - # For large files, read chunks from the end. with open(path, "rb") as f: chunk_size = 8192 lines = [] pos = size - while pos > 0 and len(lines) <= n + 1: read_size = min(chunk_size, pos) pos -= read_size f.seek(pos) - chunk = f.read(read_size) - chunk_lines = chunk.split(b"\n") + chunk_lines = f.read(read_size).split(b"\n") if lines: - # Merge the last partial line of the new chunk with the - # first partial line of what we already have. + # Join the chunk's trailing partial line with our leading partial line. lines[0] = chunk_lines[-1] + lines[0] lines = chunk_lines[:-1] + lines else: lines = chunk_lines chunk_size = min(chunk_size * 2, 65536) - - # Decode and return last N non-empty lines. decoded = [raw.decode("utf-8", errors="replace") + "\n" for raw in lines if raw.strip()] return decoded[-n:] - except Exception: - # Fallback: read entire file return _read_all_lines(path)[-n:] @@ -274,18 +233,34 @@ def _follow_log( ) -> None: """Poll a log file for new content and print matching lines.""" with open(path, "r", encoding="utf-8", errors="replace") as f: - # Seek to end f.seek(0, 2) while True: line = f.readline() - if line: - if _matches_filters(line, min_level=min_level, - session_filter=session_filter, since=since, - component_prefixes=component_prefixes): - print(line, end="") - sys.stdout.flush() - else: + if not line: time.sleep(0.3) + elif _matches_filters(line, min_level=min_level, session_filter=session_filter, + since=since, component_prefixes=component_prefixes): + print(line, end="") + sys.stdout.flush() + + +def _size_label(size: int) -> str: + if size < 1024: + return f"{size}B" + if size < 1024 * 1024: + return f"{size / 1024:.1f}KB" + return f"{size / (1024 * 1024):.1f}MB" + + +def _age_label(mtime: datetime) -> str: + age_s = (datetime.now() - mtime).total_seconds() + if age_s < 60: + return "just now" + if age_s < 3600: + return f"{int(age_s / 60)}m ago" + if age_s < 86400: + return f"{int(age_s / 3600)}h ago" + return mtime.strftime("%Y-%m-%d") def list_logs() -> None: @@ -300,24 +275,8 @@ def list_logs() -> None: for entry in sorted(log_dir.iterdir()): if entry.is_file() and entry.suffix == ".log": st = entry.stat() - size = st.st_size - mtime = datetime.fromtimestamp(st.st_mtime) - if size < 1024: - size_str = f"{size}B" - elif size < 1024 * 1024: - size_str = f"{size / 1024:.1f}KB" - else: - size_str = f"{size / (1024 * 1024):.1f}MB" - age_s = (datetime.now() - mtime).total_seconds() - if age_s < 60: - age_str = "just now" - elif age_s < 3600: - age_str = f"{int(age_s / 60)}m ago" - elif age_s < 86400: - age_str = f"{int(age_s / 3600)}h ago" - else: - age_str = mtime.strftime("%Y-%m-%d") - print(f" {entry.name:<25} {size_str:>8} {age_str}") + age_str = _age_label(datetime.fromtimestamp(st.st_mtime)) + print(f" {entry.name:<25} {_size_label(st.st_size):>8} {age_str}") found = True if not found: From ec13243b79f2df6612eed2717df2622c8e8ff5e5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:57:14 -0700 Subject: [PATCH 02/38] =?UTF-8?q?refactor(cli-misc):=20loops.py=20?= =?UTF-8?q?=E2=80=94=20compact=20docstrings/comments,=20collapse=20small?= =?UTF-8?q?=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/loops.py | 213 ++++++++++++++------------------------------ 1 file changed, 66 insertions(+), 147 deletions(-) diff --git a/hermes_cli/loops.py b/hermes_cli/loops.py index 7bff26f1d5..15ea2bb62f 100644 --- a/hermes_cli/loops.py +++ b/hermes_cli/loops.py @@ -1,9 +1,9 @@ """Recurring in-session wakeups — the /loop command (Claude Code parity). -- The agent ends a wakeup reply with ``LOOP_COMPLETE`` on its own line (the wakeup prompt teaches it -to do so when the task is done/moot). - ``--times N`` — stop after N ticks. - -Design notes / invariants (same contract as ``hermes_cli/goals.py``): +A loop stops when the agent ends a wakeup reply with ``LOOP_COMPLETE`` on its own line, when +``--times N`` ticks have fired, when the ``--until`` judge rules the condition met, or when the +``loops.max_ticks`` backstop pauses it. State lives in SessionDB ``state_meta`` (same contract as +``hermes_cli/goals.py``); CLI, gateway, and TUI all drive it through :class:`LoopManager`. """ from __future__ import annotations @@ -19,34 +19,22 @@ from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) -# ────────────────────────────────────────────────────────────────────── -# Constants & defaults -# ────────────────────────────────────────────────────────────────────── - -# Floor for fixed intervals. Claude Code allows 30s; anything tighter is -# almost always an accident that burns tokens polling state that hasn't -# changed. Overridable via loops.min_interval_seconds (still clamped ≥ 5). +# Floor for fixed intervals. Claude Code allows 30s; anything tighter is almost always an +# accident that burns tokens polling unchanged state. Config loops.min_interval_seconds (clamped ≥ 5). DEFAULT_MIN_INTERVAL_SECONDS = 30 - -# Backstop tick budget so an unattended loop can't run forever by default. -# 0 = unlimited (Claude Code behavior); config loops.max_ticks. +# Backstop tick budget so an unattended loop can't run forever. 0 = unlimited; config loops.max_ticks. DEFAULT_MAX_TICKS = 100 - -# Self-paced mode: start at the floor, double while replies are unchanged, -# cap at the ceiling, snap back to the floor on any change. +# Self-paced mode: start at the floor, double while replies are unchanged, cap at the +# ceiling, snap back to the floor on any change. DEFAULT_SELF_PACED_FLOOR_SECONDS = 60 DEFAULT_SELF_PACED_CEILING_SECONDS = 15 * 60 -# The completion sentinel the wakeup prompt teaches the agent to emit when -# the loop's task is finished or no longer applicable. +# Completion sentinel the wakeup prompt teaches the agent to emit. LOOP_COMPLETE_MARKER = "LOOP_COMPLETE" - -# Matches the marker on its own line (possibly with surrounding whitespace -# or trailing punctuation the model added despite instructions). +# Marker on its own line, tolerating surrounding whitespace / trailing punctuation. _LOOP_COMPLETE_RE = re.compile( r"(?im)^\s*" + re.escape(LOOP_COMPLETE_MARKER) + r"\s*[.!]?\s*$" ) - # Interval token: 30s / 5m / 2h / 1h30m (compound units allowed, at least one). _INTERVAL_TOKEN_RE = re.compile( r"^(?=\d)(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", re.IGNORECASE @@ -80,20 +68,15 @@ WAKEUP_PROMPT_WITH_UNTIL_TEMPLATE = ( ) -# ────────────────────────────────────────────────────────────────────── -# Interval parsing -# ────────────────────────────────────────────────────────────────────── +# --- interval parsing ------------------------------------------------------- def parse_interval_token(token: str) -> Optional[int]: - """Parse a compact interval token (``30s``/``5m``/``2h``/``1h30m``). + """Total seconds for ``30s``/``5m``/``2h``/``1h30m``, else None. - Returns total seconds, or None when the token is not an interval. A bare number is NOT an - interval (it collides with prompt text like ``/loop 3 things to check``); units are required. + A bare number is NOT an interval (it collides with prompt text like ``/loop 3 things``). """ - if not token: - return None - m = _INTERVAL_TOKEN_RE.match(token.strip()) + m = _INTERVAL_TOKEN_RE.match(token.strip()) if token else None if not m: return None h, mnt, s = (int(g) if g else 0 for g in m.groups()) @@ -102,11 +85,10 @@ def parse_interval_token(token: str) -> Optional[int]: def parse_loop_args(text: str) -> Dict[str, Any]: - """Parse the argument string of ``/loop [interval] [flags]``. + """Parse ``/loop [interval] [--times N] [--until ...]``. - Returns ``{"interval_seconds": int|None, "prompt": str, "times": int, "until": str, "error": - str|None}``. ``interval_seconds`` None means self-paced. ``error`` is set for unusable input - (empty prompt, interval-only, bad --times). + Returns ``{"interval_seconds": int|None, "prompt", "times", "until", "error"}``; + ``interval_seconds`` None means self-paced, ``error`` is set for unusable input. """ raw = (text or "").strip() result: Dict[str, Any] = {"interval_seconds": None, "prompt": "", "times": 0, "until": "", "error": None} @@ -114,10 +96,8 @@ def parse_loop_args(text: str) -> Dict[str, Any]: result["error"] = "empty" return result - # Pull trailing flags first so an interval-looking token inside the - # --until clause can't confuse the front parse. Flags may appear in - # either order at the end of the line; --until consumes to end-of-line - # (or to a following --times). + # Pull trailing flags first so an interval-looking token inside the --until clause can't + # confuse the front parse. --until consumes to end-of-line (or to a following --times). times, until = 0, "" m_times = re.search(r"\s--times\s+(\S+)", raw) if m_times: @@ -158,12 +138,9 @@ def parse_loop_args(text: str) -> Dict[str, Any]: def format_interval(seconds: float) -> str: """Render seconds as a compact human interval (``90`` → ``1m30s``).""" - seconds = int(max(0, round(seconds))) - h, rem = divmod(seconds, 3600) + h, rem = divmod(int(max(0, round(seconds))), 3600) m, s = divmod(rem, 60) - parts = [] - if h: - parts.append(f"{h}h") + parts = [f"{h}h"] if h else [] if m: parts.append(f"{m}m") if s or not parts: @@ -171,13 +148,10 @@ def format_interval(seconds: float) -> str: return "".join(parts) -# ────────────────────────────────────────────────────────────────────── -# Config -# ────────────────────────────────────────────────────────────────────── +# --- config ----------------------------------------------------------------- def _loops_config() -> Dict[str, Any]: - """Read the ``loops:`` config section (cached load_config underneath).""" try: from hermes_cli.config import load_config @@ -212,9 +186,7 @@ def self_paced_ceiling_seconds() -> int: return _config_int("self_paced_ceiling_seconds", max(floor, DEFAULT_SELF_PACED_CEILING_SECONDS), floor) -# ────────────────────────────────────────────────────────────────────── -# Dataclass -# ────────────────────────────────────────────────────────────────────── +# --- state ------------------------------------------------------------------ @dataclass @@ -233,18 +205,14 @@ class LoopState: created_at: float = 0.0 last_fired_at: float = 0.0 next_due_at: float = 0.0 - # True between "wakeup injected" and "that turn's response evaluated". - # Keeps a tick from double-firing while its turn is still running and - # tells the post-turn hook that the turn that just ended was ours. + # True between "wakeup injected" and "that turn's response evaluated": stops a tick from + # double-firing mid-turn and tells the post-turn hook the turn that just ended was ours. awaiting_response: bool = False - # Self-paced change detection: digest of the previous wakeup's reply. - last_response_digest: str = "" + last_response_digest: str = "" # self-paced change detection paused_reason: Optional[str] = None last_stop_reason: Optional[str] = None - # Gateway routing captured at creation time (platform / chat_id / - # chat_type / thread_id) so the idle wakeup watcher can inject the - # tick back into the right chat. Empty for CLI / TUI sessions, which - # drive ticks from their own session-local schedulers. + # Gateway routing (platform / chat_id / chat_type / thread_id) captured at creation so the + # idle watcher can inject ticks into the right chat. Empty for CLI/TUI (own schedulers). route: Dict[str, str] = field(default_factory=dict) def to_json(self) -> str: @@ -270,8 +238,6 @@ class LoopState: kwargs[f.name] = cast(data.get(f.name, f.default) or cast()) return cls(**kwargs) - # --- helpers ------------------------------------------------------- - def cadence_label(self) -> str: if self.mode == "self_paced": live = f", currently {format_interval(self.current_delay)}" if self.current_delay else "" @@ -282,14 +248,10 @@ class LoopState: if self.status != "active": return "" remaining = self.next_due_at - time.time() - if remaining <= 0: - return "due now" - return f"next in {format_interval(remaining)}" + return "due now" if remaining <= 0 else f"next in {format_interval(remaining)}" -# ────────────────────────────────────────────────────────────────────── -# Persistence (SessionDB state_meta) -# ────────────────────────────────────────────────────────────────────── +# --- persistence (SessionDB state_meta) ------------------------------------- _META_PREFIX = "loop:" @@ -299,13 +261,8 @@ def _meta_key(session_id: str) -> str: def _get_session_db() -> Optional[Any]: - """One SessionDB per HERMES_HOME. - - Delegates to the goals module's cached SessionDB so goals, loops, and heartbeats share one - connection (same pattern as ``hermes_cli/heartbeat.py``). The delegation also inherits the off- - loop bootstrap and the window logic: a cold cache on the loop thread never runs ``SessionDB()`` - inline. - """ + """The goals module's cached SessionDB, so goals/loops/heartbeats share one connection and + its off-loop bootstrap (a cold cache on the loop thread never runs ``SessionDB()`` inline).""" try: from hermes_cli.goals import _get_session_db as _goals_db except Exception as exc: # pragma: no cover @@ -356,17 +313,15 @@ def save_loop(session_id: str, state: LoopState) -> None: def clear_loop(session_id: str) -> None: """Mark a loop cleared in the DB (preserved for audit, status=cleared).""" state = load_loop(session_id) - if state is None: - return - state.status = "cleared" - save_loop(session_id, state) + if state is not None: + state.status = "cleared" + save_loop(session_id, state) def list_active_loops() -> List[Tuple[str, LoopState]]: - """Return ``[(session_id, LoopState), ...]`` for every ACTIVE loop. + """``[(session_id, LoopState), ...]`` for every ACTIVE loop; ``[]`` on any DB error. - Used by the gateway's idle wakeup watcher, which has no per-session scheduler and scans for - due loops on a coarse tick. Best-effort: any DB error yields ``[]``. + Used by the gateway's idle wakeup watcher, which scans for due loops on a coarse tick. """ db = _get_session_db() if db is None: @@ -386,19 +341,16 @@ def list_active_loops() -> List[Tuple[str, LoopState]]: def migrate_loop_to_session(old_session_id: str, new_session_id: str, *, reason: str = "") -> bool: - """Carry a persistent /loop from a parent session to its continuation. + """Carry a /loop from a parent session to its continuation. Best-effort, never raises. - Context compression rotates ``session_id`` to a fresh child session; without this the loop - silently dies at the compaction boundary (the same hazard /goal hit in #33618). Best-effort and - never raises. + Context compression rotates ``session_id`` to a fresh child; without this the loop silently + dies at the compaction boundary. """ if not old_session_id or not new_session_id or old_session_id == new_session_id: return False try: state = load_loop(old_session_id) - if state is None or state.status == "cleared": - return False - if load_loop(new_session_id) is not None: + if state is None or state.status == "cleared" or load_loop(new_session_id) is not None: return False save_loop(new_session_id, state) clear_loop(old_session_id) @@ -412,9 +364,7 @@ def migrate_loop_to_session(old_session_id: str, new_session_id: str, *, reason: return False -# ────────────────────────────────────────────────────────────────────── -# Response evaluation helpers -# ────────────────────────────────────────────────────────────────────── +# --- response evaluation ---------------------------------------------------- def _ticks_label(n: int) -> str: @@ -427,13 +377,9 @@ def response_signals_complete(response: str) -> bool: def _digest_response(response: str) -> str: - """Stable digest for self-paced change detection. - - Normalizes whitespace and strips volatile timestamp-ish tokens so a reply that differs only by - 'checked at 14:02:33' doesn't defeat the backoff. - """ + """Digest for self-paced change detection; whitespace-normalized with clock/timestamp/duration + tokens stripped so 'checked at 14:02:33' doesn't defeat the backoff.""" text = (response or "").strip().lower() - # Drop clock/timestamp tokens (14:02:33, 2026-07-26, 1500s, 25m ago...). text = re.sub(r"\d{1,2}:\d{2}(:\d{2})?", "", text) text = re.sub(r"\d{4}-\d{2}-\d{2}", "", text) text = re.sub(r"\b\d+(\.\d+)?\s*(s|sec|secs|seconds|m|min|mins|minutes|h|hr|hrs|hours)\b", "", text) @@ -441,26 +387,21 @@ def _digest_response(response: str) -> str: return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest() -# ────────────────────────────────────────────────────────────────────── -# LoopManager — the orchestration surface CLI + gateway + TUI talk to -# ────────────────────────────────────────────────────────────────────── +# --- LoopManager ------------------------------------------------------------ class LoopManager: """Per-session /loop state + tick decisions. - Drivers (CLI process_loop, gateway wakeup watcher, TUI ticker) call ``set``/``pause``/ - ``resume``/``clear`` for user controls, ``is_due()`` (cheap, in-memory), ``fire_tick()`` to - claim a tick and get the wakeup message, ``complete_tick()`` to evaluate the finished turn - (LOOP_COMPLETE, --until judge, --times caps, next-tick scheduling), and ``status_line()``. + Drivers call ``set``/``pause``/``resume``/``clear`` for user controls, ``is_due()`` (cheap, + in-memory), ``fire_tick()`` to claim a tick and get the wakeup message, ``complete_tick()`` to + evaluate the finished turn, and ``status_line()``. """ def __init__(self, session_id: str): self.session_id = session_id self._state: Optional[LoopState] = load_loop(session_id) - # --- introspection ------------------------------------------------ - @property def state(self) -> Optional[LoopState]: return self._state @@ -501,8 +442,6 @@ class LoopManager: return f"✓ Loop finished ({fired}{extra}): {s.prompt}" return f"Loop ({s.status}, {meta}): {s.prompt}" - # --- mutation ----------------------------------------------------- - def set( self, prompt: str, @@ -565,8 +504,6 @@ class LoopManager: self._state = None return True - # --- tick lifecycle ------------------------------------------------- - def is_due(self, now: Optional[float] = None) -> bool: """Cheap check: active, not mid-wakeup, and the clock has passed.""" s = self._state @@ -575,12 +512,11 @@ class LoopManager: return (now if now is not None else time.time()) >= s.next_due_at def fire_tick(self) -> Optional[str]: - """Claim a due tick. Returns the message to inject, or None. + """Claim a due tick; returns the message to inject, or None. - Returns the wakeup-framed prompt, or the raw command when the loop's prompt is itself a - slash command (``/loop 10m /recap``) so normal slash dispatch handles it. Marks - ``awaiting_response`` so the tick can't double-fire; drivers MUST follow up with - ``complete_tick`` (or ``abandon_tick`` on injection failure). + The message is the wakeup-framed prompt, or the raw command when the loop's prompt is + itself a slash command (``/loop 10m /recap``). Marks ``awaiting_response`` so the tick + can't double-fire; drivers MUST follow up with ``complete_tick`` (or ``abandon_tick``). """ s = self._state if s is None or not self.is_due(): @@ -588,10 +524,8 @@ class LoopManager: s.ticks_fired += 1 s.last_fired_at = time.time() s.awaiting_response = True - # Provisionally schedule the next tick from NOW; complete_tick - # reschedules from turn end (so a 10-minute turn doesn't cause an - # instant re-fire), but if the process dies mid-turn the provisional - # schedule keeps the persisted loop from being 'due' in a tight loop. + # Provisional schedule from NOW: complete_tick reschedules from turn end, but if the + # process dies mid-turn this keeps the persisted loop from being 'due' in a tight loop. delay = s.current_delay or s.interval_seconds or self_paced_floor_seconds() s.next_due_at = s.last_fired_at + delay save_loop(self.session_id, s) @@ -625,9 +559,8 @@ class LoopManager: def complete_tick(self, last_response: str) -> Dict[str, Any]: """Evaluate the finished wakeup turn and schedule what's next. - Returns ``{"status": "active|done|paused", "stopped": bool, "reason": str, - "message": str}``; ``message`` is a user-visible one-liner, "" in the common - still-looping case. + Returns ``{"status": "active|done|paused", "stopped": bool, "reason": str, "message": str}``; + ``message`` is a user-visible one-liner, "" in the common still-looping case. """ s = self._state if s is None or not s.awaiting_response: @@ -653,8 +586,7 @@ class LoopManager: return self._stop("done", f"stop condition met: {reason}", f"✓ Loop finished after {ticks} — {reason}") if verdict == "blocked": - # Judge ruled the stop condition unachievable — don't spin - # until the tick budget; pause so the user can re-scope. + # Unachievable stop condition: pause so the user can re-scope, don't spin. why = f"stop condition judged unachievable: {reason}" return self._stop("paused", why, f"⏸ Loop paused — {why}. /loop resume to keep going, /loop stop to end it.") @@ -676,10 +608,8 @@ class LoopManager: if s.mode == "self_paced": digest = _digest_response(last_response) floor = self_paced_floor_seconds() - ceiling = self_paced_ceiling_seconds() if digest and digest == s.last_response_digest: - # Nothing changed — back off. - s.current_delay = min(max(s.current_delay, floor) * 2, ceiling) + s.current_delay = min(max(s.current_delay, floor) * 2, self_paced_ceiling_seconds()) else: s.current_delay = float(floor) s.last_response_digest = digest @@ -690,31 +620,22 @@ class LoopManager: return {"status": "active", "stopped": False, "reason": "loop continues", "message": ""} -# ────────────────────────────────────────────────────────────────────── -# /goal mixing -# ────────────────────────────────────────────────────────────────────── - - def goal_blocks_loop_tick(session_id: str) -> bool: - """True when an ACTIVE /goal should defer this session's /loop tick. + """True when an ACTIVE, non-parked /goal should defer this session's /loop tick. - Both features inject synthetic turns at idle boundaries. An active, non-parked goal owns the - boundary; firing a loop wakeup in between would interleave two synthetic conversations and - burn the goal's turn budget. Parked, paused, or done goals do NOT block the loop. + Both features inject synthetic turns at idle boundaries; interleaving them would burn the + goal's turn budget. Parked (waiting), paused, or done goals do NOT block the loop. """ try: from hermes_cli.goals import GoalManager mgr = GoalManager(session_id=session_id) - # Parked (waiting) goal → the loop may use the idle time. return mgr.is_active() and not mgr.is_waiting() except Exception: return False -# ────────────────────────────────────────────────────────────────────── -# Shared slash-command dispatch (CLI + gateway + TUI use the same logic) -# ────────────────────────────────────────────────────────────────────── +# --- shared slash-command dispatch (CLI + gateway + TUI) -------------------- LOOP_HELP = ( @@ -760,12 +681,10 @@ def dispatch_loop_command( *, route: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: - """Surface-agnostic handler for ``/loop ``. + """Surface-agnostic handler for ``/loop `` → ``{"output": str, "created": bool}``. - Returns ``{"output": str, "created": bool}``. ``output`` is ready to print/send verbatim; each - surface only decorates it (dim colors on the CLI, plain text on messaging platforms). ``route`` - is stored on newly created loops so the gateway's idle watcher can inject wakeups back into the - right chat; CLI/TUI pass None. + ``output`` is printed/sent verbatim by each surface. ``route`` is stored on new loops so the + gateway's idle watcher can inject wakeups into the right chat; CLI/TUI pass None. """ arg = (args or "").strip() control = _CONTROL_COMMANDS.get(arg.lower()) From bf7cd6a6a6bfc4800df9f175f935463d7ec37809 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:06:35 -0700 Subject: [PATCH 03/38] =?UTF-8?q?refactor(cli-misc):=20linux=5Fdesktop=5Fe?= =?UTF-8?q?ntry.py=20=E2=80=94=20hoist=20nested=20helpers,=20flatten=20wra?= =?UTF-8?q?pper=20probe,=20compact=20WHY=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/linux_desktop_entry.py | 513 +++++++++++------------------- 1 file changed, 183 insertions(+), 330 deletions(-) diff --git a/hermes_cli/linux_desktop_entry.py b/hermes_cli/linux_desktop_entry.py index 6cfab8697c..1b3665076e 100644 --- a/hermes_cli/linux_desktop_entry.py +++ b/hermes_cli/linux_desktop_entry.py @@ -1,16 +1,17 @@ """Install and remove the Linux desktop entry (``hermes.desktop``). -Two values must be absolute for the entry to work: - -Cache refresh is best-effort and tool-gated: ``update-desktop-database`` for the freedesktop menu -cache, ``gtk-update-icon-cache`` for the user hicolor tree, and ``kbuildsycoca6``/``kbuildsycoca5`` -for Plasma. Run each tool only when it exists. A missing tool is not an error. +The entry must be launch-context independent: ``Exec=`` is an absolute launcher that survives the +venv (no ``#!/usr/bin/env python3`` escapes, no checkout-internal argv[0]), and ``Icon=`` is the +themed name backed by a copy in the user's hicolor tree. Cache refresh is best-effort and +tool-gated (``update-desktop-database``, ``gtk-update-icon-cache``, ``kbuildsycoca6``/``5``); a +missing tool is not an error. """ from __future__ import annotations import io import os +import re import shutil import struct import subprocess @@ -20,6 +21,8 @@ from typing import Optional DESKTOP_ENTRY_NAME = "hermes.desktop" +_SHELL_NAMES = ("bash", "sh", "dash", "zsh", "ksh") + def is_supported() -> bool: """XDG desktop entries exist only on Linux and BSD.""" @@ -34,7 +37,6 @@ def _xdg_data_home() -> Path: def desktop_entry_path() -> Path: - """Where the ``hermes.desktop`` entry lives.""" return _xdg_data_home() / "applications" / DESKTOP_ENTRY_NAME @@ -44,18 +46,17 @@ def icon_path(project_root: Path) -> Path: def _running_interpreter() -> str: - """The venv-semantic interpreter path for the persisted ``Exec=`` line. ``sys.executable`` inside a - venv is commonly a SYMLINK into a shared base-interpreter tree (uv, pyenv, conda). - ``Path.resolve()`` follows it out of the venv, and CPython discovers ``pyvenv.cfg`` from the - *lexical* argv[0] — so a dereferenced path boots without the venv's site-packages and dies on - the first third-party import (#90292, one level up; identified in #80547's review and confirmed - on real Zorin/uv hardware in this PR's review). + """Venv-semantic interpreter path for the persisted ``Exec=`` line. + + ``sys.executable`` inside a venv is commonly a SYMLINK into a shared base-interpreter tree + (uv, pyenv, conda). ``resolve()`` follows it out of the venv, and CPython discovers + ``pyvenv.cfg`` from the *lexical* argv[0] — so a dereferenced path boots without the venv's + site-packages. Keep the lexical form when any ancestor holds a ``pyvenv.cfg``. """ lexical = os.path.abspath(sys.executable) path = Path(lexical) - for base in (path.parent, *path.parent.parents): - if (base / "pyvenv.cfg").is_file(): - return lexical + if any((base / "pyvenv.cfg").is_file() for base in (path.parent, *path.parent.parents)): + return lexical return str(path.resolve()) @@ -65,10 +66,10 @@ _probe_cache: "dict[str, bool]" = {} def _can_import_hermes_cli(interpreter: Path) -> bool: """Whether *interpreter* can import ``hermes_cli.main`` unaided. - Runs the import in a subprocess under ``-I`` (isolated mode: no user site, no PYTHONPATH - inheritance, no cwd on ``sys.path``) from a neutral cwd, so the answer matches what a cold - desktop environment would get — a checkout cwd or an inherited ``PYTHONPATH`` cannot produce a - false positive. + Runs under ``-I`` (no user site, no PYTHONPATH, no cwd on ``sys.path``) from a neutral cwd, so + the answer matches a cold desktop environment. Cached per process; an unprobeable interpreter + (missing binary, spawn failure, timeout) is assumed capable and deliberately NOT cached, so one + transient hiccup doesn't freeze the assumption for the session. """ key = str(interpreter) cached = _probe_cache.get(key) @@ -83,225 +84,156 @@ def _can_import_hermes_cli(interpreter: Path) -> bool: check=False, timeout=15, ) - ok = result.returncode == 0 except (OSError, subprocess.SubprocessError): - # Unprobeable (missing binary, spawn failure, timeout): do not - # punish the entry on infra hiccups — assume capable and let the - # existing fallback chain handle a genuinely broken interpreter. - # This error-derived answer is deliberately NOT cached: one - # transient hiccup must not freeze the "capable" assumption for - # the whole session; the next install attempt re-probes. return True - _probe_cache[key] = ok + _probe_cache[key] = ok = result.returncode == 0 return ok def _running_interpreter_fallback() -> str: - """The interpreter to persist when the candidate fails the import probe. - - The RUNNING interpreter by definition has ``hermes_cli`` importable (this module is executing), - so the module-form entry under it is the safe landing when every candidate path failed the - capability check. - """ + """The RUNNING interpreter: it has ``hermes_cli`` importable by definition.""" return os.path.abspath(sys.executable) def resolve_exec_command(project_root: Optional[Path] = None) -> str: """Build the absolute ``Exec=`` command line for ``hermes desktop``. - Prefer the real ``hermes`` executable (argv[0] or PATH). When Hermes runs as a module with no - launcher installed, use the current interpreter, also absolute. - - The persisted entry must be launch-context independent: whatever process writes it, the next - launch must read and rewrite the same bytes. + Prefer the real ``hermes`` launcher; fall back to `` -m hermes_cli.main desktop``. """ from hermes_cli.relaunch import resolve_hermes_bin - bin_path = _resolve_hermes_bin_for_desktop_entry( - resolve_hermes_bin, checkout_root=project_root - ) + bin_path = _resolve_hermes_bin_for_desktop_entry(resolve_hermes_bin, checkout_root=project_root) interpreter = _running_interpreter() if not _can_import_hermes_cli(Path(interpreter)): - # The candidate interpreter cannot actually import hermes_cli.main - # (checked in isolated mode from a neutral cwd — so the probe can't - # be fooled by a checkout cwd or an inherited PYTHONPATH). Persisting - # it would write a dead entry: the DE spawns the Exec line in a cold - # environment where exactly this import has to succeed. Fall back to - # the module form under the RUNNING interpreter, which by definition - # has the CLI importable. Probe design follows the isolated-mode - # capability check proposed by @nosliwhtes (#92122 review lineage, - # commit 4150501f641) — cached here per-process so a desktop launch - # pays the subprocess cost at most once. + # Persisting an interpreter that can't import the CLI writes a dead entry (the DE spawns + # Exec in a cold environment where exactly this import must succeed). interpreter = _running_interpreter_fallback() if bin_path: resolved = Path(bin_path).resolve() if _needs_interpreter(resolved): - # The resolved launcher is a Python script whose shebang points at - # a NON-venv interpreter (e.g. the repo's `hermes` script with - # `#!/usr/bin/env python3` when argv[0] came from the shell - # installer's bash wrapper). Launched from the .desktop entry that - # shebang resolves to the SYSTEM python and dies on the first - # third-party import (#90292) — silently, since Terminal=false. - # sys.executable is the interpreter actually running Hermes (the - # venv one), so prefix it explicitly. + # A Python launcher whose shebang points OUTSIDE the venv (e.g. the repo's `hermes` + # script with `#!/usr/bin/env python3`) would die silently on the first third-party + # import under Terminal=false — run it under the venv interpreter explicitly. argv = [interpreter, str(resolved), "desktop"] else: argv = [str(resolved), "desktop"] else: - argv = [ - interpreter, - "-m", - "hermes_cli.main", - "desktop", - ] + argv = [interpreter, "-m", "hermes_cli.main", "desktop"] return " ".join(_quote_exec_arg(a) for a in argv) +def _is_interpreter(candidate: Path) -> bool: + """A python interpreter binary (``bin/python*``), not a launcher. + + Strict basename match — accepts ``python``, ``python3``, ``python3.11``; rejects lookalikes + such as ``python3-config`` and ``pythonw``. The parent-dir guard keeps a script named + ``python`` outside a bin/Scripts tree from being misclassified. + """ + if not re.fullmatch(r"python[23]?(\d+)?(\.\d+)?", candidate.name.lower()): + return False + return candidate.parent.name in {"bin", "scripts"} + + +def _inside_checkout(candidate: str, checkout_root: Path, original_argv0: str) -> bool: + """Whether a launcher candidate is a launch-context artifact rather than a durable launcher.""" + try: + path = Path(candidate).resolve() + except OSError: + return False + # Anything shipped in the tree (e.g. the repo `hermes` script) is checkout-internal. Compare + # against BOTH the lexical and resolved roots: candidates resolve, so a symlinked home needs + # the resolved comparison too. + try: + resolved_root = checkout_root.resolve() + except OSError: + resolved_root = None + for root in {checkout_root, resolved_root}: + if root is not None and (path == root or root in path.parents): + return True + # The `python -m hermes_cli.main` relaunch context surfaces the invoking interpreter as + # argv[0]; an interpreter is never a launchable entry target (it would persist a bare + # ` desktop`). Compare against argv[0]'s own file, not sys.executable — under test + # harnesses they differ. + try: + return path.samefile(original_argv0) and _is_interpreter(path) + except OSError: + return False + + def _resolve_hermes_bin_for_desktop_entry( resolve_fn=None, checkout_root: Optional[Path] = None, ) -> Optional[str]: """Resolve the launcher binary for the persisted ``.desktop`` entry. - Wraps :func:`hermes_cli.relaunch.resolve_hermes_bin` with one rule: an ``argv[0]`` inside - this checkout is a launch-context artifact, not a durable installed launcher — persisting it - makes the entry depend on how the previous launch happened (a bootstrap loop). Skip - argv[0]/relative candidates then and fall through to PATH, where the shell installer's - wrapper lives. ``resolve_fn`` is injectable for tests. + Wraps :func:`hermes_cli.relaunch.resolve_hermes_bin` with one rule: an ``argv[0]`` inside this + checkout is a launch-context artifact, not a durable installed launcher — persisting it makes + the entry depend on how the previous launch happened (a bootstrap loop). Skip such candidates + and fall through to PATH, then to the installer's known wrapper locations. ``resolve_fn`` is + injectable for tests. """ if resolve_fn is None: from hermes_cli.relaunch import resolve_hermes_bin as resolve_fn if checkout_root is None: checkout_root = _project_root() - # Keep the LEXICAL form: _inside_checkout resolves candidates for its - # own comparison anyway, and _wrapper_targets_checkout needs the - # lexical root because the installer writes $INSTALL_DIR lexically - # into the shim text (symlinked homes would otherwise mismatch). - # Production callers pass main.py's realpath'd PROJECT_ROOT; the - # module-lexical root derived from __file__ is added alongside so a - # symlinked home still matches the shim's lexically-written paths. + # Keep the LEXICAL root: the installer writes $INSTALL_DIR lexically into the shim text, so a + # symlinked home would otherwise mismatch. Callers pass main.py's realpath'd PROJECT_ROOT; the + # module-lexical root is tried alongside it. checkout_root = Path(os.path.abspath(checkout_root)) module_lexical_root = _project_root() original_argv0 = sys.argv[0] - def _inside_checkout(candidate: str) -> bool: - try: - path = Path(candidate).resolve() - except OSError: - return False - # The repo `hermes` script and anything else shipped in the tree is - # checkout-internal. Compare against BOTH the lexical and resolved - # roots (checkout_root is kept lexical; candidates resolve, so a - # symlinked home needs the resolved comparison too). - resolved_root = None - try: - resolved_root = checkout_root.resolve() - except OSError: - pass - for root in {checkout_root, resolved_root}: - if root is not None and (path == root or root in path.parents): - return True - # The `python -m hermes_cli.main` relaunch context surfaces the - # invoking interpreter (or a non-executable main.py, which the - # resolver already skips) as argv[0]; an interpreter is never a - # durable, launchable entry target (it would persist a bare - # ` desktop`). Compare against the *invoking* interpreter - # (argv[0]'s own file), not sys.executable — under test harnesses - # they differ. - try: - if path.samefile(original_argv0) and _is_interpreter(path): - return True - except OSError: - pass - return False - - def _is_interpreter(candidate: Path) -> bool: - """A python interpreter binary (``bin/python*``), not a launcher. - - Strict basename match — accepts ``python``, ``python3``, ``python3.11``; rejects lookalikes - such as ``python3-config`` and ``pythonw``. The parent-dir guard keeps a script named - ``python`` outside a bin/Scripts tree from being misclassified. - """ - import re - - name = candidate.name.lower() - if not re.fullmatch(r"python[23]?(\d+)?(\.\d+)?", name): - return False - return candidate.parent.name in {"bin", "scripts"} - - # Resolve the primary FIRST and only rerun the resolver with argv[0] - # hidden when the primary could actually be checkout-internal: for an - # already-external primary the comparison can never change the - # outcome, so skipping the rerun saves a resolver call and shortens - # the window in which a concurrent reader could see the mutated - # sys.argv. + # An external primary (another install's /opt/.../bin/hermes, a venv console script) wins + # BEFORE any known-location probing, which could silently switch the entry to a different + # installation. Only rerun the resolver with argv[0] hidden when the primary could actually + # be checkout-internal (also shortens the window a concurrent reader sees mutated sys.argv). primary = resolve_fn() - - # A primary that is NOT checkout-internal and not the invoking - # interpreter is an external launcher (e.g. /opt/.../bin/hermes from - # another install method, or a venv console script). It must be - # evaluated BEFORE any known-location probing: probing first could - # silently switch the entry to a different installation (#94443 - # review case 3). - if primary and not _inside_checkout(primary): + if primary and not _inside_checkout(primary, checkout_root, original_argv0): return primary - # Only reroute when argv[0] actually drove the resolution: re-run the - # resolver with argv[0] hidden and compare. If PATH yields nothing, - # keep the resolver's original answer (its fallback chain stays - # authoritative; #90492 semantics preserved). sys.argv[0] = "" try: rerouted = resolve_fn() finally: sys.argv[0] = original_argv0 - if primary and _inside_checkout(primary) and rerouted: + if not primary: + return primary + if rerouted: return rerouted + if rerouted is not None: + return primary - if rerouted is None and primary: - # argv[0] was checkout-internal AND PATH had no `hermes` — common - # in stripped systemd user sessions and autostart relaunches. - # The installer's wrapper lives at known locations; probe them - # directly before giving up, otherwise we'd silently persist the - # checkout-internal form this fix exists to prevent. The probe - # runs only after the primary was proven non-durable above, and - # each candidate must itself target THIS checkout (a wrapper - # from another install would make the entry stable-but-wrong — - # same failure class the external-primary-first rule avoids). - probe = _known_wrapper_candidates() - for candidate in probe: - if candidate.is_file() and os.access(candidate, os.X_OK): - if not _wrapper_shebang_safe(candidate): - # The wrapper targets this checkout but its own shebang - # would die in the DE context (e.g. `#!/usr/bin/env - # python3` resolving past the venv): skip it the same - # way a foreign-install wrapper is skipped. Idea - # credited to autumn8's #92122 rung-2 safety check; - # implemented on our ownership machinery. - continue - if _wrapper_targets_checkout( - candidate, checkout_root - ) or _wrapper_targets_checkout(candidate, module_lexical_root): - return str(candidate) - # No durable wrapper for THIS checkout exists anywhere (PATH - # miss, known locations miss or belong to another install). - # Persisting the checkout-internal primary would produce an - # entry that regenerates itself or dies on the venv escape; - # dropping to None lets resolve_exec_command emit its runnable - # module fallback. - return None - return primary + # argv[0] was checkout-internal AND PATH had no `hermes` — common in stripped systemd user + # sessions and autostart relaunches. Probe the installer's known wrapper locations; each + # candidate must be DE-safe and target THIS checkout (a foreign wrapper would make the entry + # stable-but-wrong). No durable wrapper → None, so resolve_exec_command emits its runnable + # module fallback instead of the self-regenerating checkout-internal form. + for candidate in _known_wrapper_candidates(): + if ( + candidate.is_file() + and os.access(candidate, os.X_OK) + and _wrapper_shebang_safe(candidate) + and ( + _wrapper_targets_checkout(candidate, checkout_root) + or _wrapper_targets_checkout(candidate, module_lexical_root) + ) + ): + return str(candidate) + return None + + +def _shebang_tokens(shebang: str) -> "list[str]": + return shebang[2:].strip().split() def _wrapper_shebang_safe(wrapper: Path) -> bool: """Whether an executable wrapper can actually run in the DE context. - A wrapper whose shebang escapes the venv (``#!/usr/bin/env python3`` or a bare interpreter - name) would die exactly like the broken entry this module fixes. Native binaries and shell - launchers are safe by construction; a python-shebang wrapper is safe only when its - interpreter resolves to the RUNNING venv's interpreter directory. + Native binaries and shell launchers are safe by construction (the shell script execs the right + interpreter itself). A python-shebang wrapper is safe only when its interpreter stays inside + the RUNNING venv; anything unknown fails safe toward the module fallback. """ try: with open(wrapper, "rb") as fh: @@ -311,166 +243,115 @@ def _wrapper_shebang_safe(wrapper: Path) -> bool: if head[:4] == b"\x7fELF" or head.startswith(b"MZ"): return True if not head.startswith(b"#!"): - # No shebang: the kernel cannot exec it directly either — but it - # may be sourced or exec'd via `sh` by DE-specific glue. Fail - # safe toward the module fallback. return False shebang = head.decode("utf-8", errors="replace").splitlines()[0] - tokens = shebang[2:].strip().split() + tokens = _shebang_tokens(shebang) if not tokens: return False interp = Path(tokens[0]) - # `#!/usr/bin/env bash` (the installer's own launcher form): `env` - # here is the standard trick to find bash on PATH, and the script - # itself execs the right interpreter. Only python-flavored `env` - # shebangs are the escape hazard. if interp.name == "env": - # Skip env's own flags (-S, -u VAR, ...) and inspect the first - # real token: `env -S bash` is still a shell launcher. - target = next( - (Path(t) for t in tokens[1:] if not t.startswith("-")), - Path(""), - ) - if target.name in ("bash", "sh", "dash", "zsh", "ksh"): - return True - return not _shebang_escapes_running_env(shebang) - if interp.name in ("bash", "sh", "dash", "zsh", "ksh"): - # A shell launcher execs the right interpreter itself. + # `#!/usr/bin/env bash` is the installer's own launcher form; skip env's flags (-S, -u + # VAR) and inspect the first real token. Only python-flavored `env` shebangs escape. + target = next((Path(t) for t in tokens[1:] if not t.startswith("-")), Path("")) + return target.name in _SHELL_NAMES or not _shebang_escapes_running_env(shebang) + if interp.name in _SHELL_NAMES: return True if "python" not in interp.name.lower(): - # Not a python interpreter either — fail safe toward the module - # fallback rather than trusting an unknown interpreter. return False - # Python wrapper: its shebang must stay inside the RUNNING venv. return not _shebang_escapes_running_env(shebang) +_ROOT_TERMINATORS = ('"', "'", " ", "\n", "\t", "\r", "$", "\x00", "/") + + def _wrapper_targets_checkout(wrapper: Path, checkout_root: Path) -> bool: """Whether a candidate launcher script actually launches THIS checkout. - Expects the LEXICAL checkout root (the caller keeps it un-resolved): the installer writes - ``$INSTALL_DIR`` lexically into the shim, so on a symlinked home the shim text and the resolved - root would never match. Both lexical and resolved forms of the root are tried regardless, to - tolerate either caller convention. + Boundary-aware: a bare substring test would also accept sibling paths that EXTEND this + checkout's path (``-old``, ``.bak``), so the root must end at a quote / + whitespace / EOL or continue INTO the tree (``/``). Both lexical and resolved roots are + tried: the installer writes $INSTALL_DIR lexically, so with a symlinked home the shim text + carries the lexical path while the caller may pass a resolved one. """ try: head = wrapper.read_bytes()[:4096] except OSError: return False if b"\x7fELF" in head[:4] or head.startswith(b"MZ"): - # Native binary: cannot verify, and cannot be another checkout's - # bash shim either — accept. + # Native binary: cannot verify, cannot be another checkout's bash shim either — accept. return True - try: - text = head.decode("utf-8", errors="replace") - except Exception: # noqa: BLE001 - defensive decode - return False - # Boundary-aware matching: a bare substring test would also accept - # sibling paths that EXTEND this checkout's path (an old install - # renamed aside as `-old` or `.bak`), silently - # pointing the entry at that other installation. Require the - # reference to end the path (quote, whitespace, or end-of-line - # right after the root) or continue INTO it. - # Compare both the resolved root and its lexical form: the installer - # writes $INSTALL_DIR lexically, so with a symlinked home - # (/home/user -> /mnt/disk/home/user) the shim's text carries the - # lexical path while checkout_root arrives resolved. - roots = {str(checkout_root)} + text = head.decode("utf-8", errors="replace") lexical_root = os.path.abspath(str(checkout_root)) - roots.add(lexical_root) + roots = {str(checkout_root), lexical_root} try: - resolved_lexical = str(Path(lexical_root).resolve()) - roots.add(resolved_lexical) + roots.add(str(Path(lexical_root).resolve())) except OSError: pass - for root in roots: - for terminator in ('"', "'", " ", "\n", "\t", "\r", "$", "\x00"): - if root + terminator in text: - return True - if text.rstrip("\r\n").endswith(root): - return True - # The shim's exec line continues INTO the checkout (…/python - # /hermes …): a path-continuation boundary is also a match. - if root + "/" in text: - return True - return False + stripped = text.rstrip("\r\n") + return any( + stripped.endswith(root) or any(root + t in text for t in _ROOT_TERMINATORS) + for root in roots + ) def _known_wrapper_candidates(): """Durable installed-launcher locations, most likely first. - Mirrors the installer's ``get_command_link_dir()`` layouts: user (``~/.local/bin``), root FHS - (``/usr/local/bin``), and Termux (``$PREFIX/bin``). The wrapper is always named ``hermes``. + Mirrors the installer's ``get_command_link_dir()`` layouts: Termux (``$PREFIX/bin``), root FHS + (``/usr/local/bin``), and user (``~/.local/bin``). The wrapper is always named ``hermes``. """ candidates = [] - home = Path.home() prefix = os.environ.get("PREFIX") if prefix: candidates.append(Path(prefix) / "bin" / "hermes") if hasattr(os, "geteuid") and os.geteuid() == 0: candidates.append(Path("/usr/local/bin/hermes")) - candidates.append(home / ".local" / "bin" / "hermes") + candidates.append(Path.home() / ".local" / "bin" / "hermes") return candidates def _project_root() -> Path: - """This file lives at ``/hermes_cli/linux_desktop_entry.py``. - - Lexical (no .resolve()): callers feed this into shim-text matching where the installer's - lexically-written $INSTALL_DIR must be able to match; symlinked homes would break a resolved - comparison. - """ + """```` from this file's location — lexical (no ``.resolve()``) so shim-text matching + against the installer's lexically-written $INSTALL_DIR works on symlinked homes.""" return Path(os.path.abspath(__file__)).parent.parent def _needs_interpreter(bin_path: Path) -> bool: - """Whether ``bin_path`` is a Python script that must run under - ``sys.executable`` to see Hermes' venv (rather than its own shebang).""" + """Whether ``bin_path`` is a Python script whose shebang escapes the running venv. + + Native binaries (uv shim, PyInstaller, distro package) and shell wrappers (the installer's + bash launcher execs the venv python itself) never need one. + """ try: with open(bin_path, "rb") as fh: head = fh.readline(256) except OSError: return False if not head.startswith(b"#!"): - # Native binary (uv tool shim, PyInstaller, distro package) — its own - # loader is self-sufficient. return False shebang = head.decode("utf-8", errors="replace").strip() if "python" not in shebang.lower(): - # A shell wrapper (e.g. the installer's bash launcher) execs the venv - # python itself — leave it alone. return False return _shebang_escapes_running_env(shebang) def _shebang_escapes_running_env(shebang: str) -> bool: - """Whether a python shebang resolves OUTSIDE the running interpreter's env. + """Whether a python shebang resolves OUTSIDE the running interpreter's directory. - Tokenizes the shebang (interpreter path plus any flags) and compares PATH COMPONENTS, never - substrings: ``/bin-extra/python`` is not inside ``/bin`` even though it starts with - it (sibling-directory confusion; independently surfaced in nosliwhtes' #92122 hardening - ``b96427d0`` — reimplemented here with two extensions). - - * ``env`` shebangs (``#!/usr/bin/env python3``) ALWAYS escape: ``env`` resolves through PATH, - which in the DE's cold environment is not the interactive PATH that installed the venv — the - parent-equality form could be fooled when the resolved ``env`` binary happens to sit in the same - directory tree. + Compares PATH COMPONENTS, never substrings: ``/bin-extra/python`` is not inside + ``/bin``. ``env`` shebangs ALWAYS escape — ``env`` resolves through the DE's cold PATH, + not the shell that installed the venv — except the rare ``env -S `` form, + which is judged by its absolute target. """ - tokens = shebang[2:].strip().split() + tokens = _shebang_tokens(shebang) if not tokens: - # Bare "#!python" with no path: resolves via PATH — escapes. - return True + return True # bare "#!python": resolves via PATH interp = Path(tokens[0]) if interp.name in ("env", "env.exe"): - # PATH-resolved interpreter: the DE environment's PATH decides, - # not the installing shell's — treat as escaping. A real - # ``env -S`` venv-absolute form (`env -S `), rare but valid, - # still resolves the actual interpreter from the second token. rest = [t for t in tokens[1:] if not t.startswith("-")] - if rest and Path(rest[0]).is_absolute(): - interp = Path(rest[0]) - else: + if not (rest and Path(rest[0]).is_absolute()): return True + interp = Path(rest[0]) running_dir = os.path.dirname(os.path.abspath(sys.executable)).lower() return str(interp.parent).lower() != running_dir @@ -533,18 +414,16 @@ def _run_quiet(cmd: "list[str]") -> bool: return result.returncode == 0 -# Sizes a typical hicolor ``index.theme`` actually lists. ``scalable`` is -# SVG-only — a raster PNG there is what Cinnamon's panel draws as a -# mangled low-res blob. The shipped desktop asset is 1024×1024, which is -# also not an indexed dir name, so a copy-only fallback lands in ``256x256``. +# Sizes a typical hicolor ``index.theme`` lists. ``scalable`` is SVG-only — a raster PNG there is +# what Cinnamon's panel draws as a mangled low-res blob. The shipped asset is 1024×1024 (not an +# indexed dir name), so a copy-only fallback lands in ``256x256``. _HICOLOR_INDEXED_SIZES = (16, 22, 24, 32, 36, 48, 64, 72, 96, 128, 192, 256, 512) -# Cinnamon's panel is ~24–32px. Write these so the theme loads an exact -# raster instead of downscaling a 1024px PNG at lookup time. +# Cinnamon's panel is ~24–32px: write exact rasters so the theme needn't downscale a 1024px PNG. _HICOLOR_INSTALL_SIZES = (24, 32, 48, 256) def _png_dimensions(raw: bytes) -> Optional[tuple[int, int]]: - """Return ``(width, height)`` from a PNG IHDR, or ``None`` if unreadable.""" + """``(width, height)`` from a PNG IHDR, or ``None`` if unreadable.""" if len(raw) >= 24 and raw[:8] == b"\x89PNG\r\n\x1a\n" and raw[12:16] == b"IHDR": return struct.unpack(">II", raw[16:24]) return None @@ -555,11 +434,9 @@ def _hicolor_subdir(dimensions: Optional[tuple[int, int]]) -> str: if dimensions is None: return "256x256" width, height = dimensions - if width != height or width <= 0: - return "256x256" - if width in _HICOLOR_INDEXED_SIZES: + if width in _HICOLOR_INDEXED_SIZES and width == height: return f"{width}x{width}" - if width > 256: + if width != height or width <= 0 or width > 256: return "256x256" nearest = min(_HICOLOR_INDEXED_SIZES, key=lambda size: abs(size - width)) return f"{nearest}x{nearest}" @@ -570,19 +447,15 @@ def _hicolor_icon_dest(subdir: str) -> Path: def _remove_stale_scalable_icon() -> bool: - """Drop a leftover PNG from ``scalable/`` (the pre-fix install path). - - Return True when a file was removed so the caller can refresh the icon cache. A missing file is - not an error. - """ + """Drop a leftover PNG from ``scalable/`` (the pre-fix install path); True if removed.""" stale = _hicolor_icon_dest("scalable") try: - if stale.is_file(): - stale.unlink() - return True + if not stale.is_file(): + return False + stale.unlink() + return True except OSError: return False - return False def _refresh_hicolor_cache() -> None: @@ -596,12 +469,9 @@ def _refresh_hicolor_cache() -> None: def _resized_hicolor_pngs(raw: bytes) -> Optional[dict[str, bytes]]: - """Lanczos-resize *raw* to each panel size. ``None`` when it will not decode. - - Pillow is a core dep but this module stays import-light: the import is local so the uninstaller - does not pay it. A truncated/fake PNG (tests, interrupted copy) returns None and the caller - falls back to a copy. - """ + """Lanczos-resize *raw* to each panel size; ``None`` when it will not decode (truncated/fake + PNG) so the caller falls back to a copy. Pillow is imported lazily to keep the uninstaller + import-light.""" try: from PIL import Image except ImportError: @@ -611,9 +481,8 @@ def _resized_hicolor_pngs(raw: bytes) -> Optional[dict[str, bytes]]: rgba = im.convert("RGBA") out: dict[str, bytes] = {} for size in _HICOLOR_INSTALL_SIZES: - resized = rgba.resize((size, size), Image.Resampling.LANCZOS) buf = io.BytesIO() - resized.save(buf, format="PNG") + rgba.resize((size, size), Image.Resampling.LANCZOS).save(buf, format="PNG") out[f"{size}x{size}"] = buf.getvalue() return out except (OSError, ValueError): @@ -634,12 +503,8 @@ def _write_hicolor_pngs(files: dict[str, bytes]) -> bool: def _install_icon_to_hicolor(icon: Path) -> bool: - """Install the app icon into the user's hicolor icon theme tree. - - The freedesktop icon lookup finds an installed ``apps/hermes.png`` by the unqualified name - ``hermes``, so the entry can reference the icon without an absolute checkout path. Raster PNGs - go to indexed fixed-size dirs, never ``scalable`` (SVG-only). - """ + """Install the app icon into the user's hicolor tree so ``Icon=hermes`` resolves without an + absolute checkout path. Raster PNGs go to indexed fixed-size dirs, never ``scalable``.""" try: raw = icon.read_bytes() resized = _resized_hicolor_pngs(raw) @@ -647,14 +512,11 @@ def _install_icon_to_hicolor(icon: Path) -> bool: wrote = _write_hicolor_pngs(resized) else: dest = _hicolor_icon_dest(_hicolor_subdir(_png_dimensions(raw))) - wrote = True - if dest.is_file() and dest.read_bytes() == raw: - wrote = False - else: + wrote = not (dest.is_file() and dest.read_bytes() == raw) + if wrote: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(icon, dest) - removed_stale = _remove_stale_scalable_icon() - if wrote or removed_stale: + if _remove_stale_scalable_icon() or wrote: _refresh_hicolor_cache() return True except OSError: @@ -662,22 +524,19 @@ def _install_icon_to_hicolor(icon: Path) -> bool: def install_desktop_entry(project_root: Path) -> Optional[Path]: - """Write (or refresh) the Hermes desktop entry. Return its path. + """Write (or refresh) the Hermes desktop entry and return its path. - Return ``None`` on non-Linux platforms or when the write fails. This is a convenience, never a - reason to fail a launch. + ``None`` on non-Linux platforms or when the write fails — a convenience, never a reason to + fail a launch. """ if not is_supported(): return None entry_path = desktop_entry_path() icon = icon_path(project_root) - # Prefer the themed name: the icon is COPIED into the user's hicolor - # tree, so the entry outlives the checkout (moving/archiving the - # checkout would break an absolute Icon= path — the same - # durability class the Exec line was fixed for). Fall back to the - # absolute path only when the copy is impossible (read-only tree), - # and to the themed name when the checkout has no icon at all. + # Prefer the themed name: the icon is COPIED into the hicolor tree, so the entry outlives the + # checkout (an absolute Icon= path breaks when the checkout moves). Absolute path only when + # the copy is impossible (read-only tree); themed name when the checkout has no icon at all. icon_value = str(icon) if icon.is_file() else "hermes" if icon.is_file() and _install_icon_to_hicolor(icon): icon_value = "hermes" @@ -685,21 +544,15 @@ def install_desktop_entry(project_root: Path) -> Optional[Path]: try: entry_path.parent.mkdir(parents=True, exist_ok=True) - # When nothing changed, skip the rewrite. Then a launch does not - # churn the menu caches. + # Unchanged → skip the rewrite so a launch doesn't churn the menu caches. if entry_path.is_file() and entry_path.read_text(encoding="utf-8") == contents: return entry_path - # Atomic replace: an interrupted plain write can leave a zero-byte - # entry, which permanently breaks the taskbar pin (nothing later - # rewrites a file that exists at the right path). The temp+rename - # dance in utils.atomic_write_text is the codebase's shared - # implementation — ported from #80547, which closed unmerged with - # this piece unlanded. + # Atomic replace: an interrupted plain write leaves a zero-byte entry, which permanently + # breaks the taskbar pin (nothing later rewrites a file that exists at the right path). from utils import atomic_write_text atomic_write_text(entry_path, contents, create_mode=0o755) - # Some launchers (and older Plasma) offer the entry only when it - # is executable. + # Some launchers (and older Plasma) offer the entry only when it is executable. entry_path.chmod(0o755) except OSError: return None From 52c1705cc387fccd6a5ce9f53b894362499c949e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:09:34 -0700 Subject: [PATCH 04/38] refactor(kanban_db): drop 76 zero-reference split-module re-exports --- hermes_cli/kanban_db.py | 77 +---------------------------------------- 1 file changed, 1 insertion(+), 76 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 2f6b0575cd..0d9499390f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -450,6 +450,7 @@ def scoped_current_board(slug: str): finally: _CURRENT_BOARD_OVERRIDE.reset(token) + # Slug validator: lowercase alphanumerics, digits, hyphens; 1–64 chars. # Strict enough to stop traversal (`..`) and embedded path separators, loose # enough that kebab-case names like ``atm10-server`` or ``hermes-agent`` @@ -5553,51 +5554,23 @@ def latest_summaries( # stays the single monkeypatch target). # --------------------------------------------------------------------------- from hermes_cli.kanban_db_connect import ( # noqa: E402,F401 - DEFAULT_BUSY_TIMEOUT_MS, KanbanDbCorruptError, - RepairResult, - _BUSY_MAX_RETRIES, _BUSY_RETRY_MAX_S, _BUSY_RETRY_MIN_S, _CORRUPT_BACKUP_RETENTION, - _EARLY_TASK_COLUMNS, _INITIALIZED_PATHS, - _INIT_LOCK, - _INIT_LOCK_POLL_SECONDS, _INIT_LOCK_TIMEOUT_SECONDS, _LAST_WAL_CHECKPOINT, - _LATER_TASK_COLUMNS, - _REBUILD_SPECS, - _RENAMED_TASK_COLUMNS, - _REPAIRABLE_INDEX_ERROR_PATTERNS, _SQLITE_HEADER, _WAL_CHECKPOINT_INTERVAL_SECONDS, - _WAL_CHECKPOINT_LOCK, - _attempt_index_reindex_repair, - _backup_corrupt_db, _check_file_length_invariant, _cross_process_init_lock, _dispatch_tick_lock, - _execute_boundary_with_retry, - _guard_existing_db_is_healthy, - _integrity_messages_ok, - _is_busy_error, - _looks_like_tls_record_at, _maybe_checkpoint_wal, _migrate_add_optional_columns, - _open_configured, - _probe_integrity, - _prune_corrupt_backups, - _rebuild_drifted_tables, _repairable_index_names, - _resolve_busy_timeout_ms, - _run_integrity_check, - _schema_is_present, _sqlite_connect, - _table_has_drifted, - _try_lock_nb, _unlock, - _validate_sqlite_header, connect, connect_closing, init_db, @@ -5605,83 +5578,41 @@ from hermes_cli.kanban_db_connect import ( # noqa: E402,F401 write_txn, ) from hermes_cli.kanban_db_workspace import ( # noqa: E402,F401 - _SCRATCH_TIP_MESSAGE, - _SCRATCH_TIP_SENTINEL_NAME, - _cleanup_worker_tmux, _cleanup_workspace, _cleanup_worktree_workspace, _ensure_git_worktree, - _git_branch_exists, - _git_common_dir, - _git_current_branch, - _git_dir, _git_toplevel, - _is_linked_worktree_checkout, _is_managed_scratch_path, _managed_scratch_path_info, - _mark_scratch_tip_shown, _maybe_emit_scratch_tip, - _nearest_existing_path, - _repo_root_for_worktree_target, _resolve_worktree_workspace, _scratch_tip_sentinel_path, _scratch_tip_shown, _scratch_workspace, - _try_cleanup_parent_workspaces, resolve_workspace, set_branch_name, set_workspace_path, ) from hermes_cli.kanban_db_dispatch import ( # noqa: E402,F401 DEFAULT_FAILURE_LIMIT, - DEFAULT_LOG_BACKUP_COUNT, - DEFAULT_LOG_ROTATE_BYTES, DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS, DEFAULT_SPAWN_FAILURE_LIMIT, - DERIVED_MAX_IN_PROGRESS_CEILING, - DERIVED_MAX_IN_PROGRESS_FLOOR, DispatchResult, - KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS, - MEMORY_GUARD_MB_PER_WORKER, _PROTOCOL_VIOLATION_FAILURE_LIMIT, - _PROTOCOL_VIOLATION_SCAN_LIMIT, - _RECENT_WORKER_EXITS_MAX, - _RECENT_WORKER_EXIT_TTL_SECONDS, - _RESPAWN_BLOCKER_RE, - _RESPAWN_GUARD_PR_URL_RE, - _RESPAWN_GUARD_PR_WINDOW, - _RESPAWN_GUARD_SUCCESS_WINDOW, - _STALE_HEARTBEAT_GAP_SECONDS, - _absolute_hermes_path, - _apply_default_assignee, _classify_worker_exit, _clear_failure_counter, _default_spawn, _defer_reclaim_for_live_worker, - _dispatch_lane_task, - _dispatch_once_locked, - _error_fingerprint, - _has_spawnable, - _hermes_path_argv, - _is_windows_batch_shim, _looks_like_path, _memory_pressure_level, - _module_hermes_argv, - _path_search_names, _pid_alive, _positive_int, - _protocol_violation_streak, - _recent_worker_exits, _record_spawn_failure, _record_task_failure, _record_worker_exit, _resolve_hermes_argv, _resolve_worker_cli_toolsets, _retag_legacy_worker_sessions, - _retagged_workspace_roots, - _rotate_worker_log, - _rotated_log_path, - _safe_which_no_cwd, _set_worker_pid, _system_memory_sample, _terminate_reclaimed_worker, @@ -5689,7 +5620,6 @@ from hermes_cli.kanban_db_dispatch import ( # noqa: E402,F401 _worker_terminal_timeout_env, check_respawn_guard, configured_max_in_progress, - count_running_tasks, count_running_tasks_other_boards, derive_default_max_in_progress, detect_crashed_workers, @@ -5704,14 +5634,9 @@ from hermes_cli.kanban_db_dispatch import ( # noqa: E402,F401 resolve_max_in_progress, review_dispatch_enabled, run_daemon, - worker_log_rotation_config, ) from hermes_cli.kanban_db_notify import ( # noqa: E402,F401 _NOTIFY_DELIVERY_MODES, - _decode_notify_delivery_metadata, - _encode_notify_delivery_metadata, - _notify_cursor, - _notify_profile_filter, add_notify_sub, advance_notify_cursor, claim_unseen_events_for_sub, From a4ab7170f46cf82ec1a7ca730f74172ff032f187 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:10:56 -0700 Subject: [PATCH 05/38] =?UTF-8?q?refactor(cli-misc):=20macos=5Ftcc=5Fancho?= =?UTF-8?q?r.py=20=E2=80=94=20shared=20managed-venv/marker/staging=20helpe?= =?UTF-8?q?rs,=20module-level=20name=20tuples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/macos_tcc_anchor.py | 250 ++++++++++++++------------------- 1 file changed, 109 insertions(+), 141 deletions(-) diff --git a/hermes_cli/macos_tcc_anchor.py b/hermes_cli/macos_tcc_anchor.py index cc08a113ca..985f67da1f 100644 --- a/hermes_cli/macos_tcc_anchor.py +++ b/hermes_cli/macos_tcc_anchor.py @@ -1,11 +1,12 @@ -"""Stable macOS TCC anchor for the uv-managed Python interpreter (#95596). +"""Stable macOS TCC anchor for the uv-managed Python interpreter. -1. Aliases are materialized as real-file copies of the anchor, never symlinks. 2. If the store ships -``libpython*``, it is hardlinked into ``venv/lib/`` (copy if the store is on another device). -Existing ``LC_RPATH`` already points at ``@executable_path/../lib`` — no rewrite. 3. - -All functions are no-ops on non-macOS and for interpreters that are not uv-managed. Best-effort: -never raises to callers. +macOS TCC grants are keyed to the interpreter binary's path; a venv ``bin/python`` that symlinks +into uv's store changes identity on every interpreter upgrade. The anchor replaces it with a +signed real-file copy, gated on a real boot, with uv's alias names (``python3``, +``python3.N``) materialized as real-file copies too (never symlinks — the #95541 crash shape) +and the store's ``libpython*`` hardlinked into ``venv/lib/`` (existing ``LC_RPATH`` already points +at ``@executable_path/../lib``). All functions are no-ops off macOS and for non-uv interpreters, +and never raise to callers. """ from __future__ import annotations @@ -17,6 +18,7 @@ import platform import re import shutil import subprocess +import sys import tempfile from pathlib import Path @@ -29,19 +31,20 @@ logger = logging.getLogger(__name__) _MARKER_NAME = ".tcc-anchor-source" _STORE_COMMON_MARKERS = ("cpython-", "-macos-") -# The runtime-store marker is derived from managed_uv so a rename of the -# repair-generation directory cannot silently stop the anchor from matching. +# Derived from managed_uv so a rename of the repair-generation directory cannot silently stop +# the anchor from matching. _STORE_ROOT_MARKERS = ("/uv/python/", f"/{_RUNTIME_DIR_NAME}/python/") +_ALIAS_NAMES = ("python3", f"python3.{sys.version_info.minor}") +_STORE_BIN_NAMES = (f"python3.{sys.version_info.minor}", "python3", "python") + class _BootGateFailed(Exception): """Staged copy refused to boot; the live venv must stay untouched.""" def _marker_value(source_file: Path) -> str: - """Canonical marker value: fully resolved so symlinked spellings of the same store binary - (``cpython-3.11-macos-*`` → ``cpython-3.11.15-macos-*``) compare equal. - """ + """Fully resolved so symlinked spellings of the same store binary compare equal.""" return os.path.realpath(str(source_file)) @@ -49,20 +52,6 @@ def is_macos() -> bool: return platform.system() == "Darwin" -def _sibling_names() -> tuple[str, ...]: - """Alias names uv creates inside the venv bin dir.""" - import sys as _sys - - return ("python3", f"python3.{_sys.version_info.minor}") - - -def _store_bin_names() -> tuple[str, ...]: - """Preferred interpreter file names inside a store ``bin`` dir.""" - import sys as _sys - - return (f"python3.{_sys.version_info.minor}", "python3", "python") - - def _is_uv_macos_store(path: str) -> bool: normalized = path.replace("\\", "/") if not all(marker in normalized for marker in _STORE_COMMON_MARKERS): @@ -71,11 +60,7 @@ def _is_uv_macos_store(path: str) -> bool: def _venv_dir(project_root: Path | None = None) -> Path | None: - root = ( - Path(project_root) - if project_root is not None - else Path(__file__).resolve().parents[1] - ) + root = Path(project_root) if project_root is not None else Path(__file__).resolve().parents[1] for name in ("venv", ".venv"): candidate = root / name venv_py = venv_python_path(candidate) @@ -91,7 +76,7 @@ def _interpreter_file(src: str | Path) -> Path | None: return p if not p.is_dir(): return None - for name in _store_bin_names(): + for name in _STORE_BIN_NAMES: candidate = p / name if candidate.is_file(): return candidate @@ -105,14 +90,13 @@ def _interpreter_file(src: str | Path) -> Path | None: def _interpreter_source(venv_dir: Path) -> str | None: - """Return the interpreter file the venv currently resolves to.""" + """Return the interpreter file the venv currently resolves to (symlink target or pyvenv.cfg home).""" venv_py = venv_python_path(venv_dir) if venv_py.is_symlink(): try: - resolved = venv_py.resolve(strict=False) + return str(venv_py.resolve(strict=False)) except OSError: return None - return str(resolved) cfg = venv_dir / "pyvenv.cfg" if not cfg.is_file(): return None @@ -120,8 +104,7 @@ def _interpreter_source(venv_dir: Path) -> str | None: try: for line in cfg.read_text(encoding="utf-8").splitlines(): if line.lower().startswith("home"): - _, _, home = line.partition("=") - home = home.strip() + home = line.partition("=")[2].strip() break except OSError: return None @@ -131,17 +114,34 @@ def _interpreter_source(venv_dir: Path) -> str | None: return str(interp) if interp is not None else None +def _managed_venv(project_root: Path | None) -> tuple[Path, Path, str] | str: + """``(venv_dir, venv_py, source)`` for a uv-managed macOS venv, else the skip reason.""" + if not is_macos(): + return "not macOS" + venv_dir = _venv_dir(project_root) + if venv_dir is None: + return "no venv interpreter" + source = _interpreter_source(venv_dir) + if source is None or not _is_uv_macos_store(source): + return "interpreter not uv-managed (stable path)" + return venv_dir, venv_python_path(venv_dir), source + + def _anchor_marker(venv_bin: Path) -> Path: return venv_bin / _MARKER_NAME -def _write_marker(venv_bin: Path, source_file: Path) -> None: - """Write the anchor marker atomically via the shared helper. +def _marker_matches(venv_bin: Path, expected: str) -> bool: + marker = _anchor_marker(venv_bin) + try: + return marker.is_file() and marker.read_text(encoding="utf-8").strip() == expected + except OSError: + return False - A concurrent ensure (update + doctor --fix) must never observe a partially-written marker: a - torn read would compare unequal and trigger a spurious reinstall, and ``write_text`` alone is - not atomic. - """ + +def _write_marker(venv_bin: Path, source_file: Path) -> None: + """Atomic: a concurrent ensure (update + doctor --fix) must never read a torn marker, which + would compare unequal and trigger a spurious reinstall.""" atomic_write_text( _anchor_marker(venv_bin), _marker_value(source_file), @@ -154,13 +154,11 @@ def _store_root(source_file: Path) -> Path: return source_file.resolve(strict=False).parent.parent -def _provision_libpython( - venv_dir: Path, source_file: Path, *, refresh: bool = False -) -> None: +def _provision_libpython(venv_dir: Path, source_file: Path, *, refresh: bool = False) -> None: """Hardlink (else copy) store ``libpython*`` into ``venv/lib/``. Provision-if-present: a surplus hardlink on a statically-linked build is free; a missed - detection is the only way #95425 returns. + detection is the only way the dylib-not-found crash returns. """ src_lib = _store_root(source_file) / "lib" if not src_lib.is_dir(): @@ -190,46 +188,54 @@ def _provision_libpython( logger.debug("libpython provision skipped", exc_info=True) +def _stage_copy(venv_bin: Path, prefix: str, source: Path) -> Path: + """Copy *source* to a unique (mkstemp) executable staging file in *venv_bin*. + + Unique names mean a concurrent ensure cannot promote another run's truncated interim copy. + """ + fd, tmp_name = tempfile.mkstemp(prefix=prefix, dir=str(venv_bin)) + os.close(fd) + tmp_path = Path(tmp_name) + try: + shutil.copy2(source, tmp_path) + os.chmod(tmp_path, source.stat().st_mode | 0o111) + except BaseException: + _discard(tmp_path) + raise + return tmp_path + + +def _discard(tmp_path: Path | None) -> None: + if tmp_path is not None: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + + def _copy_alias(venv_bin: Path, name: str, anchor: Path) -> bool: """Materialize *name* as a real-file copy of *anchor* (atomic rename). Returns False (and warns) on failure: a leftover alias *symlink* to the anchor is the exact - #95541 crash shape, so callers must know when the alias set is incomplete. The staging name is - unique (mkstemp) so a concurrent ensure (update + doctor --fix) cannot promote a truncated - interim copy. + crash shape, so callers must know when the alias set is incomplete. """ tmp_path: Path | None = None try: - fd, tmp_name = tempfile.mkstemp(prefix=f".{name}.tcc-", dir=str(venv_bin)) - os.close(fd) - tmp_path = Path(tmp_name) - shutil.copy2(anchor, tmp_path) - os.chmod(tmp_path, anchor.stat().st_mode | 0o111) + tmp_path = _stage_copy(venv_bin, f".{name}.tcc-", anchor) os.replace(tmp_path, venv_bin / name) return True except OSError as exc: logger.warning("TCC anchor alias %s not materialized: %s", name, exc) - if tmp_path is not None: - try: - tmp_path.unlink(missing_ok=True) - except OSError: - pass + _discard(tmp_path) return False -def _materialize_aliases( - venv_bin: Path, anchor: Path, *, refresh: bool = False -) -> bool: - """Materialize uv alias names as real-file copies of the anchor. - - Returns True only when every alias that needed materializing succeeded. - """ - names = set(_sibling_names()) +def _materialize_aliases(venv_bin: Path, anchor: Path, *, refresh: bool = False) -> bool: + """Materialize uv alias names as real-file copies; True only if every needed one succeeded.""" + names = set(_ALIAS_NAMES) try: names.update( - p.name - for p in venv_bin.glob("python3*") - if re.fullmatch(r"python3(\.\d+)?", p.name) + p.name for p in venv_bin.glob("python3*") if re.fullmatch(r"python3(\.\d+)?", p.name) ) except OSError: pass @@ -241,7 +247,6 @@ def _materialize_aliases( ok = _copy_alias(venv_bin, name, anchor) and ok except OSError: ok = False - continue return ok @@ -256,8 +261,7 @@ def _passes_boot_gate(staged: Path, venv_dir: Path) -> bool: env = { k: v for k, v in os.environ.items() - if k not in ("PYTHONHOME", "PYTHONPATH", "PYTHONSTARTUP", - "__PYVENV_LAUNCHER__") + if k not in ("PYTHONHOME", "PYTHONPATH", "PYTHONSTARTUP", "__PYVENV_LAUNCHER__") } try: proc = subprocess.run( @@ -294,12 +298,8 @@ def _install_anchor(venv_dir: Path, source_file: Path) -> None: _provision_libpython(venv_dir, source_file, refresh=True) - fd, tmp_name = tempfile.mkstemp(prefix=".python-tcc-", dir=str(venv_bin)) - os.close(fd) - tmp_path = Path(tmp_name) + tmp_path = _stage_copy(venv_bin, ".python-tcc-", source_file) try: - shutil.copy2(source_file, tmp_path) - os.chmod(tmp_path, source_file.stat().st_mode | 0o111) try: from hermes_cli.managed_uv import _macos_sign_managed_python @@ -307,17 +307,12 @@ def _install_anchor(venv_dir: Path, source_file: Path) -> None: except Exception: # pragma: no cover - never block the anchor logger.debug("anchor copy signing skipped", exc_info=True) if not _passes_boot_gate(tmp_path, venv_dir): - raise _BootGateFailed( - f"staged copy at {tmp_path} failed encodings/prefix probe" - ) + raise _BootGateFailed(f"staged copy at {tmp_path} failed encodings/prefix probe") os.replace(tmp_path, venv_py) - aliases_ok = _materialize_aliases(venv_bin, venv_py, refresh=True) - if aliases_ok: - # Marker last, atomically: it asserts the WHOLE layout (anchor + - # aliases) is complete. A partially-materialized alias set (the - # #95541 crash shape when an alias stays a symlink) must not read - # "active" in doctor — leaving the marker absent makes the next - # ensure retry the install. + if _materialize_aliases(venv_bin, venv_py, refresh=True): + # Marker last, atomically: it asserts the WHOLE layout (anchor + aliases) is complete. + # A partial alias set must not read "active" in doctor; an absent marker makes the + # next ensure retry the install. _write_marker(venv_bin, source_file) else: logger.warning( @@ -325,44 +320,30 @@ def _install_anchor(venv_dir: Path, source_file: Path) -> None: "incomplete; leaving anchor unmarked so the next run retries" ) except Exception: - try: - tmp_path.unlink(missing_ok=True) - except OSError: - pass + _discard(tmp_path) raise def ensure_tcc_anchor(project_root: Path | None = None) -> Path | None: - """Pin a dylib-complete interpreter anchor for macOS TCC (#95596). + """Pin a dylib-complete interpreter anchor for macOS TCC. - No-op (returns None) on non-macOS, when no venv interpreter exists, or when the interpreter is - not uv-managed. Idempotent. Best-effort — returns None (and logs) if the copy or boot-gate - fails; callers must never depend on success. + No-op (None) on non-macOS, without a venv interpreter, or when the interpreter is not + uv-managed. Idempotent. Best-effort — None (and logs) if the copy or boot-gate fails; callers + must never depend on success. """ - if not is_macos(): - return None - venv_dir = _venv_dir(project_root) - if venv_dir is None: - return None - venv_py = venv_python_path(venv_dir) - if not (venv_py.is_file() or venv_py.is_symlink()): - return None - source = _interpreter_source(venv_dir) - if source is None or not _is_uv_macos_store(source): + found = _managed_venv(project_root) + if isinstance(found, str): return None + venv_dir, venv_py, source = found source_file = _interpreter_file(source) if source_file is None: return None - if not venv_py.is_symlink(): - marker = _anchor_marker(venv_py.parent) + if not venv_py.is_symlink() and _marker_matches(venv_py.parent, _marker_value(source_file)): try: - if marker.is_file() and marker.read_text(encoding="utf-8").strip() == ( - _marker_value(source_file) - ): - _provision_libpython(venv_dir, source_file, refresh=False) - if _passes_boot_gate(venv_py, venv_dir): - _materialize_aliases(venv_py.parent, venv_py) - return venv_py + _provision_libpython(venv_dir, source_file, refresh=False) + if _passes_boot_gate(venv_py, venv_dir): + _materialize_aliases(venv_py.parent, venv_py) + return venv_py except OSError: pass try: @@ -379,29 +360,16 @@ def ensure_tcc_anchor(project_root: Path | None = None) -> Path | None: def tcc_anchor_state(project_root: Path | None = None) -> tuple[str, str]: """Report the anchor state for ``hermes doctor`` as ``(status, detail)``. - ``skip`` = not applicable (non-macOS, no venv, not uv-managed); ``active`` = pinned at a - stable real-file anchor; ``stale`` = pinned but the interpreter changed since the last copy; - ``missing`` = uv-managed interpreter with no anchor installed. + ``skip`` = not applicable; ``active`` = pinned at a stable real-file anchor; ``stale`` = pinned + but the interpreter changed since the last copy; ``missing`` = uv-managed with no anchor. """ - if not is_macos(): - return "skip", "not macOS" - venv_dir = _venv_dir(project_root) - if venv_dir is None: - return "skip", "no venv interpreter" - venv_py = venv_python_path(venv_dir) - if not (venv_py.is_file() or venv_py.is_symlink()): - return "skip", "no venv interpreter" - source = _interpreter_source(venv_dir) - if source is None or not _is_uv_macos_store(source): - return "skip", "interpreter not uv-managed (stable path)" - if not venv_py.is_symlink(): - marker = _anchor_marker(venv_py.parent) - source_file = _interpreter_file(source) - expected = _marker_value(source_file) if source_file is not None else source - try: - if marker.is_file() and marker.read_text(encoding="utf-8").strip() == expected: - return "active", str(venv_py) - except OSError: - pass - return "stale", str(venv_py) - return "missing", str(venv_py) + found = _managed_venv(project_root) + if isinstance(found, str): + return "skip", found + _venv, venv_py, source = found + if venv_py.is_symlink(): + return "missing", str(venv_py) + source_file = _interpreter_file(source) + expected = _marker_value(source_file) if source_file is not None else source + status = "active" if _marker_matches(venv_py.parent, expected) else "stale" + return status, str(venv_py) From 393ce7e7f19dbc6235b97b5928e183e5f2ffef94 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:12:36 -0700 Subject: [PATCH 06/38] =?UTF-8?q?refactor(cli-misc):=20lifecycle.py=20?= =?UTF-8?q?=E2=80=94=20single=20plugin-hook=20dispatch=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/lifecycle.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/hermes_cli/lifecycle.py b/hermes_cli/lifecycle.py index cce43eeb9d..bdd11a7a97 100644 --- a/hermes_cli/lifecycle.py +++ b/hermes_cli/lifecycle.py @@ -17,15 +17,18 @@ def _observe(hook_name: str, **kwargs: Any) -> None: logger.warning("Built-in observability hook failed", exc_info=True) -def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: - """Notify first-party observers, then invoke compatibility plugin hooks.""" - _observe(hook_name, **kwargs) - +def _plugin_hooks(hook_name: str, **kwargs: Any) -> List[Any]: from hermes_cli import plugins return plugins.invoke_hook(hook_name, **kwargs) +def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: + """Notify first-party observers, then invoke compatibility plugin hooks.""" + _observe(hook_name, **kwargs) + return _plugin_hooks(hook_name, **kwargs) + + def has_hook(hook_name: str) -> bool: """Return whether a first-party observer or plugin consumes a hook.""" try: @@ -57,6 +60,4 @@ def finalize_session(**kwargs: Any) -> List[Any]: except Exception: logger.warning("Core Relay session finalization failed", exc_info=True) - from hermes_cli import plugins - - return plugins.invoke_hook("on_session_finalize", **kwargs) + return _plugin_hooks("on_session_finalize", **kwargs) From e085147f574f1fcfc143d186b52ba82e56fe9408 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:12:50 -0700 Subject: [PATCH 07/38] =?UTF-8?q?refactor(kanban-cli):=20compact=20diagnos?= =?UTF-8?q?tics=20rule=20engine=20=E2=80=94=20shared=20cli=5Fhint/payload-?= =?UTF-8?q?id/run-order=20helpers,=20asdict,=20triage=20slot=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/kanban_diagnostics.py | 436 ++++++++++--------------------- 1 file changed, 138 insertions(+), 298 deletions(-) diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 61d1e201ce..f5aab6d8b9 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -3,16 +3,14 @@ A ``Diagnostic`` carries a **kind** (canonical code the UI/tests match on), a **severity**, title/detail text, and **actions** the dashboard renders as buttons and the CLI as hints. Rules are stateless and read-only over -(task, events, runs, optional graph); callers compute on demand. - -Design goals: operator-fixable signals only (not a one-off provider 502); -every diagnostic has at least one recovery action; diagnostics auto-clear -when the failure mode resolves (the audit event trail stays). +(task, events, runs, optional graph); callers compute on demand. Only +operator-fixable signals (not a one-off provider 502); every diagnostic has a +recovery action and auto-clears when the failure mode resolves. """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from typing import Any, Callable, Iterable, Optional import json import time @@ -44,12 +42,7 @@ class DiagnosticAction: suggested: bool = False def to_dict(self) -> dict: - return { - "kind": self.kind, - "label": self.label, - "payload": self.payload, - "suggested": self.suggested, - } + return asdict(self) @dataclass @@ -64,24 +57,11 @@ class Diagnostic: first_seen_at: int = 0 last_seen_at: int = 0 count: int = 1 - # Optional: the run id this diagnostic is scoped to. None = task-wide. - run_id: Optional[int] = None - # Optional structured payload for the UI (phantom ids, failure count). - data: dict = field(default_factory=dict) + run_id: Optional[int] = None # None = task-wide + data: dict = field(default_factory=dict) # structured payload for the UI def to_dict(self) -> dict: - return { - "kind": self.kind, - "severity": self.severity, - "title": self.title, - "detail": self.detail, - "actions": [a.to_dict() for a in self.actions], - "first_seen_at": self.first_seen_at, - "last_seen_at": self.last_seen_at, - "count": self.count, - "run_id": self.run_id, - "data": self.data, - } + return asdict(self) # --------------------------------------------------------------------------- @@ -105,8 +85,6 @@ def _task_field(task, name, default=None): def _parse_payload(ev) -> dict: """Tolerate event.payload being either a dict or a JSON string.""" p = _task_field(ev, "payload", None) - if p is None: - return {} if isinstance(p, dict): return p if isinstance(p, str): @@ -122,8 +100,7 @@ def _event_kind(ev) -> str: def _event_ts(ev) -> int: - t = _task_field(ev, "created_at", 0) - return int(t or 0) + return int(_task_field(ev, "created_at", 0) or 0) def _first_field(task, primary: str, legacy: str, default=None): @@ -134,20 +111,16 @@ def _first_field(task, primary: str, legacy: str, default=None): def _latest_event_ts(events: Iterable[Any], kinds: set[str]) -> int: """Max ``created_at`` over events whose kind is in ``kinds`` (0 if none).""" - latest = 0 - for ev in events: - if _event_kind(ev) in kinds: - latest = max(latest, _event_ts(ev)) - return latest + return max([0, *(_event_ts(ev) for ev in events if _event_kind(ev) in kinds)]) + + +def _cli_hint(label: str, command: str, *, suggested: bool = False) -> DiagnosticAction: + return DiagnosticAction(kind="cli_hint", label=label, payload={"command": command}, suggested=suggested) def _log_hint_action(task_id: str) -> DiagnosticAction: - return DiagnosticAction( - kind="cli_hint", - label=f"Check logs: hermes kanban log {task_id}", - payload={"command": f"hermes kanban log {task_id}"}, - suggested=True, - ) + cmd = f"hermes kanban log {task_id}" + return _cli_hint(f"Check logs: {cmd}", cmd, suggested=True) def _error_snippet(last_err) -> str: @@ -156,10 +129,7 @@ def _error_snippet(last_err) -> str: return err_text[:500] + ("…" if len(err_text) > 500 else "") if err_text else "" -def _active_hallucination_events( - events: Iterable[Any], - kind: str, -) -> list[Any]: +def _active_hallucination_events(events: Iterable[Any], kind: str) -> list[Any]: """Events of ``kind`` with no ``completed``/``edited`` event strictly after them. Requires id-sorted (arrival-order) input, which the DB provides.""" active: list[Any] = [] @@ -172,23 +142,37 @@ def _active_hallucination_events( return active -# Baseline recovery primitives every diagnostic can fall back on. +def _unique_payload_ids(hits: list[Any], key: str) -> list[str]: + """Ordered, de-duplicated ``payload[key]`` entries across ``hits``.""" + out: list[str] = [] + for ev in hits: + for pid in _parse_payload(ev).get(key, []) or []: + if pid not in out: + out.append(pid) + return out + + def _generic_recovery_actions(task: Any, *, running: bool) -> list[DiagnosticAction]: + """Baseline recovery primitives every diagnostic can fall back on.""" out: list[DiagnosticAction] = [] if running: - out.append(DiagnosticAction( - kind="reclaim", - label="Reclaim task", - payload={}, - )) + out.append(DiagnosticAction(kind="reclaim", label="Reclaim task", payload={})) out.append(DiagnosticAction( - kind="reassign", - label="Reassign to different profile", - payload={"reclaim_first": running}, + kind="reassign", label="Reassign to different profile", payload={"reclaim_first": running}, )) return out +def _is_running(task) -> bool: + return _task_field(task, "status") == "running" + + +def _runs_newest_first(runs) -> list[Any]: + # reversed(sorted()) not sorted(reverse=True): equal ids must keep the + # last-listed run first. + return list(reversed(sorted(runs, key=lambda r: _task_field(r, "id", 0)))) + + # --------------------------------------------------------------------------- # Rule implementations # --------------------------------------------------------------------------- @@ -208,10 +192,7 @@ def _aux_slot_explicit(slot: Any) -> bool: provider = str(slot.get("provider") or "").strip().lower() if provider and provider != "auto": return True - for key in ("model", "base_url", "api_key"): - if str(slot.get(key) or "").strip(): - return True - return False + return any(str(slot.get(key) or "").strip() for key in ("model", "base_url", "api_key")) def _main_model_visible(raw_config: Any) -> bool: @@ -223,12 +204,7 @@ def _main_model_visible(raw_config: Any) -> bool: model_cfg = raw_config.get("model") if isinstance(model_cfg, dict): provider = str(model_cfg.get("provider") or "").strip() - model = str( - model_cfg.get("default") - or model_cfg.get("model") - or model_cfg.get("name") - or "" - ).strip() + model = str(model_cfg.get("default") or model_cfg.get("model") or model_cfg.get("name") or "").strip() return bool(provider and model) return bool(str(model_cfg or "").strip()) @@ -239,37 +215,21 @@ def triage_aux_status(config: Optional[dict]) -> Optional[dict]: when no config context is present (keeps low-level callers/tests silent).""" if not isinstance(config, dict): return None - explicit = config.get("triage_aux_status") if isinstance(explicit, dict): return explicit aux = config.get("auxiliary") kanban_cfg = config.get("kanban") if isinstance(config.get("kanban"), dict) else {} - # No auxiliary/kanban/model keys at all => a low-level caller passing {}. - if ( - not isinstance(aux, dict) - and not kanban_cfg - and "model" not in config - ): + if not isinstance(aux, dict) and not kanban_cfg and "model" not in config: return None - - decomposer_explicit = False - specifier_explicit = False - if isinstance(aux, dict): - decomposer_explicit = _aux_slot_explicit(aux.get("kanban_decomposer")) - specifier_explicit = _aux_slot_explicit(aux.get("triage_specifier")) - - # ``auto_decompose`` defaults to True per kanban DEFAULT_CONFIG. - auto_decompose = True - if isinstance(kanban_cfg, dict) and "auto_decompose" in kanban_cfg: - auto_decompose = bool(kanban_cfg.get("auto_decompose")) - + aux = aux if isinstance(aux, dict) else {} return { - "auto_decompose": auto_decompose, - "decomposer_explicit": decomposer_explicit, - "specifier_explicit": specifier_explicit, + # ``auto_decompose`` defaults to True per kanban DEFAULT_CONFIG. + "auto_decompose": bool(kanban_cfg["auto_decompose"]) if "auto_decompose" in kanban_cfg else True, + "decomposer_explicit": _aux_slot_explicit(aux.get("kanban_decomposer")), + "specifier_explicit": _aux_slot_explicit(aux.get("triage_specifier")), "main_model_visible": _main_model_visible(config), } @@ -288,20 +248,11 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: hits = _active_hallucination_events(events, "completion_blocked_hallucination") if not hits: return [] - phantom_ids: list[str] = [] - first = _event_ts(hits[0]) - last = _event_ts(hits[-1]) - for ev in hits: - payload = _parse_payload(ev) - for pid in payload.get("phantom_cards", []) or []: - if pid not in phantom_ids: - phantom_ids.append(pid) - running = _task_field(task, "status") == "running" actions = [DiagnosticAction( kind="comment", label="Add a comment explaining what to do", suggested=False, - )] + _generic_recovery_actions(task, running=running) + )] + _generic_recovery_actions(task, running=_is_running(task)) return [Diagnostic( kind="hallucinated_cards", severity="error", @@ -314,13 +265,29 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: "return values from kanban_create." ), actions=actions, - first_seen_at=first, - last_seen_at=last, + first_seen_at=_event_ts(hits[0]), + last_seen_at=_event_ts(hits[-1]), count=len(hits), - data={"phantom_ids": phantom_ids}, + data={"phantom_ids": _unique_payload_ids(hits, "phantom_cards")}, )] +# (primary_slot, fallback_slot, primary_desc, detail_path) keyed by auto_decompose. +_TRIAGE_SLOTS = { + True: ( + "auxiliary.kanban_decomposer", "auxiliary.triage_specifier", "decomposer", + "Auto-decompose is on, so the dispatcher needs " + "auxiliary.kanban_decomposer (with auxiliary.triage_specifier as " + "a fallback for non-fan-out tasks).", + ), + False: ( + "auxiliary.triage_specifier", "auxiliary.kanban_decomposer", "specifier", + "Auto-decompose is off, so triage tasks need " + "`hermes kanban specify`, which uses auxiliary.triage_specifier.", + ), +} + + def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnostic]: """A triage task can't leave triage without a usable aux model. With auto-decompose on the primary slot is ``auxiliary.kanban_decomposer`` @@ -330,7 +297,6 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti context ({} keeps it silent).""" if _task_field(task, "status") != "triage": return [] - status = triage_aux_status(cfg) if status is None: return [] @@ -339,62 +305,25 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti decomposer_explicit = bool(status.get("decomposer_explicit")) specifier_explicit = bool(status.get("specifier_explicit")) main_visible = bool(status.get("main_model_visible")) - - # Determine the primary slot and whether it is usable. - if auto_decompose: - primary_slot = "auxiliary.kanban_decomposer" - primary_explicit = decomposer_explicit - fallback_slot = "auxiliary.triage_specifier" - fallback_explicit = specifier_explicit - primary_desc = "decomposer" - detail_path = ( - "Auto-decompose is on, so the dispatcher needs " - "auxiliary.kanban_decomposer (with auxiliary.triage_specifier as " - "a fallback for non-fan-out tasks)." - ) - else: - primary_slot = "auxiliary.triage_specifier" - primary_explicit = specifier_explicit - fallback_slot = "auxiliary.kanban_decomposer" - fallback_explicit = decomposer_explicit - primary_desc = "specifier" - detail_path = ( - "Auto-decompose is off, so triage tasks need " - "`hermes kanban specify`, which uses auxiliary.triage_specifier." - ) - + primary_slot, fallback_slot, primary_desc, detail_path = _TRIAGE_SLOTS[auto_decompose] + primary_explicit, fallback_explicit = ( + (decomposer_explicit, specifier_explicit) if auto_decompose + else (specifier_explicit, decomposer_explicit) + ) if primary_explicit or main_visible: return [] task_id = _task_field(task, "id") or "" - actions = [ - DiagnosticAction( - kind="cli_hint", - label=f"Configure {primary_slot}", - payload={ - "command": ( - f"hermes config set {primary_slot}.provider auto" - ) - }, - suggested=True, - ), - ] + actions = [_cli_hint( + f"Configure {primary_slot}", f"hermes config set {primary_slot}.provider auto", suggested=True, + )] if not fallback_explicit and not main_visible: - actions.append(DiagnosticAction( - kind="cli_hint", - label=f"Or configure fallback {fallback_slot}", - payload={ - "command": ( - f"hermes config set {fallback_slot}.provider auto" - ) - }, + actions.append(_cli_hint( + f"Or configure fallback {fallback_slot}", f"hermes config set {fallback_slot}.provider auto", )) if not auto_decompose: - actions.append(DiagnosticAction( - kind="cli_hint", - label=f"Specify manually: hermes kanban specify {task_id}", - payload={"command": f"hermes kanban specify {task_id}"}, - )) + cmd = f"hermes kanban specify {task_id}" + actions.append(_cli_hint(f"Specify manually: {cmd}", cmd)) return [Diagnostic( kind="triage_aux_unavailable", @@ -426,12 +355,6 @@ def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]: hits = _active_hallucination_events(events, "suspected_hallucinated_references") if not hits: return [] - phantom_refs: list[str] = [] - for ev in hits: - for pid in _parse_payload(ev).get("phantom_refs", []) or []: - if pid not in phantom_refs: - phantom_refs.append(pid) - running = _task_field(task, "status") == "running" return [Diagnostic( kind="prose_phantom_refs", severity="warning", @@ -442,14 +365,22 @@ def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]: "but downstream consumers parsing the summary may be pointed " "at cards that never existed." ), - actions=_generic_recovery_actions(task, running=running), + actions=_generic_recovery_actions(task, running=_is_running(task)), first_seen_at=_event_ts(hits[0]), last_seen_at=_event_ts(hits[-1]), count=len(hits), - data={"phantom_refs": phantom_refs}, + data={"phantom_refs": _unique_payload_ids(hits, "phantom_refs")}, )] +def _failure_threshold(cfg: dict) -> Any: + """``failure_threshold`` with the legacy ``spawn_failure_threshold`` alias.""" + return cfg.get("failure_threshold", cfg.get("spawn_failure_threshold", 3)) + + +_OUTCOME_LABELS = {"spawn_failed": "spawn", "timed_out": "timeout", "crashed": "crash"} + + def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: """``consecutive_failures`` >= cfg["failure_threshold"] (legacy key ``spawn_failure_threshold``), regardless of failure mode — the kernel keeps @@ -461,10 +392,7 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: if it fails too).""" if _task_field(task, "status") in ("done", "archived", "running"): return [] - threshold = _positive_int(cfg.get( - "failure_threshold", - cfg.get("spawn_failure_threshold", 3), - ), 3) + threshold = _positive_int(_failure_threshold(cfg), 3) failure_limit = _positive_int(cfg.get("failure_limit"), threshold) failures = _first_field(task, "consecutive_failures", "spawn_failures", 0) if failures is None or failures < threshold: @@ -473,45 +401,29 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: assignee = _task_field(task, "assignee") # Most recent failure outcome makes the title/action specific. - ordered_runs = sorted(runs, key=lambda r: _task_field(r, "id", 0)) - most_recent_outcome = None - for r in reversed(ordered_runs): - oc = _task_field(r, "outcome") - if oc in {"spawn_failed", "timed_out", "crashed"}: - most_recent_outcome = oc - break + most_recent_outcome = next( + (oc for oc in (_task_field(r, "outcome") for r in _runs_newest_first(runs)) + if oc in {"spawn_failed", "timed_out", "crashed"}), + None, + ) actions: list[DiagnosticAction] = [] if most_recent_outcome == "spawn_failed" and assignee and assignee != "default": # Spawn is failing specifically — profile setup issue. - actions.append(DiagnosticAction( - kind="cli_hint", - label=f"Verify profile: hermes -p {assignee} doctor", - payload={"command": f"hermes -p {assignee} doctor"}, - suggested=True, - )) - actions.append(DiagnosticAction( - kind="cli_hint", - label=f"Fix profile auth: hermes -p {assignee} auth", - payload={"command": f"hermes -p {assignee} auth"}, + actions.append(_cli_hint( + f"Verify profile: hermes -p {assignee} doctor", f"hermes -p {assignee} doctor", suggested=True, )) + actions.append(_cli_hint(f"Fix profile auth: hermes -p {assignee} auth", f"hermes -p {assignee} auth")) elif most_recent_outcome in {"timed_out", "crashed"}: - # Worker got off the ground but died. Logs are the right place - # to diagnose; reclaim/reassign are the recovery levers. + # Worker got off the ground but died: logs diagnose, reclaim/reassign recover. task_id = _task_field(task, "id") if task_id: actions.append(_log_hint_action(task_id)) - actions.extend(_generic_recovery_actions( - task, running=_task_field(task, "status") == "running", - )) + actions.extend(_generic_recovery_actions(task, running=_is_running(task))) severity = "critical" if failures >= threshold * 2 else "error" err_snippet = _error_snippet(last_err) - outcome_label = { - "spawn_failed": "spawn", - "timed_out": "timeout", - "crashed": "crash", - }.get(most_recent_outcome or "", "failure") + outcome_label = _OUTCOME_LABELS.get(most_recent_outcome or "", "failure") if err_snippet: title = f"Agent {outcome_label} x{failures}: {err_snippet.splitlines()[0][:160]}" detail = ( @@ -559,42 +471,30 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: and wouldn't break the scan).""" if _task_field(task, "status") in ("done", "archived", "running"): return [] - failure_threshold = int(cfg.get( - "failure_threshold", - cfg.get("spawn_failure_threshold", 3), - )) - unified_counter = ( - _task_field(task, "consecutive_failures", 0) or 0 - ) # Unified rule will catch this — let it handle to avoid double fire. - if unified_counter >= failure_threshold: + if (_task_field(task, "consecutive_failures", 0) or 0) >= int(_failure_threshold(cfg)): return [] threshold = int(cfg.get("crash_threshold", 2)) - ordered = sorted(runs, key=lambda r: _task_field(r, "id", 0)) - # Count trailing consecutive 'crashed' outcomes. + # Count trailing consecutive 'crashed' outcomes; a success (or manual + # reclaim) breaks the streak, other outcomes neither count nor break it. consecutive = 0 last_err = None - for r in reversed(ordered): + for r in _runs_newest_first(runs): outcome = _task_field(r, "outcome") if outcome == "crashed": consecutive += 1 if last_err is None: last_err = _task_field(r, "error") elif outcome in {"completed", "reclaimed"}: - # A success (or manual reclaim) breaks the streak. break - else: - # Other outcomes neither count as crashes nor break the streak. - continue if consecutive < threshold: return [] task_id = _task_field(task, "id") actions: list[DiagnosticAction] = [] if task_id: actions.append(_log_hint_action(task_id)) - running = _task_field(task, "status") == "running" - actions.extend(_generic_recovery_actions(task, running=running)) + actions.extend(_generic_recovery_actions(task, running=_is_running(task))) severity = "critical" if consecutive >= threshold * 2 else "error" # Error up-front so operators see WHAT broke without opening the logs. err_snippet = _error_snippet(last_err) @@ -629,11 +529,7 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn for it to be terminal. Graph-aware; deliberately mutates nothing.""" if _task_field(task, "status") != "blocked": return [] - - latest_block = None - for event in events: - if _event_kind(event) == "blocked": - latest_block = event + latest_block = next((ev for ev in reversed(list(events)) if _event_kind(ev) == "blocked"), None) if latest_block is None: return [] reason = str(_parse_payload(latest_block).get("reason") or "").strip() @@ -644,32 +540,22 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn if not isinstance(graph, dict): return [] waiting_children = [ - child - for child in (graph.get("children") or []) + child for child in (graph.get("children") or []) if isinstance(child, dict) and child.get("status") == "todo" ] if not waiting_children: return [] task_id = str(_task_field(task, "id") or "") - child_ids = [ - str(child.get("id")) - for child in waiting_children - if child.get("id") - ] + child_ids = [str(child.get("id")) for child in waiting_children if child.get("id")] actions: list[DiagnosticAction] = [] if task_id: - actions.append(DiagnosticAction( - kind="cli_hint", - label="Complete the finished implementation phase", - payload={"command": f"hermes kanban complete {task_id}"}, - suggested=True, + actions.append(_cli_hint( + "Complete the finished implementation phase", f"hermes kanban complete {task_id}", suggested=True, )) if task_id and child_ids: - actions.append(DiagnosticAction( - kind="cli_hint", - label="Or unlink the incorrectly gated reviewer", - payload={"command": f"hermes kanban unlink {task_id} {child_ids[0]}"}, + actions.append(_cli_hint( + "Or unlink the incorrectly gated reviewer", f"hermes kanban unlink {task_id} {child_ids[0]}", )) blocked_at = _event_ts(latest_block) or now @@ -700,8 +586,7 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: """Blocked for >= cfg["blocked_stale_hours"] (default 24) with no comment or unblock since the last ``blocked`` event.""" hours = float(cfg.get("blocked_stale_hours", 24)) - status = _task_field(task, "status") - if status != "blocked": + if _task_field(task, "status") != "blocked": return [] last_blocked_ts = _latest_event_ts(events, {"blocked"}) if last_blocked_ts == 0: @@ -710,16 +595,8 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: if age_hours < hours: return [] # Any comment / unblock after the block breaks the "stale" signal. - for ev in events: - if _event_kind(ev) in {"commented", "unblocked"} and _event_ts(ev) > last_blocked_ts: - return [] - actions: list[DiagnosticAction] = [ - DiagnosticAction( - kind="comment", - label="Add a comment / unblock the task", - suggested=True, - ), - ] + if any(_event_kind(ev) in {"commented", "unblocked"} and _event_ts(ev) > last_blocked_ts for ev in events): + return [] return [Diagnostic( kind="stuck_in_blocked", severity="warning", @@ -730,7 +607,7 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: f"are waiting for human input — check the block reason and " f"either unblock with feedback or answer with a comment." ), - actions=actions, + actions=[DiagnosticAction(kind="comment", label="Add a comment / unblock the task", suggested=True)], first_seen_at=last_blocked_ts, last_seen_at=last_blocked_ts, count=1, @@ -775,12 +652,8 @@ def _rule_block_unblock_cycling(task, events, runs, now, cfg) -> list[Diagnostic task_id = _task_field(task, "id") actions: list[DiagnosticAction] = [] if task_id: - actions.append(DiagnosticAction( - kind="cli_hint", - label=f"Check block reasons: hermes kanban events {task_id}", - payload={"command": f"hermes kanban events {task_id}"}, - suggested=True, - )) + cmd = f"hermes kanban events {task_id}" + actions.append(_cli_hint(f"Check block reasons: {cmd}", cmd, suggested=True)) return [Diagnostic( kind="block_unblock_cycling", severity="warning", @@ -796,10 +669,7 @@ def _rule_block_unblock_cycling(task, events, runs, now, cfg) -> list[Diagnostic first_seen_at=int(initial_blocked_ts) if initial_blocked_ts else int(now), last_seen_at=int(last_cycle_blocked_ts) if last_cycle_blocked_ts else int(now), count=cycles, - data={ - "cycles": cycles, - "window_seconds": int(window_seconds), - }, + data={"cycles": cycles, "window_seconds": int(window_seconds)}, )] @@ -809,11 +679,8 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: catches typo'd assignees, deleted profiles, and down external worker pools alike without a registry to curate. Unassigned tasks are excluded — the dispatcher's ``skipped_unassigned`` already covers them.""" - threshold_seconds = float( - cfg.get("stranded_threshold_seconds", 30 * 60) - ) - status = _task_field(task, "status") - if status != "ready": + threshold_seconds = float(cfg.get("stranded_threshold_seconds", 30 * 60)) + if _task_field(task, "status") != "ready": return [] # A live claim means it's being worked on even without progress yet. if _task_field(task, "claim_lock"): @@ -822,13 +689,10 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: if not assignee.strip(): return [] - # Most recent event that put the task into ready. - last_ready_ts = _latest_event_ts( - events, {"created", "promoted", "reclaimed", "unblocked"}, - ) - - # No qualifying event (old task / truncated events): fall back to - # created_at — over-flagging an ancient task beats missing a stranded one. + # Most recent event that put the task into ready; with none (old task / + # truncated events) fall back to created_at — over-flagging an ancient + # task beats missing a stranded one. + last_ready_ts = _latest_event_ts(events, {"created", "promoted", "reclaimed", "unblocked"}) if last_ready_ts == 0: last_ready_ts = int(_task_field(task, "created_at", default=0) or 0) if last_ready_ts == 0: @@ -838,12 +702,7 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: if age_seconds < threshold_seconds: return [] - # Format the age in the largest sensible unit. - if age_seconds >= 3600: - age_str = f"{age_seconds / 3600:.1f}h" - else: - age_str = f"{int(age_seconds / 60)}m" - + age_str = f"{age_seconds / 3600:.1f}h" if age_seconds >= 3600 else f"{int(age_seconds / 60)}m" # Escalate with age: <2x threshold warning, 2x-6x error, >6x critical. if age_seconds >= threshold_seconds * 6: severity = "critical" @@ -858,13 +717,8 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: label="Reassign to a different worker", payload={"current_assignee": assignee}, ), - DiagnosticAction( - kind="cli_hint", - label="Check dispatcher status", - payload={"command": "hermes kanban diagnostics"}, - ), + _cli_hint("Check dispatcher status", "hermes kanban diagnostics"), ] - return [Diagnostic( kind="stranded_in_ready", severity=severity, @@ -917,20 +771,18 @@ DEFAULT_CONFIG = { } +def _has_explicit_threshold(cfg: dict) -> bool: + return "failure_threshold" in cfg or "spawn_failure_threshold" in cfg + + def config_from_kanban_config(kanban_cfg: Optional[dict]) -> dict: """Diagnostics config from the ``kanban`` section. ``kanban.diagnostics. failure_threshold`` is an explicit override; otherwise the threshold is ``kanban.failure_limit`` so diagnostics match the dispatcher's breaker.""" kanban_cfg = kanban_cfg or {} diag_cfg = dict(kanban_cfg.get("diagnostics") or {}) - diag_cfg.setdefault( - "failure_limit", - kanban_cfg.get("failure_limit", DEFAULT_CONFIG["failure_threshold"]), - ) - if ( - "failure_threshold" not in diag_cfg - and "spawn_failure_threshold" not in diag_cfg - ): + diag_cfg.setdefault("failure_limit", kanban_cfg.get("failure_limit", DEFAULT_CONFIG["failure_threshold"])) + if not _has_explicit_threshold(diag_cfg): diag_cfg["failure_threshold"] = diag_cfg["failure_limit"] return diag_cfg @@ -970,15 +822,8 @@ def compute_task_diagnostics( cfg = {**DEFAULT_CONFIG, **config} if graph is not None: cfg["_graph"] = graph - if ( - "failure_threshold" not in config - and "spawn_failure_threshold" not in config - and "failure_limit" in config - ): - cfg["failure_threshold"] = _positive_int( - config.get("failure_limit"), - DEFAULT_CONFIG["failure_threshold"], - ) + if not _has_explicit_threshold(config) and "failure_limit" in config: + cfg["failure_threshold"] = _positive_int(config.get("failure_limit"), DEFAULT_CONFIG["failure_threshold"]) out: list[Diagnostic] = [] for rule in _RULES: try: @@ -987,10 +832,5 @@ def compute_task_diagnostics( # A broken rule must never 500 a whole /board request. continue severity_idx = {s: i for i, s in enumerate(SEVERITY_ORDER)} - out.sort( - key=lambda d: ( - -severity_idx.get(d.severity, -1), - -(d.last_seen_at or 0), - ) - ) + out.sort(key=lambda d: (-severity_idx.get(d.severity, -1), -(d.last_seen_at or 0))) return out From 6b206f90aea6c3925305e8625b0c06d2e2679540 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:14:54 -0700 Subject: [PATCH 08/38] refactor(kanban_db): unify slug/require-task/first-line/json-null/reclaim helpers, table-drive list filters and tick outcome --- hermes_cli/kanban_db.py | 406 ++++++++++++++++------------------------ 1 file changed, 158 insertions(+), 248 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 0d9499390f..1ea11b54e6 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -286,6 +286,15 @@ def notify_task_updated( _log.debug("kanban task updated hook failed: %s", exc) +# DispatchResult counters whose non-zero value means the tick did something. +_TICK_ACTIVITY_FIELDS = ( + "spawned", "reclaimed", "promoted", "reconciled_orphans", "crashed", "stale", + "timed_out", "auto_blocked", "rate_limited", "auto_assigned_default", + "respawn_guarded", "skipped_per_profile_capped", "skipped_unassigned", + "skipped_nonspawnable", +) + + def _fire_dispatch_tick_hook( result: "DispatchResult", *, @@ -311,22 +320,7 @@ def _fire_dispatch_tick_hook( outcome = "ok" if result.skipped_locked: outcome = "skipped_locked" - elif not any(( - result.spawned, - result.reclaimed, - result.promoted, - result.reconciled_orphans, - result.crashed, - result.stale, - result.timed_out, - result.auto_blocked, - result.rate_limited, - result.auto_assigned_default, - result.respawn_guarded, - result.skipped_per_profile_capped, - result.skipped_unassigned, - result.skipped_nonspawnable, - )): + elif not any(getattr(result, f) for f in _TICK_ACTIVITY_FIELDS): outcome = "idle" invoke_hook( "on_kanban_dispatch_tick", @@ -415,19 +409,13 @@ def _relative_age(ts: Optional[int], now: Optional[int] = None) -> str: if now is None: now = int(time.time()) delta = now - ts - if delta < 0: - # Clock skew across machines/profiles — don't claim "in the future". - return "just now" - if delta < 60: + if delta < 60: # includes negative = clock skew across machines; never claim "in the future" return "just now" if delta < 3600: - m = delta // 60 - return f"{m}m ago" + return f"{delta // 60}m ago" if delta < 86400: - h = delta // 3600 - return f"{h}h ago" - d = delta // 86400 - return f"{d}d ago" + return f"{delta // 3600}h ago" + return f"{delta // 86400}d ago" # --------------------------------------------------------------------------- @@ -461,9 +449,7 @@ _BOARD_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$") def _normalize_board_slug(slug: Optional[str]) -> Optional[str]: """Lowercase + strip a slug; validate; return ``None`` for empty.""" - if slug is None: - return None - s = str(slug).strip().lower() + s = str(slug).strip().lower() if slug is not None else "" if not s: return None if not _BOARD_SLUG_RE.match(s): @@ -474,6 +460,17 @@ def _normalize_board_slug(slug: Optional[str]) -> Optional[str]: return s +def _slug_or_default(board: Optional[str]) -> str: + return _normalize_board_slug(board) or DEFAULT_BOARD + + +def _require_slug(slug: str) -> str: + normed = _normalize_board_slug(slug) + if not normed: + raise ValueError("board slug is required") + return normed + + def kanban_home() -> Path: """Shared Hermes root anchoring the board: ``HERMES_KANBAN_HOME`` if set, else ``get_default_hermes_root()`` (```` for a @@ -556,9 +553,7 @@ def set_current_board(slug: str) -> Path: instead of silently pointing at nothing. """ _assert_not_delegated_child_mutation() - normed = _normalize_board_slug(slug) - if not normed: - raise ValueError("board slug is required") + normed = _require_slug(slug) path = current_board_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(normed + "\n", encoding="utf-8") @@ -577,8 +572,7 @@ def board_dir(board: Optional[str] = None) -> Path: only (board.json, workspaces/, logs/) — its DB stays at ``/kanban.db`` for back-compat (:func:`kanban_db_path`). """ - slug = _normalize_board_slug(board) or DEFAULT_BOARD - return boards_root() / slug + return boards_root() / _slug_or_default(board) def board_exists(board: Optional[str] = None) -> bool: @@ -588,10 +582,13 @@ def board_exists(board: Optional[str] = None) -> bool: on first :func:`connect` and there's no way for it to be missing in a configuration where the kanban feature is usable at all. """ - slug = _normalize_board_slug(board) or DEFAULT_BOARD + slug = _slug_or_default(board) if slug == DEFAULT_BOARD: return True - d = board_dir(slug) + return _dir_holds_board(board_dir(slug)) + + +def _dir_holds_board(d: Path) -> bool: return (d / "board.json").exists() or (d / "kanban.db").exists() @@ -665,8 +662,7 @@ def board_metadata_path(board: Optional[str] = None) -> Path: created_at). The on-disk slug is the canonical identity; this file is purely for presentation in the CLI / dashboard. """ - slug = _normalize_board_slug(board) or DEFAULT_BOARD - return board_dir(slug) / "board.json" + return board_dir(_slug_or_default(board)) / "board.json" def _default_board_display_name(slug: str) -> str: @@ -687,7 +683,7 @@ def read_board_metadata(board: Optional[str] = None) -> dict: Includes the canonical ``slug`` and ``db_path`` so the caller doesn't need to reconstruct them. """ - slug = _normalize_board_slug(board) or DEFAULT_BOARD + slug = _slug_or_default(board) meta: dict[str, Any] = { "slug": slug, "name": _default_board_display_name(slug), @@ -739,25 +735,20 @@ def write_board_metadata( it against ``projects_db``). """ _assert_not_delegated_child_mutation() - slug = _normalize_board_slug(board) or DEFAULT_BOARD + slug = _slug_or_default(board) meta = read_board_metadata(slug) - # Preserve existing DB-derived fields — they get re-computed each - # read but shouldn't be written into board.json. + # db_path is derived on every read; never persist it into board.json. meta.pop("db_path", None) if name is not None: meta["name"] = str(name).strip() or _default_board_display_name(slug) - if description is not None: - meta["description"] = str(description) - if icon is not None: - meta["icon"] = str(icon) - if color is not None: - meta["color"] = str(color) + for key, value in (("description", description), ("icon", icon), ("color", color)): + if value is not None: + meta[key] = str(value) if archived is not None: meta["archived"] = bool(archived) - if default_workdir is not None: - meta["default_workdir"] = str(default_workdir) if default_workdir else None - if project_id is not None: - meta["project_id"] = str(project_id) if project_id else None + for key, value in (("default_workdir", default_workdir), ("project_id", project_id)): + if value is not None: + meta[key] = str(value) if value else None if not meta.get("created_at"): meta["created_at"] = int(time.time()) path = board_metadata_path(slug) @@ -786,9 +777,7 @@ def create_board( malformed slug; returns the existing metadata (not an error) if the board already exists — matching ``mkdir -p`` semantics. """ - normed = _normalize_board_slug(slug) - if not normed: - raise ValueError("board slug is required") + normed = _require_slug(slug) meta = write_board_metadata( normed, name=name, @@ -828,11 +817,7 @@ def list_boards(*, include_archived: bool = True) -> list[dict]: normed = _normalize_board_slug(slug) except ValueError: continue - if not normed or normed in seen: - continue - has_db = (child / "kanban.db").exists() - has_meta = (child / "board.json").exists() - if not (has_db or has_meta): + if not normed or normed in seen or not _dir_holds_board(child): continue meta = read_board_metadata(normed) if meta.get("archived") and not include_archived: @@ -848,9 +833,7 @@ def remove_board(slug: str, *, archive: bool = True) -> dict: (ValueError). Returns ``{"slug", "action", "new_path"}``. """ _assert_not_delegated_child_mutation() - normed = _normalize_board_slug(slug) - if not normed: - raise ValueError("board slug is required") + normed = _require_slug(slug) if normed == DEFAULT_BOARD: raise ValueError("the 'default' board cannot be removed") d = board_dir(normed) @@ -1630,7 +1613,7 @@ def create_task( # Parent ids are validated in every mode (even triage) so the # eventual link rows don't dangle. if parents: - missing = _find_missing_parents(conn, parents) + missing = _missing_task_ids(conn, parents) if missing: raise ValueError(f"unknown parent task(s): {', '.join(missing)}") # Determine task status from parent status, unless the caller @@ -1751,17 +1734,15 @@ def _link(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: ) -def _find_missing_parents(conn: sqlite3.Connection, parents: Iterable[str]) -> list[str]: - parents = list(parents) - if not parents: +def _missing_task_ids(conn: sqlite3.Connection, ids: Iterable[str]) -> list[str]: + """Subset of ``ids`` (order kept) with no ``tasks`` row.""" + ids = list(ids) + if not ids: return [] - placeholders = ",".join("?" * len(parents)) - rows = conn.execute( - f"SELECT id FROM tasks WHERE id IN ({placeholders})", - parents, - ).fetchall() + placeholders = ",".join("?" * len(ids)) + rows = conn.execute(f"SELECT id FROM tasks WHERE id IN ({placeholders})", ids).fetchall() present = {r["id"] for r in rows} - return [p for p in parents if p not in present] + return [p for p in ids if p not in present] def _inherit_notify_subs( @@ -1847,28 +1828,18 @@ def list_tasks( workflow_template_id: Optional[str] = None, current_step_key: Optional[str] = None, ) -> list[Task]: + if status is not None and status not in VALID_STATUSES: + raise ValueError(f"status must be one of {sorted(VALID_STATUSES)}") query = "SELECT * FROM tasks WHERE 1=1" params: list[Any] = [] - if assignee is not None: - query += " AND assignee = ?" - params.append(_canonical_assignee(assignee)) - if status is not None: - if status not in VALID_STATUSES: - raise ValueError(f"status must be one of {sorted(VALID_STATUSES)}") - query += " AND status = ?" - params.append(status) - if tenant is not None: - query += " AND tenant = ?" - params.append(tenant) - if session_id is not None: - query += " AND session_id = ?" - params.append(session_id) - if workflow_template_id is not None: - query += " AND workflow_template_id = ?" - params.append(workflow_template_id) - if current_step_key is not None: - query += " AND current_step_key = ?" - params.append(current_step_key) + for col, val in ( + ("assignee", _canonical_assignee(assignee)), ("status", status), ("tenant", tenant), + ("session_id", session_id), ("workflow_template_id", workflow_template_id), + ("current_step_key", current_step_key), + ): + if val is not None: + query += f" AND {col} = ?" + params.append(val) if not include_archived and status != "archived": query += " AND status != 'archived'" if order_by is not None: @@ -2013,7 +1984,7 @@ def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: if parent_id == child_id: raise ValueError("a task cannot depend on itself") with write_txn(conn): - missing = _find_missing_parents(conn, [parent_id, child_id]) + missing = _missing_task_ids(conn, [parent_id, child_id]) if missing: raise ValueError(f"unknown task(s): {', '.join(missing)}") if _would_cycle(conn, parent_id, child_id): @@ -2063,12 +2034,9 @@ def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> boo "DELETE FROM task_links WHERE parent_id = ? AND child_id = ?", (parent_id, child_id), ) - if cur.rowcount: - _append_event( - conn, child_id, "unlinked", - {"parent": parent_id, "child": child_id}, - ) removed = cur.rowcount > 0 + if removed: + _append_event(conn, child_id, "unlinked", {"parent": parent_id, "child": child_id}) if removed: # Dependency edge removed — re-evaluate promotion eligibility for the # child immediately. Matches the contract of complete_task and @@ -2106,28 +2074,16 @@ def task_graph_contexts( return contexts placeholders = ",".join("?" for _ in ordered_ids) - for row in conn.execute( - "SELECT l.child_id AS owner_id, t.id, t.title, t.status " - "FROM task_links l JOIN tasks t ON t.id = l.parent_id " - f"WHERE l.child_id IN ({placeholders}) ORDER BY l.child_id, t.id", - tuple(ordered_ids), - ).fetchall(): - contexts[row["owner_id"]]["parents"].append({ - "id": row["id"], - "title": row["title"], - "status": row["status"], - }) - for row in conn.execute( - "SELECT l.parent_id AS owner_id, t.id, t.title, t.status " - "FROM task_links l JOIN tasks t ON t.id = l.child_id " - f"WHERE l.parent_id IN ({placeholders}) ORDER BY l.parent_id, t.id", - tuple(ordered_ids), - ).fetchall(): - contexts[row["owner_id"]]["children"].append({ - "id": row["id"], - "title": row["title"], - "status": row["status"], - }) + for bucket, own, other in (("parents", "child_id", "parent_id"), ("children", "parent_id", "child_id")): + for row in conn.execute( + f"SELECT l.{own} AS owner_id, t.id, t.title, t.status " + f"FROM task_links l JOIN tasks t ON t.id = l.{other} " + f"WHERE l.{own} IN ({placeholders}) ORDER BY l.{own}, t.id", + tuple(ordered_ids), + ).fetchall(): + contexts[row["owner_id"]][bucket].append( + {"id": row["id"], "title": row["title"], "status": row["status"]} + ) return contexts @@ -2151,10 +2107,7 @@ def add_comment( # ``allow_nested=True``: graph builders (kanban_swarm blackboard seeding) # compose comment writes under one outer commit. with write_txn(conn, allow_nested=True): - if not conn.execute( - "SELECT 1 FROM tasks WHERE id = ?", (task_id,) - ).fetchone(): - raise ValueError(f"unknown task {task_id}") + _require_task(conn, task_id) cur = conn.execute( "INSERT INTO task_comments (task_id, author, body, created_at) " "VALUES (?, ?, ?, ?)", @@ -2164,6 +2117,11 @@ def add_comment( return int(cur.lastrowid or 0) +def _require_task(conn: sqlite3.Connection, task_id: str) -> None: + if not conn.execute("SELECT 1 FROM tasks WHERE id = ?", (task_id,)).fetchone(): + raise ValueError(f"unknown task {task_id}") + + def _task_rows(conn: sqlite3.Connection, table: str, task_id: str, order: str) -> list[sqlite3.Row]: return conn.execute( f"SELECT * FROM {table} WHERE task_id = ? ORDER BY {order}", (task_id,) @@ -2327,10 +2285,7 @@ def add_attachment( raise ValueError("attachment stored_path is required") now = int(time.time()) with write_txn(conn): - if not conn.execute( - "SELECT 1 FROM tasks WHERE id = ?", (task_id,) - ).fetchone(): - raise ValueError(f"unknown task {task_id}") + _require_task(conn, task_id) cur = conn.execute( "INSERT INTO task_attachments " "(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) " @@ -2423,12 +2378,10 @@ def _append_event( (task created/edited/archived, dependency promotion) leave it None and the row carries NULL. """ - now = int(time.time()) - pl = json.dumps(payload, ensure_ascii=False) if payload else None conn.execute( "INSERT INTO task_events (task_id, run_id, kind, payload, created_at) " "VALUES (?, ?, ?, ?, ?)", - (task_id, run_id, kind, pl, now), + (task_id, run_id, kind, _json_or_null(payload), int(time.time())), ) @@ -2475,7 +2428,7 @@ def _end_run( outcome, summary, error, - json.dumps(metadata, ensure_ascii=False) if metadata else None, + _json_or_null(metadata), now, run_id, ), @@ -2486,11 +2439,22 @@ def _end_run( return run_id +def _first_line(text: Optional[str], limit: int) -> str: + """First non-blank-stripped line of ``text`` capped at ``limit`` chars; "" when empty.""" + lines = (text or "").strip().splitlines() + return lines[0][:limit] if lines else "" + + def _opt_int(value: Any) -> Optional[int]: """``int(value)`` or ``None`` when ``value`` is ``None`` (NULL column passthrough).""" return int(value) if value is not None else None +def _json_or_null(obj: Any) -> Optional[str]: + """JSON text for a payload/metadata column; falsy -> NULL.""" + return json.dumps(obj, ensure_ascii=False) if obj else None + + def _task_status(conn: sqlite3.Connection, task_id: str) -> Optional[str]: """Current ``tasks.status`` for ``task_id``, or ``None`` when no such row.""" row = conn.execute("SELECT status FROM tasks WHERE id = ?", (task_id,)).fetchone() @@ -2548,7 +2512,7 @@ def _synthesize_ended_run( task_id, profile, step_key, outcome, outcome, summary, error, - json.dumps(metadata, ensure_ascii=False) if metadata else None, + _json_or_null(metadata), now, now, ), ) @@ -2899,6 +2863,16 @@ def _retry_status_for_run( return "review" if payload.get("source_status") == "review" else "ready" +# Run outcome -> lifecycle status a goal loop should report for a handed-off run. +_RUN_OUTCOME_TERMINAL_STATUS = { + "completed": "done", + "review_requested": "review", + "changes_requested": "changes_requested", + "blocked": "blocked", + "dependency_wait": "blocked", +} + + def goal_run_status( conn: sqlite3.Connection, task_id: str, @@ -2919,22 +2893,8 @@ def goal_run_status( "SELECT outcome FROM task_runs WHERE id = ? AND task_id = ?", (int(expected_run_id), task_id), ).fetchone() - outcome = ( - str(row["outcome"]) - if row and row["outcome"] is not None - else None - ) - terminal_status = ( - { - "completed": "done", - "review_requested": "review", - "changes_requested": "changes_requested", - "blocked": "blocked", - "dependency_wait": "blocked", - }.get(outcome) - if outcome is not None - else None - ) + outcome = str(row["outcome"]) if row and row["outcome"] is not None else None + terminal_status = _RUN_OUTCOME_TERMINAL_STATUS.get(outcome) if terminal_status is not None: return terminal_status if outcome is not None or task.current_run_id != int(expected_run_id): @@ -2971,16 +2931,19 @@ def heartbeat_claim( (expires, task_id, lock), ) if cur.rowcount == 1: - run_id = _current_run_id(conn, task_id) - if run_id is not None: - conn.execute( - "UPDATE task_runs SET claim_expires = ? WHERE id = ?", - (expires, run_id), - ) + _extend_run_claim(conn, task_id, expires) return True return False +def _extend_run_claim(conn: sqlite3.Connection, task_id: str, expires: int) -> Optional[int]: + """Mirror a task claim extension onto its active run row; returns that run id.""" + run_id = _current_run_id(conn, task_id) + if run_id is not None: + conn.execute("UPDATE task_runs SET claim_expires = ? WHERE id = ?", (expires, run_id)) + return run_id + + def release_stale_claims( conn: sqlite3.Connection, *, @@ -3045,12 +3008,7 @@ def release_stale_claims( ) if cur.rowcount != 1: continue - run_id = _current_run_id(conn, row["id"]) - if run_id is not None: - conn.execute( - "UPDATE task_runs SET claim_expires = ? WHERE id = ?", - (new_expires, run_id), - ) + run_id = _extend_run_claim(conn, row["id"], new_expires) _append_event( conn, row["id"], "claim_extended", { @@ -3087,27 +3045,19 @@ def release_stale_claims( ) if cur.rowcount != 1: continue - run_id = _end_run( - conn, row["id"], - outcome="reclaimed", status="reclaimed", + run_id = _record_reclaim( + conn, row["id"], termination, error=f"stale_lock={row['claim_lock']}", - metadata=termination, - ) - payload = { - "stale_lock": row["claim_lock"], - "worker_pid": _opt_int(row["worker_pid"]), - "claim_expires": int(row["claim_expires"]), - "last_heartbeat_at": _opt_int(row["last_heartbeat_at"]), - "now": now, - "host_local": host_local, - "heartbeat_stale": bool(heartbeat_stale), - "retry_status": retry_status, - } - payload.update(termination) - _append_event( - conn, row["id"], "reclaimed", - payload, - run_id=run_id, + payload={ + "stale_lock": row["claim_lock"], + "worker_pid": _opt_int(row["worker_pid"]), + "claim_expires": int(row["claim_expires"]), + "last_heartbeat_at": _opt_int(row["last_heartbeat_at"]), + "now": now, + "host_local": host_local, + "heartbeat_stale": bool(heartbeat_stale), + "retry_status": retry_status, + }, ) reclaimed += 1 # Worker-lifecycle observer (RFC #58548): the reclaim txn above has @@ -3128,6 +3078,19 @@ def release_stale_claims( return reclaimed +def _record_reclaim( + conn: sqlite3.Connection, task_id: str, termination: dict, *, error: str, payload: dict, +) -> Optional[int]: + """Close the active run as ``reclaimed`` and emit the ``reclaimed`` event + (payload merged with the termination report). Caller holds the txn.""" + run_id = _end_run( + conn, task_id, outcome="reclaimed", status="reclaimed", error=error, metadata=termination, + ) + payload.update(termination) + _append_event(conn, task_id, "reclaimed", payload, run_id=run_id) + return run_id + + def reclaim_task( conn: sqlite3.Connection, task_id: str, @@ -3164,26 +3127,10 @@ def reclaim_task( ) if cur.rowcount != 1: return False - run_id = _end_run( - conn, task_id, - outcome="reclaimed", status="reclaimed", - error=( - f"manual_reclaim: {reason}" if reason - else f"manual_reclaim lock={prev_lock}" - ), - metadata=termination, - ) - payload = { - "manual": True, - "reason": reason, - "prev_lock": prev_lock, - "retry_status": retry_status, - } - payload.update(termination) - _append_event( - conn, task_id, "reclaimed", - payload, - run_id=run_id, + _record_reclaim( + conn, task_id, termination, + error=f"manual_reclaim: {reason}" if reason else f"manual_reclaim lock={prev_lock}", + payload={"manual": True, "reason": reason, "prev_lock": prev_lock, "retry_status": retry_status}, ) # Operator intervention — they've looked at the task, so the # consecutive-failures counter is now stale. Give the next retry @@ -3243,16 +3190,9 @@ def _verify_created_cards( but don't satisfy any of the three trust conditions. The caller decides what to do with each bucket; this helper never mutates. """ - claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()] - if not claimed: + ordered = list(dict.fromkeys(str(x).strip() for x in (claimed_ids or []) if str(x).strip())) + if not ordered: return [], [] - # Dedupe while preserving order. - seen: set[str] = set() - ordered: list[str] = [] - for cid in claimed: - if cid not in seen: - seen.add(cid) - ordered.append(cid) row = conn.execute( "SELECT assignee FROM tasks WHERE id = ?", (completing_task_id,), @@ -3313,23 +3253,7 @@ def _scan_prose_for_phantom_ids( """ if not text: return [] - matches = _TASK_ID_PROSE_RE.findall(text) - if not matches: - return [] - # Dedupe preserving order. - seen: set[str] = set() - unique: list[str] = [] - for m in matches: - if m not in seen: - seen.add(m) - unique.append(m) - placeholders = ",".join(["?"] * len(unique)) - rows = conn.execute( - f"SELECT id FROM tasks WHERE id IN ({placeholders})", - tuple(unique), - ).fetchall() - existing = {r["id"] for r in rows} - return [m for m in unique if m not in existing] + return _missing_task_ids(conn, dict.fromkeys(_TASK_ID_PROSE_RE.findall(text))) class HallucinatedCardsError(ValueError): @@ -3419,11 +3343,7 @@ def complete_task( { "phantom_cards": phantom_cards, "verified_cards": verified_cards, - "summary_preview": ( - (summary or result or "").strip().splitlines()[0][:200] - if (summary or result) - else None - ), + "summary_preview": _first_line(summary or result, 200) or None, }, ) raise HallucinatedCardsError(phantom_cards, task_id) @@ -3506,11 +3426,9 @@ def complete_task( event_summary = summary if summary is not None else result if prior_status == "review" and not event_summary: event_summary = "Review approved without additional evidence." - _ev_lines = (event_summary or "").strip().splitlines() - ev_summary = _ev_lines[0][:400] if _ev_lines else "" completed_payload: dict = { "result_len": len(result) if result else 0, - "summary": ev_summary or None, + "summary": _first_line(event_summary, 400) or None, } if verified_cards: completed_payload["verified_cards"] = verified_cards @@ -3819,17 +3737,12 @@ def edit_completed_task_result( "UPDATE task_runs SET metadata = ? WHERE id = ?", (json.dumps(metadata, ensure_ascii=False), run_id), ) - _ev_lines = (handoff_summary or "").strip().splitlines() - ev_summary = _ev_lines[0][:400] if _ev_lines else "" _append_event( conn, task_id, "edited", { - "fields": ( - ["result", "summary"] - + (["metadata"] if metadata is not None else []) - ), + "fields": ["result", "summary"] + (["metadata"] if metadata is not None else []), "result_len": len(result) if result else 0, - "summary": ev_summary or None, + "summary": _first_line(handoff_summary, 400) or None, }, run_id=run_id, ) @@ -4101,14 +4014,12 @@ def request_review( summary=summary, metadata=metadata, ) - lines = (summary or "").strip().splitlines() - event_summary = lines[0][:400] if lines else "" _append_event( conn, task_id, "review_requested", { - "summary": event_summary or None, + "summary": _first_line(summary, 400) or None, "implementer": implementer, "reviewer": reviewer, }, @@ -5189,8 +5100,7 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str: if role_rows: lines.append(f"## Recent work by @{task.assignee}") for row in role_rows: - s = (row["summary"] or "").strip().splitlines() - first = s[0][:200] if s else "(no summary)" + first = _first_line(row["summary"], 200) or "(no summary)" lines.append( f"- {row['id']} — {row['title']} ({_stamp(int(row['ended_at']))}): {first}" ) From 91995e48a3b8c41aa1750c8982280798388fa022 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:15:18 -0700 Subject: [PATCH 09/38] refactor(kanban-cli): parser spec tables as module data with shared arg constants (--help byte-identical) --- hermes_cli/kanban_parser.py | 824 +++++++++++++++++------------------- 1 file changed, 381 insertions(+), 443 deletions(-) diff --git a/hermes_cli/kanban_parser.py b/hermes_cli/kanban_parser.py index aacc28aeb6..ae8c7166e1 100644 --- a/hermes_cli/kanban_parser.py +++ b/hermes_cli/kanban_parser.py @@ -32,10 +32,17 @@ def _add_commands(sub: argparse._SubParsersAction, specs) -> None: _add_commands(p.add_subparsers(dest=dest), child_specs) +def _json_flag(**kw): + return _arg("--json", action="store_true", **kw) + + +def _reason(help: str): + return _arg("--reason", default=None, help=help) + + def _run_state_args(type_help: str): return ( - _arg("--state-type", choices=("status", "outcome"), default=None, - help=f"With --state-name: {type_help}"), + _arg("--state-type", choices=("status", "outcome"), default=None, help=f"With --state-name: {type_help}"), _arg("--state-name", default=None, metavar="VALUE", help="With --state-type: keep runs whose column equals this value"), ) @@ -44,455 +51,386 @@ def _run_state_args(type_help: str): def _triage_sweep_args(verb: str, Verb: str, noun: str): """Shared ``specify`` / ``decompose`` arguments.""" return ( - _arg("task_id", nargs="?", default=None, - help=f"Task id to {verb} (required unless --all is given)"), - _arg("--all", dest="all_triage", action="store_true", - help=f"{Verb} every task currently in the triage column"), - _arg("--tenant", default=None, - help="When used with --all, restrict the sweep to this tenant"), + _arg("task_id", nargs="?", default=None, help=f"Task id to {verb} (required unless --all is given)"), + _arg("--all", dest="all_triage", action="store_true", help=f"{Verb} every task currently in the triage column"), + _arg("--tenant", default=None, help="When used with --all, restrict the sweep to this tenant"), _arg("--author", default=None, - help="Author name recorded on the audit comment " - f"(default: $HERMES_PROFILE or '{noun}')"), - _arg("--json", action="store_true", help="Emit one JSON object per task on stdout"), + help=f"Author name recorded on the audit comment (default: $HERMES_PROFILE or '{noun}')"), + _json_flag(help="Emit one JSON object per task on stdout"), ) -def _json_flag(**kw): - return _arg("--json", action="store_true", **kw) - - def _bulk_ids(verb: str): - return _arg("--ids", nargs="+", default=None, - help=f"Additional task ids to {verb} with the same reason (bulk mode)") + return _arg("--ids", nargs="+", default=None, help=f"Additional task ids to {verb} with the same reason (bulk mode)") -def _board_specs(): - return [ - _cmd("list", [ - _json_flag(), - _arg("--all", action="store_true", help="Include archived boards too"), - ], aliases=["ls"], help="List all boards with task counts"), - _cmd("create", [ - _arg("slug", help="Board slug (kebab-case, e.g. atm10-server)"), - _arg("--name", default=None, - help="Human-readable display name (defaults to Title Case of slug)"), - _arg("--description", default=None, help="Optional description"), - _arg("--icon", default=None, - help="Optional emoji or single-character icon for the dashboard"), - _arg("--color", default=None, - help="Optional hex color (e.g. '#8b5cf6') for the dashboard"), - _arg("--switch", action="store_true", help="Switch to the new board after creating it"), - _arg("--default-workdir", default=None, - help="Default workspace path for tasks created on this board"), - ], aliases=["new"], help="Create a new board"), - _cmd("rm", [ - _arg("slug"), - _arg("--delete", action="store_true", - help="Hard-delete the board directory instead of archiving it. " - "Default is to move it to boards/_archived/ so it's recoverable."), - ], aliases=["remove", "delete"], help="Archive (default) or delete a board"), - _cmd("switch", [_arg("slug")], aliases=["use"], - help="Set the active board for subsequent CLI calls"), - _cmd("show", aliases=["current"], help="Print the currently-active board slug"), - _cmd("rename", [ - _arg("slug"), - _arg("name", help="New display name"), - ], help="Change a board's human-readable display name (slug is immutable)"), - _cmd("set-default-workdir", [ - _arg("slug"), - _arg("path", nargs="?", default=None, - help="Absolute path to use as default workdir. Omit to clear."), - ], help="Set the default workspace path for tasks on a board"), - _cmd("export", [ - _arg("slug", nargs="?", default=None, help="Board to export (default: the current board)"), - _arg("-o", "--output", default=None, help="Archive path (default: ./.tar.gz)"), - _arg("--no-attachments", action="store_true", - help="Skip attachment files, keeping the archive small"), - _arg("--include-logs", action="store_true", help="Include per-task worker logs"), - _json_flag(), - ], help="Export a board to a portable .tar.gz archive", description=( - "Package a board's tasks, comments, links, history, and file " - "attachments into one archive that can be imported on another " - "machine. Claims, worker PIDs, chat subscriptions, and paths " - "belonging to this machine are stripped. Workspaces are never " - "included — they are rebuilt on demand." - )), - _cmd("import", [ - _arg("archive", help="Path to the .tar.gz archive"), - _arg("--as", dest="as_slug", default=None, - help="Slug for the imported board (default: from the archive)"), - _arg("--switch", action="store_true", help="Switch to the imported board afterwards"), - _json_flag(), - ], help="Import a board archive as a new board", description=( - "Import a .tar.gz produced by `hermes kanban boards export`. " - "The board always lands as a NEW board — the slug gains a " - "numeric suffix if it is already taken — so an import can " - "never overwrite or merge into a board you already have." - )), - ] +_TASK_ID = _arg("task_id") +_TASK_IDS = _arg("task_ids", nargs="+") +_SLUG = _arg("slug") +_TENANT = _arg("--tenant", default=None, help="Tenant namespace") +_PRIORITY = _arg("--priority", type=int, default=0, help="Priority tiebreaker") +_RECLAIM_REASON = _reason("Human-readable reason (recorded on the reclaimed event)") +_NOTIFY_TARGET = ( + _arg("--platform", required=True), + _arg("--chat-id", required=True), + _arg("--thread-id", default=None), +) +_STEP_HANDOFF = ( + _arg("--summary", default=None, help="Structured handoff summary. Falls back to --result if omitted."), + _arg("--metadata", default=None, help="JSON dict of structured facts to store on the latest completed run."), +) +_BOARD_SPECS = [ + _cmd("list", [ + _json_flag(), + _arg("--all", action="store_true", help="Include archived boards too"), + ], aliases=["ls"], help="List all boards with task counts"), + _cmd("create", [ + _arg("slug", help="Board slug (kebab-case, e.g. atm10-server)"), + _arg("--name", default=None, help="Human-readable display name (defaults to Title Case of slug)"), + _arg("--description", default=None, help="Optional description"), + _arg("--icon", default=None, help="Optional emoji or single-character icon for the dashboard"), + _arg("--color", default=None, help="Optional hex color (e.g. '#8b5cf6') for the dashboard"), + _arg("--switch", action="store_true", help="Switch to the new board after creating it"), + _arg("--default-workdir", default=None, help="Default workspace path for tasks created on this board"), + ], aliases=["new"], help="Create a new board"), + _cmd("rm", [ + _SLUG, + _arg("--delete", action="store_true", + help="Hard-delete the board directory instead of archiving it. " + "Default is to move it to boards/_archived/ so it's recoverable."), + ], aliases=["remove", "delete"], help="Archive (default) or delete a board"), + _cmd("switch", [_SLUG], aliases=["use"], help="Set the active board for subsequent CLI calls"), + _cmd("show", aliases=["current"], help="Print the currently-active board slug"), + _cmd("rename", [_SLUG, _arg("name", help="New display name")], + help="Change a board's human-readable display name (slug is immutable)"), + _cmd("set-default-workdir", [ + _SLUG, + _arg("path", nargs="?", default=None, help="Absolute path to use as default workdir. Omit to clear."), + ], help="Set the default workspace path for tasks on a board"), + _cmd("export", [ + _arg("slug", nargs="?", default=None, help="Board to export (default: the current board)"), + _arg("-o", "--output", default=None, help="Archive path (default: ./.tar.gz)"), + _arg("--no-attachments", action="store_true", help="Skip attachment files, keeping the archive small"), + _arg("--include-logs", action="store_true", help="Include per-task worker logs"), + _json_flag(), + ], help="Export a board to a portable .tar.gz archive", description=( + "Package a board's tasks, comments, links, history, and file " + "attachments into one archive that can be imported on another " + "machine. Claims, worker PIDs, chat subscriptions, and paths " + "belonging to this machine are stripped. Workspaces are never " + "included — they are rebuilt on demand." + )), + _cmd("import", [ + _arg("archive", help="Path to the .tar.gz archive"), + _arg("--as", dest="as_slug", default=None, help="Slug for the imported board (default: from the archive)"), + _arg("--switch", action="store_true", help="Switch to the imported board afterwards"), + _json_flag(), + ], help="Import a board archive as a new board", description=( + "Import a .tar.gz produced by `hermes kanban boards export`. " + "The board always lands as a NEW board — the slug gains a " + "numeric suffix if it is already taken — so an import can " + "never overwrite or merge into a board you already have." + )), +] -def _task_specs(): - """``init`` … ``attach-rm``: creation, listing, inspection and attachment verbs.""" - return [ - _cmd("init", help="Create kanban.db if missing (idempotent)"), - _cmd("boards", children=("boards_action", _board_specs()), - help="Manage kanban boards (one board per project / workstream)", - description=( - "Boards let you separate unrelated streams of work " - "(projects, repos, domains) into isolated queues. Each " - "board has its own DB, workspaces directory, and dispatcher " - "loop — tasks on one board cannot collide with tasks on " - "another. The first board is 'default' and always exists." - )), - _cmd("create", [ - _arg("title", help="Task title"), - _arg("--body", default=None, help="Optional opening post"), - _arg("--assignee", default=None, help="Profile name to assign"), - _arg("--parent", action="append", default=[], help="Parent task id (repeatable)"), - _arg("--workspace", default="scratch", - help="scratch | worktree | worktree: | dir: (default: scratch)"), - _arg("--branch", default=None, help="Branch name for worktree tasks, e.g. wt/t6-wire"), - _arg("--project", default=None, - help="Link to a project (id or slug). Anchors the task's " - "worktree under the project's primary repo with a " - "deterministic branch. See `hermes project list`."), - _arg("--tenant", default=None, help="Tenant namespace"), - _arg("--priority", type=int, default=0, help="Priority tiebreaker"), - _arg("--triage", action="store_true", - help="Park in triage — a specifier will flesh out the spec and promote to todo"), - _arg("--idempotency-key", default=None, - help="Dedup key. If a non-archived task with this key exists, " - "its id is returned instead of creating a duplicate."), - _arg("--max-runtime", default=None, - help="Per-task runtime cap. Accepts seconds (300) or durations (90s, " - "30m, 2h, 1d). When exceeded, the dispatcher SIGTERMs (then " - "SIGKILLs) the worker and re-queues the task."), - _arg("--created-by", default="user", - help="Author name recorded on the task (default: user)"), - _arg("--skill", action="append", default=[], dest="skills", - help="Skill to force-load into the worker (repeatable). The kanban " - "lifecycle is already injected automatically. Example: --skill " - "translation --skill github-code-review"), - _arg("--max-retries", type=int, default=None, metavar="N", - help="Per-task override for the consecutive-failure " - "circuit breaker. Trip on the Nth failure — " - "e.g. --max-retries 1 blocks on the first " - "failure (no retries), --max-retries 3 allows " - "two retries. Omit to use the dispatcher's " - "kanban.failure_limit config " - f"(default {kb.DEFAULT_FAILURE_LIMIT})."), - _arg("--model", default=None, dest="model_override", - help="Pin the worker to this model (passed as -m ) without " - "changing the profile's configured model. Combine with --provider " - "when the model belongs to a different backend than the profile's " - "default."), - _arg("--provider", default=None, dest="provider_override", - help="Provider the --model belongs to (passed as --provider to " - "the worker). Requires --model."), - _arg("--goal", action="store_true", dest="goal_mode", - help="Run the worker in a goal loop: after each turn a judge checks the " - "response against the card title/body and, if not done, the worker " - "keeps going in the same session until the judge agrees it's " - "complete (or the turn budget runs out, which blocks the card for " - "review). Best for open-ended cards one shot rarely finishes."), - _arg("--goal-max-turns", type=int, default=None, metavar="N", dest="goal_max_turns", - help="Turn budget for --goal workers (default 20). Ignored without --goal."), - _arg("--initial-status", choices=sorted(kb.VALID_INITIAL_STATUSES), default="running", - help="Initial card status. Use 'blocked' for cards " - "that require immediate human ops (R3 gate) " - "to skip the brief running-to-blocked transition."), - _json_flag(help="Emit JSON output"), - ], help="Create a new task"), - _cmd("swarm", [ - _arg("goal", help="Swarm goal / final outcome"), - _arg("--worker", action="append", default=[], metavar="PROFILE:TITLE[:SKILL,SKILL]", - help="Parallel worker card (repeatable)"), - _arg("--verifier", required=True, help="Verifier profile"), - _arg("--synthesizer", required=True, help="Synthesizer/writer profile"), - _arg("--tenant", default=None, help="Tenant namespace"), - _arg("--priority", type=int, default=0, help="Priority tiebreaker"), - _arg("--created-by", default=None, help="Creator/anchor profile"), - _arg("--idempotency-key", default=None, help="Dedup key for the root card"), - _json_flag(help="Emit JSON output"), - ], help="Create a Kanban Swarm v1 graph (parallel workers → verifier → synthesizer)"), - _cmd("list", [ - _arg("--mine", action="store_true", help="Filter by $HERMES_PROFILE as assignee"), - _arg("--assignee", default=None), - _arg("--status", default=None, choices=sorted(kb.VALID_STATUSES)), - _arg("--tenant", default=None), - _arg("--session", default=None, - help="Filter by originating chat/agent session id " - "(set on tasks created from inside an ACP loop)"), - _arg("--archived", action="store_true", help="Include archived tasks"), - _json_flag(), - _arg("--sort", default=None, choices=sorted(kb.VALID_SORT_ORDERS.keys()), - help="Sort order for listed tasks (default: priority)"), - _arg("--workflow-template-id", default=None, metavar="ID", - help="Restrict to tasks with this workflow_template_id"), - _arg("--step-key", default=None, dest="current_step_key", metavar="KEY", - help="Restrict to tasks with this current_step_key"), - ], aliases=["ls"], help="List tasks"), - _cmd("show", [ - _arg("task_id"), - _json_flag(), - *_run_state_args("filter listed runs by task_runs column"), - ], help="Show a task with comments + events"), - _cmd("assign", [ - _arg("task_id"), - _arg("profile", help="Profile name (or 'none' to unassign)"), - ], help="Assign or reassign a task"), - _cmd("set-model", [ - _arg("task_id"), - _arg("model", nargs="?", default=None, - help="Model to pin the worker to (or 'none' to clear the override)"), - _arg("--provider", default=None, - help="Provider the model belongs to (worker is spawned with " - "--provider ). Cleared together with the model."), - ], help="Set or clear a task's model/provider override (takes effect on the next dispatch)"), - _cmd("reclaim", [ - _arg("task_id"), - _arg("--reason", default=None, help="Human-readable reason (recorded on the reclaimed event)"), - ], help="Release an active worker claim on a running task"), - _cmd("reassign", [ - _arg("task_id"), - _arg("profile", help="New profile name (or 'none' to unassign)"), - _arg("--reclaim", action="store_true", - help="Release any active claim before reassigning (required if task is running)"), - _arg("--reason", default=None, help="Human-readable reason (recorded on the reclaimed event)"), - ], help="Reassign a task to a different profile, optionally reclaiming first"), - _cmd("diagnostics", [ - _arg("--severity", choices=["warning", "error", "critical"], default=None, - help="Only show diagnostics at or above this severity"), - _arg("--task", default=None, help="Only show diagnostics for one task id"), - _json_flag(help="Emit JSON (structured) instead of the default human table"), - ], aliases=["diag"], help="List active diagnostics on the current board"), - _cmd("link", [_arg("parent_id"), _arg("child_id")], help="Add a parent->child dependency"), - _cmd("unlink", [_arg("parent_id"), _arg("child_id")], help="Remove a parent->child dependency"), - _cmd("claim", [ - _arg("task_id"), - _arg("--ttl", type=int, default=kb.DEFAULT_CLAIM_TTL_SECONDS, - help="Claim TTL in seconds (default: 900)"), - ], help="Atomically claim a ready task (prints resolved workspace path)"), - _cmd("comment", [ - _arg("task_id"), - _arg("text", nargs="+", help="Comment body"), - _arg("--author", default=None, help="Author name (default: $HERMES_PROFILE or 'user')"), - _arg("--max-len", type=int, default=None, - help="Trim the stored comment body to this many characters"), - ], help="Append a comment"), - _cmd("attach", [ - _arg("task_id"), - _arg("path", help="Path to the local file to attach"), - _arg("--content-type", default=None, - help="MIME type (default: guessed from the file extension)"), - _arg("--name", default=None, help="Stored filename (default: the source file's basename)"), - _arg("--author", default=None, help="uploaded_by label (default: $HERMES_PROFILE or 'user')"), - ], help="Attach a local file to a task"), - _cmd("attachments", [_arg("task_id"), _json_flag()], help="List a task's attachments"), - _cmd("attach-rm", [_arg("attachment_id", type=int)], help="Delete an attachment by id"), - ] - - -def _lifecycle_specs(): - """``complete`` … ``repair``: state transitions, dispatcher, notify and maintenance verbs.""" - return [ - _cmd("complete", [ - _arg("task_ids", nargs="+", - help="One or more task ids (only --result applies to all of them)"), - _arg("--result", default=None, help="Result summary"), - _arg("--summary", default=None, - help="Structured handoff summary for downstream tasks. " - "Falls back to --result if omitted."), - _arg("--metadata", default=None, - help='JSON dict of structured facts (e.g. \'{"changed_files": [...], ' - '"tests_run": 12}\'). Stored on the closing run.'), - ], help="Mark one or more tasks done"), - _cmd("edit", [ - _arg("task_id"), - _arg("--result", required=True, help="Backfilled task result text for a done task"), - _arg("--summary", default=None, - help="Structured handoff summary. Falls back to --result if omitted."), - _arg("--metadata", default=None, - help="JSON dict of structured facts to store on the latest completed run."), - ], help="Edit recovery fields on an already-completed task"), - _cmd("block", [ - _arg("task_id"), - _arg("reason", nargs="*", help="Reason (also appended as a comment)"), - _bulk_ids("block"), - _arg("--kind", default=None, choices=sorted(kb.VALID_BLOCK_KINDS), - help="Typed block reason. 'dependency' waits in todo (auto-promoted when " - "parents finish, no human); 'needs_input'/'capability' go to " - "blocked for a human; 'transient' marks a maybe-flaky failure. " - "Repeated same-kind re-blocks after unblock route the task to " - "triage to break unblock loops. Omit for a generic block."), - ], help="Mark one or more tasks blocked"), - _cmd("schedule", [ - _arg("task_id"), - _arg("reason", nargs="*", help="Reason/timing note (also appended as a comment)"), - _bulk_ids("schedule"), - ], help="Park one or more tasks in Scheduled (waiting on time, not human input)"), - _cmd("unblock", [ - _arg("--reason", default=None, - help="Optional reason/note — recorded as a comment before unblocking. " - "Quote multi-word reasons."), - _arg("task_ids", nargs="+"), - ], help="Return blocked/scheduled tasks to ready, or todo while parents remain open"), - _cmd("request-review", [ - _arg("task_id"), - _arg("--summary", default=None, - help="What was implemented and how it was verified — shown to the reviewer."), - _arg("--reviewer", default=None, - help="Optional reviewer profile; reassigns the task before review dispatch."), - _arg("--metadata", default=None, help="JSON object with structured reviewer handoff facts."), - _arg("--force", action="store_true", - help="Override the live-claim guard: move a running, claimed " - "task to review even without owning its run (clears the " - "worker's claim)."), - ], help="Move a task to 'review' (implementation done, awaiting review) — NOT a block"), - _cmd("request-changes", [ - _arg("task_id"), - _arg("reason", nargs="+", help="Concrete changes required before re-review"), - ], help="Reviewer verdict: return the active review run to its implementer"), - _cmd("reopen-review", [ - _arg("task_ids", nargs="+"), - _arg("--reason", default=None, - help="Optional reason/note — recorded as a comment before " - "reopening. Quote multi-word reasons."), - ], help="Send one or more review tasks back for changes (review -> ready/todo)"), - _cmd("promote", [ - _arg("task_id"), - _arg("reason", nargs="*", help="Audit-trail reason (recorded on the task_events row)"), - _bulk_ids("promote"), - _arg("--force", action="store_true", - help="Promote even if parent dependencies are not yet done/archived"), - _arg("--dry-run", action="store_true", help="Validate the promotion without mutating state"), - _arg("--json", dest="json", action="store_true", help="Emit machine-readable JSON result"), - ], help="Manually move one or more todo/blocked tasks to ready (recovery path)"), - _cmd("archive", [ - _arg("task_ids", nargs="*", help="Task ids to archive (default mode)"), - _arg("--rm", dest="purge_ids", nargs="+", default=None, - help="Permanently delete already-archived task ids from the board"), - ], help="Archive one or more tasks"), - _cmd("tail", [ - _arg("task_id"), - _arg("--interval", type=float, default=1.0), - ], help="Follow a task's event stream"), - _cmd("dispatch", [ - _arg("--dry-run", action="store_true", - help="Don't actually spawn processes; just print what would happen"), - _arg("--max", type=int, default=None, help="Cap number of spawns this pass"), - _arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_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})"), - _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, default=None, help="Cap number of spawns per tick"), - _arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT), - _arg("--pidfile", default=None, 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 - # --help so nobody casually keeps the double-dispatcher pattern alive. - _arg("--force", action="store_true", help=argparse.SUPPRESS), - ], help="DEPRECATED — dispatcher now runs in the gateway. Use `hermes gateway start`."), - _cmd("watch", [ - _arg("--assignee", default=None, help="Only show events for tasks assigned to this profile"), - _arg("--tenant", default=None, help="Only show events from tasks in this tenant"), - _arg("--kinds", default=None, - help="Comma-separated event kinds to include " - "(e.g. 'completed,blocked,gave_up,crashed,timed_out')"), - _arg("--interval", type=float, default=0.5, help="Poll interval in seconds (default: 0.5)"), - ], help="Live-stream task_events to the terminal (Ctrl+C to exit)"), - _cmd("stats", [_json_flag()], help="Per-status + per-assignee counts + oldest-ready age"), - _cmd("notify-subscribe", [ - _arg("task_id"), - _arg("--platform", required=True), - _arg("--chat-id", required=True), - _arg("--thread-id", default=None), - _arg("--user-id", default=None), - _arg("--user-id-alt", default=None), - _arg("--chat-type", choices=("dm", "group", "channel", "thread"), default=None, - help="Originating source chat_type, recorded so the active-wake delivery " - "modes resolve the operator's real session. Omit to leave an " - "existing sub unchanged (new subs default to 'dm')."), - _arg("--notifier-profile", default=None, - 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, default=None, - 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 " - "it reads the full board context and replies in its own voice), or " - "'wake' (wake the agent only, no passive message). Omit to leave an " - "existing subscription's mode unchanged (new subs default to 'notify')."), - ], help="Subscribe a gateway source to a task's terminal events (used by " - "/kanban subscribe in the gateway adapter)"), - _cmd("notify-list", [ - _arg("task_id", nargs="?", default=None), - _json_flag(), - ], help="List notification subscriptions (optionally for a single task)"), - _cmd("notify-unsubscribe", [ - _arg("task_id"), - _arg("--platform", required=True), - _arg("--chat-id", required=True), - _arg("--thread-id", default=None), - ], help="Remove a gateway subscription from a task"), - _cmd("log", [ - _arg("task_id"), - _arg("--tail", type=int, default=None, help="Only print the last N bytes"), - ], help="Print the worker log for a task (from /kanban/logs/)"), - _cmd("runs", [ - _arg("task_id"), - _json_flag(), - *_run_state_args("filter runs by task_runs column"), - ], help="Show attempt history for a task (one row per run: profile, outcome, elapsed, summary)"), - _cmd("heartbeat", [ - _arg("task_id"), - _arg("--note", default=None, help="Optional short note attached to the heartbeat event"), - ], help="Emit a heartbeat event for a running task (worker liveness signal)"), - _cmd("assignees", [_json_flag()], - help="List known profiles + per-profile task counts (union of " - "~/.hermes/profiles/ and current assignees on the board)"), - _cmd("context", [_arg("task_id")], - help="Print the full context a worker sees for a task (title + body + " - "parent results + comments)."), - _cmd("specify", _triage_sweep_args("specify", "Specify", "specifier"), - help="Flesh out a triage-column task into a concrete spec (title + " - "body) and promote it to todo. Uses the auxiliary LLM " - "configured under auxiliary.triage_specifier."), - _cmd("decompose", _triage_sweep_args("decompose", "Decompose", "decomposer"), - help="Decompose a triage-column task into a graph of child tasks " - "routed to specialist profiles by description. Falls back " - "to specify-style single-task promotion when the task " - "doesn't benefit from fan-out. Uses " - "auxiliary.kanban_decomposer."), - _cmd("gc", [ - _arg("--event-retention-days", type=int, default=30, - help="Delete task_events older than N days for terminal tasks (default: 30)"), - _arg("--log-retention-days", type=int, default=30, - help="Delete worker log files older than N days (default: 30)"), - ], help="Garbage-collect archived-task workspaces, old events, and old logs"), - _cmd("repair", [_json_flag(help="Emit the repair report as JSON")], - help="Check kanban.db integrity and auto-repair index-only corruption", - description=( - "Runs PRAGMA integrity_check on the board's DB and reports the " - "result. When the failure consists only of index-scoped errors " - "('wrong # of entries in index ' / 'row N missing from " - "index '), the corrupt file is quarantined to a " - ".corrupt..bak sibling first and the damaged indexes are " - "rebuilt with REINDEX — the same narrow auto-repair the " - "connect-time guard applies. Any other corruption class is " - "reported and left untouched (fail-closed). Exits 0 when the DB " - "is healthy or was repaired, non-zero when it is still corrupt." - )), - ] - - -def _specs(): - """Top-level ``hermes kanban `` records, in ``--help`` order.""" - return _task_specs() + _lifecycle_specs() +# Top-level ``hermes kanban `` records, in ``--help`` order. +_SPECS = [ + _cmd("init", help="Create kanban.db if missing (idempotent)"), + _cmd("boards", children=("boards_action", _BOARD_SPECS), + help="Manage kanban boards (one board per project / workstream)", + description=( + "Boards let you separate unrelated streams of work " + "(projects, repos, domains) into isolated queues. Each " + "board has its own DB, workspaces directory, and dispatcher " + "loop — tasks on one board cannot collide with tasks on " + "another. The first board is 'default' and always exists." + )), + _cmd("create", [ + _arg("title", help="Task title"), + _arg("--body", default=None, help="Optional opening post"), + _arg("--assignee", default=None, help="Profile name to assign"), + _arg("--parent", action="append", default=[], help="Parent task id (repeatable)"), + _arg("--workspace", default="scratch", + help="scratch | worktree | worktree: | dir: (default: scratch)"), + _arg("--branch", default=None, help="Branch name for worktree tasks, e.g. wt/t6-wire"), + _arg("--project", default=None, + help="Link to a project (id or slug). Anchors the task's " + "worktree under the project's primary repo with a " + "deterministic branch. See `hermes project list`."), + _TENANT, + _PRIORITY, + _arg("--triage", action="store_true", + help="Park in triage — a specifier will flesh out the spec and promote to todo"), + _arg("--idempotency-key", default=None, + help="Dedup key. If a non-archived task with this key exists, " + "its id is returned instead of creating a duplicate."), + _arg("--max-runtime", default=None, + help="Per-task runtime cap. Accepts seconds (300) or durations (90s, " + "30m, 2h, 1d). When exceeded, the dispatcher SIGTERMs (then " + "SIGKILLs) the worker and re-queues the task."), + _arg("--created-by", default="user", help="Author name recorded on the task (default: user)"), + _arg("--skill", action="append", default=[], dest="skills", + help="Skill to force-load into the worker (repeatable). The kanban " + "lifecycle is already injected automatically. Example: --skill " + "translation --skill github-code-review"), + _arg("--max-retries", type=int, default=None, metavar="N", + help="Per-task override for the consecutive-failure " + "circuit breaker. Trip on the Nth failure — " + "e.g. --max-retries 1 blocks on the first " + "failure (no retries), --max-retries 3 allows " + "two retries. Omit to use the dispatcher's " + "kanban.failure_limit config " + f"(default {kb.DEFAULT_FAILURE_LIMIT})."), + _arg("--model", default=None, dest="model_override", + help="Pin the worker to this model (passed as -m ) without " + "changing the profile's configured model. Combine with --provider " + "when the model belongs to a different backend than the profile's " + "default."), + _arg("--provider", default=None, dest="provider_override", + help="Provider the --model belongs to (passed as --provider to " + "the worker). Requires --model."), + _arg("--goal", action="store_true", dest="goal_mode", + help="Run the worker in a goal loop: after each turn a judge checks the " + "response against the card title/body and, if not done, the worker " + "keeps going in the same session until the judge agrees it's " + "complete (or the turn budget runs out, which blocks the card for " + "review). Best for open-ended cards one shot rarely finishes."), + _arg("--goal-max-turns", type=int, default=None, metavar="N", dest="goal_max_turns", + help="Turn budget for --goal workers (default 20). Ignored without --goal."), + _arg("--initial-status", choices=sorted(kb.VALID_INITIAL_STATUSES), default="running", + help="Initial card status. Use 'blocked' for cards " + "that require immediate human ops (R3 gate) " + "to skip the brief running-to-blocked transition."), + _json_flag(help="Emit JSON output"), + ], help="Create a new task"), + _cmd("swarm", [ + _arg("goal", help="Swarm goal / final outcome"), + _arg("--worker", action="append", default=[], metavar="PROFILE:TITLE[:SKILL,SKILL]", + help="Parallel worker card (repeatable)"), + _arg("--verifier", required=True, help="Verifier profile"), + _arg("--synthesizer", required=True, help="Synthesizer/writer profile"), + _TENANT, + _PRIORITY, + _arg("--created-by", default=None, help="Creator/anchor profile"), + _arg("--idempotency-key", default=None, help="Dedup key for the root card"), + _json_flag(help="Emit JSON output"), + ], help="Create a Kanban Swarm v1 graph (parallel workers → verifier → synthesizer)"), + _cmd("list", [ + _arg("--mine", action="store_true", help="Filter by $HERMES_PROFILE as assignee"), + _arg("--assignee", default=None), + _arg("--status", default=None, choices=sorted(kb.VALID_STATUSES)), + _arg("--tenant", default=None), + _arg("--session", default=None, + help="Filter by originating chat/agent session id (set on tasks created from inside an ACP loop)"), + _arg("--archived", action="store_true", help="Include archived tasks"), + _json_flag(), + _arg("--sort", default=None, choices=sorted(kb.VALID_SORT_ORDERS.keys()), + help="Sort order for listed tasks (default: priority)"), + _arg("--workflow-template-id", default=None, metavar="ID", help="Restrict to tasks with this workflow_template_id"), + _arg("--step-key", default=None, dest="current_step_key", metavar="KEY", + help="Restrict to tasks with this current_step_key"), + ], aliases=["ls"], help="List tasks"), + _cmd("show", [_TASK_ID, _json_flag(), *_run_state_args("filter listed runs by task_runs column")], + help="Show a task with comments + events"), + _cmd("assign", [_TASK_ID, _arg("profile", help="Profile name (or 'none' to unassign)")], + help="Assign or reassign a task"), + _cmd("set-model", [ + _TASK_ID, + _arg("model", nargs="?", default=None, help="Model to pin the worker to (or 'none' to clear the override)"), + _arg("--provider", default=None, + help="Provider the model belongs to (worker is spawned with " + "--provider ). Cleared together with the model."), + ], help="Set or clear a task's model/provider override (takes effect on the next dispatch)"), + _cmd("reclaim", [_TASK_ID, _RECLAIM_REASON], help="Release an active worker claim on a running task"), + _cmd("reassign", [ + _TASK_ID, + _arg("profile", help="New profile name (or 'none' to unassign)"), + _arg("--reclaim", action="store_true", + help="Release any active claim before reassigning (required if task is running)"), + _RECLAIM_REASON, + ], help="Reassign a task to a different profile, optionally reclaiming first"), + _cmd("diagnostics", [ + _arg("--severity", choices=["warning", "error", "critical"], default=None, + help="Only show diagnostics at or above this severity"), + _arg("--task", default=None, help="Only show diagnostics for one task id"), + _json_flag(help="Emit JSON (structured) instead of the default human table"), + ], aliases=["diag"], help="List active diagnostics on the current board"), + _cmd("link", [_arg("parent_id"), _arg("child_id")], help="Add a parent->child dependency"), + _cmd("unlink", [_arg("parent_id"), _arg("child_id")], help="Remove a parent->child dependency"), + _cmd("claim", [ + _TASK_ID, + _arg("--ttl", type=int, default=kb.DEFAULT_CLAIM_TTL_SECONDS, help="Claim TTL in seconds (default: 900)"), + ], help="Atomically claim a ready task (prints resolved workspace path)"), + _cmd("comment", [ + _TASK_ID, + _arg("text", nargs="+", help="Comment body"), + _arg("--author", default=None, help="Author name (default: $HERMES_PROFILE or 'user')"), + _arg("--max-len", type=int, default=None, help="Trim the stored comment body to this many characters"), + ], help="Append a comment"), + _cmd("attach", [ + _TASK_ID, + _arg("path", help="Path to the local file to attach"), + _arg("--content-type", default=None, help="MIME type (default: guessed from the file extension)"), + _arg("--name", default=None, help="Stored filename (default: the source file's basename)"), + _arg("--author", default=None, help="uploaded_by label (default: $HERMES_PROFILE or 'user')"), + ], help="Attach a local file to a task"), + _cmd("attachments", [_TASK_ID, _json_flag()], help="List a task's attachments"), + _cmd("attach-rm", [_arg("attachment_id", type=int)], help="Delete an attachment by id"), + _cmd("complete", [ + _arg("task_ids", nargs="+", help="One or more task ids (only --result applies to all of them)"), + _arg("--result", default=None, help="Result summary"), + _arg("--summary", default=None, + help="Structured handoff summary for downstream tasks. Falls back to --result if omitted."), + _arg("--metadata", default=None, + help='JSON dict of structured facts (e.g. \'{"changed_files": [...], ' + '"tests_run": 12}\'). Stored on the closing run.'), + ], help="Mark one or more tasks done"), + _cmd("edit", [ + _TASK_ID, + _arg("--result", required=True, help="Backfilled task result text for a done task"), + *_STEP_HANDOFF, + ], help="Edit recovery fields on an already-completed task"), + _cmd("block", [ + _TASK_ID, + _arg("reason", nargs="*", help="Reason (also appended as a comment)"), + _bulk_ids("block"), + _arg("--kind", default=None, choices=sorted(kb.VALID_BLOCK_KINDS), + help="Typed block reason. 'dependency' waits in todo (auto-promoted when " + "parents finish, no human); 'needs_input'/'capability' go to " + "blocked for a human; 'transient' marks a maybe-flaky failure. " + "Repeated same-kind re-blocks after unblock route the task to " + "triage to break unblock loops. Omit for a generic block."), + ], help="Mark one or more tasks blocked"), + _cmd("schedule", [ + _TASK_ID, + _arg("reason", nargs="*", help="Reason/timing note (also appended as a comment)"), + _bulk_ids("schedule"), + ], help="Park one or more tasks in Scheduled (waiting on time, not human input)"), + _cmd("unblock", [ + _reason("Optional reason/note — recorded as a comment before unblocking. Quote multi-word reasons."), + _TASK_IDS, + ], help="Return blocked/scheduled tasks to ready, or todo while parents remain open"), + _cmd("request-review", [ + _TASK_ID, + _arg("--summary", default=None, help="What was implemented and how it was verified — shown to the reviewer."), + _arg("--reviewer", default=None, help="Optional reviewer profile; reassigns the task before review dispatch."), + _arg("--metadata", default=None, help="JSON object with structured reviewer handoff facts."), + _arg("--force", action="store_true", + help="Override the live-claim guard: move a running, claimed " + "task to review even without owning its run (clears the " + "worker's claim)."), + ], help="Move a task to 'review' (implementation done, awaiting review) — NOT a block"), + _cmd("request-changes", [_TASK_ID, _arg("reason", nargs="+", help="Concrete changes required before re-review")], + help="Reviewer verdict: return the active review run to its implementer"), + _cmd("reopen-review", [ + _TASK_IDS, + _reason("Optional reason/note — recorded as a comment before reopening. Quote multi-word reasons."), + ], help="Send one or more review tasks back for changes (review -> ready/todo)"), + _cmd("promote", [ + _TASK_ID, + _arg("reason", nargs="*", help="Audit-trail reason (recorded on the task_events row)"), + _bulk_ids("promote"), + _arg("--force", action="store_true", help="Promote even if parent dependencies are not yet done/archived"), + _arg("--dry-run", action="store_true", help="Validate the promotion without mutating state"), + _arg("--json", dest="json", action="store_true", help="Emit machine-readable JSON result"), + ], help="Manually move one or more todo/blocked tasks to ready (recovery path)"), + _cmd("archive", [ + _arg("task_ids", nargs="*", help="Task ids to archive (default mode)"), + _arg("--rm", dest="purge_ids", nargs="+", default=None, + help="Permanently delete already-archived task ids from the board"), + ], help="Archive one or more tasks"), + _cmd("tail", [_TASK_ID, _arg("--interval", type=float, default=1.0)], help="Follow a task's event stream"), + _cmd("dispatch", [ + _arg("--dry-run", action="store_true", help="Don't actually spawn processes; just print what would happen"), + _arg("--max", type=int, default=None, help="Cap number of spawns this pass"), + _arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_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})"), + _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, default=None, help="Cap number of spawns per tick"), + _arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT), + _arg("--pidfile", default=None, 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 + # --help so nobody casually keeps the double-dispatcher pattern alive. + _arg("--force", action="store_true", help=argparse.SUPPRESS), + ], help="DEPRECATED — dispatcher now runs in the gateway. Use `hermes gateway start`."), + _cmd("watch", [ + _arg("--assignee", default=None, help="Only show events for tasks assigned to this profile"), + _arg("--tenant", default=None, help="Only show events from tasks in this tenant"), + _arg("--kinds", default=None, + help="Comma-separated event kinds to include (e.g. 'completed,blocked,gave_up,crashed,timed_out')"), + _arg("--interval", type=float, default=0.5, help="Poll interval in seconds (default: 0.5)"), + ], help="Live-stream task_events to the terminal (Ctrl+C to exit)"), + _cmd("stats", [_json_flag()], help="Per-status + per-assignee counts + oldest-ready age"), + _cmd("notify-subscribe", [ + _TASK_ID, + *_NOTIFY_TARGET, + _arg("--user-id", default=None), + _arg("--user-id-alt", default=None), + _arg("--chat-type", choices=("dm", "group", "channel", "thread"), default=None, + help="Originating source chat_type, recorded so the active-wake delivery " + "modes resolve the operator's real session. Omit to leave an " + "existing sub unchanged (new subs default to 'dm')."), + _arg("--notifier-profile", default=None, + 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, default=None, + 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 " + "it reads the full board context and replies in its own voice), or " + "'wake' (wake the agent only, no passive message). Omit to leave an " + "existing subscription's mode unchanged (new subs default to 'notify')."), + ], help="Subscribe a gateway source to a task's terminal events (used by /kanban subscribe in the gateway adapter)"), + _cmd("notify-list", [_arg("task_id", nargs="?", default=None), _json_flag()], + help="List notification subscriptions (optionally for a single task)"), + _cmd("notify-unsubscribe", [_TASK_ID, *_NOTIFY_TARGET], help="Remove a gateway subscription from a task"), + _cmd("log", [_TASK_ID, _arg("--tail", type=int, default=None, help="Only print the last N bytes")], + help="Print the worker log for a task (from /kanban/logs/)"), + _cmd("runs", [_TASK_ID, _json_flag(), *_run_state_args("filter runs by task_runs column")], + help="Show attempt history for a task (one row per run: profile, outcome, elapsed, summary)"), + _cmd("heartbeat", [ + _TASK_ID, + _arg("--note", default=None, help="Optional short note attached to the heartbeat event"), + ], help="Emit a heartbeat event for a running task (worker liveness signal)"), + _cmd("assignees", [_json_flag()], + help="List known profiles + per-profile task counts (union of ~/.hermes/profiles/ and current assignees on the board)"), + _cmd("context", [_TASK_ID], + help="Print the full context a worker sees for a task (title + body + parent results + comments)."), + _cmd("specify", _triage_sweep_args("specify", "Specify", "specifier"), + help="Flesh out a triage-column task into a concrete spec (title + " + "body) and promote it to todo. Uses the auxiliary LLM " + "configured under auxiliary.triage_specifier."), + _cmd("decompose", _triage_sweep_args("decompose", "Decompose", "decomposer"), + help="Decompose a triage-column task into a graph of child tasks " + "routed to specialist profiles by description. Falls back " + "to specify-style single-task promotion when the task " + "doesn't benefit from fan-out. Uses " + "auxiliary.kanban_decomposer."), + _cmd("gc", [ + _arg("--event-retention-days", type=int, default=30, + help="Delete task_events older than N days for terminal tasks (default: 30)"), + _arg("--log-retention-days", type=int, default=30, help="Delete worker log files older than N days (default: 30)"), + ], help="Garbage-collect archived-task workspaces, old events, and old logs"), + _cmd("repair", [_json_flag(help="Emit the repair report as JSON")], + help="Check kanban.db integrity and auto-repair index-only corruption", + description=( + "Runs PRAGMA integrity_check on the board's DB and reports the " + "result. When the failure consists only of index-scoped errors " + "('wrong # of entries in index ' / 'row N missing from " + "index '), the corrupt file is quarantined to a " + ".corrupt..bak sibling first and the damaged indexes are " + "rebuilt with REINDEX — the same narrow auto-repair the " + "connect-time guard applies. Any other corruption class is " + "reported and left untouched (fail-closed). Exits 0 when the DB " + "is healthy or was repaired, non-zero when it is still corrupt." + )), +] def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: @@ -516,6 +454,6 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu "via `hermes kanban boards switch ` or the " "HERMES_KANBAN_BOARD env var). Use `hermes kanban boards " "list` to see all boards.") - _add_commands(kanban_parser.add_subparsers(dest="kanban_action"), _specs()) + _add_commands(kanban_parser.add_subparsers(dest="kanban_action"), _SPECS) kanban_parser.set_defaults(_kanban_parser=kanban_parser) return kanban_parser From fd55049657c17207c3c8109caa32b7a1c2288ede Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:15:37 -0700 Subject: [PATCH 10/38] =?UTF-8?q?refactor(cli-misc):=20logs.py=20=E2=80=94?= =?UTF-8?q?=20pass=20filter=20kwargs=20through=20instead=20of=20re-listing?= =?UTF-8?q?=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/logs.py | 32 +++++--------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/hermes_cli/logs.py b/hermes_cli/logs.py index 8ee79f5cbe..590af0e4bb 100644 --- a/hermes_cli/logs.py +++ b/hermes_cli/logs.py @@ -164,27 +164,13 @@ def tail_log( print("\n--- stopped ---") -def _read_tail( - path: Path, - num_lines: int, - *, - has_filters: bool = False, - min_level: Optional[str] = None, - session_filter: Optional[str] = None, - since: Optional[datetime] = None, - component_prefixes: Optional[Sequence[str]] = None, -) -> list: - """Read the last *num_lines* matching lines from a log file.""" +def _read_tail(path: Path, num_lines: int, *, has_filters: bool = False, **filters) -> list: + """Read the last *num_lines* matching lines; ``filters`` are ``_matches_filters`` kwargs.""" if not has_filters: return _read_last_n_lines(path, num_lines) # Over-read so enough lines survive filtering. raw_lines = _read_last_n_lines(path, max(num_lines * 20, 2000)) - filtered = [ - l for l in raw_lines - if _matches_filters(l, min_level=min_level, session_filter=session_filter, - since=since, component_prefixes=component_prefixes) - ] - return filtered[-num_lines:] + return [l for l in raw_lines if _matches_filters(l, **filters)][-num_lines:] def _read_all_lines(path: Path) -> list: @@ -223,14 +209,7 @@ def _read_last_n_lines(path: Path, n: int) -> list: return _read_all_lines(path)[-n:] -def _follow_log( - path: Path, - *, - min_level: Optional[str] = None, - session_filter: Optional[str] = None, - since: Optional[datetime] = None, - component_prefixes: Optional[Sequence[str]] = None, -) -> None: +def _follow_log(path: Path, **filters) -> None: """Poll a log file for new content and print matching lines.""" with open(path, "r", encoding="utf-8", errors="replace") as f: f.seek(0, 2) @@ -238,8 +217,7 @@ def _follow_log( line = f.readline() if not line: time.sleep(0.3) - elif _matches_filters(line, min_level=min_level, session_filter=session_filter, - since=since, component_prefixes=component_prefixes): + elif _matches_filters(line, **filters): print(line, end="") sys.stdout.flush() From 9d896da1cc32c3f0d00c9bc25c63d92514f5c42c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:19:59 -0700 Subject: [PATCH 11/38] =?UTF-8?q?refactor(kanban-cli):=20ops=20=E2=80=94?= =?UTF-8?q?=20shared=20=5Fpoll=5Floop=20for=20tail/watch,=20reuse=20kb.=5F?= =?UTF-8?q?positive=5Fint,=20hoist=20daemon=20deprecation=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/kanban_ops.py | 240 +++++++++++++++++---------------------- 1 file changed, 102 insertions(+), 138 deletions(-) diff --git a/hermes_cli/kanban_ops.py b/hermes_cli/kanban_ops.py index 0b1540b98c..faf5d78567 100644 --- a/hermes_cli/kanban_ops.py +++ b/hermes_cli/kanban_ops.py @@ -26,32 +26,32 @@ def _kanban_config() -> dict: return {} -def _cmd_tail(args: argparse.Namespace) -> int: - last_id = 0 - print(f"Tailing events for {args.task_id}. Ctrl-C to stop.") +def _poll_loop(interval: float, tick) -> int: + """Run ``tick()`` every ``interval`` seconds (floor 0.1) until Ctrl-C.""" try: while True: - with kb.connect_closing() as conn: - events = kb.list_events(conn, args.task_id) - for e in events: - if e.id > last_id: - pl = f" {e.payload}" if e.payload else "" - print(f"[{_fmt_ts(e.created_at)}] {e.kind}{pl}", flush=True) - last_id = e.id - time.sleep(max(0.1, args.interval)) + tick() + time.sleep(max(0.1, interval)) except KeyboardInterrupt: print("\n(stopped)") return 0 -def _coerce_positive_int(value): - if value is None: - return None - try: - ival = int(value) - except (TypeError, ValueError): - return None - return ival if ival >= 1 else None +def _cmd_tail(args: argparse.Namespace) -> int: + last_id = 0 + print(f"Tailing events for {args.task_id}. Ctrl-C to stop.") + + def tick(): + nonlocal last_id + with kb.connect_closing() as conn: + events = kb.list_events(conn, args.task_id) + for e in events: + if e.id > last_id: + pl = f" {e.payload}" if e.payload else "" + print(f"[{_fmt_ts(e.created_at)}] {e.kind}{pl}", flush=True) + last_id = e.id + + return _poll_loop(args.interval, tick) def _cmd_dispatch(args: argparse.Namespace) -> int: @@ -63,22 +63,14 @@ 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 = _coerce_positive_int( - _kanban_cfg.get("max_in_progress_per_profile") - ) + max_in_progress_per_profile = kb._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( - _coerce_positive_int(_kanban_cfg.get("max_in_progress")) - ) + max_in_progress = kb.resolve_max_in_progress(kb._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 _coerce_positive_int( - _kanban_cfg.get("max_spawn") - ) + max_spawn = cli_max if cli_max is not None else kb._positive_int(_kanban_cfg.get("max_spawn"), None) except Exception: - default_assignee = None - max_in_progress_per_profile = None - max_in_progress = None + 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( @@ -143,34 +135,34 @@ def _cmd_dispatch(args: argparse.Namespace) -> int: return 0 -def _cmd_daemon(args: argparse.Namespace) -> int: - """Deprecated — the dispatcher now runs inside the gateway. +_DAEMON_DEPRECATED = ( + "hermes kanban daemon: DEPRECATED — the dispatcher now runs\n" + "inside the gateway. To use kanban:\n" + "\n" + " hermes gateway start # starts the gateway + embedded dispatcher\n" + "\n" + "Ready tasks will be picked up on the next dispatcher tick\n" + "(default: every 60 seconds). Configure via config.yaml:\n" + "\n" + " kanban:\n" + " dispatch_in_gateway: true # default\n" + " dispatch_interval_seconds: 60\n" + " failure_limit: 2 # consecutive non-success attempts before auto-block\n" + "\n" + "Running both the gateway AND this standalone daemon will\n" + "race for claims. If you truly need the old standalone\n" + "daemon (no gateway available), rerun with --force." +) - Kept as a stub so old scripts/systemd units get a clear migration message. - ``--force`` (hidden from --help) keeps the standalone loop for hosts that - truly cannot run the gateway; the default path exits 2 so nobody - accidentally runs two dispatchers against the same kanban.db. - """ + +def _cmd_daemon(args: argparse.Namespace) -> int: + """Deprecated — the dispatcher now runs inside the gateway. Kept so old + scripts/systemd units get a clear migration message; ``--force`` (hidden + from --help) keeps the standalone loop for hosts that truly cannot run the + gateway. The default path exits 2 so nobody accidentally runs two + dispatchers against the same kanban.db.""" if not getattr(args, "force", False): - return _err( - "hermes kanban daemon: DEPRECATED — the dispatcher now runs\n" - "inside the gateway. To use kanban:\n" - "\n" - " hermes gateway start # starts the gateway + embedded dispatcher\n" - "\n" - "Ready tasks will be picked up on the next dispatcher tick\n" - "(default: every 60 seconds). Configure via config.yaml:\n" - "\n" - " kanban:\n" - " dispatch_in_gateway: true # default\n" - " dispatch_interval_seconds: 60\n" - " failure_limit: 2 # consecutive non-success attempts before auto-block\n" - "\n" - "Running both the gateway AND this standalone daemon will\n" - "race for claims. If you truly need the old standalone\n" - "daemon (no gateway available), rerun with --force.", - 2, - ) + return _err(_DAEMON_DEPRECATED, 2) # Init before printing "started" so the DB path is right and init errors # surface immediately. @@ -200,13 +192,18 @@ def _cmd_daemon(args: argparse.Namespace) -> int: HEALTH_WINDOW = 6 # ticks (default 30s at interval=5) health_state = {"bad_ticks": 0, "last_warn_at": 0} + def _ready_queue_nonempty() -> bool: + """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) + except Exception: + return False + def _on_tick(res): ready_pending = bool(res.skipped_unassigned) or _ready_queue_nonempty() - spawned_any = bool(res.spawned) - if ready_pending and not spawned_any: - health_state["bad_ticks"] += 1 - else: - health_state["bad_ticks"] = 0 + health_state["bad_ticks"] = health_state["bad_ticks"] + 1 if ready_pending and not res.spawned else 0 # Warn once per HEALTH_WINDOW bad ticks, at most every 5 minutes. if health_state["bad_ticks"] >= HEALTH_WINDOW: now = int(time.time()) @@ -238,15 +235,6 @@ def _cmd_daemon(args: argparse.Namespace) -> int: flush=True, ) - def _ready_queue_nonempty() -> bool: - """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) - except Exception: - return False - try: kb.run_daemon( interval=args.interval, @@ -266,50 +254,39 @@ def _cmd_daemon(args: argparse.Namespace) -> int: def _cmd_watch(args: argparse.Namespace) -> int: """Live-stream task_events to the terminal.""" - kinds = ( - {k.strip() for k in args.kinds.split(",") if k.strip()} - if args.kinds else None - ) + 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: - row = conn.execute( - "SELECT COALESCE(MAX(id), 0) AS m FROM task_events" - ).fetchone() - cursor = int(row["m"]) + cursor = int(conn.execute("SELECT COALESCE(MAX(id), 0) AS m FROM task_events").fetchone()["m"]) - try: - while True: - with kb.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 > ? ORDER BY e.id ASC LIMIT 200", - (cursor,), - ).fetchall() - for r in rows: - cursor = max(cursor, int(r["id"])) - if kinds and r["kind"] not in kinds: - continue - if args.assignee and r["assignee"] != args.assignee: - continue - if args.tenant and r["tenant"] != args.tenant: - continue - try: - payload = json.loads(r["payload"]) if r["payload"] else None - except Exception: - payload = None - pl = f" {payload}" if payload else "" - print( - f"[{_fmt_ts(r['created_at'])}] {r['task_id']:10s} " - f"{r['kind']:18s} (@{r['assignee'] or '-'}){pl}", - flush=True, - ) - time.sleep(max(0.1, args.interval)) - except KeyboardInterrupt: - print("\n(stopped)") - return 0 + def tick(): + nonlocal cursor + with kb.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 > ? ORDER BY e.id ASC LIMIT 200", + (cursor,), + ).fetchall() + for r in rows: + cursor = max(cursor, int(r["id"])) + if (kinds and r["kind"] not in kinds) or (args.assignee and r["assignee"] != args.assignee) \ + or (args.tenant and r["tenant"] != args.tenant): + continue + try: + payload = json.loads(r["payload"]) if r["payload"] else None + except Exception: + payload = None + pl = f" {payload}" if payload else "" + print( + f"[{_fmt_ts(r['created_at'])}] {r['task_id']:10s} " + f"{r['kind']:18s} (@{r['assignee'] or '-'}){pl}", + flush=True, + ) + + return _poll_loop(args.interval, tick) def _cmd_gc(args: argparse.Namespace) -> int: @@ -351,12 +328,8 @@ 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: - 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, - ) + 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), " f"{removed_events} event row(s), {removed_logs} log file(s) removed") return 0 @@ -377,9 +350,7 @@ def _cmd_repair(args: argparse.Namespace) -> int: "db_path": str(report.db_path), "messages": report.messages, "post_repair_messages": report.post_repair_messages, - "backup_path": ( - str(report.backup_path) if report.backup_path else None - ), + "backup_path": str(report.backup_path) if report.backup_path else None, "reindexed": report.reindexed, }, ascii=True) return 0 if report.status in {"ok", "repaired", "missing"} else 1 @@ -398,29 +369,22 @@ def _cmd_repair(args: argparse.Namespace) -> int: print(" integrity_check now ok.") return 0 # still corrupt - print(f"{report.db_path}: CORRUPT.", file=sys.stderr) + def err(line: str) -> None: + print(line, file=sys.stderr) + + err(f"{report.db_path}: CORRUPT.") for line in (report.messages or [])[:10]: - print(f" {line}", file=sys.stderr) + err(f" {line}") if report.reindexed: - print( - f" REINDEX ({', '.join(report.reindexed)}) attempted but " - f"integrity_check is still failing:", - file=sys.stderr, - ) + err(f" REINDEX ({', '.join(report.reindexed)}) attempted but integrity_check is still failing:") for line in (report.post_repair_messages or [])[:10]: - print(f" {line}", file=sys.stderr) + err(f" {line}") else: - print( - " Not an index-only failure — automatic REINDEX repair does " - "not apply (fail-closed).", - file=sys.stderr, - ) + err(" Not an index-only failure — automatic REINDEX repair does not apply (fail-closed).") if report.backup_path: - print(f" corrupt copy quarantined at: {report.backup_path}", - file=sys.stderr) - print( + err(f" corrupt copy quarantined at: {report.backup_path}") + err( " Recover manually (e.g. `sqlite3 kanban.db \".recover\"` into a " - "fresh file) or move the file aside to start a new board.", - file=sys.stderr, + "fresh file) or move the file aside to start a new board." ) return 1 From 938919502e89ef1c8d6b8b0512b521891e2a466c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:20:21 -0700 Subject: [PATCH 12/38] refactor(kanban_db): split create_task and complete_task into phase helpers --- hermes_cli/kanban_db.py | 378 ++++++++++++++++++---------------------- 1 file changed, 169 insertions(+), 209 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 1ea11b54e6..29fd14906d 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1550,26 +1550,19 @@ def create_task( # (deterministic worktree + branch) without each surface repeating it. if project_id is None: try: - _bmeta = read_board_metadata(board if board else get_current_board()) - _board_project = (_bmeta.get("project_id") or "").strip() - if _board_project: - project_id = _board_project + project_id = (_board_meta_for(board).get("project_id") or "").strip() or None except Exception: pass project_id, project_obj, project_repo, workspace_kind = _resolve_project_link( conn, project_id, project_source_task_id, workspace_kind, workspace_path ) - parents = tuple(p for p in parents if p) - skills_list = _normalize_task_skills(skills) - # Idempotency check — return the existing task instead of creating a - # duplicate. Done BEFORE entering write_txn to keep the fast path fast - # and to avoid holding a write lock during the lookup. Race is - # acceptable: two concurrent creators with the same key might both - # insert, at which point both rows exist but the next lookup stabilises. + # Idempotency check BEFORE the write txn (fast path, no write lock held). + # Race is acceptable: two concurrent creators may both insert; the next + # lookup stabilises on the newest. if idempotency_key: row = conn.execute( "SELECT id FROM tasks WHERE idempotency_key = ? " @@ -1582,23 +1575,13 @@ def create_task( now = int(time.time()) - # Resolve workspace_path from board-level default_workdir when the - # caller did not specify one explicitly. Board defaults represent - # persistent project checkouts, so only persistent workspace kinds may - # inherit them. Scratch workspaces are auto-deleted on completion and - # must stay under the per-board scratch root created by - # ``resolve_workspace``; inheriting ``default_workdir`` for a scratch - # task would point cleanup at the user's source tree (#28818). The - # containment guard in ``_cleanup_workspace`` is the safety rail, but - # we also stop the bad state from being created in the first place. - if ( - workspace_path is None - and project_repo is None - and workspace_kind in {"dir", "worktree"} - ): - board_slug = board if board else get_current_board() - board_meta = read_board_metadata(board_slug) - board_default = board_meta.get("default_workdir") + # Board ``default_workdir`` is a persistent checkout, so only persistent + # kinds inherit it. A scratch task must stay under the per-board scratch + # root: inheriting the default would point cleanup at the user's source + # tree (#28818). ``_cleanup_workspace`` containment is the safety rail; + # this stops the bad state from being created at all. + if workspace_path is None and project_repo is None and workspace_kind in {"dir", "worktree"}: + board_default = _board_meta_for(board).get("default_workdir") if board_default: workspace_path = str(board_default) @@ -1610,49 +1593,16 @@ def create_task( # compose create_task calls under one outer commit so the # dispatcher can never observe a partially constructed graph. with write_txn(conn, allow_nested=True): - # Parent ids are validated in every mode (even triage) so the - # eventual link rows don't dangle. - if parents: - missing = _missing_task_ids(conn, parents) - if missing: - raise ValueError(f"unknown parent task(s): {', '.join(missing)}") - # Determine task status from parent status, unless the caller - # parks it directly in blocked for human-ops review or in - # triage for a specifier. - if initial_status == "blocked": - task_status = "blocked" - elif triage: - task_status = "triage" - else: - task_status = "ready" - if parents: - # If any parent is not yet done, we're todo. - rows = conn.execute( - "SELECT status FROM tasks WHERE id IN " - "(" + ",".join("?" * len(parents)) + ")", - parents, - ).fetchall() - if any(r["status"] != "done" for r in rows): - task_status = "todo" - + task_status = _initial_task_status(conn, parents, initial_status, triage) # Project-linked worktree: a fresh worktree dir under the repo - # plus a deterministic branch (project slug + task id). Together - # these kill the random ``wt/`` worker fallback and the - # unanchored ``.worktrees/`` under the dispatcher's cwd. + # plus a deterministic branch (project slug + task id) — kills the + # random ``wt/`` worker fallback and the unanchored + # ``.worktrees/`` under the dispatcher's cwd. if project_obj is not None and workspace_kind == "worktree": if project_repo and not workspace_path: - workspace_path = os.path.join( - project_repo, ".worktrees", task_id - ) + workspace_path = os.path.join(project_repo, ".worktrees", task_id) if not branch_name: - from hermes_cli import projects_db as _pdb - - try: - branch_name = _pdb.branch_name_for( - project_obj, task_id, title=title or "" - ) - except Exception: - branch_name = None + branch_name = _project_branch_name(project_obj, task_id, title) conn.execute( """ @@ -1667,37 +1617,18 @@ def create_task( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - task_id, - title.strip(), - body, - assignee, - task_status, - priority, - created_by, - now, - workspace_kind, - workspace_path, - branch_name, - project_id, - tenant, - idempotency_key, + task_id, title.strip(), body, assignee, task_status, priority, + created_by, now, workspace_kind, workspace_path, + branch_name, project_id, tenant, idempotency_key, _opt_int(max_runtime_seconds), json.dumps(skills_list) if skills_list is not None else None, - _opt_int(max_retries), - model_override, - provider_override, + _opt_int(max_retries), model_override, provider_override, reasoning_effort, - 1 if goal_mode else 0, - _opt_int(goal_max_turns), - session_id, + 1 if goal_mode else 0, _opt_int(goal_max_turns), session_id, ), ) for pid in parents: _link(conn, pid, task_id) - # Notify-sub inheritance (ACK-edge: the originating channel - # still hears about a child that BLOCKs, not just the final - # fan-in) is handled by the single-owner helper below — - # _inherit_notify_subs copies every routing/delivery column. _append_event( conn, task_id, @@ -1717,16 +1648,54 @@ def create_task( "provider_override": provider_override, }, ) + # ACK-edge: the originating channel still hears about a child + # that BLOCKs, not just the final fan-in. _inherit_notify_subs(conn, task_id, parents, created_at=now) return task_id except sqlite3.IntegrityError: if attempt == 1: raise - # Retry with a fresh id. - continue raise RuntimeError("unreachable") +def _board_meta_for(board: Optional[str]) -> dict: + return read_board_metadata(board if board else get_current_board()) + + +def _initial_task_status( + conn: sqlite3.Connection, parents: tuple[str, ...], initial_status: str, triage: bool, +) -> str: + """Status for a new task: ``blocked``/``triage`` when parked by the caller, + else ``ready`` unless a parent is not yet ``done`` (-> ``todo``). Parent ids + are validated in every mode (even triage) so link rows never dangle.""" + if parents: + missing = _missing_task_ids(conn, parents) + if missing: + raise ValueError(f"unknown parent task(s): {', '.join(missing)}") + if initial_status == "blocked": + return "blocked" + if triage: + return "triage" + if parents: + rows = conn.execute( + "SELECT status FROM tasks WHERE id IN " + "(" + ",".join("?" * len(parents)) + ")", + parents, + ).fetchall() + if any(r["status"] != "done" for r in rows): + return "todo" + return "ready" + + +def _project_branch_name(project_obj: Any, task_id: str, title: Optional[str]) -> Optional[str]: + from hermes_cli import projects_db as _pdb + + try: + return _pdb.branch_name_for(project_obj, task_id, title=title or "") + except Exception: + return None + + def _link(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: conn.execute( "INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)", @@ -3322,41 +3291,19 @@ def complete_task( and never blocks. """ now = int(time.time()) - # Fail before validating cards or staging artifacts; re-check inside the - # final write transaction below to close the parent-reopen race. + # Fail before validating cards or staging artifacts; re-checked inside the + # write txn below to close the parent-reopen race. if not _parents_satisfied(conn, task_id): return False - - # Gate: verify created_cards BEFORE the main write txn. A rejected - # completion still needs an auditable event, so we emit it in a - # tiny dedicated txn, then raise. The caller is responsible for - # surfacing HallucinatedCardsError to the worker; this function - # never mutates task state on a phantom-card rejection. - if created_cards: - verified_cards, phantom_cards = _verify_created_cards( - conn, task_id, created_cards - ) - if phantom_cards: - with write_txn(conn): - _append_event( - conn, task_id, "completion_blocked_hallucination", - { - "phantom_cards": phantom_cards, - "verified_cards": verified_cards, - "summary_preview": _first_line(summary or result, 200) or None, - }, - ) - raise HallucinatedCardsError(phantom_cards, task_id) - else: - verified_cards = [] - + verified_cards = _gate_created_cards(conn, task_id, created_cards, summary or result) metadata = _merge_completion_prose_artifacts( conn, task_id, metadata, summary=summary, result=result, ) + handoff_summary = summary if summary is not None else result with write_txn(conn): # Parent completion is a hard invariant even for direct human review - # approval. A parent may have been reopened after this task entered - # ``review`` or ``running``. + # approval: a parent may have been reopened while this task sat in + # ``review`` / ``running``. if not _parents_satisfied(conn, task_id): return False prior_status = _task_status(conn, task_id) @@ -3377,109 +3324,40 @@ def complete_task( if expected_run_id is not None: sql += " AND current_run_id = ?" params = (*params, int(expected_run_id)) - cur = conn.execute(sql, params) - if cur.rowcount != 1: + if conn.execute(sql, params).rowcount != 1: return False if isinstance(metadata, dict): - _persist_scratch_completion_artifacts(conn, task_id, metadata) - for stored_path in metadata.pop("_staged_artifacts", []): - path = Path(stored_path) - _insert_completion_attachment( - conn, - task_id, - filename=path.name, - stored_path=str(path), - size=path.stat().st_size, - created_at=now, - ) + _stage_completion_artifacts(conn, task_id, metadata, now) run_id = _end_run( conn, task_id, outcome="completed", status="done", - summary=summary if summary is not None else result, + summary=handoff_summary, metadata=metadata, ) - # If complete_task was called on a never-claimed task (ready or - # blocked → done with no run in flight), synthesize a - # zero-duration run so the handoff fields are persisted in - # attempt history instead of silently lost. - if run_id is None and ( - summary or metadata or result or prior_status == "review" - ): - synth_summary = summary if summary is not None else result - synth_metadata = metadata + # Never-claimed task (ready/blocked/review -> done with no run in + # flight): synthesize a zero-duration run so the handoff fields land in + # attempt history instead of being silently lost. + if run_id is None and (summary or metadata or result or prior_status == "review"): + synth_summary, synth_metadata = handoff_summary, metadata if prior_status == "review" and not synth_summary and not synth_metadata: - synth_summary = "Review approved without additional evidence." - synth_metadata = { - "source_status": "review", - "approval": "manual", - } + synth_summary = _REVIEW_APPROVED_NOTE + synth_metadata = {"source_status": "review", "approval": "manual"} run_id = _synthesize_ended_run( - conn, task_id, - outcome="completed", - summary=synth_summary, - metadata=synth_metadata, + conn, task_id, outcome="completed", summary=synth_summary, metadata=synth_metadata, ) - # Carry the handoff summary in the event payload so gateway - # notifiers and dashboard WS consumers can render it without a - # second SQL round-trip. First line only, 400 char cap — the - # full summary stays on the run row. - event_summary = summary if summary is not None else result + event_summary = handoff_summary if prior_status == "review" and not event_summary: - event_summary = "Review approved without additional evidence." - completed_payload: dict = { - "result_len": len(result) if result else 0, - "summary": _first_line(event_summary, 400) or None, - } - if verified_cards: - completed_payload["verified_cards"] = verified_cards - # Carry artifact paths in the event payload so the gateway - # notifier can upload them as native attachments alongside the - # completion message. Workers pass these via - # ``kanban_complete(artifacts=[...])`` which stashes the list in - # ``metadata["artifacts"]`` — we promote it onto the event so - # consumers don't have to fetch the run row to find it. - if isinstance(metadata, dict): - md_artifacts = metadata.get("artifacts") - if isinstance(md_artifacts, (list, tuple)): - cleaned_artifacts = [ - str(p).strip() for p in md_artifacts if isinstance(p, str) and str(p).strip() - ] - if cleaned_artifacts: - completed_payload["artifacts"] = cleaned_artifacts + event_summary = _REVIEW_APPROVED_NOTE _append_event( conn, task_id, "completed", - completed_payload, + _completed_event_payload(result, event_summary, verified_cards, metadata), run_id=run_id, ) - # Prose-scan the summary + result for t_ references that do - # not resolve. Advisory — does not block the completion. Runs in - # its own txn so the completion itself is already durable by the - # time we emit the warning. - scan_text = " ".join(filter(None, [summary, result])) - if scan_text: - phantom_refs = _scan_prose_for_phantom_ids(conn, scan_text) - # Drop any phantom refs that were already flagged as verified - # above (shouldn't happen — verified means they exist — but - # belt-and-suspenders). - phantom_refs = [p for p in phantom_refs if p not in set(verified_cards)] - if phantom_refs: - with write_txn(conn): - _append_event( - conn, task_id, "suspected_hallucinated_references", - { - "phantom_refs": phantom_refs, - "source": "completion_summary", - }, - run_id=run_id, - ) - # Successful completion — wipe the consecutive-failures counter. - # Failure history stays on the event log for audit; the counter - # just tracks "is there a current pathology the breaker should - # care about", and a success resets that question. + _flag_phantom_prose_refs(conn, task_id, run_id, summary, result, verified_cards) + # Success wipes the consecutive-failures counter; history stays on the + # event log, the counter only tracks the *current* pathology. _clear_failure_counter(conn, task_id) - # Recompute ready status for dependents (separate txn so children see done). - recompute_ready(conn) - # Clean up the scratch workspace and any stale tmux session for the worker. + recompute_ready(conn) # separate txn so children see ``done`` _cleanup_workspace(conn, task_id) _done_task = get_task(conn, task_id) if fire_lifecycle_hook: @@ -3489,11 +3367,93 @@ def complete_task( board=get_current_board(), assignee=_done_task.assignee if _done_task else None, run_id=run_id, - summary=(summary if summary is not None else result), + summary=handoff_summary, ) return True +_REVIEW_APPROVED_NOTE = "Review approved without additional evidence." + + +def _gate_created_cards( + conn: sqlite3.Connection, task_id: str, created_cards: Optional[Iterable[str]], preview_text: Optional[str], +) -> list[str]: + """Verify ``created_cards`` BEFORE the main write txn; returns the verified ids. + + A phantom id blocks completion: the rejection is recorded in its own tiny + txn (auditable) and raised as :class:`HallucinatedCardsError` without + touching task state. + """ + if not created_cards: + return [] + verified_cards, phantom_cards = _verify_created_cards(conn, task_id, created_cards) + if phantom_cards: + with write_txn(conn): + _append_event( + conn, task_id, "completion_blocked_hallucination", + { + "phantom_cards": phantom_cards, + "verified_cards": verified_cards, + "summary_preview": _first_line(preview_text, 200) or None, + }, + ) + raise HallucinatedCardsError(phantom_cards, task_id) + return verified_cards + + +def _stage_completion_artifacts(conn: sqlite3.Connection, task_id: str, metadata: dict, now: int) -> None: + """Copy scratch artifacts to the attachments dir and record each as an attachment row.""" + _persist_scratch_completion_artifacts(conn, task_id, metadata) + for stored_path in metadata.pop("_staged_artifacts", []): + path = Path(stored_path) + _insert_completion_attachment( + conn, task_id, filename=path.name, stored_path=str(path), + size=path.stat().st_size, created_at=now, + ) + + +def _completed_event_payload( + result: Optional[str], event_summary: Optional[str], verified_cards: list[str], metadata: Any, +) -> dict: + """``completed`` event payload: first summary line (400 chars) so gateway + notifiers / dashboard WS render without a second round-trip; verified + cards; and ``metadata["artifacts"]`` promoted so the notifier can upload + them as native attachments without fetching the run row.""" + payload: dict = { + "result_len": len(result) if result else 0, + "summary": _first_line(event_summary, 400) or None, + } + if verified_cards: + payload["verified_cards"] = verified_cards + if isinstance(metadata, dict): + md_artifacts = metadata.get("artifacts") + if isinstance(md_artifacts, (list, tuple)): + cleaned = [str(p).strip() for p in md_artifacts if isinstance(p, str) and str(p).strip()] + if cleaned: + payload["artifacts"] = cleaned + return payload + + +def _flag_phantom_prose_refs( + conn: sqlite3.Connection, task_id: str, run_id: Optional[int], + summary: Optional[str], result: Optional[str], verified_cards: list[str], +) -> None: + """Advisory post-commit scan of summary+result for unresolvable ``t_`` + references; emits ``suspected_hallucinated_references`` in its own txn so + the completion is already durable. Never blocks.""" + scan_text = " ".join(filter(None, [summary, result])) + if not scan_text: + return + phantom_refs = [p for p in _scan_prose_for_phantom_ids(conn, scan_text) if p not in set(verified_cards)] + if phantom_refs: + with write_txn(conn): + _append_event( + conn, task_id, "suspected_hallucinated_references", + {"phantom_refs": phantom_refs, "source": "completion_summary"}, + run_id=run_id, + ) + + # --------------------------------------------------------------------------- # Workspace / tmux cleanup # --------------------------------------------------------------------------- From cc369d088e699539617beb543e3fa480bf95091a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:21:37 -0700 Subject: [PATCH 13/38] refactor(cli-misc): loops.py _save helper; linux_desktop_entry.py shared head/shebang readers, probe via _run_quiet --- hermes_cli/linux_desktop_entry.py | 62 ++++++++++++++++--------------- hermes_cli/loops.py | 46 +++++++++++------------ 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/hermes_cli/linux_desktop_entry.py b/hermes_cli/linux_desktop_entry.py index 1b3665076e..a81900a4c9 100644 --- a/hermes_cli/linux_desktop_entry.py +++ b/hermes_cli/linux_desktop_entry.py @@ -75,18 +75,13 @@ def _can_import_hermes_cli(interpreter: Path) -> bool: cached = _probe_cache.get(key) if cached is not None: return cached - try: - result = subprocess.run( - [key, "-I", "-c", "import hermes_cli.main"], - cwd=os.path.abspath(os.sep), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - timeout=15, - ) - except (OSError, subprocess.SubprocessError): + ok = _run_quiet( + [key, "-I", "-c", "import hermes_cli.main"], + cwd=os.path.abspath(os.sep), timeout=15, on_error=None, + ) + if ok is None: return True - _probe_cache[key] = ok = result.returncode == 0 + _probe_cache[key] = ok return ok @@ -228,6 +223,18 @@ def _shebang_tokens(shebang: str) -> "list[str]": return shebang[2:].strip().split() +def _is_native_binary(head: bytes) -> bool: + return head[:4] == b"\x7fELF" or head.startswith(b"MZ") + + +def _read_head(path: Path, size: int = 4096) -> Optional[bytes]: + try: + with open(path, "rb") as fh: + return fh.read(size) + except OSError: + return None + + def _wrapper_shebang_safe(wrapper: Path) -> bool: """Whether an executable wrapper can actually run in the DE context. @@ -235,12 +242,10 @@ def _wrapper_shebang_safe(wrapper: Path) -> bool: interpreter itself). A python-shebang wrapper is safe only when its interpreter stays inside the RUNNING venv; anything unknown fails safe toward the module fallback. """ - try: - with open(wrapper, "rb") as fh: - head = fh.read(4096) - except OSError: + head = _read_head(wrapper) + if head is None: return False - if head[:4] == b"\x7fELF" or head.startswith(b"MZ"): + if _is_native_binary(head): return True if not head.startswith(b"#!"): return False @@ -273,11 +278,10 @@ def _wrapper_targets_checkout(wrapper: Path, checkout_root: Path) -> bool: tried: the installer writes $INSTALL_DIR lexically, so with a symlinked home the shim text carries the lexical path while the caller may pass a resolved one. """ - try: - head = wrapper.read_bytes()[:4096] - except OSError: + head = _read_head(wrapper) + if head is None: return False - if b"\x7fELF" in head[:4] or head.startswith(b"MZ"): + if _is_native_binary(head): # Native binary: cannot verify, cannot be another checkout's bash shim either — accept. return True text = head.decode("utf-8", errors="replace") @@ -322,14 +326,10 @@ def _needs_interpreter(bin_path: Path) -> bool: Native binaries (uv shim, PyInstaller, distro package) and shell wrappers (the installer's bash launcher execs the venv python itself) never need one. """ - try: - with open(bin_path, "rb") as fh: - head = fh.readline(256) - except OSError: + head = _read_head(bin_path, 256) + if head is None or not head.startswith(b"#!"): return False - if not head.startswith(b"#!"): - return False - shebang = head.decode("utf-8", errors="replace").strip() + shebang = head.decode("utf-8", errors="replace").splitlines()[0].strip() if "python" not in shebang.lower(): return False return _shebang_escapes_running_env(shebang) @@ -400,17 +400,19 @@ def refresh_desktop_databases(applications_dir: Path) -> "list[str]": return ran -def _run_quiet(cmd: "list[str]") -> bool: +def _run_quiet(cmd: "list[str]", *, timeout: int = 60, on_error: Optional[bool] = False, **kwargs) -> Optional[bool]: + """Exit-status success of a silenced subprocess; ``on_error`` when it could not be run at all.""" try: result = subprocess.run( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - timeout=60, + timeout=timeout, + **kwargs, ) except (OSError, subprocess.SubprocessError): - return False + return on_error return result.returncode == 0 diff --git a/hermes_cli/loops.py b/hermes_cli/loops.py index 15ea2bb62f..1d0d959e48 100644 --- a/hermes_cli/loops.py +++ b/hermes_cli/loops.py @@ -231,9 +231,9 @@ class LoopState: "last_stop_reason": data.get("last_stop_reason"), "route": route if isinstance(route, dict) else {}, } + # Remaining numeric/str fields: missing key -> dataclass default; present-but-falsy -> zero. for f in fields(cls): if f.name not in kwargs: - # Missing key -> dataclass default; present-but-falsy -> the type's zero. cast = {"str": str, "int": int, "float": float}[f.type] kwargs[f.name] = cast(data.get(f.name, f.default) or cast()) return cls(**kwargs) @@ -472,35 +472,34 @@ class LoopManager: route=dict(route or {}), ) self._state = state - save_loop(self.session_id, state) - return state + return self._save() + + def _save(self) -> LoopState: + save_loop(self.session_id, self._state) + return self._state def pause(self, reason: str = "user-paused") -> Optional[LoopState]: - if not self._state or self._state.status not in {"active", "paused"}: + s = self._state + if not s or s.status not in {"active", "paused"}: return None - self._state.status = "paused" - self._state.paused_reason = reason - self._state.awaiting_response = False - save_loop(self.session_id, self._state) - return self._state + s.status, s.paused_reason, s.awaiting_response = "paused", reason, False + return self._save() def resume(self) -> Optional[LoopState]: - if not self._state or self._state.status == "cleared": + s = self._state + if not s or s.status == "cleared": return None - self._state.status = "active" - self._state.paused_reason = None - self._state.awaiting_response = False + s.status, s.paused_reason, s.awaiting_response = "active", None, False # Re-arm relative to now so a long pause doesn't fire instantly N times. - delay = self._state.current_delay or self._state.interval_seconds or self_paced_floor_seconds() - self._state.next_due_at = time.time() + min(delay, 5.0) - save_loop(self.session_id, self._state) - return self._state + delay = s.current_delay or s.interval_seconds or self_paced_floor_seconds() + s.next_due_at = time.time() + min(delay, 5.0) + return self._save() def clear(self) -> bool: if self._state is None or self._state.status == "cleared": return False self._state.status = "cleared" - save_loop(self.session_id, self._state) + self._save() self._state = None return True @@ -526,9 +525,8 @@ class LoopManager: s.awaiting_response = True # Provisional schedule from NOW: complete_tick reschedules from turn end, but if the # process dies mid-turn this keeps the persisted loop from being 'due' in a tight loop. - delay = s.current_delay or s.interval_seconds or self_paced_floor_seconds() - s.next_due_at = s.last_fired_at + delay - save_loop(self.session_id, s) + s.next_due_at = s.last_fired_at + (s.current_delay or s.interval_seconds or self_paced_floor_seconds()) + self._save() if s.prompt.lstrip().startswith("/"): return s.prompt.strip() @@ -543,7 +541,7 @@ class LoopManager: return s.awaiting_response = False s.ticks_fired = max(0, s.ticks_fired - 1) - save_loop(self.session_id, s) + self._save() def _stop(self, status: str, reason: str, message: str) -> Dict[str, Any]: """Persist a terminal (``done``) or recoverable (``paused``) stop and build the result.""" @@ -553,7 +551,7 @@ class LoopManager: s.last_stop_reason = reason else: s.paused_reason = reason - save_loop(self.session_id, s) + self._save() return {"status": status, "stopped": True, "reason": reason, "message": message} def complete_tick(self, last_response: str) -> Dict[str, Any]: @@ -616,7 +614,7 @@ class LoopManager: else: s.current_delay = s.interval_seconds s.next_due_at = now + s.current_delay - save_loop(self.session_id, s) + self._save() return {"status": "active", "stopped": False, "reason": "loop continues", "message": ""} From 33c1d69fb77c4e982a0be60f673a251cae240f5d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:23:28 -0700 Subject: [PATCH 14/38] refactor(kanban_db): split build_worker_context into per-section renderers --- hermes_cli/kanban_db.py | 338 ++++++++++++++++++++-------------------- 1 file changed, 165 insertions(+), 173 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 29fd14906d..b64da5438e 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4877,46 +4877,56 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str: task = get_task(conn, task_id) if not task: raise ValueError(f"unknown task {task_id}") - - # Single clock reading shared by every relative-age stamp below, so all - # ages in one rendering are consistent ("3h ago" / "3h ago", not drifting - # by the seconds it takes to build the block). - _now = int(time.time()) - - def _cap(s: Optional[str], limit: int = _CTX_MAX_FIELD_BYTES) -> str: - """Truncate a string to `limit` chars with a visible ellipsis.""" - if not s: - return "" - s = s.strip() - if len(s) <= limit: - return s - return s[:limit] + f"… [truncated, {len(s) - limit} chars omitted]" - - def _stamp(ts: int) -> str: - """``YYYY-MM-DD HH:MM`` plus a relative age when one is available.""" - disp = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - age = _relative_age(ts, _now) - return f"{disp}, {age}" if age else disp - - def _metadata_line(metadata: Any) -> Optional[str]: - if not metadata: - return None - try: - return f"_metadata_: `{_cap(json.dumps(metadata, ensure_ascii=False, sort_keys=True))}`" - except Exception: - return None - - def _tail(items: list, cap: int, noun: str) -> tuple[list, Optional[str]]: - """Keep the newest ``cap`` items; describe the omitted head, if any.""" - omitted = max(0, len(items) - cap) - if not omitted: - return items, None - return items[-cap:], ( - f"_({omitted} earlier {noun}{'s' if omitted != 1 else ''} " - f"omitted; showing most recent {cap})_" - ) - + # One clock reading so every relative age in this rendering agrees. + now = int(time.time()) lines: list[str] = [] + _ctx_header(lines, task) + _ctx_attachments(lines, list_attachments(conn, task_id)) + _ctx_prior_attempts(lines, conn, task_id, now) + _ctx_parent_results(lines, conn, task_id, now) + _ctx_role_history(lines, conn, task, now) + _ctx_comments(lines, list_comments(conn, task_id), now) + return "\n".join(lines).rstrip() + "\n" + + +def _ctx_cap(s: Optional[str], limit: int = _CTX_MAX_FIELD_BYTES) -> str: + """Truncate to ``limit`` chars with a visible ellipsis.""" + if not s: + return "" + s = s.strip() + if len(s) <= limit: + return s + return s[:limit] + f"… [truncated, {len(s) - limit} chars omitted]" + + +def _ctx_stamp(ts: int, now: int) -> str: + """``YYYY-MM-DD HH:MM`` plus a relative age when one is available.""" + disp = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) + age = _relative_age(ts, now) + return f"{disp}, {age}" if age else disp + + +def _ctx_metadata_line(metadata: Any) -> Optional[str]: + if not metadata: + return None + try: + return f"_metadata_: `{_ctx_cap(json.dumps(metadata, ensure_ascii=False, sort_keys=True))}`" + except Exception: + return None + + +def _ctx_tail(items: list, cap: int, noun: str) -> tuple[list, Optional[str]]: + """Keep the newest ``cap`` items; describe the omitted head, if any.""" + omitted = max(0, len(items) - cap) + if not omitted: + return items, None + return items[-cap:], ( + f"_({omitted} earlier {noun}{'s' if omitted != 1 else ''} " + f"omitted; showing most recent {cap})_" + ) + + +def _ctx_header(lines: list[str], task: Task) -> None: lines.append(f"# Kanban task {task.id}: {task.title}") lines.append("") lines.append(f"Assignee: {task.assignee or '(unassigned)'}") @@ -4936,156 +4946,138 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str: if task.branch_name: lines.append(f"Branch: {task.branch_name}") lines.append("") - if task.body and task.body.strip(): lines.append("## Body") - lines.append(_cap(task.body, _CTX_MAX_BODY_BYTES)) + lines.append(_ctx_cap(task.body, _CTX_MAX_BODY_BYTES)) lines.append("") - # Attachments — files uploaded to this task (PDFs, source docs, - # images). Surface the absolute on-disk path so the worker, which has - # full file-tool access, can read them directly (read_file, terminal - # `pdftotext`, etc.). On the local terminal backend the path resolves - # as-is; remote backends need the kanban attachments dir mounted. - attachments = list_attachments(conn, task_id) - if attachments: - lines.append("## Attachments") - lines.append( - "Files attached to this task. Read them with the file/terminal " - "tools at the absolute paths below:" - ) - for att in attachments: - size_kb = max(1, (att.size + 1023) // 1024) if att.size else 0 - size_str = f", {size_kb} KB" if size_kb else "" - ctype = f", {att.content_type}" if att.content_type else "" - lines.append(f"- `{att.filename}`{ctype}{size_str} → `{att.stored_path}`") - lines.append("") - # Prior attempts — show closed runs so a retrying worker sees the - # history. Skip the currently-active run (that's this worker). - # Cap at _CTX_MAX_PRIOR_ATTEMPTS most-recent closed runs; older - # attempts get collapsed into a one-line marker so the worker knows - # more exist without bloating the prompt. +def _ctx_attachments(lines: list[str], attachments: list[Attachment]) -> None: + """Absolute on-disk paths so the worker's file tools read them directly + (remote terminal backends need the attachments dir mounted).""" + if not attachments: + return + lines.append("## Attachments") + lines.append( + "Files attached to this task. Read them with the file/terminal " + "tools at the absolute paths below:" + ) + for att in attachments: + size_kb = max(1, (att.size + 1023) // 1024) if att.size else 0 + size_str = f", {size_kb} KB" if size_kb else "" + ctype = f", {att.content_type}" if att.content_type else "" + lines.append(f"- `{att.filename}`{ctype}{size_str} → `{att.stored_path}`") + lines.append("") + + +def _ctx_prior_attempts(lines: list[str], conn: sqlite3.Connection, task_id: str, now: int) -> None: + """Closed runs on this task (the active run is this worker), newest + ``_CTX_MAX_PRIOR_ATTEMPTS`` in full, older ones as a one-line marker.""" all_prior = [r for r in list_runs(conn, task_id) if r.ended_at is not None] - # list_runs returns ascending by started_at; "most recent" = last N - shown, omitted_note = _tail(all_prior, _CTX_MAX_PRIOR_ATTEMPTS, "attempt") + shown, omitted_note = _ctx_tail(all_prior, _CTX_MAX_PRIOR_ATTEMPTS, "attempt") + if not shown: + return first_shown_idx = len(all_prior) - len(shown) + 1 - if shown: - lines.append("## Prior attempts on this task") - if omitted_note: - lines.append(omitted_note) - for offset, run in enumerate(shown): - idx = first_shown_idx + offset - profile = run.profile or "(unknown)" - outcome = run.outcome or run.status - lines.append(f"### Attempt {idx} — {outcome} ({profile}, {_stamp(run.started_at)})") - if run.summary and run.summary.strip(): - lines.append(_cap(run.summary)) - if run.error and run.error.strip(): - lines.append(f"_error_: {_cap(run.error)}") - meta_line = _metadata_line(run.metadata) - if meta_line: - lines.append(meta_line) - lines.append("") + lines.append("## Prior attempts on this task") + if omitted_note: + lines.append(omitted_note) + for offset, run in enumerate(shown): + profile = run.profile or "(unknown)" + outcome = run.outcome or run.status + lines.append( + f"### Attempt {first_shown_idx + offset} — {outcome} ({profile}, {_ctx_stamp(run.started_at, now)})" + ) + if run.summary and run.summary.strip(): + lines.append(_ctx_cap(run.summary)) + if run.error and run.error.strip(): + lines.append(f"_error_: {_ctx_cap(run.error)}") + meta_line = _ctx_metadata_line(run.metadata) + if meta_line: + lines.append(meta_line) + lines.append("") - # Parents: prefer the most-recent 'completed' run's summary + metadata, - # fall back to ``task.result`` when no run rows exist (legacy DBs, - # or tasks completed before the runs table landed). + +def _ctx_parent_results(lines: list[str], conn: sqlite3.Connection, task_id: str, now: int) -> None: + """Done-parent handoffs: newest ``completed`` run's summary+metadata, + falling back to ``task.result`` for pre-runs-table data. Stamped with a + relative age so the worker re-verifies stale upstream results.""" parent_rows = conn.execute( "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id", (task_id,), ).fetchall() - parent_ids = [r["parent_id"] for r in parent_rows] + wrote_header = False + for pid in (r["parent_id"] for r in parent_rows): + pt = get_task(conn, pid) + if not pt or pt.status != "done": + continue + runs = [r for r in list_runs(conn, pid) if r.outcome == "completed"] + runs.sort(key=lambda r: r.started_at, reverse=True) + run = runs[0] if runs else None + if not wrote_header: + lines.append("## Parent task results") + lines.append( + "_Handoffs from upstream tasks, captured when each parent " + "completed (see age below). These are point-in-time " + "snapshots, not live state — if a result drives your " + "current work and it's not recent, re-verify against the " + "source before acting on it as current._" + ) + wrote_header = True + done_ts = run.ended_at if run is not None and run.ended_at else (pt.completed_at or None) + age = _relative_age(done_ts, now) + lines.append(f"### {pid}" + (f" (completed {age})" if age else "")) + if run is not None and run.summary and run.summary.strip(): + lines.append(_ctx_cap(run.summary)) + elif pt.result: + lines.append(_ctx_cap(pt.result)) + else: + lines.append("(no result recorded)") + meta_line = _ctx_metadata_line(run.metadata) if run is not None else None + if meta_line: + lines.append(meta_line) + lines.append("") - if parent_ids: - wrote_header = False - for pid in parent_ids: - pt = get_task(conn, pid) - if not pt or pt.status != "done": - continue - runs = [r for r in list_runs(conn, pid) if r.outcome == "completed"] - runs.sort(key=lambda r: r.started_at, reverse=True) - run = runs[0] if runs else None - if not wrote_header: - lines.append("## Parent task results") - lines.append( - "_Handoffs from upstream tasks, captured when each parent " - "completed (see age below). These are point-in-time " - "snapshots, not live state — if a result drives your " - "current work and it's not recent, re-verify against the " - "source before acting on it as current._" - ) - wrote_header = True +def _ctx_role_history(lines: list[str], conn: sqlite3.Connection, task: Task, now: int) -> None: + """The assignee's 5 most recent completed runs on OTHER tasks — implicit + role continuity without wiring anything into SOUL.md / MEMORY.md.""" + if not task.assignee: + return + role_rows = conn.execute( + "SELECT t.id, t.title, r.summary, r.ended_at " + "FROM task_runs r JOIN tasks t ON r.task_id = t.id " + "WHERE r.profile = ? AND r.task_id != ? " + " AND r.outcome = 'completed' " + "ORDER BY r.ended_at DESC LIMIT 5", + (task.assignee, task.id), + ).fetchall() + if not role_rows: + return + lines.append(f"## Recent work by @{task.assignee}") + for row in role_rows: + first = _first_line(row["summary"], 200) or "(no summary)" + lines.append( + f"- {row['id']} — {row['title']} ({_ctx_stamp(int(row['ended_at']), now)}): {first}" + ) + lines.append("") - # When did this parent's result get produced? Prefer the - # completed run's end time; fall back to the task's completed_at. - done_ts = None - if run is not None and getattr(run, "ended_at", None): - done_ts = run.ended_at - elif pt.completed_at: - done_ts = pt.completed_at - age = _relative_age(done_ts, _now) - lines.append(f"### {pid}" + (f" (completed {age})" if age else "")) - body_lines: list[str] = [] - if run is not None and run.summary and run.summary.strip(): - body_lines.append(_cap(run.summary)) - elif pt.result: - body_lines.append(_cap(pt.result)) - else: - body_lines.append("(no result recorded)") - - meta_line = _metadata_line(run.metadata) if run is not None else None - if meta_line: - body_lines.append(meta_line) - lines.extend(body_lines) - lines.append("") - - # Cross-task role history: what else has THIS assignee completed - # recently? Gives the worker implicit continuity — "I'm the reviewer - # and my last three reviews focused on security" — without forcing - # the user to wire anything into SOUL.md / MEMORY.md. Bounded to the - # most recent 5 completed runs, excluding this task so the retry - # section above isn't duplicated. Safe on assignee=None (skipped). - if task.assignee: - role_rows = conn.execute( - "SELECT t.id, t.title, r.summary, r.ended_at " - "FROM task_runs r JOIN tasks t ON r.task_id = t.id " - "WHERE r.profile = ? AND r.task_id != ? " - " AND r.outcome = 'completed' " - "ORDER BY r.ended_at DESC LIMIT 5", - (task.assignee, task_id), - ).fetchall() - if role_rows: - lines.append(f"## Recent work by @{task.assignee}") - for row in role_rows: - first = _first_line(row["summary"], 200) or "(no summary)" - lines.append( - f"- {row['id']} — {row['title']} ({_stamp(int(row['ended_at']))}): {first}" - ) - lines.append("") - - # Comments: cap at the most-recent _CTX_MAX_COMMENTS so - # comment-storm tasks don't blow out the worker's prompt. Older - # comments summarised in a one-line marker like prior attempts. - shown_c, omitted_note = _tail(list_comments(conn, task_id), _CTX_MAX_COMMENTS, "comment") - if shown_c: - lines.append("## Comment thread") - if omitted_note: - lines.append(omitted_note) - for c in shown_c: - # Explicit "comment from worker" framing so operator-controlled - # HERMES_PROFILE values like "hermes-system" or "operator" can't be - # misread by the next worker as a system directive above the - # (attacker-influenceable) comment body. Defense-in-depth on top of - # the closed LLM-controlled author-forgery surface. - safe_author = (c.author or "").replace("`", "") - lines.append(f"comment from worker `{safe_author}` at {_stamp(c.created_at)}:") - lines.append(_cap(c.body, _CTX_MAX_COMMENT_BYTES)) - lines.append("") - - return "\n".join(lines).rstrip() + "\n" +def _ctx_comments(lines: list[str], comments: list[Comment], now: int) -> None: + """Newest ``_CTX_MAX_COMMENTS`` comments. The explicit "comment from + worker" framing stops an operator-controlled HERMES_PROFILE like + "hermes-system" being read as a system directive above an + attacker-influenceable body (defense-in-depth).""" + shown, omitted_note = _ctx_tail(comments, _CTX_MAX_COMMENTS, "comment") + if not shown: + return + lines.append("## Comment thread") + if omitted_note: + lines.append(omitted_note) + for c in shown: + safe_author = (c.author or "").replace("`", "") + lines.append(f"comment from worker `{safe_author}` at {_ctx_stamp(c.created_at, now)}:") + lines.append(_ctx_cap(c.body, _CTX_MAX_COMMENT_BYTES)) + lines.append("") # --------------------------------------------------------------------------- From 35876c91004668d29be0f635512fecaabc56d78e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:25:28 -0700 Subject: [PATCH 15/38] refactor(kanban-cli): specify/decompose share triage-load + aux-call helpers; decompose_task split into routing/single/fanout phases --- hermes_cli/kanban_decompose.py | 352 ++++++++++++++------------------- hermes_cli/kanban_specify.py | 131 ++++++------ 2 files changed, 217 insertions(+), 266 deletions(-) diff --git a/hermes_cli/kanban_decompose.py b/hermes_cli/kanban_decompose.py index fffe52850f..3af8ac9099 100644 --- a/hermes_cli/kanban_decompose.py +++ b/hermes_cli/kanban_decompose.py @@ -1,23 +1,18 @@ """Kanban decomposer — fan a triage task out into a graph of child tasks. -Invoked by ``hermes kanban decompose [task_id | --all]`` and the -auto-decompose path in the gateway dispatcher loop. Reads the user's -profile roster (with descriptions) and asks the auxiliary LLM to -return a task graph in JSON. Then atomically creates the children, -links them under the root, and flips the root ``triage -> todo``. +Invoked by ``hermes kanban decompose [task_id | --all]`` and the gateway +dispatcher's auto-decompose path. Reads the profile roster (with +descriptions), asks the auxiliary LLM for a task graph in JSON, then +atomically creates the children, links them under the root, and flips the +root ``triage -> todo``. The root stays alive as parent of every leaf child so +it wakes back up when the graph completes and its assignee (the orchestrator +profile) can judge completion and add more work. -The root task stays alive and becomes the parent of every leaf child, -so when the whole graph completes the root wakes back up — its -assignee (the orchestrator profile) gets a chance to judge completion -and add more tasks if the work isn't done yet. - -Design notes: mirrors ``kanban_specify`` (lazy aux import, lenient parse, -never raises on expected failures). The prompt sees the configured profile -roster; undescribed profiles are listed with a note so name-matching still -works. ``fanout=false`` collapses to the ``specify`` behaviour (tighten + -promote, no children), making ``decompose`` a strict superset. Unknown -assignees are rewritten to ``default_assignee`` — a child NEVER ends up with -``assignee=None``. +Mirrors ``kanban_specify`` (lazy aux import, lenient parse, never raises on +expected failures). ``fanout=false`` collapses to the ``specify`` behaviour +(tighten + promote, no children), making ``decompose`` a strict superset. +Unknown assignees are rewritten to ``default_assignee`` — a child NEVER ends +up with ``assignee=None``. """ from __future__ import annotations @@ -29,7 +24,9 @@ from typing import Optional from hermes_cli import kanban_db as kb from hermes_cli import profiles as profiles_mod -from hermes_cli.kanban_specify import _extract_json_blob, _title_body, _truncate +from hermes_cli.kanban_specify import ( + _call_aux, _extract_json_blob, _load_triage_task, _task_prompt_fields, _title_body, +) from hermes_cli.kanban_specify import _profile_author as _specify_author logger = logging.getLogger(__name__) @@ -138,10 +135,8 @@ def _load_config() -> dict: def _resolve_profile_from_cfg(cfg: dict, key: str) -> str: """``kanban.`` if it names an existing profile, else the active default profile — so a task is never stranded for lack of an owner. - ``orchestrator_profile`` owns the root after fan-out; ``default_assignee`` - catches children the decomposer can't route. - """ + catches children the decomposer can't route.""" kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} explicit = (kanban_cfg.get(key) or "").strip() if explicit: @@ -159,13 +154,12 @@ def _resolve_profile_from_cfg(cfg: dict, key: str) -> str: def _build_roster() -> tuple[list[dict], set[str]]: """``(roster_for_prompt, valid_assignee_names)``; entries are ``{name, description, has_description}``.""" - roster: list[dict] = [] - valid: set[str] = set() try: all_profiles = profiles_mod.list_profiles() except Exception as exc: logger.warning("decompose: failed to list profiles: %s", exc) - return roster, valid + return [], set() + roster = [] for p in all_profiles: desc = (p.description or "").strip() roster.append({ @@ -173,34 +167,131 @@ def _build_roster() -> tuple[list[dict], set[str]]: "description": desc or f"(no description; profile named {p.name!r})", "has_description": bool(desc), }) - valid.add(p.name) - return roster, valid + return roster, {p.name for p in all_profiles} def _format_roster(roster: list[dict]) -> str: if not roster: return " (no profiles installed — decomposer cannot route work)" - lines = [] - for entry in roster: - tag = "" if entry["has_description"] else " ⚠ undescribed" - lines.append(f" - {entry['name']}{tag}: {entry['description']}") - return "\n".join(lines) + return "\n".join( + f" - {entry['name']}{'' if entry['has_description'] else ' ⚠ undescribed'}: {entry['description']}" + for entry in roster + ) -def _normalize_assignee_choice( - assignee: object, - *, - default_assignee: str, - valid_names: set[str], -) -> str: +def _normalize_assignee_choice(assignee: object, *, default_assignee: str, valid_names: set[str]) -> str: """A valid assignee, else ``default_assignee`` — promoted work is never left unassigned.""" if not isinstance(assignee, str) or not assignee.strip(): return default_assignee chosen = assignee.strip() - if chosen not in valid_names: - return default_assignee - return chosen + return chosen if chosen in valid_names else default_assignee + + +@dataclass +class _Routing: + """Config-derived routing context for one decomposition.""" + + orchestrator: str + default_assignee: str + auto_promote: bool + roster: list[dict] + valid_names: set[str] + + +def _load_routing() -> _Routing: + cfg = _load_config() + kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} + roster, valid_names = _build_roster() + return _Routing( + orchestrator=_resolve_profile_from_cfg(cfg, "orchestrator_profile"), + default_assignee=_resolve_profile_from_cfg(cfg, "default_assignee"), + auto_promote=bool(kanban_cfg.get("auto_promote_children", True)), + roster=roster, + valid_names=valid_names, + ) + + +def _apply_single(task: kb.Task, parsed: dict, routing: _Routing, author: str) -> DecomposeOutcome: + """``fanout=false``: single-task spec promotion (same effect as specify).""" + title_val, body_val = _title_body(parsed) + assignee_val = None + if not task.assignee: + assignee_val = _normalize_assignee_choice( + parsed.get("assignee"), default_assignee=routing.default_assignee, valid_names=routing.valid_names, + ) + 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: + ok = kb.specify_triage_task( + conn, task.id, title=title_val, body=body_val, assignee=assignee_val, author=author, + ) + if not ok: + return DecomposeOutcome(task.id, False, "task moved out of triage before promotion") + return DecomposeOutcome(task.id, True, "single task (no fanout)", fanout=False, new_title=title_val) + + +def _clean_children(task_id: str, raw_tasks: list, routing: _Routing) -> tuple[list[dict], str]: + """Validate/normalise the LLM's ``tasks`` list; ``(children, "")`` or ``([], reason)``. + Unknown assignees route to the default; never assignee=None.""" + children: list[dict] = [] + for idx, entry in enumerate(raw_tasks): + if not isinstance(entry, dict): + return [], f"tasks[{idx}] is not an object" + title = entry.get("title") + if not isinstance(title, str) or not title.strip(): + return [], f"tasks[{idx}].title is missing or empty" + body = entry.get("body") + assignee = entry.get("assignee") + chosen = _normalize_assignee_choice( + assignee, default_assignee=routing.default_assignee, valid_names=routing.valid_names, + ) + if isinstance(assignee, str) and assignee.strip() and assignee.strip() not in routing.valid_names: + logger.info( + "decompose: task %s child %d picked unknown assignee %r — " + "routing to default_assignee %r", + task_id, idx, assignee, routing.default_assignee, + ) + parents = entry.get("parents") or [] + if not isinstance(parents, list): + parents = [] + children.append({ + "title": title.strip()[:200], + "body": body.strip() if isinstance(body, str) else "", + "assignee": chosen, + # Drop non-int, out-of-range and self parent indices. + "parents": [p for p in parents if isinstance(p, int) and 0 <= p < len(raw_tasks) and p != idx], + }) + return children, "" + + +def _apply_fanout(task_id: str, parsed: dict, routing: _Routing, author: str) -> DecomposeOutcome: + raw_tasks = parsed.get("tasks") or [] + if not isinstance(raw_tasks, list) or not raw_tasks: + return DecomposeOutcome(task_id, False, "decomposer returned fanout=true with empty tasks list") + children, reason = _clean_children(task_id, raw_tasks, routing) + if reason: + return DecomposeOutcome(task_id, False, reason) + try: + with kb.connect_closing() as conn: + child_ids = kb.decompose_triage_task( + conn, + task_id, + root_assignee=routing.orchestrator, + children=children, + author=author, + auto_promote=routing.auto_promote, + ) + except ValueError as exc: + return DecomposeOutcome(task_id, False, f"DB rejected graph: {exc}") + except Exception as exc: + logger.exception("decompose: DB error on task %s", task_id) + return DecomposeOutcome(task_id, False, f"DB error: {type(exc).__name__}") + if child_ids is None: + return DecomposeOutcome(task_id, False, "task moved out of triage before decomposition") + return DecomposeOutcome( + task_id, True, f"decomposed into {len(child_ids)} children", fanout=True, child_ids=child_ids, + ) def decompose_task( @@ -212,182 +303,35 @@ def decompose_task( """Decompose a triage task into a graph of child tasks. Expected failures (not in triage, no aux client, API error, malformed/empty reply) surface as ``ok=False``.""" - with kb.connect_closing() as conn: - task = kb.get_task(conn, task_id) + task, reason = _load_triage_task(task_id) if task is None: - return DecomposeOutcome(task_id, False, "unknown task id") - if task.status != "triage": - return DecomposeOutcome( - task_id, False, f"task is not in triage (status={task.status!r})" - ) + return DecomposeOutcome(task_id, False, reason) - cfg = _load_config() - orchestrator = _resolve_profile_from_cfg(cfg, "orchestrator_profile") - default_assignee = _resolve_profile_from_cfg(cfg, "default_assignee") - kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} - auto_promote = bool(kanban_cfg.get("auto_promote_children", True)) - roster, valid_names = _build_roster() - - try: - from agent.auxiliary_client import call_llm # type: ignore - except Exception as exc: - logger.debug("decompose: auxiliary client import failed: %s", exc) - return DecomposeOutcome(task_id, False, "auxiliary client unavailable") - - user_msg = _USER_TEMPLATE.format( - task_id=task.id, - title=_truncate(task.title or "", 400), - body=_truncate(task.body or "(no body)", 4000), - roster=_format_roster(roster), - default_assignee=default_assignee, + routing = _load_routing() + raw, reason = _call_aux( + "decompose", task_id, aux_task="kanban_decomposer", system=_SYSTEM_PROMPT, + user=_USER_TEMPLATE.format( + **_task_prompt_fields(task), + roster=_format_roster(routing.roster), + default_assignee=routing.default_assignee, + ), + max_tokens=4000, timeout=timeout or 180, log=logger, ) - - try: - # call_llm applies all auxiliary.kanban_decomposer.* config - # (provider/model/base_url, extra_body, reasoning_effort, retries). - resp = call_llm( - task="kanban_decomposer", - messages=[ - {"role": "system", "content": _SYSTEM_PROMPT}, - {"role": "user", "content": user_msg}, - ], - temperature=0.3, - max_tokens=4000, - timeout=timeout or 180, - ) - except Exception as exc: - logger.info( - "decompose: API call failed for %s (%s)", task_id, exc, - ) - return DecomposeOutcome(task_id, False, f"LLM error: {type(exc).__name__}") - - try: - raw = resp.choices[0].message.content or "" - except Exception: - raw = "" + if raw is None: + return DecomposeOutcome(task_id, False, reason) parsed = _extract_json_blob(raw, _FENCE_RE) if parsed is None: return DecomposeOutcome(task_id, False, "LLM returned malformed JSON") - fanout = bool(parsed.get("fanout")) audit_author = author or _profile_author() - - if not fanout: - # Fall back to single-task spec promotion (same effect as specify). - title_val, body_val = _title_body(parsed) - assignee_val = None - if not task.assignee: - assignee_val = _normalize_assignee_choice( - parsed.get("assignee"), - default_assignee=default_assignee, - valid_names=valid_names, - ) - 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: - ok = kb.specify_triage_task( - conn, - task_id, - title=title_val, - body=body_val, - assignee=assignee_val, - author=audit_author, - ) - if not ok: - return DecomposeOutcome( - task_id, False, "task moved out of triage before promotion", - ) - return DecomposeOutcome( - task_id, True, "single task (no fanout)", - fanout=False, new_title=title_val, - ) - - raw_tasks = parsed.get("tasks") or [] - if not isinstance(raw_tasks, list) or not raw_tasks: - return DecomposeOutcome( - task_id, False, "decomposer returned fanout=true with empty tasks list", - ) - - # Unknown assignees route to the default; never assignee=None. - children: list[dict] = [] - for idx, entry in enumerate(raw_tasks): - if not isinstance(entry, dict): - return DecomposeOutcome( - task_id, False, f"tasks[{idx}] is not an object", - ) - title = entry.get("title") - if not isinstance(title, str) or not title.strip(): - return DecomposeOutcome( - task_id, False, f"tasks[{idx}].title is missing or empty", - ) - body = entry.get("body") - if not isinstance(body, str): - body = "" - assignee = entry.get("assignee") - chosen = _normalize_assignee_choice( - assignee, - default_assignee=default_assignee, - valid_names=valid_names, - ) - if ( - isinstance(assignee, str) - and assignee.strip() - and assignee.strip() not in valid_names - ): - logger.info( - "decompose: task %s child %d picked unknown assignee %r — " - "routing to default_assignee %r", - task_id, idx, assignee, default_assignee, - ) - parents = entry.get("parents") or [] - if not isinstance(parents, list): - parents = [] - # Clean parent indices: drop non-int and out-of-range. - clean_parents = [p for p in parents if isinstance(p, int) and 0 <= p < len(raw_tasks) and p != idx] - children.append({ - "title": title.strip()[:200], - "body": body.strip(), - "assignee": chosen, - "parents": clean_parents, - }) - - try: - with kb.connect_closing() as conn: - child_ids = kb.decompose_triage_task( - conn, - task_id, - root_assignee=orchestrator, - children=children, - author=audit_author, - auto_promote=auto_promote, - ) - except ValueError as exc: - return DecomposeOutcome(task_id, False, f"DB rejected graph: {exc}") - except Exception as exc: - logger.exception("decompose: DB error on task %s", task_id) - return DecomposeOutcome(task_id, False, f"DB error: {type(exc).__name__}") - - if child_ids is None: - return DecomposeOutcome( - task_id, False, "task moved out of triage before decomposition", - ) - - return DecomposeOutcome( - task_id, True, f"decomposed into {len(child_ids)} children", - fanout=True, child_ids=child_ids, - ) + if not parsed.get("fanout"): + return _apply_single(task, parsed, routing, audit_author) + return _apply_fanout(task_id, parsed, routing, audit_author) def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]: """Return task ids currently in the triage column.""" with kb.connect_closing() as conn: - rows = kb.list_tasks( - conn, - status="triage", - tenant=tenant, - limit=1000, - ) + rows = kb.list_tasks(conn, status="triage", tenant=tenant, limit=1000) return [row.id for row in rows] diff --git a/hermes_cli/kanban_specify.py b/hermes_cli/kanban_specify.py index 24605d3fb5..0067c601d8 100644 --- a/hermes_cli/kanban_specify.py +++ b/hermes_cli/kanban_specify.py @@ -99,9 +99,8 @@ def _extract_json_blob(raw: str, fence_re: re.Pattern = _FENCE_RE) -> Optional[d last = stripped.rfind("}") if first == -1 or last == -1 or last <= first: return None - candidate = stripped[first : last + 1] try: - val = json.loads(candidate) + val = json.loads(stripped[first : last + 1]) except (ValueError, json.JSONDecodeError): return None return val if isinstance(val, dict) else None @@ -124,6 +123,60 @@ def _profile_author(default: str = "specifier") -> str: return os.environ.get("HERMES_PROFILE") or os.environ.get("USER") or default +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: + task = kb.get_task(conn, task_id) + if task is None: + return None, "unknown task id" + if task.status != "triage": + return None, f"task is not in triage (status={task.status!r})" + return task, "" + + +def _task_prompt_fields(task: kb.Task) -> dict[str, str]: + """Bounded ``task_id``/``title``/``body`` for the user prompt templates.""" + return { + "task_id": task.id, + "title": _truncate(task.title or "", 400), + "body": _truncate(task.body or "(no body)", 4000), + } + + +def _call_aux(verb: str, task_id: str, *, aux_task: str, system: str, user: str, + max_tokens: int, timeout: int, log: logging.Logger = logger) -> tuple[Optional[str], str]: + """One auxiliary LLM call; ``(reply_text, "")`` or ``(None, reason)``. + + ``call_llm`` applies all ``auxiliary..*`` config (provider/model/ + base_url, extra_body, reasoning_effort, retries). Imported lazily so a + missing aux client degrades to a skip instead of an import-time crash. + """ + try: + from agent.auxiliary_client import call_llm + except Exception as exc: # pragma: no cover — import smoke test + log.debug("%s: auxiliary client import failed: %s", verb, exc) + return None, "auxiliary client unavailable" + try: + resp = call_llm( + task=aux_task, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=0.3, + max_tokens=max_tokens, + timeout=timeout, + ) + except Exception as exc: + suffix = " — skipping" if verb == "specify" else "" + log.info("%s: API call failed for %s (%s)%s", verb, task_id, exc, suffix) + return None, f"LLM error: {type(exc).__name__}" + try: + return resp.choices[0].message.content or "", "" + except Exception: + return "", "" + + def specify_task( task_id: str, *, @@ -133,73 +186,29 @@ def specify_task( """Specify one triage task and promote it to ``todo``. Expected failures (not in triage, no aux client, API error, malformed reply) surface as ``ok=False`` so an ``--all`` sweep continues.""" - with kb.connect_closing() as conn: - task = kb.get_task(conn, task_id) + task, reason = _load_triage_task(task_id) if task is None: - return SpecifyOutcome(task_id, False, "unknown task id") - if task.status != "triage": - return SpecifyOutcome( - task_id, False, f"task is not in triage (status={task.status!r})" - ) + return SpecifyOutcome(task_id, False, reason) - try: - from agent.auxiliary_client import call_llm - except Exception as exc: # pragma: no cover — import smoke test - logger.debug("specify: auxiliary client import failed: %s", exc) - return SpecifyOutcome(task_id, False, "auxiliary client unavailable") - - user_msg = _USER_TEMPLATE.format( - task_id=task.id, - title=_truncate(task.title or "", 400), - body=_truncate(task.body or "(no body)", 4000), + raw, reason = _call_aux( + "specify", task_id, aux_task="triage_specifier", system=_SYSTEM_PROMPT, + user=_USER_TEMPLATE.format(**_task_prompt_fields(task)), + max_tokens=HERMES_KANBAN_SPECIFY_MAX_TOKENS, timeout=timeout or 120, ) - - try: - # call_llm applies all auxiliary.triage_specifier.* config - # (provider/model/base_url, extra_body, reasoning_effort, retries). - resp = call_llm( - task="triage_specifier", - messages=[ - {"role": "system", "content": _SYSTEM_PROMPT}, - {"role": "user", "content": user_msg}, - ], - temperature=0.3, - max_tokens=HERMES_KANBAN_SPECIFY_MAX_TOKENS, - timeout=timeout or 120, - ) - except Exception as exc: - logger.info( - "specify: API call failed for %s (%s) — skipping", - task_id, exc, - ) - return SpecifyOutcome( - task_id, False, f"LLM error: {type(exc).__name__}" - ) - - try: - raw = (resp.choices[0].message.content or "").strip() - except Exception: - raw = "" + if raw is None: + return SpecifyOutcome(task_id, False, reason) + raw = raw.strip() parsed = _extract_json_blob(raw) - - new_title: Optional[str] - new_body: Optional[str] if parsed is None: # Whole reply becomes the body; the user can edit afterward. - stripped_raw = raw.strip() - if not stripped_raw: - return SpecifyOutcome( - task_id, False, "LLM returned an empty response" - ) - new_title = None - new_body = stripped_raw + if not raw: + return SpecifyOutcome(task_id, False, "LLM returned an empty response") + new_title, new_body = None, raw else: new_title, new_body = _title_body(parsed) if new_body is None and new_title is None: - return SpecifyOutcome( - task_id, False, "LLM response missing title and body" - ) + return SpecifyOutcome(task_id, False, "LLM response missing title and body") with kb.connect_closing() as conn: ok = kb.specify_triage_task( @@ -211,9 +220,7 @@ def specify_task( ) if not ok: # Race: promoted/archived between our read and the write. - return SpecifyOutcome( - task_id, False, "task moved out of triage before promotion" - ) + return SpecifyOutcome(task_id, False, "task moved out of triage before promotion") return SpecifyOutcome(task_id, True, "specified", new_title=new_title) From 0e7d9f0af3ec60cece2e824d6fa199e28d96f69b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:27:06 -0700 Subject: [PATCH 16/38] refactor(kanban_db): phase helpers for decompose, stale-claim extension, project-link recovery; collapse review param assembly --- hermes_cli/kanban_db.py | 527 +++++++++++++++++----------------------- 1 file changed, 218 insertions(+), 309 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index b64da5438e..2b7c7fd426 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1346,86 +1346,82 @@ def _resolve_project_link( task id exists. An unresolvable id/slug drops the link (never a dangling reference, never a crash). """ - project_obj = None + project_id = (str(project_id).strip() or None) if project_id is not None else None + if not project_id: + return None, None, None, workspace_kind + from hermes_cli import projects_db as _pdb + project_repo: Optional[str] = None - if project_id is not None: - project_id = str(project_id).strip() or None - if project_id: - from hermes_cli import projects_db as _pdb + try: + with _pdb.connect_closing() as _pconn: + project_obj = _pdb.get_project(_pconn, project_id) + except Exception: + project_obj = None + if project_obj is None and project_source_task_id: + project_obj, project_repo = _project_from_source_task( + conn, _pdb, project_id, str(project_source_task_id), + ) + if project_obj is not None and workspace_kind == "scratch": + workspace_kind = "worktree" + if project_obj is None: + # Unresolvable id/slug: drop the link (never a dangling reference, + # never a crash) and create an ordinary scratch task. + return None, None, None, workspace_kind + # Canonicalise (a slug may have been passed) and anchor the worktree + # under the project's primary repo. + if workspace_kind == "scratch" and project_obj.primary_path: + workspace_kind = "worktree" + if workspace_kind == "worktree" and workspace_path is None and project_obj.primary_path: + # Concrete path is deferred to the insert loop: a fresh + # ``/.worktrees/`` keyed on the new task id. + project_repo = str(project_obj.primary_path) + return project_obj.id, project_obj, project_repo, workspace_kind + +def _project_from_source_task( + conn: sqlite3.Connection, _pdb: Any, project_id: str, source_task_id: str, +) -> tuple[Any, Optional[str]]: + """Recover a Project (and its repo) from a canonical project-linked + worktree task on this board. Worker profiles have their own projects.db + while the Kanban DB is shared, so this carries the repo + branch + convention forward without opening the creator's store and without + reusing the source task's literal worktree path. ``(None, None)`` when + the source task is not a ``/.worktrees/`` project worktree.""" + source_task = get_task(conn, source_task_id) + if not ( + source_task is not None + and source_task.project_id == project_id + and source_task.workspace_kind == "worktree" + and source_task.workspace_path + ): + return None, None + source_path = Path(source_task.workspace_path) + if not ( + source_path.is_absolute() + and source_path.name == source_task.id + and source_path.parent.name == ".worktrees" + ): + return None, None + project_slug = None + if source_task.branch_name: + prefix, separator, leaf = source_task.branch_name.partition("/") + if separator and (leaf == source_task.id or leaf.startswith(f"{source_task.id}-")): + try: + project_slug = _pdb.normalize_slug(prefix) + except ValueError: + project_slug = None + if project_slug is None: try: - with _pdb.connect_closing() as _pconn: - project_obj = _pdb.get_project(_pconn, project_id) - except Exception: - project_obj = None - if project_obj is None and project_source_task_id: - # Worker profiles have their own projects.db, while the Kanban DB is - # intentionally shared. Recover routing only from a canonical - # project-linked source task in this same board. This carries the - # repo + project branch convention forward without copying or - # opening the creator profile's project store, and without reusing - # the source task's literal worktree path. - source_task = get_task(conn, str(project_source_task_id)) - if ( - source_task is not None - and source_task.project_id == project_id - and source_task.workspace_kind == "worktree" - and source_task.workspace_path - ): - source_path = Path(source_task.workspace_path) - if ( - source_path.is_absolute() - and source_path.name == source_task.id - and source_path.parent.name == ".worktrees" - ): - project_slug = None - if source_task.branch_name: - prefix, separator, leaf = source_task.branch_name.partition("/") - if separator and ( - leaf == source_task.id - or leaf.startswith(f"{source_task.id}-") - ): - try: - project_slug = _pdb.normalize_slug(prefix) - except ValueError: - project_slug = None - if project_slug is None: - try: - project_slug = _pdb.normalize_slug(project_id) - except ValueError: - project_slug = None - if project_slug: - project_repo = str(source_path.parent.parent) - project_obj = _pdb.Project( - id=project_id, - slug=project_slug, - name=project_slug, - created_at=0, - primary_path=project_repo, - ) - if workspace_kind == "scratch": - workspace_kind = "worktree" - - if project_obj is None: - # A project id/slug that doesn't resolve must not crash task - # creation or persist a dangling reference — drop the link and - # create the task as an ordinary (scratch) task. - project_id = None - else: - # Canonicalise (a slug may have been passed) and anchor the - # worktree under the project's primary repo. - project_id = project_obj.id - if workspace_kind == "scratch" and project_obj.primary_path: - workspace_kind = "worktree" - if ( - workspace_kind == "worktree" - and workspace_path is None - and project_obj.primary_path - ): - # Defer the concrete path to the insert loop: it's a fresh - # ``/.worktrees/`` dir keyed on the new task id. - project_repo = str(project_obj.primary_path) - return project_id, project_obj, project_repo, workspace_kind + project_slug = _pdb.normalize_slug(project_id) + except ValueError: + return None, None + if not project_slug: + return None, None + project_repo = str(source_path.parent.parent) + project_obj = _pdb.Project( + id=project_id, slug=project_slug, name=project_slug, created_at=0, primary_path=project_repo, + ) + return project_obj, project_repo def _normalize_task_skills(skills: Optional[Iterable[str]]) -> Optional[list[str]]: @@ -2948,48 +2944,13 @@ def release_stale_claims( (now,), ).fetchall() for row in stale: - lock = row["claim_lock"] or "" - host_local = lock.startswith(host_prefix) + host_local = (row["claim_lock"] or "").startswith(host_prefix) hb = row["last_heartbeat_at"] - # Heartbeat staleness backstop: if we have a heartbeat at all - # and it's older than the max-stale threshold, the worker is - # not making observable progress. Reclaim instead of extending, - # even if the PID is still alive (it's likely in a logic loop). - heartbeat_stale = ( - hb is not None - and (now - int(hb)) > DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS - ) - if ( - host_local - and row["worker_pid"] - and _pid_alive(row["worker_pid"]) - and not heartbeat_stale - ): - new_expires = now + _resolve_claim_ttl_seconds() - with write_txn(conn): - cur = conn.execute( - "UPDATE tasks SET claim_expires = ? " - "WHERE id = ? AND status = 'running' " - " AND claim_lock IS ? " - " AND claim_expires IS NOT NULL " - " AND claim_expires < ?", - (new_expires, row["id"], row["claim_lock"], now), - ) - if cur.rowcount != 1: - continue - run_id = _extend_run_claim(conn, row["id"], new_expires) - _append_event( - conn, row["id"], "claim_extended", - { - "reason": "pid_alive", - "worker_pid": int(row["worker_pid"]), - "claim_lock": row["claim_lock"], - "claim_expires_was": int(row["claim_expires"]), - "claim_expires_now": new_expires, - "last_heartbeat_at": _opt_int(row["last_heartbeat_at"]), - }, - run_id=run_id, - ) + # Backstop: a heartbeat older than the max-stale threshold means no + # observable progress — reclaim even if the PID is alive (logic loop). + heartbeat_stale = hb is not None and (now - int(hb)) > DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS + if host_local and row["worker_pid"] and _pid_alive(row["worker_pid"]) and not heartbeat_stale: + _extend_live_stale_claim(conn, row, now) continue termination = _terminate_reclaimed_worker( @@ -3060,6 +3021,37 @@ def _record_reclaim( return run_id +def _extend_live_stale_claim(conn: sqlite3.Connection, row: sqlite3.Row, now: int) -> None: + """TTL-expired claim whose host-local worker is alive: extend instead of + reclaiming (``claim_extended`` event). CAS on the same expired lock so a + concurrent reclaimer wins cleanly.""" + new_expires = now + _resolve_claim_ttl_seconds() + with write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET claim_expires = ? " + "WHERE id = ? AND status = 'running' " + " AND claim_lock IS ? " + " AND claim_expires IS NOT NULL " + " AND claim_expires < ?", + (new_expires, row["id"], row["claim_lock"], now), + ) + if cur.rowcount != 1: + return + run_id = _extend_run_claim(conn, row["id"], new_expires) + _append_event( + conn, row["id"], "claim_extended", + { + "reason": "pid_alive", + "worker_pid": int(row["worker_pid"]), + "claim_lock": row["claim_lock"], + "claim_expires_was": int(row["claim_expires"]), + "claim_expires_now": new_expires, + "last_heartbeat_at": _opt_int(row["last_heartbeat_at"]), + }, + run_id=run_id, + ) + + def reclaim_task( conn: sqlite3.Connection, task_id: str, @@ -3746,8 +3738,7 @@ def block_task( else "ready" ) prev_kind = _row_get(cur_row, "block_kind") - prev_recurrences = _row_get(cur_row, "block_recurrences") - prev_recurrences = int(prev_recurrences) if prev_recurrences is not None else 0 + prev_recurrences = int(_row_get(cur_row, "block_recurrences") or 0) # Dependency blocks never enter the human ``blocked`` bucket — they # wait in ``todo`` and let ``recompute_ready`` gate on parents. Routing @@ -3911,34 +3902,24 @@ def request_review( "ORDER BY id DESC LIMIT 1", (task_id,), ).fetchone() - changes_event = None if changes_run is not None: - changes_event = _latest_event( - conn, task_id, "changes_requested", changes_run["id"], - ) - prior_reviewer = _json_dict(_row_get(changes_event, "payload")).get("reviewer") - if changes_run is not None: - if not isinstance(prior_reviewer, str) or not prior_reviewer.strip(): + changes_event = _latest_event(conn, task_id, "changes_requested", changes_run["id"]) + reviewer = _json_dict(_row_get(changes_event, "payload")).get("reviewer") + if not isinstance(reviewer, str) or not reviewer.strip(): return _ret( False, "re-review has no durable reviewer provenance (the " "latest changes_requested event is missing or " "malformed); pass reviewer= explicitly", ) - reviewer = prior_reviewer - reviewer = _canonical_assignee(reviewer) if reviewer is not None else None + reviewer = _canonical_assignee(reviewer) assignee_sql = ", assignee = ?" if reviewer is not None else "" - params: tuple[Any, ...] - if expected_run_id is None: - params = (reviewer, task_id) if reviewer is not None else (task_id,) - run_guard = "" - else: - params = ( - (reviewer, task_id, int(expected_run_id)) - if reviewer is not None - else (task_id, int(expected_run_id)) - ) - run_guard = " AND current_run_id = ?" + run_guard = "" if expected_run_id is None else " AND current_run_id = ?" + params: tuple[Any, ...] = ( + *(() if reviewer is None else (reviewer,)), + task_id, + *(() if expected_run_id is None else (int(expected_run_id),)), + ) cur = conn.execute( """ UPDATE tasks @@ -4032,10 +4013,7 @@ def request_changes( if not isinstance(implementer, str) or not implementer.strip(): return False, "review handoff has no valid implementer provenance" reviewer = task_row["assignee"] - if isinstance(reviewer, str) and reviewer.strip(): - reviewer = _canonical_assignee(reviewer) - else: - reviewer = None + reviewer = _canonical_assignee(reviewer) if isinstance(reviewer, str) and reviewer.strip() else None new_status = _landing_status_after_parents(conn, task_id) # NOTE: consecutive_failures is deliberately PRESERVED (neither @@ -4270,19 +4248,13 @@ def reopen_review_task(conn: sqlite3.Connection, task_id: str) -> bool: implementer = handoff.get("implementer") if not isinstance(implementer, str) or not implementer.strip(): implementer = None - assignee_sql = ", assignee = ?" if implementer else "" - params: tuple[Any, ...] = ( - (new_status, implementer, task_id) - if implementer - else (new_status, task_id) - ) + params: tuple[Any, ...] = (new_status, *((implementer,) if implementer else ()), task_id) cur = conn.execute( + # consecutive_failures deliberately PRESERVED: review reopen is not + # a success signal; only complete_task resets the breaker (#35072). "UPDATE tasks SET status = ?, current_run_id = NULL, " "claim_lock = NULL, claim_expires = NULL, worker_pid = NULL " - # consecutive_failures deliberately PRESERVED: review reopen is - # not a success signal; only complete_task resets the breaker - # counter (mirrors unblock_task, #35072). - + assignee_sql + + (", assignee = ?" if implementer else "") + " WHERE id = ? AND status = 'review'", params, ) @@ -4372,15 +4344,10 @@ def invalidate_descendants_for_parent_reopen( if previous_status == "review": resume_status = "review" elif previous_status == "running": - resume_status = _retry_status_for_run( - conn, row["id"], row["current_run_id"] - ) + resume_status = _retry_status_for_run(conn, row["id"], row["current_run_id"]) terminations.append((row["worker_pid"], row["claim_lock"])) run_id = _end_run( - conn, - row["id"], - outcome="reclaimed", - status="todo", + conn, row["id"], outcome="reclaimed", status="todo", summary=f"ancestor {task_id} reopened", ) # consecutive_failures = 0: deliberate operator reset — see @@ -4391,30 +4358,22 @@ def invalidate_descendants_for_parent_reopen( "current_run_id = NULL, consecutive_failures = 0 WHERE id = ?", (row["id"],), ) + entry = { + "id": row["id"], "prior_status": previous_status, + "new_status": "todo", "resume_status": resume_status, + } _append_event( - conn, - row["id"], - "descendant_invalidated", - { - "ancestor": task_id, - "prior_status": previous_status, - "new_status": "todo", - "resume_status": resume_status, - }, + conn, row["id"], "descendant_invalidated", + {"ancestor": task_id, **{k: v for k, v in entry.items() if k != "id"}}, run_id=run_id, ) - # Legacy 'status' event kept so existing live-feed consumers - # still see the move without learning the new event kind. + # Legacy 'status' event so existing live-feed consumers still see + # the move without learning the new event kind. _append_event( - conn, - row["id"], - "status", + conn, row["id"], "status", { - "status": "todo", - "reason": "ancestor_reopened", - "parent": task_id, - "previous_status": previous_status, - "resume_status": resume_status, + "status": "todo", "reason": "ancestor_reopened", "parent": task_id, + "previous_status": previous_status, "resume_status": resume_status, }, run_id=run_id, ) @@ -4425,14 +4384,7 @@ def invalidate_descendants_for_parent_reopen( f"(will resume via '{resume_status}').", now, ) - invalidated.append( - { - "id": row["id"], - "prior_status": previous_status, - "new_status": "todo", - "resume_status": resume_status, - } - ) + invalidated.append(entry) if not caller_owns_txn: # Standalone call: we committed above, so the audit trail is durable # — safe to kill workers now. Composed calls leave this to the @@ -4551,22 +4503,21 @@ def _validate_children_graph(children: list) -> None: if p == idx: raise ValueError(f"child[{idx}] cannot list itself as a parent") - _in_deg = [0] * len(children) - _adj: list[list[int]] = [[] for _ in range(len(children))] - for _i, _c in enumerate(children): - for _p in (_c.get("parents") or []): - _adj[_p].append(_i) - _in_deg[_i] += 1 - _queue = [_i for _i in range(len(children)) if _in_deg[_i] == 0] - _seen = 0 - while _queue: - _node = _queue.pop() - _seen += 1 - for _nb in _adj[_node]: - _in_deg[_nb] -= 1 - if _in_deg[_nb] == 0: - _queue.append(_nb) - if _seen != len(children): + in_deg = [0] * len(children) + adj: list[list[int]] = [[] for _ in children] + for i, c in enumerate(children): + for p in (c.get("parents") or []): + adj[p].append(i) + in_deg[i] += 1 + queue = [i for i in range(len(children)) if in_deg[i] == 0] + seen = 0 + while queue: + seen += 1 + for nb in adj[queue.pop()]: + in_deg[nb] -= 1 + if in_deg[nb] == 0: + queue.append(nb) + if seen != len(children): raise ValueError("cyclic dependency detected in decomposed children list") @@ -4597,121 +4548,43 @@ def decompose_triage_task( return None if root_assignee is not None: root_assignee = _canonical_assignee(root_assignee) - _validate_children_graph(children) - # We do the full decomposition in a SINGLE write_txn so it's - # atomic: either every child is created AND the root flips to - # ``todo``, or nothing changes. We deliberately do NOT call any - # kb helper that opens its own write_txn (create_task, link_tasks, - # add_comment) from inside this block — see architecture.md - # write_txn pitfalls. Instead we inline the INSERTs and - # _append_event calls. + # ONE write_txn so the fan-out is atomic (every child + the root flip, or + # nothing). Helpers that open their own write_txn (create_task, + # link_tasks, add_comment) must not be called in here — INSERTs and + # _append_event are inlined instead. now = int(time.time()) - child_ids: list[str] = [] with write_txn(conn): root_row = conn.execute( "SELECT id, status, tenant, workspace_kind, workspace_path " "FROM tasks WHERE id = ?", (task_id,), ).fetchone() - if root_row is None: + if root_row is None or root_row["status"] != "triage": return None - if root_row["status"] != "triage": - return None - tenant = root_row["tenant"] - # Children inherit the root's workspace by default so a fan-out - # of a code-gen task lands in the parent's project dir/worktree - # rather than throwaway scratch tmp dirs. A child dict can still - # override with its own 'workspace_kind' / 'workspace_path'. - root_ws_kind = root_row["workspace_kind"] or "scratch" - root_ws_path = root_row["workspace_path"] - - # Create children. Status is 'todo' regardless of parents — we - # link them under the root AFTER creation so the dispatcher - # sees a coherent state, and recompute_ready() at the end - # promotes parent-free children to 'ready'. - for idx, child in enumerate(children): - new_id = _new_task_id() - title = child["title"].strip() - body = child.get("body") - assignee = _canonical_assignee(child.get("assignee")) - # Per-child override wins; otherwise inherit the root's - # workspace. A child that sets workspace_kind without a path - # falls back to the root path only when kinds match (so a - # child can't accidentally point a 'dir' at the root's - # worktree path or vice versa). - child_ws_kind = child.get("workspace_kind") or root_ws_kind - if child.get("workspace_path"): - child_ws_path = child.get("workspace_path") - elif child_ws_kind == "worktree": - # Never share one worktree checkout between siblings: the - # root's literal path would put every child in the same - # directory on the first-dispatched sibling's branch, with - # no lock — siblings can be promoted and dispatched - # concurrently. Leave the path unset so dispatch - # materializes a fresh /.worktrees/ per - # child from the board anchor. - child_ws_path = None - elif child_ws_kind == root_ws_kind: - child_ws_path = root_ws_path - else: - child_ws_path = None - conn.execute( - "INSERT INTO tasks " - "(id, title, body, assignee, status, workspace_kind, " - " workspace_path, tenant, created_at, created_by) " - "VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?)", - ( - new_id, - title, - body if isinstance(body, str) else None, - assignee, - child_ws_kind, - child_ws_path, - tenant, - now, - (author or "decomposer"), - ), - ) - _append_event( - conn, new_id, "created", - {"by": author or "decomposer", "from_decompose_of": task_id}, - ) - _inherit_notify_subs(conn, new_id, (task_id,), created_at=now) - child_ids.append(new_id) - - # Link children to their sibling parents (within the decomposed graph). + child_ids = [ + _insert_decomposed_child(conn, task_id, root_row, child, author, now) + for child in children + ] + # Sibling edges within the decomposed graph. for idx, child in enumerate(children): for p_idx in child.get("parents") or []: - parent_id = child_ids[p_idx] - child_id = child_ids[idx] + parent_id, child_id = child_ids[p_idx], child_ids[idx] _link(conn, parent_id, child_id) - _append_event( - conn, child_id, "linked", - {"parent": parent_id, "child": child_id}, - ) - - # Link the ROOT task as a child of every leaf child — i.e. the - # root waits for the whole graph. Simpler than computing leaves: - # link root under every child. Cycle-free because the root is - # only ever a child here, never a parent of children. + _append_event(conn, child_id, "linked", {"parent": parent_id, "child": child_id}) + # Root waits for the whole graph: link it under EVERY child (simpler + # than computing leaves; cycle-free since the root is only ever a child). for cid in child_ids: _link(conn, cid, task_id) - - # Flip the root: triage -> todo, set assignee to the orchestrator. + # Flip the root triage -> todo, assignee -> orchestrator. sets = ["status = 'todo'"] params: list[Any] = [] if root_assignee is not None: sets.append("assignee = ?") params.append(root_assignee) params.append(task_id) - conn.execute( - f"UPDATE tasks SET {', '.join(sets)} WHERE id = ?", - tuple(params), - ) - - # Audit comment + event on the root so the timeline shows the fan-out. + conn.execute(f"UPDATE tasks SET {', '.join(sets)} WHERE id = ?", tuple(params)) if author and author.strip(): _insert_comment( conn, task_id, author.strip(), @@ -4720,23 +4593,59 @@ def decompose_triage_task( now, ) _append_event( - conn, task_id, "decomposed", - { - "child_ids": child_ids, - "root_assignee": root_assignee, - }, + conn, task_id, "decomposed", {"child_ids": child_ids, "root_assignee": root_assignee}, ) - - # Outside the write_txn: promote parent-free children to 'ready' - # so the dispatcher picks them up on its next tick. Same pattern - # specify_triage_task uses. When auto_promote is False children - # stay in 'todo' until the user manually promotes them — useful - # for manual-review-first workflows. + # Outside the txn (own IMMEDIATE txn). ``auto_promote=False`` leaves the + # children in ``todo`` for manual-review-first workflows. if auto_promote: recompute_ready(conn) return child_ids +def _insert_decomposed_child( + conn: sqlite3.Connection, root_id: str, root_row: sqlite3.Row, child: dict, + author: Optional[str], now: int, +) -> str: + """Insert one decomposed child as ``todo`` (linked under the root later so + the dispatcher only ever sees a coherent graph); returns its id. + + Workspace: per-child override wins, else inherit the root's kind. Path + inherits only when kinds match (a 'dir' child must not point at the + root's worktree) and NEVER for worktrees — siblings dispatch concurrently + and one shared checkout would put them all on the first sibling's branch + with no lock; leaving it unset makes dispatch materialize a fresh + ``/.worktrees/`` per child from the board anchor. + """ + root_ws_kind = root_row["workspace_kind"] or "scratch" + child_ws_kind = child.get("workspace_kind") or root_ws_kind + if child.get("workspace_path"): + child_ws_path = child.get("workspace_path") + elif child_ws_kind == "worktree": + child_ws_path = None + elif child_ws_kind == root_ws_kind: + child_ws_path = root_row["workspace_path"] + else: + child_ws_path = None + new_id = _new_task_id() + body = child.get("body") + conn.execute( + "INSERT INTO tasks " + "(id, title, body, assignee, status, workspace_kind, " + " workspace_path, tenant, created_at, created_by) " + "VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?)", + ( + new_id, child["title"].strip(), body if isinstance(body, str) else None, + _canonical_assignee(child.get("assignee")), child_ws_kind, child_ws_path, + root_row["tenant"], now, (author or "decomposer"), + ), + ) + _append_event( + conn, new_id, "created", {"by": author or "decomposer", "from_decompose_of": root_id}, + ) + _inherit_notify_subs(conn, new_id, (root_id,), created_at=now) + return new_id + + def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: with write_txn(conn): cur = conn.execute( From 524e52aec9162ffae151f4f59fa02f824ab06ed6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:29:58 -0700 Subject: [PATCH 17/38] refactor(cli-misc): loops.py parse/status compaction with corpus parity; tcc anchor pyvenv.cfg + discard helpers --- hermes_cli/loops.py | 59 +++++++++++++--------------------- hermes_cli/macos_tcc_anchor.py | 16 +++------ 2 files changed, 28 insertions(+), 47 deletions(-) diff --git a/hermes_cli/loops.py b/hermes_cli/loops.py index 1d0d959e48..5c3019cdbe 100644 --- a/hermes_cli/loops.py +++ b/hermes_cli/loops.py @@ -93,8 +93,7 @@ def parse_loop_args(text: str) -> Dict[str, Any]: raw = (text or "").strip() result: Dict[str, Any] = {"interval_seconds": None, "prompt": "", "times": 0, "until": "", "error": None} if not raw: - result["error"] = "empty" - return result + return {**result, "error": "empty"} # Pull trailing flags first so an interval-looking token inside the --until clause can't # confuse the front parse. --until consumes to end-of-line (or to a following --times). @@ -106,8 +105,7 @@ def parse_loop_args(text: str) -> Dict[str, Any]: if times < 1: raise ValueError except ValueError: - result["error"] = f"--times expects a positive integer, got {m_times.group(1)!r}" - return result + return {**result, "error": f"--times expects a positive integer, got {m_times.group(1)!r}"} raw = (raw[: m_times.start()] + raw[m_times.end():]).strip() m_until = re.search(r"\s--until\s+(.+)$", raw, re.DOTALL) @@ -121,19 +119,13 @@ def parse_loop_args(text: str) -> Dict[str, Any]: raw = tokens[1] tokens = raw.split(None, 1) - interval: Optional[int] = None - if tokens: - maybe = parse_interval_token(tokens[0]) - if maybe is not None: - interval = maybe - raw = tokens[1].strip() if len(tokens) > 1 else "" + interval = parse_interval_token(tokens[0]) if tokens else None + if interval is not None: + raw = tokens[1].strip() if len(tokens) > 1 else "" if not raw: - result["error"] = "missing prompt (usage: /loop [interval] )" - return result - - result.update(interval_seconds=interval, prompt=raw, times=times, until=until) - return result + return {**result, "error": "missing prompt (usage: /loop [interval] )"} + return {**result, "interval_seconds": interval, "prompt": raw, "times": times, "until": until} def format_interval(seconds: float) -> str: @@ -226,16 +218,15 @@ class LoopState: "prompt": data.get("prompt", ""), "status": data.get("status", "active"), "mode": data.get("mode", "interval"), - "awaiting_response": bool(data.get("awaiting_response", False)), "paused_reason": data.get("paused_reason"), "last_stop_reason": data.get("last_stop_reason"), "route": route if isinstance(route, dict) else {}, } - # Remaining numeric/str fields: missing key -> dataclass default; present-but-falsy -> zero. + # Remaining scalar fields: missing key -> dataclass default; present-but-falsy -> type zero. + casts = {"str": str, "int": int, "float": float, "bool": bool} for f in fields(cls): if f.name not in kwargs: - cast = {"str": str, "int": int, "float": float}[f.type] - kwargs[f.name] = cast(data.get(f.name, f.default) or cast()) + kwargs[f.name] = casts[f.type](data.get(f.name, f.default) or casts[f.type]()) return cls(**kwargs) def cadence_label(self) -> str: @@ -371,6 +362,10 @@ def _ticks_label(n: int) -> str: return f"{n} tick{'s' if n != 1 else ''}" +def _dash(reason: Optional[str]) -> str: + return f" — {reason}" if reason else "" + + def response_signals_complete(response: str) -> bool: """True when the agent ended its reply with the LOOP_COMPLETE marker.""" return bool(response) and _LOOP_COMPLETE_RE.search(response) is not None @@ -416,6 +411,10 @@ class LoopManager: def has_loop(self) -> bool: return self._state is not None and self._state.status in {"active", "paused"} + def _save(self) -> LoopState: + save_loop(self.session_id, self._state) + return self._state + def status_line(self) -> str: s = self._state if s is None or s.status == "cleared": @@ -435,11 +434,9 @@ class LoopManager: tail = ", wakeup running" if s.awaiting_response else (f", {remaining}" if remaining else "") return f"↻ Loop (active, {meta}{tail}): {s.prompt}" if s.status == "paused": - extra = f" — {s.paused_reason}" if s.paused_reason else "" - return f"⏸ Loop (paused, {meta}{extra}): {s.prompt}" + return f"⏸ Loop (paused, {meta}{_dash(s.paused_reason)}): {s.prompt}" if s.status == "done": - extra = f" — {s.last_stop_reason}" if s.last_stop_reason else "" - return f"✓ Loop finished ({fired}{extra}): {s.prompt}" + return f"✓ Loop finished ({fired}{_dash(s.last_stop_reason)}): {s.prompt}" return f"Loop ({s.status}, {meta}): {s.prompt}" def set( @@ -474,10 +471,6 @@ class LoopManager: self._state = state return self._save() - def _save(self) -> LoopState: - save_loop(self.session_id, self._state) - return self._state - def pause(self, reason: str = "user-paused") -> Optional[LoopState]: s = self._state if not s or s.status not in {"active", "paused"}: @@ -659,16 +652,12 @@ def _resume_output(mgr: "LoopManager") -> str: return "No loop to resume." if state is None else f"▶ Loop resumed ({state.cadence_label()}): {state.prompt}" -def _stop_output(mgr: "LoopManager") -> str: - return "✓ Loop stopped." if mgr.clear() else "No active loop." - - # Control words -> handler returning the output text. Anything else is a new loop spec. _CONTROL_COMMANDS = { **dict.fromkeys(("", "status"), lambda mgr: mgr.status_line()), "pause": _pause_output, "resume": _resume_output, - **dict.fromkeys(("stop", "clear", "cancel"), _stop_output), + **dict.fromkeys(("stop", "clear", "cancel"), lambda mgr: "✓ Loop stopped." if mgr.clear() else "No active loop."), **dict.fromkeys(("help", "--help", "-h"), lambda mgr: LOOP_HELP), } @@ -724,10 +713,8 @@ def dispatch_loop_command( lines.append(f"Stops when: {state.until}") if not state.times and state.max_ticks: lines.append(f"Backstop budget: {state.max_ticks} ticks (loops.max_ticks; 0 = unlimited).") - if state.status == "active": - lines.append("First wakeup fires now, then on the cadence above. Controls: /loop status · pause · resume · stop.") - else: - lines.append(f"First wakeup {state.remaining_label()}. Controls: /loop status · pause · resume · stop.") + first = "fires now, then on the cadence above" if state.status == "active" else state.remaining_label() + lines.append(f"First wakeup {first}. Controls: /loop status · pause · resume · stop.") if replacing: lines.insert(1, "(replaced the previous loop for this session)") return {"output": "\n".join(lines), "created": True} diff --git a/hermes_cli/macos_tcc_anchor.py b/hermes_cli/macos_tcc_anchor.py index 985f67da1f..987f3f7103 100644 --- a/hermes_cli/macos_tcc_anchor.py +++ b/hermes_cli/macos_tcc_anchor.py @@ -11,6 +11,7 @@ and never raise to callers. from __future__ import annotations +import contextlib import errno import logging import os @@ -100,17 +101,12 @@ def _interpreter_source(venv_dir: Path) -> str | None: cfg = venv_dir / "pyvenv.cfg" if not cfg.is_file(): return None - home = "" try: - for line in cfg.read_text(encoding="utf-8").splitlines(): - if line.lower().startswith("home"): - home = line.partition("=")[2].strip() - break + lines = cfg.read_text(encoding="utf-8").splitlines() except OSError: return None - if not home: - return None - interp = _interpreter_file(home) + home = next((l.partition("=")[2].strip() for l in lines if l.lower().startswith("home")), "") + interp = _interpreter_file(home) if home else None return str(interp) if interp is not None else None @@ -207,10 +203,8 @@ def _stage_copy(venv_bin: Path, prefix: str, source: Path) -> Path: def _discard(tmp_path: Path | None) -> None: if tmp_path is not None: - try: + with contextlib.suppress(OSError): tmp_path.unlink(missing_ok=True) - except OSError: - pass def _copy_alias(venv_bin: Path, name: str, anchor: Path) -> bool: From d2bc586f58429baaca6129343afb40f38cb78218 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:29:58 -0700 Subject: [PATCH 18/38] =?UTF-8?q?refactor(kanban-cli):=20transfer/swarm=20?= =?UTF-8?q?=E2=80=94=20module=20docstrings=20compacted,=20closing()=20snap?= =?UTF-8?q?shot,=20placeholder=20helper,=20asdict,=20common=20create=20kwa?= =?UTF-8?q?rgs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/kanban_swarm.py | 105 ++++++---------------- hermes_cli/kanban_transfer.py | 161 ++++++++++------------------------ 2 files changed, 72 insertions(+), 194 deletions(-) diff --git a/hermes_cli/kanban_swarm.py b/hermes_cli/kanban_swarm.py index 5a047eb67d..813ac8f38c 100644 --- a/hermes_cli/kanban_swarm.py +++ b/hermes_cli/kanban_swarm.py @@ -1,22 +1,21 @@ """Kanban Swarm v1: thin swarm topology helpers on top of Kanban. -This module intentionally does not introduce a second scheduler. It writes a -small task graph into the existing Kanban kernel: +Deliberately no second scheduler — a small task graph written into the +existing Kanban kernel: planning root (completed immediately) ├─ parallel specialist workers (ready) └─ verifier (todo until all workers done) └─ synthesizer (todo until verifier done) -The shared blackboard is also deliberately low-tech: structured JSON comments on -the root task. That keeps all state in existing task_comments/task_events rows, -so the dashboard, notifier, slash command, and dispatcher keep working without a -new service. +The shared blackboard is structured JSON comments on the root task, so all +state lives in existing task_comments/task_events rows and the dashboard, +notifier, slash command and dispatcher keep working without a new service. """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field import json import sqlite3 import time @@ -49,12 +48,7 @@ class SwarmCreated: synthesizer_id: str def as_dict(self) -> dict[str, Any]: - return { - "root_id": self.root_id, - "worker_ids": list(self.worker_ids), - "verifier_id": self.verifier_id, - "synthesizer_id": self.synthesizer_id, - } + return asdict(self) def _require_text(value: str, field_name: str) -> str: @@ -89,7 +83,6 @@ def _activate_root_inline( would run while the outer txn can still roll back). The caller runs ``recompute_ready`` after the outer commit. """ - now = int(time.time()) cur = conn.execute( """ UPDATE tasks @@ -101,23 +94,13 @@ def _activate_root_inline( WHERE id = ? AND status = 'blocked' """, - (now, root_id), + (int(time.time()), root_id), ) if cur.rowcount != 1: return False - run_id = kb._synthesize_ended_run( - conn, - root_id, - outcome="completed", - summary=summary, - metadata=metadata, - ) + run_id = kb._synthesize_ended_run(conn, root_id, outcome="completed", summary=summary, metadata=metadata) kb._append_event( - conn, - root_id, - "completed", - {"result_len": 0, "summary": summary[:400] or None}, - run_id=run_id, + conn, root_id, "completed", {"result_len": 0, "summary": summary[:400] or None}, run_id=run_id, ) return True @@ -140,9 +123,7 @@ def create_swarm( idempotency_key: Optional[str] = None, ) -> SwarmCreated: """Atomically create a durable, immediately dispatchable Kanban swarm.""" - activation_summary = ( - "Swarm topology planned; root remains the shared blackboard." - ) + activation_summary = "Swarm topology planned; root remains the shared blackboard." activated = False with kb.write_txn(conn): created = _create_swarm_uncommitted( @@ -209,13 +190,9 @@ def _create_swarm_uncommitted( priority: int = 0, idempotency_key: Optional[str] = None, ) -> SwarmCreated: - """Create a durable Kanban swarm graph. - - The returned graph is immediately dispatchable: the planning root is marked - ``done`` with topology metadata, parallel workers are ``ready``, the verifier - waits for every worker, and the synthesizer waits for the verifier. - """ - + """Create the swarm graph inside the caller's transaction: planning root + (``blocked`` until the caller activates it), parallel workers, a verifier + waiting on every worker, and a synthesizer waiting on the verifier.""" goal = _require_text(goal, "goal") verifier_assignee = _require_text(verifier_assignee, "verifier_assignee") synthesizer_assignee = _require_text(synthesizer_assignee, "synthesizer_assignee") @@ -226,6 +203,10 @@ def _create_swarm_uncommitted( _require_text(spec.profile, f"workers[{i}].profile") _require_text(spec.title, f"workers[{i}].title") + common = dict( + created_by=created_by, tenant=tenant, + workspace_kind=workspace_kind, workspace_path=workspace_path, + ) root = kb.create_task( conn, title=root_title or f"Swarm: {goal.splitlines()[0][:80]}", @@ -236,13 +217,10 @@ def _create_swarm_uncommitted( f"Goal:\n{goal}" ), assignee=created_by, - created_by=created_by, - tenant=tenant, priority=priority, idempotency_key=idempotency_key, initial_status="blocked", - workspace_kind=workspace_kind, - workspace_path=workspace_path, + **common, ) # Idempotency may return an existing root: recover its topology from the @@ -253,18 +231,9 @@ def _create_swarm_uncommitted( verifier_id = existing.get("verifier_id") synthesizer_id = existing.get("synthesizer_id") if worker_ids and verifier_id and synthesizer_id: - return SwarmCreated( - root_id=root, - worker_ids=worker_ids, - verifier_id=str(verifier_id), - synthesizer_id=str(synthesizer_id), - ) + return SwarmCreated(root, worker_ids, str(verifier_id), str(synthesizer_id)) context_suffix = _swarm_context(root, goal) - common = dict( - created_by=created_by, tenant=tenant, - workspace_kind=workspace_kind, workspace_path=workspace_path, - ) worker_ids = [ kb.create_task( conn, @@ -279,7 +248,6 @@ def _create_swarm_uncommitted( ) for spec in worker_specs ] - verifier = kb.create_task( conn, title=verifier_title, @@ -295,7 +263,6 @@ def _create_swarm_uncommitted( skills=["requesting-code-review"], **common, ) - synthesizer = kb.create_task( conn, title=synthesizer_title, @@ -312,26 +279,12 @@ def _create_swarm_uncommitted( ) created = SwarmCreated(root, worker_ids, verifier, synthesizer) - post_blackboard_update( - conn, - root, - author=created_by, - key="topology", - value=created.as_dict() | {"goal": goal}, - ) + post_blackboard_update(conn, root, author=created_by, key="topology", value=created.as_dict() | {"goal": goal}) return created -def post_blackboard_update( - conn: sqlite3.Connection, - root_id: str, - *, - author: str, - key: str, - value: Any, -) -> int: +def post_blackboard_update(conn: sqlite3.Connection, root_id: str, *, author: str, key: str, value: Any) -> int: """Append one structured update to the swarm root blackboard.""" - _require_text(root_id, "root_id") author = _require_text(author, "author") key = _require_text(key, "key") @@ -340,12 +293,9 @@ def post_blackboard_update( def latest_blackboard(conn: sqlite3.Connection, root_id: str) -> dict[str, Any]: - """Merge structured blackboard comments on a root card. - - Later comments replace earlier values for the same key. ``_authors`` records - the author of the winning value for traceability. - """ - + """Merge structured blackboard comments on a root card. Later comments + replace earlier values for the same key; ``_authors`` records the author + of the winning value for traceability.""" merged: dict[str, Any] = {} authors: dict[str, str] = {} for comment in kb.list_comments(conn, root_id): @@ -368,11 +318,8 @@ def latest_blackboard(conn: sqlite3.Connection, root_id: str) -> dict[str, Any]: def parse_worker_arg(raw: str) -> SwarmWorkerSpec: """Parse CLI ``--worker profile:title[:skill,skill]`` values.""" - parts = [p.strip() for p in raw.split(":", 2)] if len(parts) < 2: raise ValueError("worker must be profile:title or profile:title:skill,skill") - skills: list[str] = [] - if len(parts) == 3 and parts[2]: - skills = [s.strip() for s in parts[2].split(",") if s.strip()] + skills = [s.strip() for s in parts[2].split(",") if s.strip()] if len(parts) == 3 and parts[2] else [] return SwarmWorkerSpec(profile=parts[0], title=parts[1], body=parts[1], skills=skills) diff --git a/hermes_cli/kanban_transfer.py b/hermes_cli/kanban_transfer.py index 41b1a0daeb..401e480322 100644 --- a/hermes_cli/kanban_transfer.py +++ b/hermes_cli/kanban_transfer.py @@ -1,34 +1,23 @@ """Kanban board export / import — move a whole board between machines. -Backs ``hermes kanban export|import``, the matching ``/boards/{slug}/export`` -and ``/boards/import`` REST endpoints, and the desktop board switcher's -Export/Import items. +Backs ``hermes kanban export|import``, the ``/boards/{slug}/export`` and +``/boards/import`` REST endpoints, and the desktop board switcher. Archive +layout (``.tar.gz``, one top-level dir named for the source slug): +``manifest.json`` (format/version/provenance/counts), ``board.json`` (display +metadata, machine-local fields stripped), ``kanban.db`` (consistent snapshot), +``attachments//…`` (unless --no-attachments), ``logs/.log`` (only +with --include-logs). -Archive layout (``.tar.gz``, one top-level directory named for the -source board's slug):: - - / - manifest.json format + version + provenance + row counts - board.json display metadata, machine-local fields stripped - kanban.db consistent snapshot of the board database - attachments//… attachment blobs (unless --no-attachments) - logs/.log worker logs (only with --include-logs) - -Two things make this more than ``tar czf`` of the board directory: - -* **The database is live** (WAL mode, dispatcher may be mid-write), so the - export uses SQLite's online-backup API for a consistent image instead of a - file copy that would miss the ``-wal`` sidecar. -* **Rows carry machine-local state** — claims, PIDs, heartbeats, absolute - paths, gateway chat subscriptions, session ids. Shipping them verbatim - would import claims owned by a stranger's process or push events into a - stranger's Telegram thread. Scrubbed on export and re-scrubbed on import - (an archive is untrusted); see :func:`_scrub_local_state` and - :func:`_relocate_imported_rows`. - -Imports always land as a **new** board (slug auto-suffixes on collision), so -an import never mutates an existing board and is never ``default`` — which -lets the importer ignore the default board's split on-disk layout. +Two things make this more than ``tar czf`` of the board directory: the DB is +live (WAL mode, dispatcher may be mid-write) so export uses SQLite's online +backup instead of a file copy that would miss the ``-wal`` sidecar; and rows +carry machine-local state (claims, PIDs, heartbeats, absolute paths, gateway +chat subscriptions, session ids) that would import a stranger's claims or push +events into a stranger's Telegram thread — scrubbed on export and re-scrubbed +on import (an archive is untrusted); see :func:`_scrub_local_state` and +:func:`_relocate_imported_rows`. Imports always land as a **new** board (slug +auto-suffixes on collision), never ``default``, so the importer can ignore the +default board's split on-disk layout. """ from __future__ import annotations @@ -58,6 +47,11 @@ ARCHIVE_FORMAT_VERSION = 1 # if it is in one of these — terminal and already-parked tasks are left # alone rather than having their history rewritten. _DISPATCHABLE_STATUSES = ("ready", "running", "todo", "scheduled") +_COUNTED_TABLES = ("tasks", "task_links", "task_comments", "task_events", "task_runs", "task_attachments") + + +def _placeholders(items) -> str: + return ", ".join("?" * len(items)) # --------------------------------------------------------------------------- @@ -67,15 +61,9 @@ _DISPATCHABLE_STATUSES = ("ready", "running", "todo", "scheduled") def _snapshot_db(source: Path, target: Path) -> None: """Consistent copy of ``source`` via the online-backup API (a file copy would miss pages still in the ``-wal`` sidecar and could tear).""" - src = sqlite3.connect(str(source)) - try: - dst = sqlite3.connect(str(target)) - try: - src.backup(dst) - finally: - dst.close() - finally: - src.close() + with contextlib.closing(sqlite3.connect(str(source))) as src, \ + contextlib.closing(sqlite3.connect(str(target))) as dst: + src.backup(dst) def _scrub_local_state(conn: sqlite3.Connection) -> None: @@ -115,20 +103,11 @@ def _scrub_local_state(conn: sqlite3.Connection) -> None: def _write_json(path: Path, payload: dict[str, Any]) -> None: - path.write_text( - json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" - ) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def _count_rows(conn: sqlite3.Connection) -> dict[str, int]: - tables = ( - "tasks", "task_links", "task_comments", - "task_events", "task_runs", "task_attachments", - ) - return { - t: int(conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]) - for t in tables - } + return {t: int(conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]) for t in _COUNTED_TABLES} def export_board( @@ -149,8 +128,7 @@ def export_board( if not db_path.exists(): raise FileNotFoundError(f"board {slug!r} has no database at {db_path}") - output = Path(output_path).expanduser() - base = str(output).removesuffix(".tar.gz").removesuffix(".tgz") + base = str(Path(output_path).expanduser()).removesuffix(".tar.gz").removesuffix(".tgz") Path(base).parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory() as tmpdir: @@ -173,16 +151,8 @@ def export_board( meta["project_id"] = None _write_json(staged / "board.json", meta) - attachments = 0 - if include_attachments: - attachments = copy_regular_files( - kb.attachments_root(slug), staged / "attachments" - ) - logs = 0 - if include_logs: - logs = copy_regular_files( - kb.worker_logs_dir(slug), staged / "logs" - ) + attachments = copy_regular_files(kb.attachments_root(slug), staged / "attachments") if include_attachments else 0 + logs = copy_regular_files(kb.worker_logs_dir(slug), staged / "logs") if include_logs else 0 try: from hermes_cli import __version__ as hermes_version @@ -196,10 +166,7 @@ def export_board( "board_name": meta.get("name") or slug, "exported_at": int(time.time()), "hermes_version": str(hermes_version), - "includes": { - "attachments": bool(include_attachments), - "logs": bool(include_logs), - }, + "includes": {"attachments": bool(include_attachments), "logs": bool(include_logs)}, "counts": {**counts, "attachment_files": attachments, "log_files": logs}, } _write_json(staged / "manifest.json", manifest) @@ -226,19 +193,15 @@ def _available_slug(preferred: str) -> str: # Leave headroom for the suffix inside the 64-char slug limit. stem = preferred[:58].rstrip("-_") or "board" n = 2 - while True: - candidate = f"{stem}-{n}" - if not kb.board_exists(candidate): - return candidate + while kb.board_exists(f"{stem}-{n}"): n += 1 + return f"{stem}-{n}" def _read_manifest(root: Path) -> dict[str, Any]: path = root / "manifest.json" if not path.exists(): - raise ValueError( - "archive is not a Hermes kanban board export (no manifest.json)" - ) + raise ValueError("archive is not a Hermes kanban board export (no manifest.json)") try: manifest = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: @@ -266,11 +229,8 @@ def _read_board_metadata(path: Path) -> dict[str, Any]: return raw if isinstance(raw, dict) else {} -def _relocate_imported_rows( - conn: sqlite3.Connection, slug: str -) -> tuple[dict[str, int], list[str]]: - """Re-anchor an imported board's rows to this machine; returns - ``(stats, warnings)``. +def _relocate_imported_rows(conn: sqlite3.Connection, slug: str) -> tuple[dict[str, int], list[str]]: + """Re-anchor an imported board's rows to this machine; returns ``(stats, warnings)``. * Attachment rows are repointed at this board's tree; rows whose blob did not travel (``--no-attachments``) are dropped, since a dangling row @@ -288,44 +248,29 @@ def _relocate_imported_rows( with kb.write_txn(conn): _scrub_local_state(conn) - dropped = 0 - rehomed = 0 - for row in conn.execute( - "SELECT id, task_id, stored_path FROM task_attachments" - ).fetchall(): + dropped = rehomed = 0 + for row in conn.execute("SELECT id, task_id, stored_path FROM task_attachments").fetchall(): landed = attachments_dir / row["task_id"] / Path(row["stored_path"]).name if landed.is_file(): - conn.execute( - "UPDATE task_attachments SET stored_path = ? WHERE id = ?", - (str(landed), row["id"]), - ) + conn.execute("UPDATE task_attachments SET stored_path = ? WHERE id = ?", (str(landed), row["id"])) rehomed += 1 else: - conn.execute( - "DELETE FROM task_attachments WHERE id = ?", (row["id"],) - ) + conn.execute("DELETE FROM task_attachments WHERE id = ?", (row["id"],)) dropped += 1 if dropped: - warnings.append( - f"{dropped} attachment record(s) dropped — the files were not " - f"in the archive" - ) + warnings.append(f"{dropped} attachment record(s) dropped — the files were not in the archive") parked = [ r["id"] for r in conn.execute( "SELECT id FROM tasks WHERE workspace_kind IN ('dir', 'worktree') " - f"AND status IN ({', '.join('?' * len(_DISPATCHABLE_STATUSES))})", + f"AND status IN ({_placeholders(_DISPATCHABLE_STATUSES)})", _DISPATCHABLE_STATUSES, ).fetchall() ] conn.execute("UPDATE tasks SET workspace_path = NULL, branch_name = NULL") if parked: - conn.execute( - f"UPDATE tasks SET status = 'triage' " - f"WHERE id IN ({', '.join('?' * len(parked))})", - parked, - ) + conn.execute(f"UPDATE tasks SET status = 'triage' WHERE id IN ({_placeholders(parked)})", parked) warnings.append( f"{len(parked)} task(s) moved to triage — their workspace was a " f"directory or git worktree on the exporting machine and needs " @@ -336,17 +281,7 @@ def _relocate_imported_rows( conn.execute( "INSERT INTO task_events (task_id, run_id, kind, payload, created_at) " "VALUES (?, NULL, 'imported', ?, ?)", - ( - row["id"], - json.dumps( - { - "board": slug, - "parked": row["id"] in parked, - }, - ensure_ascii=False, - ), - now, - ), + (row["id"], json.dumps({"board": slug, "parked": row["id"] in parked}, ensure_ascii=False), now), ) return {"attachments": rehomed, "parked": len(parked)}, warnings @@ -366,9 +301,7 @@ def import_board( roots = archive_root_dirs(archive) if len(roots) != 1: - raise ValueError( - "a kanban board archive must contain exactly one top-level directory" - ) + raise ValueError("a kanban board archive must contain exactly one top-level directory") archive_root = roots.pop() with tempfile.TemporaryDirectory() as tmpdir: @@ -381,9 +314,7 @@ def import_board( if not staged_db.is_file(): raise ValueError("archive is missing kanban.db") - requested = kb._normalize_board_slug( - slug or manifest.get("board") or archive_root - ) + requested = kb._normalize_board_slug(slug or manifest.get("board") or archive_root) if not requested: raise ValueError( "cannot determine a board name from the archive — pass one " From f7480f49d17e066a43bda7859465e5f153d262e0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:30:38 -0700 Subject: [PATCH 19/38] refactor(kanban_db): AST-neutral collapse of short hanging bracket spans --- hermes_cli/kanban_db.py | 192 +++++++++------------------------------- 1 file changed, 40 insertions(+), 152 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 2b7c7fd426..72a495a827 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -154,9 +154,7 @@ def normalize_reasoning_effort(effort: Optional[str]) -> Optional[str]: if value == "none" or value in VALID_REASONING_EFFORTS: return value allowed = ", ".join(("none", *VALID_REASONING_EFFORTS)) - raise ValueError( - f"reasoning_effort must be one of {allowed}, got {effort!r}" - ) + raise ValueError(f"reasoning_effort must be one of {allowed}, got {effort!r}") KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names()) @@ -182,9 +180,7 @@ def _assert_not_delegated_child_mutation() -> None: except Exception: delegated = bool(os.environ.get("HERMES_DELEGATED_CHILD_CONTEXT")) if delegated: - raise PermissionError( - "delegate_task child contexts cannot mutate Kanban tasks or boards" - ) + raise PermissionError("delegate_task child contexts cannot mutate Kanban tasks or boards") def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None: @@ -1528,9 +1524,7 @@ def create_task( if not title or not title.strip(): raise ValueError("title is required") if initial_status not in VALID_INITIAL_STATUSES: - raise ValueError( - f"initial_status must be one of {sorted(VALID_INITIAL_STATUSES)}" - ) + raise ValueError(f"initial_status must be one of {sorted(VALID_INITIAL_STATUSES)}") if workspace_kind not in VALID_WORKSPACE_KINDS: raise ValueError( f"workspace_kind must be one of {sorted(VALID_WORKSPACE_KINDS)}, " @@ -1752,12 +1746,7 @@ def _inherit_notify_subs( FROM kanban_notify_subs WHERE task_id IN ({placeholders}) """, - ( - child_id, - int(created_at if created_at is not None else time.time()), - cursor, - *parent_ids, - ), + (child_id, int(created_at if created_at is not None else time.time()), cursor, *parent_ids), ) @@ -1810,9 +1799,7 @@ def list_tasks( if order_by is not None: order_by = order_by.strip().lower() if order_by not in VALID_SORT_ORDERS: - raise ValueError( - f"order_by must be one of {sorted(VALID_SORT_ORDERS.keys())}" - ) + raise ValueError(f"order_by must be one of {sorted(VALID_SORT_ORDERS.keys())}") query += f" ORDER BY {VALID_SORT_ORDERS[order_by]}" else: query += " ORDER BY priority DESC, created_at ASC" @@ -1903,11 +1890,7 @@ def set_model_override( return True -def set_reasoning_effort( - conn: sqlite3.Connection, - task_id: str, - effort: Optional[str], -) -> bool: +def set_reasoning_effort(conn: sqlite3.Connection, task_id: str, effort: Optional[str]) -> bool: """Set (or clear) the per-task reasoning effort. ``effort=None`` (or empty) clears the override — the worker falls back to @@ -1926,16 +1909,9 @@ def set_reasoning_effort( if status is None: return False if status == "archived": - raise RuntimeError( - f"cannot set reasoning effort on archived task {task_id}" - ) - conn.execute( - "UPDATE tasks SET reasoning_effort = ? WHERE id = ?", - (effort, task_id), - ) - _append_event( - conn, task_id, "reasoning_effort_set", {"reasoning_effort": effort} - ) + raise RuntimeError(f"cannot set reasoning effort on archived task {task_id}") + conn.execute("UPDATE tasks SET reasoning_effort = ? WHERE id = ?", (effort, task_id)) + _append_event(conn, task_id, "reasoning_effort_set", {"reasoning_effort": effort}) # Task-mutation observer (RFC #58548), fired AFTER the txn commits. notify_task_updated(conn, task_id, ("reasoning_effort",)) return True @@ -1953,9 +1929,7 @@ def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: if missing: raise ValueError(f"unknown task(s): {', '.join(missing)}") if _would_cycle(conn, parent_id, child_id): - raise ValueError( - f"linking {parent_id} -> {child_id} would create a cycle" - ) + raise ValueError(f"linking {parent_id} -> {child_id} would create a cycle") _link(conn, parent_id, child_id) # If child was ready but parent is not yet done, demote child to todo. if _task_status(conn, parent_id) != "done": @@ -2026,15 +2000,10 @@ def child_ids(conn: sqlite3.Connection, task_id: str) -> list[str]: return _linked_ids(conn, "child_id", "parent_id", task_id) -def task_graph_contexts( - conn: sqlite3.Connection, task_ids: Iterable[str] -) -> dict[str, dict]: +def task_graph_contexts(conn: sqlite3.Connection, task_ids: Iterable[str]) -> dict[str, dict]: """Bulk-load compact direct graph state for graph-aware diagnostics.""" ordered_ids = list(dict.fromkeys(str(task_id) for task_id in task_ids if task_id)) - contexts = { - task_id: {"parents": [], "children": []} - for task_id in ordered_ids - } + contexts = {task_id: {"parents": [], "children": []} for task_id in ordered_ids} if not ordered_ids: return contexts @@ -2061,9 +2030,7 @@ def task_graph_context(conn: sqlite3.Connection, task_id: str) -> dict: # Comments & events # --------------------------------------------------------------------------- -def add_comment( - conn: sqlite3.Connection, task_id: str, author: str, body: str -) -> int: +def add_comment(conn: sqlite3.Connection, task_id: str, author: str, body: str) -> int: if not body or not body.strip(): raise ValueError("comment body is required") if not author or not author.strip(): @@ -2202,9 +2169,7 @@ def store_attachment_bytes( if max_bytes is None: max_bytes = KANBAN_ATTACHMENT_MAX_BYTES if len(data) > max_bytes: - raise AttachmentTooLarge( - f"attachment exceeds {max_bytes // (1024 * 1024)} MB limit" - ) + raise AttachmentTooLarge(f"attachment exceeds {max_bytes // (1024 * 1024)} MB limit") safe_name = _safe_attachment_name(filename) dest_dir = task_attachments_dir(task_id, board=board) dest_dir.mkdir(parents=True, exist_ok=True) @@ -2255,15 +2220,7 @@ def add_attachment( "INSERT INTO task_attachments " "(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - task_id, - filename.strip(), - stored_path, - content_type, - int(size), - uploaded_by, - now, - ), + (task_id, filename.strip(), stored_path, content_type, int(size), uploaded_by, now), ) _append_event( conn, @@ -2279,9 +2236,7 @@ def list_attachments(conn: sqlite3.Connection, task_id: str) -> list[Attachment] def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]: - r = conn.execute( - "SELECT * FROM task_attachments WHERE id = ?", (attachment_id,) - ).fetchone() + r = conn.execute("SELECT * FROM task_attachments WHERE id = ?", (attachment_id,)).fetchone() return None if r is None else Attachment.from_row(r) @@ -2297,9 +2252,7 @@ def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[ if att is None: return None conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,)) - _append_event( - conn, att.task_id, "attachment_removed", {"filename": att.filename} - ) + _append_event(conn, att.task_id, "attachment_removed", {"filename": att.filename}) try: p = Path(att.stored_path) if p.is_file(): @@ -2388,19 +2341,9 @@ def _end_run( WHERE id = ? AND ended_at IS NULL """, - ( - status or outcome, - outcome, - summary, - error, - _json_or_null(metadata), - now, - run_id, - ), - ) - conn.execute( - "UPDATE tasks SET current_run_id = NULL WHERE id = ?", (task_id,), + (status or outcome, outcome, summary, error, _json_or_null(metadata), now, run_id), ) + conn.execute("UPDATE tasks SET current_run_id = NULL WHERE id = ?", (task_id,)) return run_id @@ -2427,9 +2370,7 @@ def _task_status(conn: sqlite3.Connection, task_id: str) -> Optional[str]: def _current_run_id(conn: sqlite3.Connection, task_id: str) -> Optional[int]: - row = conn.execute( - "SELECT current_run_id FROM tasks WHERE id = ?", (task_id,), - ).fetchone() + row = conn.execute("SELECT current_run_id FROM tasks WHERE id = ?", (task_id,)).fetchone() return int(row["current_run_id"]) if row and row["current_run_id"] else None @@ -2547,9 +2488,7 @@ def _resume_status_from_events(conn: sqlite3.Connection, task_id: str) -> str: return "ready" -def recompute_ready( - conn: sqlite3.Connection, failure_limit: int = None, -) -> int: +def recompute_ready(conn: sqlite3.Connection, failure_limit: int = None) -> int: """Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``. Returns the number of tasks promoted. Opens its own IMMEDIATE txn, so it @@ -2698,10 +2637,7 @@ def _claim_and_open_run( ), ) run_id = run_cur.lastrowid - conn.execute( - "UPDATE tasks SET current_run_id = ? WHERE id = ?", - (run_id, task_id), - ) + conn.execute("UPDATE tasks SET current_run_id = ? WHERE id = ?", (run_id, task_id)) _append_event( conn, task_id, "claimed", {"lock": lock, "expires": expires, "run_id": run_id, **(event_extra or {})}, @@ -2909,11 +2845,7 @@ def _extend_run_claim(conn: sqlite3.Connection, task_id: str, expires: int) -> O return run_id -def release_stale_claims( - conn: sqlite3.Connection, - *, - signal_fn=None, -) -> int: +def release_stale_claims(conn: sqlite3.Connection, *, signal_fn=None) -> int: """Reset any ``running`` task whose claim has expired. A stale-by-TTL claim whose host-local worker PID is still alive is @@ -3074,9 +3006,7 @@ def reclaim_task( # Nothing to reclaim — already ready / blocked / done. return False prev_lock = row["claim_lock"] - termination = _terminate_reclaimed_worker( - row["worker_pid"], prev_lock, signal_fn=signal_fn, - ) + termination = _terminate_reclaimed_worker(row["worker_pid"], prev_lock, signal_fn=signal_fn) with write_txn(conn): retry_status = _retry_status_for_run(conn, task_id) cur = conn.execute( @@ -3155,9 +3085,7 @@ def _verify_created_cards( if not ordered: return [], [] - row = conn.execute( - "SELECT assignee FROM tasks WHERE id = ?", (completing_task_id,), - ).fetchone() + row = conn.execute("SELECT assignee FROM tasks WHERE id = ?", (completing_task_id,)).fetchone() if row is None: # Completing task not found — nothing resolves. return [], ordered @@ -3200,10 +3128,7 @@ def _verify_created_cards( _TASK_ID_PROSE_RE = re.compile(r"\bt_[a-f0-9]{8,}\b") -def _scan_prose_for_phantom_ids( - conn: sqlite3.Connection, - text: str, -) -> list[str]: +def _scan_prose_for_phantom_ids(conn: sqlite3.Connection, text: str) -> list[str]: """Regex-scan free-form text for ``t_`` references; return the ones that don't exist in ``tasks``. @@ -3657,10 +3582,7 @@ def edit_completed_task_result( with write_txn(conn): if _task_status(conn, task_id) != "done": return False - conn.execute( - "UPDATE tasks SET result = ? WHERE id = ?", - (result, task_id), - ) + conn.execute("UPDATE tasks SET result = ? WHERE id = ?", (result, task_id)) run = conn.execute( """ SELECT id FROM task_runs @@ -3680,10 +3602,7 @@ def edit_completed_task_result( metadata=metadata, ) else: - conn.execute( - "UPDATE task_runs SET summary = ? WHERE id = ?", - (handoff_summary, run_id), - ) + conn.execute("UPDATE task_runs SET summary = ? WHERE id = ?", (handoff_summary, run_id)) if metadata is not None: conn.execute( "UPDATE task_runs SET metadata = ? WHERE id = ?", @@ -3722,9 +3641,7 @@ def block_task( escalates. Returns True on any transition, False when not blockable. """ if kind is not None and kind not in VALID_BLOCK_KINDS: - raise ValueError( - f"block kind must be one of {sorted(VALID_BLOCK_KINDS)} or None" - ) + raise ValueError(f"block kind must be one of {sorted(VALID_BLOCK_KINDS)} or None") with write_txn(conn): cur_row = conn.execute( "SELECT status, block_kind, block_recurrences FROM tasks WHERE id = ?", @@ -3788,17 +3705,11 @@ def block_task( cur = conn.execute(sql, params) if cur.rowcount != 1: return False - run_id = _end_run( - conn, task_id, - outcome="blocked", status="blocked", - summary=reason, - ) + run_id = _end_run(conn, task_id, outcome="blocked", status="blocked", summary=reason) # Synthesize a run when blocking a never-claimed task so the reason # is preserved in attempt history. if run_id is None and reason: - run_id = _synthesize_ended_run( - conn, task_id, outcome="blocked", summary=reason, - ) + run_id = _synthesize_ended_run(conn, task_id, outcome="blocked", summary=reason) _append_event(conn, task_id, event_kind, payload, run_id=run_id) _blocked_task = get_task(conn, task_id) @@ -4497,9 +4408,7 @@ def _validate_children_graph(children: list) -> None: raise ValueError(f"child[{idx}].parents must be a list") for p in parents_idx: if not isinstance(p, int) or p < 0 or p >= len(children): - raise ValueError( - f"child[{idx}].parents[{p}] is not a valid index into children" - ) + raise ValueError(f"child[{idx}].parents[{p}] is not a valid index into children") if p == idx: raise ValueError(f"child[{idx}] cannot list itself as a parent") @@ -4677,9 +4586,7 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: def _delete_task_relations(conn: sqlite3.Connection, task_id: str) -> None: """Delete every row referencing ``task_id`` (schema has no ON DELETE CASCADE).""" - conn.execute( - "DELETE FROM task_links WHERE parent_id = ? OR child_id = ?", (task_id, task_id), - ) + conn.execute("DELETE FROM task_links WHERE parent_id = ? OR child_id = ?", (task_id, task_id)) for table in ("task_comments", "task_events", "task_runs", "kanban_notify_subs"): conn.execute(f"DELETE FROM {table} WHERE task_id = ?", (task_id,)) @@ -4749,17 +4656,9 @@ def schedule_task( cur = conn.execute(sql, params) if cur.rowcount != 1: return False - run_id = _end_run( - conn, task_id, - outcome="scheduled", status="scheduled", - summary=reason, - ) + run_id = _end_run(conn, task_id, outcome="scheduled", status="scheduled", summary=reason) if run_id is None and reason: - run_id = _synthesize_ended_run( - conn, task_id, - outcome="scheduled", - summary=reason, - ) + run_id = _synthesize_ended_run(conn, task_id, outcome="scheduled", summary=reason) _append_event(conn, task_id, "scheduled", {"reason": reason}, run_id=run_id) return True @@ -5065,9 +4964,7 @@ def task_age(task: Task) -> dict: _co = _to_epoch(task.completed_at) age_since_created = now - _c if _c is not None else None age_since_started = now - _s if _s is not None else None - time_to_complete = ( - _co - (_s or _c) if _co is not None else None - ) + time_to_complete = (_co - (_s or _c) if _co is not None else None) return { "created_age_seconds": age_since_created, "started_age_seconds": age_since_started, @@ -5079,9 +4976,7 @@ def task_age(task: Task) -> dict: # Retention + garbage collection # --------------------------------------------------------------------------- -def gc_events( - conn: sqlite3.Connection, *, older_than_seconds: int = 30 * 24 * 3600, -) -> int: +def gc_events(conn: sqlite3.Connection, *, older_than_seconds: int = 30 * 24 * 3600) -> int: """Delete task_events rows older than ``older_than_seconds`` for tasks in a terminal state (``done`` or ``archived``). Returns the number of rows deleted. Running / ready / blocked tasks keep their full event @@ -5096,10 +4991,7 @@ def gc_events( return int(cur.rowcount or 0) -def gc_worker_logs( - *, older_than_seconds: int = 30 * 24 * 3600, - board: Optional[str] = None, -) -> int: +def gc_worker_logs(*, older_than_seconds: int = 30 * 24 * 3600, board: Optional[str] = None) -> int: """Delete worker log files older than ``older_than_seconds``. Returns the number of files removed. Kept separate from ``gc_events`` because log files live on disk, not in SQLite. Scoped to ``board`` (defaults @@ -5259,9 +5151,7 @@ def list_runs( def get_run(conn: sqlite3.Connection, run_id: int) -> Optional[Run]: - row = conn.execute( - "SELECT * FROM task_runs WHERE id = ?", (int(run_id),), - ).fetchone() + row = conn.execute("SELECT * FROM task_runs WHERE id = ?", (int(run_id),)).fetchone() return Run.from_row(row) if row else None @@ -5290,9 +5180,7 @@ def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]: return row["summary"] if row else None -def latest_summaries( - conn: sqlite3.Connection, task_ids: Iterable[str] -) -> dict[str, str]: +def latest_summaries(conn: sqlite3.Connection, task_ids: Iterable[str]) -> dict[str, str]: """``{task_id: latest non-null run summary}`` for many tasks in one query (dashboard board endpoint; avoids N+1 :func:`latest_summary` calls). Window function -> needs SQLite >= 3.25 (default on every supported From 7f4c6707f27ceb313b26af49c86df40642a86a9e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:32:27 -0700 Subject: [PATCH 20/38] refactor(cli-misc): desktop-entry argv/probe/cache collapse; tcc anchor venv/interpreter lookups via next() --- hermes_cli/linux_desktop_entry.py | 59 +++++++++++++------------------ hermes_cli/macos_tcc_anchor.py | 23 +++++------- 2 files changed, 33 insertions(+), 49 deletions(-) diff --git a/hermes_cli/linux_desktop_entry.py b/hermes_cli/linux_desktop_entry.py index a81900a4c9..521abcb9a9 100644 --- a/hermes_cli/linux_desktop_entry.py +++ b/hermes_cli/linux_desktop_entry.py @@ -31,9 +31,7 @@ def is_supported() -> bool: def _xdg_data_home() -> Path: raw = os.environ.get("XDG_DATA_HOME") - if raw and raw.strip(): - return Path(raw).expanduser() - return Path.home() / ".local" / "share" + return Path(raw).expanduser() if raw and raw.strip() else Path.home() / ".local" / "share" def desktop_entry_path() -> Path: @@ -41,7 +39,6 @@ def desktop_entry_path() -> Path: def icon_path(project_root: Path) -> Path: - """The app icon shipped in the desktop workspace.""" return project_root / "apps" / "desktop" / "assets" / "icon.png" @@ -72,9 +69,8 @@ def _can_import_hermes_cli(interpreter: Path) -> bool: transient hiccup doesn't freeze the assumption for the session. """ key = str(interpreter) - cached = _probe_cache.get(key) - if cached is not None: - return cached + if key in _probe_cache: + return _probe_cache[key] ok = _run_quiet( [key, "-I", "-c", "import hermes_cli.main"], cwd=os.path.abspath(os.sep), timeout=15, on_error=None, @@ -86,7 +82,7 @@ def _can_import_hermes_cli(interpreter: Path) -> bool: def _running_interpreter_fallback() -> str: - """The RUNNING interpreter: it has ``hermes_cli`` importable by definition.""" + """The RUNNING interpreter — it has ``hermes_cli`` importable by definition.""" return os.path.abspath(sys.executable) @@ -103,30 +99,24 @@ def resolve_exec_command(project_root: Optional[Path] = None) -> str: # Persisting an interpreter that can't import the CLI writes a dead entry (the DE spawns # Exec in a cold environment where exactly this import must succeed). interpreter = _running_interpreter_fallback() + argv = [interpreter, "-m", "hermes_cli.main", "desktop"] if bin_path: resolved = Path(bin_path).resolve() - if _needs_interpreter(resolved): - # A Python launcher whose shebang points OUTSIDE the venv (e.g. the repo's `hermes` - # script with `#!/usr/bin/env python3`) would die silently on the first third-party - # import under Terminal=false — run it under the venv interpreter explicitly. - argv = [interpreter, str(resolved), "desktop"] - else: - argv = [str(resolved), "desktop"] - else: - argv = [interpreter, "-m", "hermes_cli.main", "desktop"] + # A Python launcher whose shebang points OUTSIDE the venv (e.g. the repo's `hermes` script + # with `#!/usr/bin/env python3`) would die silently on the first third-party import under + # Terminal=false — run it under the venv interpreter explicitly. + prefix = [interpreter] if _needs_interpreter(resolved) else [] + argv = [*prefix, str(resolved), "desktop"] return " ".join(_quote_exec_arg(a) for a in argv) def _is_interpreter(candidate: Path) -> bool: - """A python interpreter binary (``bin/python*``), not a launcher. - - Strict basename match — accepts ``python``, ``python3``, ``python3.11``; rejects lookalikes - such as ``python3-config`` and ``pythonw``. The parent-dir guard keeps a script named - ``python`` outside a bin/Scripts tree from being misclassified. - """ - if not re.fullmatch(r"python[23]?(\d+)?(\.\d+)?", candidate.name.lower()): - return False - return candidate.parent.name in {"bin", "scripts"} + """A python interpreter binary (``bin/python*``), not a launcher: strict basename match + (rejects ``python3-config``, ``pythonw``) inside a bin/Scripts dir (rejects a stray script + named ``python`` elsewhere).""" + return bool(re.fullmatch(r"python[23]?(\d+)?(\.\d+)?", candidate.name.lower())) and ( + candidate.parent.name in {"bin", "scripts"} + ) def _inside_checkout(candidate: str, checkout_root: Path, original_argv0: str) -> bool: @@ -391,11 +381,10 @@ def refresh_desktop_databases(applications_dir: Path) -> "list[str]": # Plasma 6 first, then Plasma 5. Only one of them is ever installed. for tool in ("kbuildsycoca6", "kbuildsycoca5"): resolved = shutil.which(tool) - if not resolved: - continue - if _run_quiet([resolved, "--noincremental"]): - ran.append(tool) - break + if resolved: + if _run_quiet([resolved, "--noincremental"]): + ran.append(tool) + break return ran @@ -452,10 +441,10 @@ def _remove_stale_scalable_icon() -> bool: """Drop a leftover PNG from ``scalable/`` (the pre-fix install path); True if removed.""" stale = _hicolor_icon_dest("scalable") try: - if not stale.is_file(): - return False - stale.unlink() - return True + removed = stale.is_file() + if removed: + stale.unlink() + return removed except OSError: return False diff --git a/hermes_cli/macos_tcc_anchor.py b/hermes_cli/macos_tcc_anchor.py index 987f3f7103..75afd8e920 100644 --- a/hermes_cli/macos_tcc_anchor.py +++ b/hermes_cli/macos_tcc_anchor.py @@ -62,12 +62,11 @@ def _is_uv_macos_store(path: str) -> bool: def _venv_dir(project_root: Path | None = None) -> Path | None: root = Path(project_root) if project_root is not None else Path(__file__).resolve().parents[1] - for name in ("venv", ".venv"): - candidate = root / name - venv_py = venv_python_path(candidate) - if venv_py.is_file() or venv_py.is_symlink(): - return candidate - return None + return next((root / n for n in ("venv", ".venv") if _present(venv_python_path(root / n))), None) + + +def _present(path: Path) -> bool: + return path.is_file() or path.is_symlink() def _interpreter_file(src: str | Path) -> Path | None: @@ -77,17 +76,13 @@ def _interpreter_file(src: str | Path) -> Path | None: return p if not p.is_dir(): return None - for name in _STORE_BIN_NAMES: - candidate = p / name - if candidate.is_file(): - return candidate try: - for candidate in sorted(p.glob("python3.*")): - if candidate.is_file() and not candidate.name.endswith((".dSYM", ".txt")): - return candidate + candidates = [p / n for n in _STORE_BIN_NAMES] + sorted( + c for c in p.glob("python3.*") if not c.name.endswith((".dSYM", ".txt")) + ) + return next((c for c in candidates if c.is_file()), None) except OSError: return None - return None def _interpreter_source(venv_dir: Path) -> str | None: From 5d2f0f47894fa0235d28eba6c4bbf79dbba46b01 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:33:27 -0700 Subject: [PATCH 21/38] refactor(kanban_db): table-driven Task/Run.from_row, _route_block, shared override/lifecycle-hook helpers --- hermes_cli/kanban_db.py | 228 +++++++++++++++++----------------------- 1 file changed, 96 insertions(+), 132 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 72a495a827..266cb57587 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -198,6 +198,14 @@ def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None _log.debug("kanban lifecycle hook %s failed: %s", event, exc) +def _fire_task_hook(event: str, task: Optional["Task"], task_id: str, run_id: Optional[int], **fields: Any) -> None: + """Lifecycle hook for a task transition; ``assignee`` from the (possibly missing) row.""" + _fire_kanban_lifecycle_hook( + event, task_id, board=get_current_board(), + assignee=task.assignee if task else None, run_id=run_id, **fields, + ) + + def _hook_profile_name() -> str: """Active profile for hook payloads; ``"default"`` when it cannot be resolved.""" from hermes_cli.profiles import get_active_profile_name @@ -927,50 +935,36 @@ class Task: parsed = _json_or(g("skills")) skills_value = [str(s) for s in parsed if s] if isinstance(parsed, list) else None return cls( - id=row["id"], - title=row["title"], - body=row["body"], - assignee=row["assignee"], - status=row["status"], - priority=row["priority"], - created_by=row["created_by"], - created_at=row["created_at"], - started_at=row["started_at"], - completed_at=row["completed_at"], - workspace_kind=row["workspace_kind"], - workspace_path=row["workspace_path"], - branch_name=g("branch_name"), - project_id=g("project_id"), - claim_lock=row["claim_lock"], - claim_expires=row["claim_expires"], - tenant=g("tenant"), - result=g("result"), - idempotency_key=g("idempotency_key"), + **{col: row[col] for col in _TASK_REQUIRED_COLUMNS}, + **{col: g(col) for col in _TASK_OPTIONAL_COLUMNS}, + **{col: g(col) or None for col in _TASK_EMPTY_IS_NULL_COLUMNS}, # Pre-migration fallbacks (spawn_failures / last_spawn_error) are only # reachable on a DB never opened since the rename migration landed. consecutive_failures=g("consecutive_failures", g("spawn_failures", 0)), - worker_pid=g("worker_pid"), last_failure_error=g("last_failure_error", g("last_spawn_error")), - max_runtime_seconds=g("max_runtime_seconds"), - last_heartbeat_at=g("last_heartbeat_at"), - current_run_id=g("current_run_id"), - workflow_template_id=g("workflow_template_id"), - current_step_key=g("current_step_key"), skills=skills_value, - model_override=g("model_override") or None, - provider_override=g("provider_override") or None, - reasoning_effort=g("reasoning_effort") or None, - max_retries=g("max_retries"), goal_mode=bool(g("goal_mode")), - goal_max_turns=g("goal_max_turns") or None, - session_id=g("session_id"), - block_kind=g("block_kind") or None, - block_recurrences=( - int(g("block_recurrences")) if g("block_recurrences") is not None else 0 - ), + block_recurrences=int(g("block_recurrences") or 0), ) +# Columns every schema version has (KeyError if the SELECT omitted them). +_TASK_REQUIRED_COLUMNS = ( + "id", "title", "body", "assignee", "status", "priority", "created_by", "created_at", + "started_at", "completed_at", "workspace_kind", "workspace_path", "claim_lock", "claim_expires", +) +# Later-added columns read as NULL when absent from the row. +_TASK_OPTIONAL_COLUMNS = ( + "branch_name", "project_id", "tenant", "result", "idempotency_key", "worker_pid", + "max_runtime_seconds", "last_heartbeat_at", "current_run_id", "workflow_template_id", + "current_step_key", "max_retries", "session_id", +) +# Text columns where "" is stored/read as "not set". +_TASK_EMPTY_IS_NULL_COLUMNS = ( + "model_override", "provider_override", "reasoning_effort", "goal_max_turns", "block_kind", +) + + @dataclass class Run: """In-memory view of a ``task_runs`` row. @@ -1002,22 +996,16 @@ class Run: @classmethod def from_row(cls, row: sqlite3.Row) -> "Run": return cls( + **{ + col: row[col] for col in ( + "task_id", "profile", "step_key", "status", "claim_lock", "claim_expires", + "worker_pid", "max_runtime_seconds", "last_heartbeat_at", "outcome", "summary", "error", + ) + }, id=int(row["id"]), - task_id=row["task_id"], - profile=row["profile"], - step_key=row["step_key"], - status=row["status"], - claim_lock=row["claim_lock"], - claim_expires=row["claim_expires"], - worker_pid=row["worker_pid"], - max_runtime_seconds=row["max_runtime_seconds"], - last_heartbeat_at=row["last_heartbeat_at"], started_at=int(row["started_at"]), ended_at=_opt_int(row["ended_at"]), - outcome=row["outcome"], - summary=row["summary"], metadata=_json_or(row["metadata"]), - error=row["error"], ) @@ -1871,22 +1859,29 @@ def set_model_override( raise ValueError("provider_override requires a model_override") if not model: provider = None + return _set_task_override( + conn, task_id, + "UPDATE tasks SET model_override = ?, provider_override = ? WHERE id = ?", (model, provider), + "model_override_set", {"model": model, "provider": provider}, + ("model_override", "provider_override"), archived_msg="cannot set model override", + ) + + +def _set_task_override( + conn: sqlite3.Connection, task_id: str, sql: str, params: tuple, event_kind: str, payload: dict, + changed_fields: tuple[str, ...], *, archived_msg: str, +) -> bool: + """Per-task override write: refuse archived tasks, record ``event_kind``, + then fire the task-updated observer AFTER commit (RFC #58548).""" with write_txn(conn): status = _task_status(conn, task_id) if status is None: return False if status == "archived": - raise RuntimeError(f"cannot set model override on archived task {task_id}") - conn.execute( - "UPDATE tasks SET model_override = ?, provider_override = ? WHERE id = ?", - (model, provider, task_id), - ) - _append_event( - conn, task_id, "model_override_set", - {"model": model, "provider": provider}, - ) - # Task-mutation observer (RFC #58548), fired AFTER the txn commits. - notify_task_updated(conn, task_id, ("model_override", "provider_override")) + raise RuntimeError(f"{archived_msg} on archived task {task_id}") + conn.execute(sql, (*params, task_id)) + _append_event(conn, task_id, event_kind, payload) + notify_task_updated(conn, task_id, changed_fields) return True @@ -1904,17 +1899,12 @@ def set_reasoning_effort(conn: sqlite3.Connection, task_id: str, effort: Optiona running task. Returns True on success. """ effort = normalize_reasoning_effort(effort) - with write_txn(conn): - status = _task_status(conn, task_id) - if status is None: - return False - if status == "archived": - raise RuntimeError(f"cannot set reasoning effort on archived task {task_id}") - conn.execute("UPDATE tasks SET reasoning_effort = ? WHERE id = ?", (effort, task_id)) - _append_event(conn, task_id, "reasoning_effort_set", {"reasoning_effort": effort}) - # Task-mutation observer (RFC #58548), fired AFTER the txn commits. - notify_task_updated(conn, task_id, ("reasoning_effort",)) - return True + return _set_task_override( + conn, task_id, + "UPDATE tasks SET reasoning_effort = ? WHERE id = ?", (effort,), + "reasoning_effort_set", {"reasoning_effort": effort}, + ("reasoning_effort",), archived_msg="cannot set reasoning effort", + ) # --------------------------------------------------------------------------- @@ -2691,13 +2681,7 @@ def claim_task( if run_id is None: return None claimed = get_task(conn, task_id) - _fire_kanban_lifecycle_hook( - "kanban_task_claimed", - task_id, - board=get_current_board(), - assignee=claimed.assignee if claimed else None, - run_id=run_id, - ) + _fire_task_hook("kanban_task_claimed", claimed, task_id, run_id) return claimed @@ -3278,14 +3262,7 @@ def complete_task( _cleanup_workspace(conn, task_id) _done_task = get_task(conn, task_id) if fire_lifecycle_hook: - _fire_kanban_lifecycle_hook( - "kanban_task_completed", - task_id, - board=get_current_board(), - assignee=_done_task.assignee if _done_task else None, - run_id=run_id, - summary=handoff_summary, - ) + _fire_task_hook("kanban_task_completed", _done_task, task_id, run_id, summary=handoff_summary) return True @@ -3654,40 +3631,11 @@ def block_task( if cur_row["status"] == "running" else "ready" ) - prev_kind = _row_get(cur_row, "block_kind") - prev_recurrences = int(_row_get(cur_row, "block_recurrences") or 0) - - # Dependency blocks never enter the human ``blocked`` bucket — they - # wait in ``todo`` and let ``recompute_ready`` gate on parents. Routing - # here (rather than ``blocked``) is what keeps a cron from ever seeing - # a dependency-wait as something to "unblock". - if kind == "dependency": - new_status, event_kind = "todo", "dependency_wait" - set_sql, params = "block_kind = ?", (kind,) - payload = {"reason": reason, "kind": kind, "source_status": source_status} - else: - # Truly-blocked kinds. Increment the unblock-loop counter when this - # is a re-block for the SAME reason after a prior unblock: block_task - # only fires from running/ready (i.e. AFTER an unblock returned the - # task to the work pool), so a stored block_kind matching the - # incoming kind means blocked → unblocked → re-block, same cause. - # An un-typed (None) block compares as "same" to a prior un-typed one. - recurrences = prev_recurrences + 1 if prev_kind == kind else 1 - set_sql = "block_kind = ?,\n block_recurrences = ?" - params = (kind, recurrences) - payload = { - "reason": reason, - "kind": kind, - "recurrences": recurrences, - "source_status": source_status, - } - if recurrences >= BLOCK_RECURRENCE_LIMIT: - # Loop detected — route to triage for a human-in-the-loop - # decision instead of letting the unblocker spin this task. - new_status, event_kind = "triage", "block_loop_detected" - payload["limit"] = BLOCK_RECURRENCE_LIMIT - else: - new_status, event_kind = "blocked", "blocked" + new_status, event_kind, set_sql, params, payload = _route_block( + kind, reason, source_status, + prev_kind=_row_get(cur_row, "block_kind"), + prev_recurrences=int(_row_get(cur_row, "block_recurrences") or 0), + ) sql = f""" UPDATE tasks SET status = '{new_status}', @@ -3711,26 +3659,42 @@ def block_task( if run_id is None and reason: run_id = _synthesize_ended_run(conn, task_id, outcome="blocked", summary=reason) _append_event(conn, task_id, event_kind, payload, run_id=run_id) - _blocked_task = get_task(conn, task_id) - - def _fire_blocked_hook() -> None: - _fire_kanban_lifecycle_hook( - "kanban_task_blocked", - task_id, - board=get_current_board(), - assignee=_blocked_task.assignee if _blocked_task else None, - run_id=run_id, - reason=reason, - ) - + blocked_task = get_task(conn, task_id) if kind == "dependency": # Historical ordering: the dependency lane fires inside the txn. - _fire_blocked_hook() + _fire_task_hook("kanban_task_blocked", blocked_task, task_id, run_id, reason=reason) return True - _fire_blocked_hook() + _fire_task_hook("kanban_task_blocked", blocked_task, task_id, run_id, reason=reason) return True +def _route_block( + kind: Optional[str], reason: Optional[str], source_status: str, *, + prev_kind: Optional[str], prev_recurrences: int, +) -> tuple[str, str, str, tuple, dict]: + """``(new_status, event_kind, set_sql, params, payload)`` for :func:`block_task`. + + ``dependency`` never enters the human ``blocked`` bucket: it waits in + ``todo`` for ``recompute_ready``, so a cron never sees a dependency-wait + as something to "unblock". Every other kind counts unblock-loop + recurrences: block_task only fires from running/ready (AFTER an unblock + returned the task to the pool), so a stored ``block_kind`` equal to the + incoming one means blocked -> unblocked -> re-block for the same cause + (un-typed None compares equal to a prior un-typed block). At + ``BLOCK_RECURRENCE_LIMIT`` the task routes to ``triage`` for a human. + """ + payload = {"reason": reason, "kind": kind, "source_status": source_status} + if kind == "dependency": + return "todo", "dependency_wait", "block_kind = ?", (kind,), payload + recurrences = prev_recurrences + 1 if prev_kind == kind else 1 + set_sql = "block_kind = ?,\n block_recurrences = ?" + payload = {"reason": reason, "kind": kind, "recurrences": recurrences, "source_status": source_status} + if recurrences >= BLOCK_RECURRENCE_LIMIT: + payload["limit"] = BLOCK_RECURRENCE_LIMIT + return "triage", "block_loop_detected", set_sql, (kind, recurrences), payload + return "blocked", "blocked", set_sql, (kind, recurrences), payload + + def redact_review_value(value: Any) -> Any: """Redact secrets at the domain boundary for durable review handoffs.""" if isinstance(value, str): From 6eb324ae344a56888d24631cac72b6c43f75ebb3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:33:49 -0700 Subject: [PATCH 22/38] =?UTF-8?q?refactor(cli-misc):=20linux=5Fdesktop=5Fe?= =?UTF-8?q?ntry.py=20=E2=80=94=20collapse=20reroute=20decision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/linux_desktop_entry.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/hermes_cli/linux_desktop_entry.py b/hermes_cli/linux_desktop_entry.py index 521abcb9a9..5bdd833111 100644 --- a/hermes_cli/linux_desktop_entry.py +++ b/hermes_cli/linux_desktop_entry.py @@ -185,10 +185,8 @@ def _resolve_hermes_bin_for_desktop_entry( if not primary: return primary - if rerouted: - return rerouted if rerouted is not None: - return primary + return rerouted or primary # argv[0] was checkout-internal AND PATH had no `hermes` — common in stripped systemd user # sessions and autostart relaunches. Probe the installer's known wrapper locations; each From caa14848c567d12d8ee2df5262ecd932fd256708 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:34:04 -0700 Subject: [PATCH 23/38] refactor(kanban-cli): diagnostics constructor kwargs grouped; output tables compacted --- hermes_cli/kanban_diagnostics.py | 95 +++++++++++--------------------- hermes_cli/kanban_output.py | 19 ++----- 2 files changed, 37 insertions(+), 77 deletions(-) diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index f5aab6d8b9..e8bc6976d9 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -248,14 +248,10 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: hits = _active_hallucination_events(events, "completion_blocked_hallucination") if not hits: return [] - actions = [DiagnosticAction( - kind="comment", - label="Add a comment explaining what to do", - suggested=False, - )] + _generic_recovery_actions(task, running=_is_running(task)) + actions = [DiagnosticAction(kind="comment", label="Add a comment explaining what to do", suggested=False)] + actions += _generic_recovery_actions(task, running=_is_running(task)) return [Diagnostic( - kind="hallucinated_cards", - severity="error", + kind="hallucinated_cards", severity="error", title="Worker claimed cards that don't exist", detail=( "The completing worker declared created_cards that either didn't " @@ -265,9 +261,7 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: "return values from kanban_create." ), actions=actions, - first_seen_at=_event_ts(hits[0]), - last_seen_at=_event_ts(hits[-1]), - count=len(hits), + first_seen_at=_event_ts(hits[0]), last_seen_at=_event_ts(hits[-1]), count=len(hits), data={"phantom_ids": _unique_payload_ids(hits, "phantom_cards")}, )] @@ -326,8 +320,7 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti actions.append(_cli_hint(f"Specify manually: {cmd}", cmd)) return [Diagnostic( - kind="triage_aux_unavailable", - severity="warning", + kind="triage_aux_unavailable", severity="warning", title=f"Triage {primary_desc} has no usable model", detail=( f"This task is still in triage and no working auxiliary model is " @@ -337,9 +330,7 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti f"main model so the auto fallback can take over." ), actions=actions, - first_seen_at=now, - last_seen_at=now, - count=1, + first_seen_at=now, last_seen_at=now, count=1, data={ "task_id": task_id, "auto_decompose": auto_decompose, @@ -356,8 +347,7 @@ def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]: if not hits: return [] return [Diagnostic( - kind="prose_phantom_refs", - severity="warning", + kind="prose_phantom_refs", severity="warning", title="Completion summary references unknown task ids", detail=( "The completion summary mentions task ids that don't resolve " @@ -366,9 +356,7 @@ def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]: "at cards that never existed." ), actions=_generic_recovery_actions(task, running=_is_running(task)), - first_seen_at=_event_ts(hits[0]), - last_seen_at=_event_ts(hits[-1]), - count=len(hits), + first_seen_at=_event_ts(hits[0]), last_seen_at=_event_ts(hits[-1]), count=len(hits), data={"phantom_refs": _unique_payload_ids(hits, "phantom_refs")}, )] @@ -410,10 +398,9 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: actions: list[DiagnosticAction] = [] if most_recent_outcome == "spawn_failed" and assignee and assignee != "default": # Spawn is failing specifically — profile setup issue. - actions.append(_cli_hint( - f"Verify profile: hermes -p {assignee} doctor", f"hermes -p {assignee} doctor", suggested=True, - )) - actions.append(_cli_hint(f"Fix profile auth: hermes -p {assignee} auth", f"hermes -p {assignee} auth")) + doctor, auth = f"hermes -p {assignee} doctor", f"hermes -p {assignee} auth" + actions.append(_cli_hint(f"Verify profile: {doctor}", doctor, suggested=True)) + actions.append(_cli_hint(f"Fix profile auth: {auth}", auth)) elif most_recent_outcome in {"timed_out", "crashed"}: # Worker got off the ground but died: logs diagnose, reclaim/reassign recover. task_id = _task_field(task, "id") @@ -442,14 +429,9 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: f"captured. Check the suggested command or the worker log." ) return [Diagnostic( - kind="repeated_failures", - severity=severity, - title=title, - detail=detail, - actions=actions, - first_seen_at=now, - last_seen_at=now, - count=failures, + kind="repeated_failures", severity=severity, + title=title, detail=detail, actions=actions, + first_seen_at=now, last_seen_at=now, count=failures, data={ "consecutive_failures": failures, "most_recent_outcome": most_recent_outcome, @@ -511,14 +493,9 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: f"no error text was captured. Check the worker log for more." ) return [Diagnostic( - kind="repeated_crashes", - severity=severity, - title=title, - detail=detail, - actions=actions, - first_seen_at=now, - last_seen_at=now, - count=consecutive, + kind="repeated_crashes", severity=severity, + title=title, detail=detail, actions=actions, + first_seen_at=now, last_seen_at=now, count=consecutive, data={"consecutive_crashes": consecutive, "last_error": last_err}, )] @@ -560,8 +537,7 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn blocked_at = _event_ts(latest_block) or now return [Diagnostic( - kind="review_dependency_deadlock", - severity="error", + kind="review_dependency_deadlock", severity="error", title=f"Review handoff blocks {len(child_ids)} dependent task(s)", detail=( "This implementation is sticky-blocked for review while its " @@ -571,9 +547,7 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn "first-class review lifecycle." ), actions=actions, - first_seen_at=blocked_at, - last_seen_at=blocked_at, - count=len(child_ids), + first_seen_at=blocked_at, last_seen_at=blocked_at, count=len(child_ids), data={ "blocked_parent_id": task_id, "waiting_child_ids": child_ids, @@ -595,11 +569,11 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: if age_hours < hours: return [] # Any comment / unblock after the block breaks the "stale" signal. - if any(_event_kind(ev) in {"commented", "unblocked"} and _event_ts(ev) > last_blocked_ts for ev in events): + if any(_event_kind(ev) in {"commented", "unblocked"} and _event_ts(ev) > last_blocked_ts + for ev in events): return [] return [Diagnostic( - kind="stuck_in_blocked", - severity="warning", + kind="stuck_in_blocked", severity="warning", title=f"Task has been blocked for {int(age_hours)}h", detail=( f"This task transitioned to blocked {int(age_hours)}h ago and " @@ -608,9 +582,7 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: f"either unblock with feedback or answer with a comment." ), actions=[DiagnosticAction(kind="comment", label="Add a comment / unblock the task", suggested=True)], - first_seen_at=last_blocked_ts, - last_seen_at=last_blocked_ts, - count=1, + first_seen_at=last_blocked_ts, last_seen_at=last_blocked_ts, count=1, data={"blocked_at": last_blocked_ts, "age_hours": round(age_hours, 1)}, )] @@ -655,8 +627,7 @@ def _rule_block_unblock_cycling(task, events, runs, now, cfg) -> list[Diagnostic cmd = f"hermes kanban events {task_id}" actions.append(_cli_hint(f"Check block reasons: {cmd}", cmd, suggested=True)) return [Diagnostic( - kind="block_unblock_cycling", - severity="warning", + kind="block_unblock_cycling", severity="warning", title=f"Task block→unblock cycled {cycles}x in {int(window_seconds/3600)}h", detail=( f"This task has been blocked {cycles} times after being " @@ -712,16 +683,12 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: severity = "warning" actions = [ - DiagnosticAction( - kind="reassign", - label="Reassign to a different worker", - payload={"current_assignee": assignee}, - ), + DiagnosticAction(kind="reassign", label="Reassign to a different worker", + payload={"current_assignee": assignee}), _cli_hint("Check dispatcher status", "hermes kanban diagnostics"), ] return [Diagnostic( - kind="stranded_in_ready", - severity=severity, + kind="stranded_in_ready", severity=severity, title=f"Ready for {age_str} with no worker", detail=( f"This task has been ready for {age_str} but nothing has " @@ -731,9 +698,7 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: f"is correct and that a worker is actually polling for it." ), actions=actions, - first_seen_at=last_ready_ts, - last_seen_at=last_ready_ts, - count=1, + first_seen_at=last_ready_ts, last_seen_at=last_ready_ts, count=1, data={ "ready_since": last_ready_ts, "age_seconds": int(age_seconds), @@ -823,7 +788,9 @@ def compute_task_diagnostics( if graph is not None: cfg["_graph"] = graph if not _has_explicit_threshold(config) and "failure_limit" in config: - cfg["failure_threshold"] = _positive_int(config.get("failure_limit"), DEFAULT_CONFIG["failure_threshold"]) + cfg["failure_threshold"] = _positive_int( + config.get("failure_limit"), DEFAULT_CONFIG["failure_threshold"], + ) out: list[Diagnostic] = [] for rule in _RULES: try: diff --git a/hermes_cli/kanban_output.py b/hermes_cli/kanban_output.py index f84a4805c3..a8ee535ede 100644 --- a/hermes_cli/kanban_output.py +++ b/hermes_cli/kanban_output.py @@ -11,13 +11,8 @@ from typing import Any, Callable, Iterable, Optional from hermes_cli import kanban_db as kb _STATUS_ICONS = { - "todo": "◻", - "ready": "▶", - "running": "●", - "scheduled":"⏱", - "blocked": "⊘", - "done": "✓", - "archived": "—", + "todo": "◻", "ready": "▶", "running": "●", "scheduled": "⏱", + "blocked": "⊘", "done": "✓", "archived": "—", } _TASK_DICT_FIELDS = ( @@ -35,9 +30,7 @@ _RUNS_RUN_FIELDS = ( "id", "profile", "status", "outcome", "started_at", "ended_at", "summary", "error", "metadata", "worker_pid", "step_key", ) -_ATTACHMENT_FIELDS = ( - "id", "filename", "content_type", "size", "uploaded_by", "stored_path", "created_at", -) +_ATTACHMENT_FIELDS = ("id", "filename", "content_type", "size", "uploaded_by", "stored_path", "created_at") def _fmt_ts(ts: Optional[int]) -> str: @@ -70,11 +63,11 @@ def _bulk_apply(ids: Iterable[str], op: Callable[[str], Any], """Run ``op(tid) -> bool`` per id, print ok/fail lines, exit 1 if any failed.""" failed = False for tid in ids: - if not op(tid): + if op(tid): + print(ok_msg(tid)) + else: failed = True print(fail_msg(tid), file=sys.stderr) - else: - print(ok_msg(tid)) return 1 if failed else 0 From 535003c68a29b7377bf57c84739619f4a770f948 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:37:51 -0700 Subject: [PATCH 24/38] refactor(kanban-cli): join implicit string fragments (AST-identical) --- hermes_cli/kanban_diagnostics.py | 3 +-- hermes_cli/kanban_ops.py | 6 ++---- hermes_cli/kanban_parser.py | 12 ++++-------- hermes_cli/kanban_swarm.py | 6 ++---- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index e8bc6976d9..ac887488ce 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -415,8 +415,7 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: title = f"Agent {outcome_label} x{failures}: {err_snippet.splitlines()[0][:160]}" detail = ( f"This task has failed {failures} times in a row " - f"(most recent: {outcome_label}). Full last error:\n\n" - f"{err_snippet}\n\n" + f"(most recent: {outcome_label}). Full last error:\n\n{err_snippet}\n\n" f"The dispatcher circuit breaker is configured for " f"{failure_limit} consecutive non-success attempts. Fix the " f"root cause and reclaim or unblock the task to retry." diff --git a/hermes_cli/kanban_ops.py b/hermes_cli/kanban_ops.py index faf5d78567..26190702ca 100644 --- a/hermes_cli/kanban_ops.py +++ b/hermes_cli/kanban_ops.py @@ -181,8 +181,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int: f"Kanban dispatcher running STANDALONE via --force " f"(interval={args.interval}s, pid={os.getpid()}). " f"Ctrl-C to stop. NOTE: if a gateway is also running with " - f"dispatch_in_gateway=true (default), you have two dispatchers " - f"racing for claims.", + f"dispatch_in_gateway=true (default), you have two dispatchers racing for claims.", file=sys.stderr, ) @@ -214,8 +213,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int: f"consecutive ticks but 0 workers spawned successfully. " f"Check profile health (venv, PATH, credentials) and " f"`hermes kanban list --status ready` / " - f"`hermes kanban list --status blocked` for recent " - f"spawn_failed tasks.", + f"`hermes kanban list --status blocked` for recent spawn_failed tasks.", file=sys.stderr, flush=True, ) health_state["last_warn_at"] = now diff --git a/hermes_cli/kanban_parser.py b/hermes_cli/kanban_parser.py index ae8c7166e1..56e506e8ae 100644 --- a/hermes_cli/kanban_parser.py +++ b/hermes_cli/kanban_parser.py @@ -179,14 +179,12 @@ _SPECS = [ "circuit breaker. Trip on the Nth failure — " "e.g. --max-retries 1 blocks on the first " "failure (no retries), --max-retries 3 allows " - "two retries. Omit to use the dispatcher's " - "kanban.failure_limit config " + "two retries. Omit to use the dispatcher's kanban.failure_limit config " f"(default {kb.DEFAULT_FAILURE_LIMIT})."), _arg("--model", default=None, dest="model_override", help="Pin the worker to this model (passed as -m ) without " "changing the profile's configured model. Combine with --provider " - "when the model belongs to a different backend than the profile's " - "default."), + "when the model belongs to a different backend than the profile's default."), _arg("--provider", default=None, dest="provider_override", help="Provider the --model belongs to (passed as --provider to " "the worker). Requires --model."), @@ -318,8 +316,7 @@ _SPECS = [ _arg("--metadata", default=None, help="JSON object with structured reviewer handoff facts."), _arg("--force", action="store_true", help="Override the live-claim guard: move a running, claimed " - "task to review even without owning its run (clears the " - "worker's claim)."), + "task to review even without owning its run (clears the worker's claim)."), ], help="Move a task to 'review' (implementation done, awaiting review) — NOT a block"), _cmd("request-changes", [_TASK_ID, _arg("reason", nargs="+", help="Concrete changes required before re-review")], help="Reviewer verdict: return the active review run to its implementer"), @@ -410,8 +407,7 @@ _SPECS = [ help="Decompose a triage-column task into a graph of child tasks " "routed to specialist profiles by description. Falls back " "to specify-style single-task promotion when the task " - "doesn't benefit from fan-out. Uses " - "auxiliary.kanban_decomposer."), + "doesn't benefit from fan-out. Uses auxiliary.kanban_decomposer."), _cmd("gc", [ _arg("--event-retention-days", type=int, default=30, help="Delete task_events older than N days for terminal tasks (default: 30)"), diff --git a/hermes_cli/kanban_swarm.py b/hermes_cli/kanban_swarm.py index 813ac8f38c..a4cae7b52c 100644 --- a/hermes_cli/kanban_swarm.py +++ b/hermes_cli/kanban_swarm.py @@ -60,8 +60,7 @@ def _require_text(value: str, field_name: str) -> str: def _swarm_context(root_id: str, goal: str) -> str: return ( - "\n\n## Swarm protocol\n" - f"- Swarm root / shared blackboard: `{root_id}`.\n" + f"\n\n## Swarm protocol\n- Swarm root / shared blackboard: `{root_id}`.\n" "- Read sibling/parent handoffs from Kanban context before working.\n" "- Put machine-readable facts in completion metadata.\n" "- Put cross-worker notes on the root task using structured comments.\n" @@ -213,8 +212,7 @@ def _create_swarm_uncommitted( body=( "Kanban Swarm v1 planning/root card. This card is completed " "immediately so parallel workers can start while it remains the " - "shared blackboard and audit anchor.\n\n" - f"Goal:\n{goal}" + f"shared blackboard and audit anchor.\n\nGoal:\n{goal}" ), assignee=created_by, priority=priority, From aa37c9664c1f6968006fcbcd66b06099c40ba530 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:39:47 -0700 Subject: [PATCH 25/38] =?UTF-8?q?refactor(cli-misc):=20loops.py=20?= =?UTF-8?q?=E2=80=94=20shared=20=5Fparse=5Fstate=20for=20load/list,=20drop?= =?UTF-8?q?=20section=20banners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/loops.py | 64 +++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 43 deletions(-) diff --git a/hermes_cli/loops.py b/hermes_cli/loops.py index 5c3019cdbe..0d7b98f49a 100644 --- a/hermes_cli/loops.py +++ b/hermes_cli/loops.py @@ -68,9 +68,6 @@ WAKEUP_PROMPT_WITH_UNTIL_TEMPLATE = ( ) -# --- interval parsing ------------------------------------------------------- - - def parse_interval_token(token: str) -> Optional[int]: """Total seconds for ``30s``/``5m``/``2h``/``1h30m``, else None. @@ -140,9 +137,6 @@ def format_interval(seconds: float) -> str: return "".join(parts) -# --- config ----------------------------------------------------------------- - - def _loops_config() -> Dict[str, Any]: try: from hermes_cli.config import load_config @@ -178,9 +172,6 @@ def self_paced_ceiling_seconds() -> int: return _config_int("self_paced_ceiling_seconds", max(floor, DEFAULT_SELF_PACED_CEILING_SECONDS), floor) -# --- state ------------------------------------------------------------------ - - @dataclass class LoopState: """Serializable /loop state stored per session.""" @@ -242,8 +233,6 @@ class LoopState: return "due now" if remaining <= 0 else f"next in {format_interval(remaining)}" -# --- persistence (SessionDB state_meta) ------------------------------------- - _META_PREFIX = "loop:" @@ -271,23 +260,25 @@ def _db_op(label: str, fn, default=None): return default -def load_loop(session_id: str) -> Optional[LoopState]: - """Load the loop for a session, or None if none exists.""" - if not session_id: - return None - db = _get_session_db() - if db is None: - return None - raw = _db_op("get_meta", lambda: db.get_meta(_meta_key(session_id))) - if not raw: - return None +def _parse_state(raw: str, session_id: str = "") -> Optional[LoopState]: + """``LoopState`` from stored JSON; None (warning when *session_id* given) on corrupt data.""" try: return LoopState.from_json(raw) except Exception as exc: - logger.warning("LoopManager: could not parse stored loop for %s: %s", session_id, exc) + if session_id: + logger.warning("LoopManager: could not parse stored loop for %s: %s", session_id, exc) return None +def load_loop(session_id: str) -> Optional[LoopState]: + """Load the loop for a session, or None if none exists.""" + db = _get_session_db() if session_id else None + if db is None: + return None + raw = _db_op("get_meta", lambda: db.get_meta(_meta_key(session_id))) + return _parse_state(raw, session_id) if raw else None + + def save_loop(session_id: str, state: LoopState) -> None: """Persist a loop to SessionDB. No-op if DB unavailable.""" if not session_id: @@ -320,13 +311,8 @@ def list_active_loops() -> List[Tuple[str, LoopState]]: out: List[Tuple[str, LoopState]] = [] for key, raw in _db_op("list_meta_prefix", lambda: db.list_meta_prefix(_META_PREFIX), []): session_id = key[len(_META_PREFIX):] - if not session_id or not raw: - continue - try: - state = LoopState.from_json(raw) - except Exception: - continue - if state.status == "active": + state = _parse_state(raw) if session_id and raw else None + if state is not None and state.status == "active": out.append((session_id, state)) return out @@ -355,9 +341,6 @@ def migrate_loop_to_session(old_session_id: str, new_session_id: str, *, reason: return False -# --- response evaluation ---------------------------------------------------- - - def _ticks_label(n: int) -> str: return f"{n} tick{'s' if n != 1 else ''}" @@ -382,9 +365,6 @@ def _digest_response(response: str) -> str: return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest() -# --- LoopManager ------------------------------------------------------------ - - class LoopManager: """Per-session /loop state + tick decisions. @@ -499,9 +479,10 @@ class LoopManager: def is_due(self, now: Optional[float] = None) -> bool: """Cheap check: active, not mid-wakeup, and the clock has passed.""" s = self._state - if s is None or s.status != "active" or s.awaiting_response: - return False - return (now if now is not None else time.time()) >= s.next_due_at + return ( + s is not None and s.status == "active" and not s.awaiting_response + and (now if now is not None else time.time()) >= s.next_due_at + ) def fire_tick(self) -> Optional[str]: """Claim a due tick; returns the message to inject, or None. @@ -626,9 +607,6 @@ def goal_blocks_loop_tick(session_id: str) -> bool: return False -# --- shared slash-command dispatch (CLI + gateway + TUI) -------------------- - - LOOP_HELP = ( "Usage: /loop [interval] [--times N] [--until ]\n" " /loop 5m check the deploy status — first run now, then every 5m\n" @@ -697,6 +675,8 @@ def dispatch_loop_command( return {"output": f"/loop: {exc}", "created": False} lines = [f"↻ Loop set ({state.cadence_label()}): {state.prompt}"] + if replacing: + lines.append("(replaced the previous loop for this session)") if parsed["interval_seconds"] is not None and parsed["interval_seconds"] < state.interval_seconds: lines.append( f"(interval raised to the {format_interval(state.interval_seconds)} minimum — " @@ -715,8 +695,6 @@ def dispatch_loop_command( lines.append(f"Backstop budget: {state.max_ticks} ticks (loops.max_ticks; 0 = unlimited).") first = "fires now, then on the cadence above" if state.status == "active" else state.remaining_label() lines.append(f"First wakeup {first}. Controls: /loop status · pause · resume · stop.") - if replacing: - lines.insert(1, "(replaced the previous loop for this session)") return {"output": "\n".join(lines), "created": True} From 2b8184d462fe71bd2e6b2527a55f5a7eb1a5b41b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:40:37 -0700 Subject: [PATCH 26/38] refactor(kanban_db): _end_or_synthesize_run, _prior_reviewer, _validate_model_override, _counts_by_assignee; defensive-layer collapse --- hermes_cli/kanban_db.py | 229 ++++++++++++++++++---------------------- 1 file changed, 104 insertions(+), 125 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 266cb57587..6bdfdf063f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1301,6 +1301,17 @@ def _host_prefix() -> str: # Task creation / mutation # --------------------------------------------------------------------------- +def _validate_model_override(model: Optional[str], provider: Optional[str]) -> tuple[Optional[str], Optional[str]]: + """Strip both; a provider without a model is rejected (a bare ``--provider`` + would re-resolve the profile's model against another backend — exactly + the mismatch the override exists to kill).""" + model = (model or "").strip() or None + provider = (provider or "").strip() or None + if provider and not model: + raise ValueError("provider_override requires a model_override") + return model, provider + + def _canonical_assignee(assignee: Optional[str]) -> Optional[str]: """Lowercase-assignee normalization for Kanban rows (dashboard/CLI parity).""" if assignee is None: @@ -1503,11 +1514,8 @@ def create_task( this board supplies the repo and branch convention (its literal worktree is never reused). See ``_resolve_project_link``. """ - model_override = (model_override or "").strip() or None - provider_override = (provider_override or "").strip() or None + model_override, provider_override = _validate_model_override(model_override, provider_override) reasoning_effort = normalize_reasoning_effort(reasoning_effort) - if provider_override and not model_override: - raise ValueError("provider_override requires a model_override") assignee = _canonical_assignee(assignee) if not title or not title.strip(): raise ValueError("title is required") @@ -1523,8 +1531,7 @@ def create_task( if branch_name and workspace_kind != "worktree": raise ValueError("branch_name is only valid for worktree workspaces") - # Inherit the board's scoped project when the caller didn't name one, so a - # project-scoped board anchors every new task to that project's repo + # A project-scoped board anchors every new task to its project's repo # (deterministic worktree + branch) without each surface repeating it. if project_id is None: try: @@ -1853,12 +1860,7 @@ def set_model_override( running task that's about to be reclaimed/retried is the primary rate-limit-recovery flow. Returns True on success. """ - model = (model or "").strip() or None - provider = (provider or "").strip() or None - if provider and not model: - raise ValueError("provider_override requires a model_override") - if not model: - provider = None + model, provider = _validate_model_override(model, provider) return _set_task_override( conn, task_id, "UPDATE tasks SET model_override = ?, provider_override = ? WHERE id = ?", (model, provider), @@ -2243,12 +2245,10 @@ def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[ return None conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,)) _append_event(conn, att.task_id, "attachment_removed", {"filename": att.filename}) - try: + with contextlib.suppress(OSError): p = Path(att.stored_path) if p.is_file(): p.unlink() - except OSError: - pass return att @@ -2364,6 +2364,18 @@ def _current_run_id(conn: sqlite3.Connection, task_id: str) -> Optional[int]: return int(row["current_run_id"]) if row and row["current_run_id"] else None +def _end_or_synthesize_run( + conn: sqlite3.Connection, task_id: str, *, outcome: str, status: str, + summary: Optional[str] = None, metadata: Optional[dict] = None, synthesize: bool, +) -> Optional[int]: + """:func:`_end_run`; when no run was active and ``synthesize`` holds, record a + zero-duration run instead so the handoff fields survive in attempt history.""" + run_id = _end_run(conn, task_id, outcome=outcome, status=status, summary=summary, metadata=metadata) + if run_id is None and synthesize: + run_id = _synthesize_ended_run(conn, task_id, outcome=outcome, summary=summary, metadata=metadata) + return run_id + + def _synthesize_ended_run( conn: sqlite3.Connection, task_id: str, @@ -3445,19 +3457,17 @@ def _persist_scratch_completion_artifacts( persisted.append(artifact) continue + problem = None if not src.is_file(): - _discard_copies() - raise ArtifactPreservationError( - f"declared scratch artifact is unavailable or not a regular file: {artifact}" - ) - - size = resolved_src.stat().st_size - if size > KANBAN_ATTACHMENT_MAX_BYTES: - _discard_copies() - raise ArtifactPreservationError( + problem = f"declared scratch artifact is unavailable or not a regular file: {artifact}" + elif resolved_src.stat().st_size > KANBAN_ATTACHMENT_MAX_BYTES: + problem = ( f"declared scratch artifact exceeds the " f"{KANBAN_ATTACHMENT_MAX_BYTES}-byte limit: {artifact}" ) + if problem: + _discard_copies() + raise ArtifactPreservationError(problem) dest: Optional[Path] = None try: @@ -3653,11 +3663,9 @@ def block_task( cur = conn.execute(sql, params) if cur.rowcount != 1: return False - run_id = _end_run(conn, task_id, outcome="blocked", status="blocked", summary=reason) - # Synthesize a run when blocking a never-claimed task so the reason - # is preserved in attempt history. - if run_id is None and reason: - run_id = _synthesize_ended_run(conn, task_id, outcome="blocked", summary=reason) + run_id = _end_or_synthesize_run( + conn, task_id, outcome="blocked", status="blocked", summary=reason, synthesize=bool(reason), + ) _append_event(conn, task_id, event_kind, payload, run_id=run_id) blocked_task = get_task(conn, task_id) if kind == "dependency": @@ -3771,22 +3779,14 @@ def request_review( ) implementer = trow["assignee"] if reviewer is None: - changes_run = conn.execute( - "SELECT id FROM task_runs " - "WHERE task_id = ? AND outcome = 'changes_requested' " - "ORDER BY id DESC LIMIT 1", - (task_id,), - ).fetchone() - if changes_run is not None: - changes_event = _latest_event(conn, task_id, "changes_requested", changes_run["id"]) - reviewer = _json_dict(_row_get(changes_event, "payload")).get("reviewer") - if not isinstance(reviewer, str) or not reviewer.strip(): - return _ret( - False, - "re-review has no durable reviewer provenance (the " - "latest changes_requested event is missing or " - "malformed); pass reviewer= explicitly", - ) + reviewer = _prior_reviewer(conn, task_id) + if reviewer is False: + return _ret( + False, + "re-review has no durable reviewer provenance (the " + "latest changes_requested event is missing or " + "malformed); pass reviewer= explicitly", + ) reviewer = _canonical_assignee(reviewer) assignee_sql = ", assignee = ?" if reviewer is not None else "" run_guard = "" if expected_run_id is None else " AND current_run_id = ?" @@ -3814,22 +3814,10 @@ def request_review( "task is not in running/ready (or expected_run_id did not " "match the current run)", ) - run_id = _end_run( - conn, - task_id, - outcome="review_requested", - status="review", - summary=summary, - metadata=metadata, + run_id = _end_or_synthesize_run( + conn, task_id, outcome="review_requested", status="review", + summary=summary, metadata=metadata, synthesize=bool(summary or metadata), ) - if run_id is None and (summary or metadata): - run_id = _synthesize_ended_run( - conn, - task_id, - outcome="review_requested", - summary=summary, - metadata=metadata, - ) _append_event( conn, task_id, @@ -3844,6 +3832,27 @@ def request_review( return _ret(True) +def _prior_reviewer(conn: sqlite3.Connection, task_id: str): + """Reviewer recorded by the latest ``changes_requested`` run's event. + ``None`` = first review (no such run); ``False`` = a run exists but its + provenance is missing/malformed.""" + changes_run = conn.execute( + "SELECT id FROM task_runs " + "WHERE task_id = ? AND outcome = 'changes_requested' " + "ORDER BY id DESC LIMIT 1", + (task_id,), + ).fetchone() + if changes_run is None: + return None + changes_event = _latest_event(conn, task_id, "changes_requested", changes_run["id"]) + reviewer = _json_dict(_row_get(changes_event, "payload")).get("reviewer") + return reviewer if isinstance(reviewer, str) and reviewer.strip() else False + + +def _nonblank_str(value: Any) -> Optional[str]: + return value if isinstance(value, str) and value.strip() else None + + def request_changes( conn: sqlite3.Connection, task_id: str, @@ -3884,11 +3893,10 @@ def request_changes( requested_event = _latest_event(conn, task_id, "review_requested") if requested_event is None: return False, "no prior review_requested event" - implementer = _json_dict(requested_event["payload"]).get("implementer") - if not isinstance(implementer, str) or not implementer.strip(): + implementer = _nonblank_str(_json_dict(requested_event["payload"]).get("implementer")) + if implementer is None: return False, "review handoff has no valid implementer provenance" - reviewer = task_row["assignee"] - reviewer = _canonical_assignee(reviewer) if isinstance(reviewer, str) and reviewer.strip() else None + reviewer = _canonical_assignee(_nonblank_str(task_row["assignee"])) new_status = _landing_status_after_parents(conn, task_id) # NOTE: consecutive_failures is deliberately PRESERVED (neither @@ -3967,10 +3975,7 @@ def promote_task( "WHERE l.child_id = ?", (task_id,), ).fetchall() - unsatisfied = [ - p["id"] for p in parents - if p["status"] not in ("done", "archived") - ] + unsatisfied = [p["id"] for p in parents if p["status"] not in ("done", "archived")] if unsatisfied: return False, ( f"unsatisfied parent dependencies: " @@ -4120,9 +4125,7 @@ def reopen_review_task(conn: sqlite3.Connection, task_id: str) -> bool: new_status = _landing_status_after_parents(conn, task_id) review_event = _latest_event(conn, task_id, "review_requested") handoff = _json_dict(_row_get(review_event, "payload")) - implementer = handoff.get("implementer") - if not isinstance(implementer, str) or not implementer.strip(): - implementer = None + implementer = _nonblank_str(handoff.get("implementer")) params: tuple[Any, ...] = (new_status, *((implementer,) if implementer else ()), task_id) cur = conn.execute( # consecutive_failures deliberately PRESERVED: review reopen is not @@ -4620,9 +4623,9 @@ def schedule_task( cur = conn.execute(sql, params) if cur.rowcount != 1: return False - run_id = _end_run(conn, task_id, outcome="scheduled", status="scheduled", summary=reason) - if run_id is None and reason: - run_id = _synthesize_ended_run(conn, task_id, outcome="scheduled", summary=reason) + run_id = _end_or_synthesize_run( + conn, task_id, outcome="scheduled", status="scheduled", summary=reason, synthesize=bool(reason), + ) _append_event(conn, task_id, "scheduled", {"reason": reason}, run_id=run_id) return True @@ -4867,13 +4870,7 @@ def board_stats(conn: sqlite3.Connection) -> dict: ): by_status[row["status"]] = int(row["n"]) - by_assignee: dict[str, dict[str, int]] = {} - for row in conn.execute( - "SELECT assignee, status, COUNT(*) AS n FROM tasks " - "WHERE status != 'archived' AND assignee IS NOT NULL " - "GROUP BY assignee, status" - ): - by_assignee.setdefault(row["assignee"], {})[row["status"]] = int(row["n"]) + by_assignee = _counts_by_assignee(conn) oldest_row = conn.execute( "SELECT MIN(created_at) AS ts FROM tasks WHERE status = 'ready'" @@ -4892,6 +4889,18 @@ def board_stats(conn: sqlite3.Connection) -> dict: } +def _counts_by_assignee(conn: sqlite3.Connection) -> dict[str, dict[str, int]]: + """``{assignee: {status: n}}`` over non-archived tasks.""" + counts: dict[str, dict[str, int]] = {} + for row in conn.execute( + "SELECT assignee, status, COUNT(*) AS n FROM tasks " + "WHERE status != 'archived' AND assignee IS NOT NULL " + "GROUP BY assignee, status" + ): + counts.setdefault(row["assignee"], {})[row["status"]] = int(row["n"]) + return counts + + def _to_epoch(val) -> Optional[int]: """Normalise a timestamp to unix epoch seconds. @@ -4900,9 +4909,7 @@ def _to_epoch(val) -> Optional[int]: """ if val is None: return None - if isinstance(val, int): - return val - if isinstance(val, float): + if isinstance(val, (int, float)): return int(val) s = str(val).strip() if not s: @@ -4926,13 +4933,10 @@ def task_age(task: Task) -> dict: _c = _to_epoch(task.created_at) _s = _to_epoch(task.started_at) _co = _to_epoch(task.completed_at) - age_since_created = now - _c if _c is not None else None - age_since_started = now - _s if _s is not None else None - time_to_complete = (_co - (_s or _c) if _co is not None else None) return { - "created_age_seconds": age_since_created, - "started_age_seconds": age_since_started, - "time_to_complete_seconds": time_to_complete, + "created_age_seconds": now - _c if _c is not None else None, + "started_age_seconds": now - _s if _s is not None else None, + "time_to_complete_seconds": _co - (_s or _c) if _co is not None else None, } @@ -4967,12 +4971,10 @@ def gc_worker_logs(*, older_than_seconds: int = 30 * 24 * 3600, board: Optional[ cutoff = time.time() - older_than_seconds removed = 0 for p in log_dir.iterdir(): - try: + with contextlib.suppress(OSError): if p.is_file() and p.stat().st_mtime < cutoff: p.unlink() removed += 1 - except OSError: - continue return removed @@ -5008,16 +5010,13 @@ def read_worker_log( with open(path, "rb") as f: if size > tail_bytes: f.seek(size - tail_bytes) - # Skip a partial line if we tailed mid-line. But if the - # window has no newline at all (one giant log line), - # readline() would eat everything — in that case don't - # skip and return the raw tail. + # Skip the partial first line — unless the window has no + # newline at all (one giant line), where readline() would eat + # everything; then return the raw tail. probe = f.tell() - partial = f.readline() - if not partial.endswith(b"\n") and f.tell() >= size: + if not f.readline().endswith(b"\n") and f.tell() >= size: f.seek(probe) - data = f.read() - return data.decode("utf-8", errors="replace") + return f.read().decode("utf-8", errors="replace") except OSError: return None @@ -5041,17 +5040,11 @@ def list_profiles_on_disk() -> list[str]: names: set[str] = set() if default_root.exists(): names.add("default") - if profiles_dir.is_dir(): try: - for entry in sorted(profiles_dir.iterdir()): - if not entry.is_dir(): - continue - if (entry / "config.yaml").is_file(): - names.add(entry.name) + names.update(e.name for e in profiles_dir.iterdir() if e.is_dir() and (e / "config.yaml").is_file()) except OSError: pass - return sorted(names) @@ -5061,24 +5054,10 @@ def known_assignees(conn: sqlite3.Connection) -> list[dict]: profile appears in pickers before it has any task. """ on_disk = set(list_profiles_on_disk()) - - # Count tasks per (assignee, status), excluding archived. - counts: dict[str, dict[str, int]] = {} - for row in conn.execute( - "SELECT assignee, status, COUNT(*) AS n FROM tasks " - "WHERE status != 'archived' AND assignee IS NOT NULL " - "GROUP BY assignee, status" - ): - counts.setdefault(row["assignee"], {})[row["status"]] = int(row["n"]) - - names = sorted(on_disk | set(counts.keys())) + counts = _counts_by_assignee(conn) return [ - { - "name": name, - "on_disk": name in on_disk, - "counts": counts.get(name, {}), - } - for name in names + {"name": name, "on_disk": name in on_disk, "counts": counts.get(name, {})} + for name in sorted(on_disk | set(counts)) ] From 9ed22aac2ed97561a9b6f0a7a8a9e551fb72608d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:41:59 -0700 Subject: [PATCH 27/38] refactor(kanban-cli): reflow wrapped string literals to 100 cols (AST-identical; SQL/daemon text untouched) --- hermes_cli/kanban_diagnostics.py | 78 ++++++++++++++------------------ hermes_cli/kanban_ops.py | 21 ++++----- hermes_cli/kanban_parser.py | 48 +++++++++----------- hermes_cli/kanban_swarm.py | 9 ++-- hermes_cli/kanban_transfer.py | 5 +- 5 files changed, 70 insertions(+), 91 deletions(-) diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index ac887488ce..d671d3fdca 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -254,11 +254,10 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: kind="hallucinated_cards", severity="error", title="Worker claimed cards that don't exist", detail=( - "The completing worker declared created_cards that either didn't " - "exist or weren't created by its profile. The completion was " - "blocked and the task stayed in its prior state. " - "Usually means the worker hallucinated ids instead of capturing " - "return values from kanban_create." + "The completing worker declared created_cards that either didn't exist or weren't " + "created by its profile. The completion was blocked and the task stayed in its prior " + "state. Usually means the worker hallucinated ids instead of capturing return values " + "from kanban_create." ), actions=actions, first_seen_at=_event_ts(hits[0]), last_seen_at=_event_ts(hits[-1]), count=len(hits), @@ -270,9 +269,8 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: _TRIAGE_SLOTS = { True: ( "auxiliary.kanban_decomposer", "auxiliary.triage_specifier", "decomposer", - "Auto-decompose is on, so the dispatcher needs " - "auxiliary.kanban_decomposer (with auxiliary.triage_specifier as " - "a fallback for non-fan-out tasks).", + "Auto-decompose is on, so the dispatcher needs auxiliary.kanban_decomposer (with " + "auxiliary.triage_specifier as a fallback for non-fan-out tasks).", ), False: ( "auxiliary.triage_specifier", "auxiliary.kanban_decomposer", "specifier", @@ -323,11 +321,10 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti kind="triage_aux_unavailable", severity="warning", title=f"Triage {primary_desc} has no usable model", detail=( - f"This task is still in triage and no working auxiliary model is " - f"visible to the dispatcher. {detail_path} The default slot uses " - f"`provider: auto` which falls back to the main model, but no main " - f"model is configured either. Configure the slot directly or set a " - f"main model so the auto fallback can take over." + f"This task is still in triage and no working auxiliary model is visible to the " + f"dispatcher. {detail_path} The default slot uses `provider: auto` which falls back to " + f"the main model, but no main model is configured either. Configure the slot directly " + f"or set a main model so the auto fallback can take over." ), actions=actions, first_seen_at=now, last_seen_at=now, count=1, @@ -350,10 +347,9 @@ def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]: kind="prose_phantom_refs", severity="warning", title="Completion summary references unknown task ids", detail=( - "The completion summary mentions task ids that don't resolve " - "in this board's database. The completion itself succeeded, " - "but downstream consumers parsing the summary may be pointed " - "at cards that never existed." + "The completion summary mentions task ids that don't resolve in this board's database. " + "The completion itself succeeded, but downstream consumers parsing the summary may be " + "pointed at cards that never existed." ), actions=_generic_recovery_actions(task, running=_is_running(task)), first_seen_at=_event_ts(hits[0]), last_seen_at=_event_ts(hits[-1]), count=len(hits), @@ -414,18 +410,16 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: if err_snippet: title = f"Agent {outcome_label} x{failures}: {err_snippet.splitlines()[0][:160]}" detail = ( - f"This task has failed {failures} times in a row " - f"(most recent: {outcome_label}). Full last error:\n\n{err_snippet}\n\n" - f"The dispatcher circuit breaker is configured for " - f"{failure_limit} consecutive non-success attempts. Fix the " - f"root cause and reclaim or unblock the task to retry." + f"This task has failed {failures} times in a row (most recent: {outcome_label}). Full " + f"last error:\n\n{err_snippet}\n\nThe dispatcher circuit breaker is configured for " + f"{failure_limit} consecutive non-success attempts. Fix the root cause and reclaim or " + f"unblock the task to retry." ) else: title = f"Agent {outcome_label} x{failures} (no error recorded)" detail = ( - f"This task has failed {failures} times in a row " - f"(most recent: {outcome_label}) but no error text was " - f"captured. Check the suggested command or the worker log." + f"This task has failed {failures} times in a row (most recent: {outcome_label}) but no " + f"error text was captured. Check the suggested command or the worker log." ) return [Diagnostic( kind="repeated_failures", severity=severity, @@ -539,11 +533,10 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn kind="review_dependency_deadlock", severity="error", title=f"Review handoff blocks {len(child_ids)} dependent task(s)", detail=( - "This implementation is sticky-blocked for review while its " - "downstream task(s) require the implementation to be done or " - "archived before they can run. Complete the finished phase, unlink " - "the incorrect dependency, or migrate this workflow to the " - "first-class review lifecycle." + "This implementation is sticky-blocked for review while its downstream task(s) require " + "the implementation to be done or archived before they can run. Complete the finished " + "phase, unlink the incorrect dependency, or migrate this workflow to the first-class " + "review lifecycle." ), actions=actions, first_seen_at=blocked_at, last_seen_at=blocked_at, count=len(child_ids), @@ -575,10 +568,9 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: kind="stuck_in_blocked", severity="warning", title=f"Task has been blocked for {int(age_hours)}h", detail=( - f"This task transitioned to blocked {int(age_hours)}h ago and " - f"has had no comments or unblock attempts since. Blocked tasks " - f"are waiting for human input — check the block reason and " - f"either unblock with feedback or answer with a comment." + f"This task transitioned to blocked {int(age_hours)}h ago and has had no comments or " + f"unblock attempts since. Blocked tasks are waiting for human input — check the block " + f"reason and either unblock with feedback or answer with a comment." ), actions=[DiagnosticAction(kind="comment", label="Add a comment / unblock the task", suggested=True)], first_seen_at=last_blocked_ts, last_seen_at=last_blocked_ts, count=1, @@ -629,11 +621,10 @@ def _rule_block_unblock_cycling(task, events, runs, now, cfg) -> list[Diagnostic kind="block_unblock_cycling", severity="warning", title=f"Task block→unblock cycled {cycles}x in {int(window_seconds/3600)}h", detail=( - f"This task has been blocked {cycles} times after being " - "unblocked, suggesting the unblock is not addressing the " - "root cause and the worker keeps hitting the same wall. " - "Review the block reasons in the event history; a different " - "intervention (reassign, change scope, archive) may be needed." + f"This task has been blocked {cycles} times after being unblocked, suggesting the " + f"unblock is not addressing the root cause and the worker keeps hitting the same wall. " + f"Review the block reasons in the event history; a different intervention (reassign, " + f"change scope, archive) may be needed." ), actions=actions, first_seen_at=int(initial_blocked_ts) if initial_blocked_ts else int(now), @@ -690,11 +681,10 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: kind="stranded_in_ready", severity=severity, title=f"Ready for {age_str} with no worker", detail=( - f"This task has been ready for {age_str} but nothing has " - f"claimed it. Common causes: assignee {assignee!r} is " - f"misspelled, the profile was deleted, or the external " - f"worker pool for this lane is down. Confirm the assignee " - f"is correct and that a worker is actually polling for it." + f"This task has been ready for {age_str} but nothing has claimed it. Common causes: " + f"assignee {assignee!r} is misspelled, the profile was deleted, or the external worker " + f"pool for this lane is down. Confirm the assignee is correct and that a worker is " + f"actually polling for it." ), actions=actions, first_seen_at=last_ready_ts, last_seen_at=last_ready_ts, count=1, diff --git a/hermes_cli/kanban_ops.py b/hermes_cli/kanban_ops.py index 26190702ca..83aa2e6ab1 100644 --- a/hermes_cli/kanban_ops.py +++ b/hermes_cli/kanban_ops.py @@ -178,9 +178,8 @@ def _cmd_daemon(args: argparse.Namespace) -> int: verbose = bool(getattr(args, "verbose", False)) print( - f"Kanban dispatcher running STANDALONE via --force " - f"(interval={args.interval}s, pid={os.getpid()}). " - f"Ctrl-C to stop. NOTE: if a gateway is also running with " + f"Kanban dispatcher running STANDALONE via --force (interval={args.interval}s, " + f"pid={os.getpid()}). Ctrl-C to stop. NOTE: if a gateway is also running with " f"dispatch_in_gateway=true (default), you have two dispatchers racing for claims.", file=sys.stderr, ) @@ -208,12 +207,11 @@ def _cmd_daemon(args: argparse.Namespace) -> int: now = int(time.time()) if now - health_state["last_warn_at"] >= 300: print( - f"[{_fmt_ts(now)}] WARN dispatcher stuck: " - f"ready queue non-empty for {health_state['bad_ticks']} " - f"consecutive ticks but 0 workers spawned successfully. " - f"Check profile health (venv, PATH, credentials) and " - f"`hermes kanban list --status ready` / " - f"`hermes kanban list --status blocked` for recent spawn_failed tasks.", + f"[{_fmt_ts(now)}] WARN dispatcher stuck: ready queue non-empty for " + f"{health_state['bad_ticks']} consecutive ticks but 0 workers spawned " + f"successfully. Check profile health (venv, PATH, credentials) and `hermes " + f"kanban list --status ready` / `hermes kanban list --status blocked` for " + f"recent spawn_failed tasks.", file=sys.stderr, flush=True, ) health_state["last_warn_at"] = now @@ -225,9 +223,8 @@ def _cmd_daemon(args: argparse.Namespace) -> int: ) if did_work: print( - f"[{_fmt_ts(int(time.time()))}] " - f"reclaimed={res.reclaimed} crashed={len(res.crashed)} " - f"timed_out={len(res.timed_out)} stale={len(res.stale)} " + f"[{_fmt_ts(int(time.time()))}] reclaimed={res.reclaimed} " + f"crashed={len(res.crashed)} timed_out={len(res.timed_out)} stale={len(res.stale)} " f"promoted={res.promoted} spawned={len(res.spawned)} " f"auto_blocked={len(res.auto_blocked)}", flush=True, diff --git a/hermes_cli/kanban_parser.py b/hermes_cli/kanban_parser.py index 56e506e8ae..1944cfc415 100644 --- a/hermes_cli/kanban_parser.py +++ b/hermes_cli/kanban_parser.py @@ -115,11 +115,10 @@ _BOARD_SPECS = [ _arg("--include-logs", action="store_true", help="Include per-task worker logs"), _json_flag(), ], help="Export a board to a portable .tar.gz archive", description=( - "Package a board's tasks, comments, links, history, and file " - "attachments into one archive that can be imported on another " - "machine. Claims, worker PIDs, chat subscriptions, and paths " - "belonging to this machine are stripped. Workspaces are never " - "included — they are rebuilt on demand." + "Package a board's tasks, comments, links, history, and file attachments into one archive " + "that can be imported on another machine. Claims, worker PIDs, chat subscriptions, and " + "paths belonging to this machine are stripped. Workspaces are never included — they are " + "rebuilt on demand." )), _cmd("import", [ _arg("archive", help="Path to the .tar.gz archive"), @@ -127,9 +126,8 @@ _BOARD_SPECS = [ _arg("--switch", action="store_true", help="Switch to the imported board afterwards"), _json_flag(), ], help="Import a board archive as a new board", description=( - "Import a .tar.gz produced by `hermes kanban boards export`. " - "The board always lands as a NEW board — the slug gains a " - "numeric suffix if it is already taken — so an import can " + "Import a .tar.gz produced by `hermes kanban boards export`. The board always lands as a " + "NEW board — the slug gains a numeric suffix if it is already taken — so an import can " "never overwrite or merge into a board you already have." )), ] @@ -140,11 +138,10 @@ _SPECS = [ _cmd("boards", children=("boards_action", _BOARD_SPECS), help="Manage kanban boards (one board per project / workstream)", description=( - "Boards let you separate unrelated streams of work " - "(projects, repos, domains) into isolated queues. Each " - "board has its own DB, workspaces directory, and dispatcher " - "loop — tasks on one board cannot collide with tasks on " - "another. The first board is 'default' and always exists." + "Boards let you separate unrelated streams of work (projects, repos, domains) into " + "isolated queues. Each board has its own DB, workspaces directory, and dispatcher " + "loop — tasks on one board cannot collide with tasks on another. The first board is " + "'default' and always exists." )), _cmd("create", [ _arg("title", help="Task title"), @@ -176,11 +173,10 @@ _SPECS = [ "translation --skill github-code-review"), _arg("--max-retries", type=int, default=None, metavar="N", help="Per-task override for the consecutive-failure " - "circuit breaker. Trip on the Nth failure — " - "e.g. --max-retries 1 blocks on the first " - "failure (no retries), --max-retries 3 allows " - "two retries. Omit to use the dispatcher's kanban.failure_limit config " - f"(default {kb.DEFAULT_FAILURE_LIMIT})."), + f"circuit breaker. Trip on the Nth failure — e.g. --max-retries 1 blocks on the " + f"first failure (no retries), --max-retries 3 allows two retries. Omit to use " + f"the dispatcher's kanban.failure_limit config (default " + f"{kb.DEFAULT_FAILURE_LIMIT})."), _arg("--model", default=None, dest="model_override", help="Pin the worker to this model (passed as -m ) without " "changing the profile's configured model. Combine with --provider " @@ -416,15 +412,13 @@ _SPECS = [ _cmd("repair", [_json_flag(help="Emit the repair report as JSON")], help="Check kanban.db integrity and auto-repair index-only corruption", description=( - "Runs PRAGMA integrity_check on the board's DB and reports the " - "result. When the failure consists only of index-scoped errors " - "('wrong # of entries in index ' / 'row N missing from " - "index '), the corrupt file is quarantined to a " - ".corrupt..bak sibling first and the damaged indexes are " - "rebuilt with REINDEX — the same narrow auto-repair the " - "connect-time guard applies. Any other corruption class is " - "reported and left untouched (fail-closed). Exits 0 when the DB " - "is healthy or was repaired, non-zero when it is still corrupt." + "Runs PRAGMA integrity_check on the board's DB and reports the result. When the " + "failure consists only of index-scoped errors ('wrong # of entries in index ' / " + "'row N missing from index '), the corrupt file is quarantined to a " + ".corrupt..bak sibling first and the damaged indexes are rebuilt with REINDEX — " + "the same narrow auto-repair the connect-time guard applies. Any other corruption " + "class is reported and left untouched (fail-closed). Exits 0 when the DB is healthy " + "or was repaired, non-zero when it is still corrupt." )), ] diff --git a/hermes_cli/kanban_swarm.py b/hermes_cli/kanban_swarm.py index a4cae7b52c..8551ae3289 100644 --- a/hermes_cli/kanban_swarm.py +++ b/hermes_cli/kanban_swarm.py @@ -60,11 +60,10 @@ def _require_text(value: str, field_name: str) -> str: def _swarm_context(root_id: str, goal: str) -> str: return ( - f"\n\n## Swarm protocol\n- Swarm root / shared blackboard: `{root_id}`.\n" - "- Read sibling/parent handoffs from Kanban context before working.\n" - "- Put machine-readable facts in completion metadata.\n" - "- Put cross-worker notes on the root task using structured comments.\n" - f"- Goal: {goal.strip()}\n" + f"\n\n## Swarm protocol\n- Swarm root / shared blackboard: `{root_id}`.\n- Read " + f"sibling/parent handoffs from Kanban context before working.\n- Put machine-readable " + f"facts in completion metadata.\n- Put cross-worker notes on the root task using " + f"structured comments.\n- Goal: {goal.strip()}\n" ) diff --git a/hermes_cli/kanban_transfer.py b/hermes_cli/kanban_transfer.py index 401e480322..37d9a303a5 100644 --- a/hermes_cli/kanban_transfer.py +++ b/hermes_cli/kanban_transfer.py @@ -272,9 +272,8 @@ def _relocate_imported_rows(conn: sqlite3.Connection, slug: str) -> tuple[dict[s if parked: conn.execute(f"UPDATE tasks SET status = 'triage' WHERE id IN ({_placeholders(parked)})", parked) warnings.append( - f"{len(parked)} task(s) moved to triage — their workspace was a " - f"directory or git worktree on the exporting machine and needs " - f"to be pointed somewhere on this one" + f"{len(parked)} task(s) moved to triage — their workspace was a directory or git " + f"worktree on the exporting machine and needs to be pointed somewhere on this one" ) for row in conn.execute("SELECT id FROM tasks").fetchall(): From 4dacc112ead35239bc2621acc05dfa4135be6eca Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:47:26 -0700 Subject: [PATCH 28/38] =?UTF-8?q?refactor(kanban-cli):=20diagnostics=20?= =?UTF-8?q?=E2=80=94=20pack=20data=20payload=20dicts,=20one-line=20section?= =?UTF-8?q?=20banners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/kanban_diagnostics.py | 33 ++++++++------------------------ 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index d671d3fdca..571aad57f9 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -64,9 +64,7 @@ class Diagnostic: return asdict(self) -# --------------------------------------------------------------------------- -# Rule helpers -# --------------------------------------------------------------------------- +# --- Rule helpers --- def _task_field(task, name, default=None): """Read a field from a sqlite3.Row, a kanban_db.Task dataclass, or a dict.""" @@ -173,9 +171,7 @@ def _runs_newest_first(runs) -> list[Any]: return list(reversed(sorted(runs, key=lambda r: _task_field(r, "id", 0)))) -# --------------------------------------------------------------------------- -# Rule implementations -# --------------------------------------------------------------------------- +# --- Rule implementations --- # Each rule: (task, events, runs, now_ts, config) -> list[Diagnostic]. # ``events``/``runs`` are kanban_db rows/dataclasses or same-shaped dicts. @@ -293,10 +289,9 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti if status is None: return [] - auto_decompose = bool(status.get("auto_decompose")) + auto_decompose, main_visible = bool(status.get("auto_decompose")), bool(status.get("main_model_visible")) decomposer_explicit = bool(status.get("decomposer_explicit")) specifier_explicit = bool(status.get("specifier_explicit")) - main_visible = bool(status.get("main_model_visible")) primary_slot, fallback_slot, primary_desc, detail_path = _TRIAGE_SLOTS[auto_decompose] primary_explicit, fallback_explicit = ( (decomposer_explicit, specifier_explicit) if auto_decompose @@ -328,12 +323,8 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti ), actions=actions, first_seen_at=now, last_seen_at=now, count=1, - data={ - "task_id": task_id, - "auto_decompose": auto_decompose, - "primary_slot": primary_slot, - "main_model_visible": main_visible, - }, + data={"task_id": task_id, "auto_decompose": auto_decompose, + "primary_slot": primary_slot, "main_model_visible": main_visible}, )] @@ -540,11 +531,7 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn ), actions=actions, first_seen_at=blocked_at, last_seen_at=blocked_at, count=len(child_ids), - data={ - "blocked_parent_id": task_id, - "waiting_child_ids": child_ids, - "block_reason": reason, - }, + data={"blocked_parent_id": task_id, "waiting_child_ids": child_ids, "block_reason": reason}, )] @@ -688,12 +675,8 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: ), actions=actions, first_seen_at=last_ready_ts, last_seen_at=last_ready_ts, count=1, - data={ - "ready_since": last_ready_ts, - "age_seconds": int(age_seconds), - "assignee": assignee, - "threshold_seconds": int(threshold_seconds), - }, + data={"ready_since": last_ready_ts, "age_seconds": int(age_seconds), + "assignee": assignee, "threshold_seconds": int(threshold_seconds)}, )] From 0303d3c3525bc0577dc194283f3c738bd026a7c8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:48:00 -0700 Subject: [PATCH 29/38] refactor(kanban_db): compact docstrings by hand (module <=8 lines; every invariant/WHY kept) --- hermes_cli/kanban_db.py | 1053 ++++++++++----------------------------- 1 file changed, 254 insertions(+), 799 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 6bdfdf063f..6c21c538ab 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1,42 +1,12 @@ -"""SQLite-backed Kanban board for multi-profile, multi-project collaboration. +"""SQLite-backed Kanban board shared across profiles (the cross-profile coordination primitive). -The board lives under the **shared Hermes root** ```` (the parent of any -active profile; ``HERMES_HOME`` itself in Docker / custom deployments). -Profiles intentionally collapse onto a shared board — it IS the cross-profile -coordination primitive: a worker spawned with ``hermes -p `` joins the -same board as the dispatcher that claimed its task. - -**Boards:** each extra board is ``/kanban/boards//`` with its own -``kanban.db``, ``workspaces/`` and ``logs/``; a worker on one board cannot see -or enumerate others and its dispatcher ticks never touch their DBs. The first -board is ``default`` and, for back-compat, its DB stays at ``/kanban.db`` -so pre-boards installs need zero migration (see :func:`kanban_db_path`). - -Board resolution order (highest precedence first, all optional): - -* ``board=`` argument to :func:`connect` / :func:`init_db` (CLI ``--board``, - dashboard ``?board=``). -* ``HERMES_KANBAN_BOARD`` env var (dispatcher pins workers to their board). -* ``HERMES_KANBAN_DB`` env var (pins the DB file path directly; legacy - override, wins when the file path itself is what the caller forces). -* ``/kanban/current`` — one-line slug file written by - ``hermes kanban boards switch``; absent → ``default``. - -Legacy overrides ``HERMES_KANBAN_DB`` / ``HERMES_KANBAN_WORKSPACES_ROOT`` / -``HERMES_KANBAN_HOME`` (umbrella root; tests and unusual deployments) still -work. The dispatcher injects the DB, workspaces-root and board env vars into -worker subprocesses so they converge on the exact DB it claimed from, even -under unusual symlink or Docker layouts. - -Schema: tasks, task_links, task_comments, task_events (+ runs, attachments, -notify subs). ``workspace_kind`` decouples coordination from git worktrees so -research / ops workloads run alongside coding. See -``docs/hermes-kanban-v1-spec.pdf``. - -Concurrency: WAL + ``BEGIN IMMEDIATE`` write transactions + compare-and-swap -updates on ``tasks.status`` / ``tasks.claim_lock``. SQLite serializes writers, -so at most one claimer wins a task; losers see zero affected rows and move on -— no retry loops, no distributed locks. CAS is per-board (one DB per board). +Lives under the shared Hermes root: ``default`` board DB at ``/kanban.db`` (pre-boards +back-compat), other boards at ``/kanban/boards//``; a worker on one board never sees +another. Board resolution: ``board=`` arg > ``HERMES_KANBAN_BOARD`` > ``HERMES_KANBAN_DB`` (pins the +file path) > ``/kanban/current`` > ``default``; the dispatcher injects these into workers. +Concurrency: WAL + ``BEGIN IMMEDIATE`` + compare-and-swap on ``tasks.status``/``claim_lock`` — +SQLite serializes writers so one claimer wins, losers see zero rows (no retries, no distributed +locks). Schema: tasks, task_links, task_comments, task_events, task_runs, attachments, notify subs. """ from __future__ import annotations @@ -138,14 +108,9 @@ VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} def normalize_reasoning_effort(effort: Optional[str]) -> Optional[str]: - """Normalize a per-task reasoning effort into a storable level. - - Accepts any level in ``hermes_constants.VALID_REASONING_EFFORTS`` plus - ``"none"`` (thinking disabled), case-insensitively. Empty / None means - "inherit the worker profile's own ``agent.reasoning_effort``" and stores - NULL. Anything else is rejected rather than silently dropped — a typo'd - level must not quietly hand the task back to the profile default. - """ + """``VALID_REASONING_EFFORTS`` or ``"none"`` (thinking off), case-insensitive; + empty/None = inherit the profile's own effort (NULL). Anything else raises — + a typo'd level must not quietly hand the task back to the profile default.""" from hermes_constants import VALID_REASONING_EFFORTS value = str(effort or "").strip().lower() @@ -163,15 +128,11 @@ KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024 def _assert_not_delegated_child_mutation() -> None: - """Reject Kanban state mutations from ``delegate_task`` child contexts. + """Reject Kanban mutations from ``delegate_task`` child contexts. - The structured kanban tools and CLI dispatch layer both have fast-fail - guards for better UX, but neither is a trust boundary: a delegated child can - still shell out to the CLI or import this module directly. The actual - invariant belongs at the DB/filesystem mutation layer so every public - mutator that uses ``write_txn`` (tasks, runs, comments, attachments, - dispatcher claims, repair events, subscriptions, GC, etc.) and every board - metadata mutator fails closed before touching durable state. + The tool/CLI fast-fail guards are UX, not a trust boundary (a child can shell + out or import this module); the invariant lives here so every ``write_txn`` + user and board-metadata mutator fails closed before touching durable state. """ try: from agent.delegation_context import is_delegated_child_process_context @@ -184,12 +145,9 @@ def _assert_not_delegated_child_mutation() -> None: def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None: - """Fire a lifecycle plugin hook, best-effort. Callers invoke it AFTER their - write txn commits so plugins never run under a SQLite write lock and - always see durable state; any failure is swallowed so an observer can - never break a transition. ``profile_name`` comes from the active - HERMES_HOME so dispatcher- and worker-side hooks agree without plumbing. - """ + """Best-effort lifecycle hook. Call AFTER the write txn commits (plugins never + run under the SQLite write lock, always see durable state); failures are + swallowed so an observer can never break a transition.""" try: from hermes_cli.lifecycle import invoke_hook @@ -217,11 +175,8 @@ def _hook_profile_name() -> str: def _kanban_observer_consumed(event: str) -> bool: - """Whether any observer/plugin consumes *event* — hot-path short-circuit - so per-tick / per-write observers skip payload assembly when nothing - subscribes. If inspection fails the event counts as unconsumed (the - invoke path would fail identically; dropping an observer is always safe). - """ + """Hot-path short-circuit: skip payload assembly when nothing subscribes. + Inspection failure counts as unconsumed (dropping an observer is always safe).""" try: from hermes_cli.lifecycle import has_hook @@ -238,10 +193,7 @@ def _fire_worker_spawned_hook( *, board: Optional[str] = None, ) -> None: - """Fire ``on_kanban_worker_spawned`` AFTER ``spawn_fn`` returned and the - reported PID is durably persisted. Best-effort: a misbehaving observer can - never break the dispatch loop. - """ + """``on_kanban_worker_spawned`` AFTER the PID is durably persisted; best-effort.""" if not _kanban_observer_consumed("on_kanban_worker_spawned"): return try: @@ -265,12 +217,9 @@ def notify_task_updated( *, board: Optional[str] = None, ) -> None: - """Fire ``on_kanban_task_updated`` AFTER a task-row mutation outside the - claim/complete/block lifecycle has committed — including direct-SQL - surfaces that bypass every ``kanban_db`` mutator (dashboard field - editors). ``changed_fields`` carries field NAMES only, never values. - Observer-only, best-effort; one ``has_hook`` probe when nothing subscribes. - """ + """``on_kanban_task_updated`` AFTER a non-lifecycle task mutation commits + (also for direct-SQL surfaces like dashboard field editors). + ``changed_fields`` carries field NAMES only, never values.""" if not _kanban_observer_consumed("on_kanban_task_updated"): return try: @@ -305,11 +254,8 @@ def _fire_dispatch_tick_hook( board: Optional[str] = None, dry_run: bool = False, ) -> None: - """Fire ``on_kanban_dispatch_tick`` after one tick — strictly AFTER - ``_dispatch_tick_lock`` is released, so a slow subscriber cannot extend - the single-writer critical section and stall a sibling dispatcher. - Observer-only, best-effort: subscriber failures are swallowed. - """ + """``on_kanban_dispatch_tick`` — strictly AFTER ``_dispatch_tick_lock`` is + released so a slow subscriber cannot stall a sibling dispatcher.""" if not _kanban_observer_consumed("on_kanban_dispatch_tick"): return try: @@ -356,9 +302,7 @@ RECLAIM_DEFER_GRACE_SECONDS = 120 def _resolve_claim_ttl_seconds(ttl_seconds: Optional[int] = None) -> int: - """Explicit ``ttl_seconds`` wins; else a positive - ``HERMES_KANBAN_CLAIM_TTL_SECONDS`` overrides ``DEFAULT_CLAIM_TTL_SECONDS`` - (invalid values fall back silently so existing installs keep working).""" + """Explicit ``ttl_seconds`` > ``HERMES_KANBAN_CLAIM_TTL_SECONDS`` > default.""" if ttl_seconds is not None: return max(1, int(ttl_seconds)) @@ -378,14 +322,12 @@ KANBAN_RATE_LIMIT_EXIT_CODE = 75 def _resolve_crash_grace_seconds() -> int: - """``HERMES_KANBAN_CRASH_GRACE_SECONDS`` (0 = immediate reclaim, for tests) - else ``DEFAULT_CRASH_GRACE_SECONDS``.""" + """``HERMES_KANBAN_CRASH_GRACE_SECONDS`` (0 = immediate, for tests) else default.""" return _env_int("HERMES_KANBAN_CRASH_GRACE_SECONDS", DEFAULT_CRASH_GRACE_SECONDS) def _resolve_rate_limit_cooldown_seconds() -> int: - """``HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS`` (0 = respawn next tick, for - tests) else ``DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS``.""" + """``HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS`` (0 = next tick, for tests) else default.""" return _env_int("HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS", DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS) @@ -399,11 +341,9 @@ _CTX_MAX_COMMENT_BYTES = 2 * 1024 # per comment def _relative_age(ts: Optional[int], now: Optional[int] = None) -> str: - """Coarse relative age (``just now`` / ``18h ago`` / ``3d ago``); "" for a - missing/invalid timestamp so callers append unconditionally. An LLM reads - a bare absolute timestamp as current fact; the relative age is what - prompts a worker to re-verify stale sibling work before acting on it. - """ + """``just now`` / ``18h ago`` / ``3d ago``; "" for a missing/invalid ts. An LLM + reads a bare absolute timestamp as current fact — the relative age is what + prompts a worker to re-verify stale sibling work.""" if ts is None: return "" try: @@ -435,7 +375,7 @@ _CURRENT_BOARD_OVERRIDE: ContextVar[str | None] = ContextVar( @contextlib.contextmanager def scoped_current_board(slug: str): - """Temporarily pin the active board for the current context only.""" + """Pin the active board for the current context only.""" token: Token[str | None] = _CURRENT_BOARD_OVERRIDE.set(slug) try: yield @@ -476,14 +416,9 @@ def _require_slug(slug: str) -> str: def kanban_home() -> Path: - """Shared Hermes root anchoring the board: ``HERMES_KANBAN_HOME`` if set, - else ``get_default_hermes_root()`` (```` for a - ``/profiles/`` HERMES_HOME, HERMES_HOME itself otherwise). - - The board is shared across profiles BY DESIGN; resolving through the - active profile's HERMES_HOME would silently fork it per profile and break - the dispatcher / worker handoff. - """ + """``HERMES_KANBAN_HOME`` else ``get_default_hermes_root()``. Shared across + profiles BY DESIGN: resolving through the active profile's HERMES_HOME would + fork the board per profile and break the dispatcher/worker handoff.""" override = os.environ.get("HERMES_KANBAN_HOME", "").strip() if override: return Path(override).expanduser() @@ -492,35 +427,20 @@ def kanban_home() -> Path: def boards_root() -> Path: - """Return ``/kanban/boards`` — the parent of non-default board dirs. - - ``default`` is intentionally NOT under this directory — its DB lives at - ``/kanban.db`` for back-compat with pre-boards installs. This - function returns the directory where *additional* named boards live, - used by :func:`list_boards` to enumerate them. - """ + """``/kanban/boards`` — parent of the *additional* named boards. + ``default`` is deliberately not here (its DB stays at ``/kanban.db``).""" return kanban_home() / "kanban" / "boards" def current_board_path() -> Path: - """Return the path to ``/kanban/current``. - - One-line text file written by ``hermes kanban boards switch `` - to persist the user's board selection across CLI invocations. Absent - by default (meaning: active board is ``default``). - """ + """``/kanban/current`` — one-line slug written by ``boards switch``; absent = ``default``.""" return kanban_home() / "kanban" / "current" def get_current_board() -> str: - """Active board slug: ``HERMES_KANBAN_BOARD`` env (dispatcher injects it - on spawn) -> ``/kanban/current`` (``boards switch``, only while that - board still exists) -> ``DEFAULT_BOARD``. - - A malformed/stale slug falls through to the next layer with a warning — - the dispatcher must never crash because a user hand-edited a file or - removed a board directory. - """ + """Active slug: context override -> ``HERMES_KANBAN_BOARD`` -> ``/kanban/current`` + (only while that board exists) -> ``DEFAULT_BOARD``. A malformed/stale slug + falls through — the dispatcher must never crash on a hand-edited file.""" def _existing(candidate: str) -> Optional[str]: if not candidate: return None @@ -549,13 +469,8 @@ def get_current_board() -> str: def set_current_board(slug: str) -> Path: - """Persist ``slug`` as the active board. Returns the file written. - - Writes ``/kanban/current``. The caller should validate the slug - exists first (via :func:`board_exists`) — this function does not — - so that ``hermes kanban boards switch `` returns an error - instead of silently pointing at nothing. - """ + """Persist ``slug`` as the active board; returns the file written. Does NOT + check the board exists — callers do (so ``boards switch `` errors).""" _assert_not_delegated_child_mutation() normed = _require_slug(slug) path = current_board_path() @@ -580,12 +495,7 @@ def board_dir(board: Optional[str] = None) -> Path: def board_exists(board: Optional[str] = None) -> bool: - """Return True if the board has persisted metadata or a DB on disk. - - ``default`` is considered to always exist — its DB is created - on first :func:`connect` and there's no way for it to be missing - in a configuration where the kanban feature is usable at all. - """ + """Board has ``board.json`` or ``kanban.db`` on disk; ``default`` always exists.""" slug = _slug_or_default(board) if slug == DEFAULT_BOARD: return True @@ -614,32 +524,20 @@ def _board_path( def kanban_db_path(board: Optional[str] = None) -> Path: - """``kanban.db`` path for ``board``: ``HERMES_KANBAN_DB`` pins it (the - dispatcher injects it into worker env so workers are immune to any - path-resolution disagreement); else ``default`` -> ``/kanban.db`` - (back-compat), other boards -> ``/kanban/boards//kanban.db``. - """ + """``kanban.db`` path: ``HERMES_KANBAN_DB`` pins it (injected into workers); + ``default`` -> ``/kanban.db`` (back-compat), else the board dir.""" return _board_path("HERMES_KANBAN_DB", board, ("kanban.db",), "kanban.db") def workspaces_root(board: Optional[str] = None) -> Path: - """Per-board ``scratch`` workspace root (``HERMES_KANBAN_WORKSPACES_ROOT`` - wins — the dispatcher injects it into worker env). ``default`` keeps the - legacy ``/kanban/workspaces/`` so pre-boards workspaces survive; - other boards use ``/kanban/boards//workspaces/``. - """ + """Per-board scratch workspace root (``HERMES_KANBAN_WORKSPACES_ROOT`` wins); + ``default`` keeps the legacy ``/kanban/workspaces/``.""" return _board_path("HERMES_KANBAN_WORKSPACES_ROOT", board, ("kanban", "workspaces"), "workspaces") def attachments_root(board: Optional[str] = None) -> Path: - """Per-board attachments root (``HERMES_KANBAN_ATTACHMENTS_ROOT`` wins). - - ``default`` -> ``/kanban/attachments/``, other boards -> - ``/kanban/boards//attachments/``; each task gets its own - ``/`` subdir. Workers read attachments by the absolute path - surfaced in :func:`build_worker_context`, so remote terminal backends - (Docker/Modal) need this directory mounted. - """ + """Per-board attachments root (``HERMES_KANBAN_ATTACHMENTS_ROOT`` wins). Workers + read attachments by absolute path, so remote terminal backends must mount it.""" return _board_path("HERMES_KANBAN_ATTACHMENTS_ROOT", board, ("kanban", "attachments"), "attachments") @@ -649,44 +547,24 @@ def task_attachments_dir(task_id: str, board: Optional[str] = None) -> Path: def worker_logs_dir(board: Optional[str] = None) -> Path: - """Return the directory under which per-task worker logs are written. - - ``default`` keeps the legacy path ``/kanban/logs/``. Other - boards use ``/kanban/boards//logs/``. Logs follow the - board — makes ``hermes kanban log`` unambiguous even when multiple - boards have tasks with the same id. - """ + """Per-board worker log dir (logs follow the board so ``hermes kanban log`` + is unambiguous when two boards share a task id).""" return _board_path(None, board, ("kanban", "logs"), "logs") def board_metadata_path(board: Optional[str] = None) -> Path: - """Return the path to ``board.json`` for ``board``. - - Stores display metadata (display name, description, icon, color, - created_at). The on-disk slug is the canonical identity; this file - is purely for presentation in the CLI / dashboard. - """ + """``board.json`` path — display metadata only; the directory slug is the identity.""" return board_dir(_slug_or_default(board)) / "board.json" def _default_board_display_name(slug: str) -> str: - """Turn a slug into a reasonable default display name. - - ``atm10-server`` → ``Atm10 Server``. Users can override via - ``board.json`` but the default should look presentable in the - dashboard without any follow-up editing. - """ + """``atm10-server`` -> ``Atm10 Server``.""" return " ".join(part.capitalize() for part in slug.replace("_", "-").split("-") if part) or slug def read_board_metadata(board: Optional[str] = None) -> dict: - """Return ``board.json`` contents (or synthesized defaults). - - Never raises — a missing / malformed ``board.json`` falls back to a - synthesised entry so the dashboard always has something to render. - Includes the canonical ``slug`` and ``db_path`` so the caller - doesn't need to reconstruct them. - """ + """``board.json`` merged over defaults, plus ``slug`` and ``db_path``. Never + raises — a missing/malformed file yields the synthesized entry.""" slug = _slug_or_default(board) meta: dict[str, Any] = { "slug": slug, @@ -729,15 +607,9 @@ def write_board_metadata( default_workdir: Optional[str] = None, project_id: Optional[str] = None, ) -> dict: - """Create / update ``board.json`` for ``board``. - - Preserves any existing fields not mentioned in the call. Sets - ``created_at`` on first write. Returns the resulting metadata dict. - - ``project_id``: ``None`` leaves it unchanged; empty string clears the - project scope; a value sets it (not validated here — the caller resolves - it against ``projects_db``). - """ + """Create/update ``board.json``; unmentioned fields are preserved, ``created_at`` + set on first write. ``project_id``/``default_workdir``: ``None`` = unchanged, + "" = clear (``project_id`` is not validated here).""" _assert_not_delegated_child_mutation() slug = _slug_or_default(board) meta = read_board_metadata(slug) @@ -775,12 +647,7 @@ def create_board( default_workdir: Optional[str] = None, project_id: Optional[str] = None, ) -> dict: - """Create a new board directory + DB + metadata. Idempotent. - - Returns the resulting metadata. Raises :class:`ValueError` for a - malformed slug; returns the existing metadata (not an error) if the - board already exists — matching ``mkdir -p`` semantics. - """ + """Create board dir + DB + metadata (``mkdir -p`` semantics: existing board returns its metadata).""" normed = _require_slug(slug) meta = write_board_metadata( normed, @@ -797,11 +664,8 @@ def create_board( def list_boards(*, include_archived: bool = True) -> list[dict]: - """Metadata dicts for every board on disk, ``default`` first (always - present — its DB lives at the legacy path, so no ``boards/default/`` dir - is needed) then alphabetical; a ``boards//`` counts when it holds a - ``kanban.db`` or ``board.json``. - """ + """Metadata for every board: ``default`` first (always present), then + ``boards//`` dirs holding a ``kanban.db`` or ``board.json``, sorted.""" entries: list[dict] = [] seen: set[str] = set() @@ -832,10 +696,8 @@ def list_boards(*, include_archived: bool = True) -> list[dict]: def remove_board(slug: str, *, archive: bool = True) -> dict: - """Archive (move to ``boards/_archived/-/``, recoverable) - or, with ``archive=False``, delete a board. ``default`` cannot be removed - (ValueError). Returns ``{"slug", "action", "new_path"}``. - """ + """Archive (to ``boards/_archived/-/``) or delete a board; + ``default`` cannot be removed. Returns ``{"slug", "action", "new_path"}``.""" _assert_not_delegated_child_mutation() normed = _require_slug(slug) if normed == DEFAULT_BOARD: @@ -967,14 +829,8 @@ _TASK_EMPTY_IS_NULL_COLUMNS = ( @dataclass class Run: - """In-memory view of a ``task_runs`` row. - - A run is one attempt to execute a task — created on claim, closed - on complete/block/crash/timeout/spawn_failure/reclaim. Multiple runs - per task when retries happen. Carries the claim machinery, PID, - heartbeat, and the structured handoff summary that downstream workers - read via ``build_worker_context``. - """ + """One attempt at a task (``task_runs`` row): opened on claim, closed on + complete/block/crash/timeout/reclaim; carries the handoff summary.""" id: int task_id: str @@ -1274,11 +1130,8 @@ CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_ # --------------------------------------------------------------------------- def _new_task_id() -> str: - """Short URL-safe id: 4 hex bytes (~4.3B; collision ~1.2e-5 at 10k tasks, - ~1.2e-3 at 100k — 2 bytes hit the birthday paradox at ~50% by 10k). - Idempotency belongs to ``create_task(idempotency_key=...)``, not to id - uniqueness. - """ + """``t_`` + 4 hex bytes (collision ~1e-3 at 100k tasks; 2 bytes would hit 50% + by 10k). Idempotency belongs to ``idempotency_key``, not id uniqueness.""" return "t_" + secrets.token_hex(4) @@ -1328,18 +1181,13 @@ def _resolve_project_link( workspace_kind: str, workspace_path: Optional[str], ) -> tuple[Optional[str], Any, Optional[str], str]: - """Resolve the optional first-class Project link for ``create_task``. + """``(project_id, project_obj, project_repo, workspace_kind)`` for ``create_task``. - Returns ``(project_id, project_obj, project_repo, workspace_kind)``. A - project-linked task is anchored to the project's primary repo as a git - worktree so its branch can be named deterministically (project slug + task - id) instead of the random ``wt/`` worker fallback. Projects live in - the creator's per-profile projects.db; the repo path is absolute and the - branch name pure, so the cross-profile dispatcher needs no projects.db - access at dispatch time. ``project_repo`` is the primary repo of a - project-linked worktree task whose path still has to be derived once the - task id exists. An unresolvable id/slug drops the link (never a dangling - reference, never a crash). + A project-linked task is anchored to the project's primary repo as a + worktree with a deterministic branch (slug + task id). Projects live in the + creator's per-profile projects.db, but the stored repo path is absolute so + the cross-profile dispatcher needs no projects.db access. ``project_repo`` + is set when the worktree path must still be derived from the new task id. """ project_id = (str(project_id).strip() or None) if project_id is not None else None if not project_id: @@ -1420,14 +1268,9 @@ def _project_from_source_task( def _normalize_task_skills(skills: Optional[Iterable[str]]) -> Optional[list[str]]: - """Strip, drop empties, dedupe (order-preserving) a per-task skills list. - - Refuses commas inside a single name so a comma-joined string is never - splattered into one argv slot (the ``hermes --skills X,Y`` comma syntax is - handled in the dispatcher, not here). Toolset names are rejected all at - once — agents that confuse skills with toolsets usually pass several - (``["web", "browser", "terminal"]``) and serial-correcting wastes tokens. - """ + """Strip/dedupe a skills list. Commas are refused (a comma-joined string must + not land in one argv slot); toolset names are rejected all at once because + agents that confuse the two usually pass several.""" if skills is None: return None cleaned: list[str] = [] @@ -1493,26 +1336,16 @@ def create_task( project_id: Optional[str] = None, project_source_task_id: Optional[str] = None, ) -> str: - """Create a new task and optionally link it under parent tasks; returns its id. + """Create a task (optionally under ``parents``); returns its id. - Status is ``ready`` with no parents (or all parents ``done``), else ``todo``; - ``triage=True`` forces ``triage`` regardless of parents (a specifier promotes - it later); ``initial_status="blocked"`` parks it for human-ops review. - - ``idempotency_key``: if a non-archived task with the same key exists its id - is returned instead of creating a duplicate (retried webhooks/automation). - ``max_runtime_seconds``: cap before the dispatcher SIGTERMs (then SIGKILLs - after a grace window) and re-queues; ``None`` = no cap. - ``skills``: skill names force-loaded into the worker (``hermes --skills``); - see ``_normalize_task_skills``. ``model_override``/``provider_override`` pin - the worker model (``-m [--provider ]``); provider requires - model. ``reasoning_effort`` pins thinking depth (``--reasoning ``), - independent of the model override. - ``project_source_task_id``: internal cross-profile fallback for a - worker-created child — when the active profile cannot resolve - ``project_id`` in its own projects.db, a canonical project-linked task in - this board supplies the repo and branch convention (its literal worktree is - never reused). See ``_resolve_project_link``. + Status: ``ready`` unless a parent is not ``done`` (``todo``); ``triage=True`` + forces ``triage``; ``initial_status="blocked"`` parks it for human ops. + ``idempotency_key``: an existing non-archived task with the key is returned + instead of a duplicate. ``max_runtime_seconds``: cap before the dispatcher + SIGTERMs and re-queues. ``model_override``/``provider_override`` pin the + worker model (provider requires model); ``reasoning_effort`` is independent. + ``project_source_task_id``: cross-profile fallback when ``project_id`` is not + in the active profile's projects.db — see ``_resolve_project_link``. """ model_override, provider_override = _validate_model_override(model_override, provider_override) reasoning_effort = normalize_reasoning_effort(reasoning_effort) @@ -1706,19 +1539,12 @@ def _inherit_notify_subs( *, created_at: Optional[int] = None, ) -> None: - """Copy gateway notification subscriptions from parent tasks to a child. + """Copy parents' notify subscriptions to a child, cursor caught up to the + child's current event so a late ``link_tasks`` never replays history. - The inherited subscription starts caught up to the child's current event - cursor. This makes manual `link_tasks(parent, existing_child)` safe: the - parent chat receives future child terminal events without replaying the - child's pre-link history. - - Copies EVERY routing/delivery column (chat_type, user_id_alt, - delivery_mode, delivery_metadata included) — this helper is the single - owner of subscription inheritance for create_task, link_tasks, and triage - decomposition. Omitting columns here silently degrades routing: a - DM-originated child completion falls back to chat_type='group' and wakes - a fresh group-scoped session instead of the originating DM (issue #73030). + Single owner of inheritance (create_task, link_tasks, decompose). It must + copy EVERY routing/delivery column: dropping ``chat_type`` made DM-originated + completions wake a fresh group session instead of the originating DM. """ parent_ids = tuple(dict.fromkeys(p for p in parents if p)) if not parent_ids: @@ -1805,11 +1631,7 @@ def list_tasks( def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) -> bool: - """Assign or reassign a task. Returns True on success. - - Refuses to reassign a task that's currently running (claim_lock set). - Reassign after the current run completes if needed. - """ + """Assign/reassign; raises RuntimeError while the task is running under a claim.""" profile = _canonical_assignee(profile) with write_txn(conn): row = conn.execute( @@ -1846,20 +1668,9 @@ def set_model_override( model: Optional[str], provider: Optional[str] = None, ) -> bool: - """Set (or clear) the per-task model/provider override. - - ``model=None`` (or empty) clears BOTH overrides — the worker falls back - to its profile's configured model. ``provider`` without ``model`` is - rejected: a bare provider switch has no defined meaning for the worker - spawn (``--provider`` alone would re-resolve the profile's model name - against a different backend, which is exactly the mismatch class this - feature exists to kill). - - Allowed on any non-archived task, including ``running`` ones — the - override only takes effect on the NEXT dispatch, so setting it on a - running task that's about to be reclaimed/retried is the primary - rate-limit-recovery flow. Returns True on success. - """ + """Set (empty ``model`` clears BOTH) the per-task model/provider override. + Allowed while ``running``: it applies on the NEXT dispatch, which is the + rate-limit-recovery flow (set, then reclaim/retry).""" model, provider = _validate_model_override(model, provider) return _set_task_override( conn, task_id, @@ -1888,18 +1699,9 @@ def _set_task_override( def set_reasoning_effort(conn: sqlite3.Connection, task_id: str, effort: Optional[str]) -> bool: - """Set (or clear) the per-task reasoning effort. - - ``effort=None`` (or empty) clears the override — the worker falls back to - its profile's own ``agent.reasoning_effort``. ``"none"`` is a real value, - not a clear: it pins thinking OFF for this task. - - Deliberately independent of :func:`set_model_override`: a task may run the - profile's own model at a different depth, and clearing a model override - must not silently reset the depth the operator chose. Like the model - override, it takes effect on the NEXT dispatch, so it is settable on a - running task. Returns True on success. - """ + """Set (empty clears; ``"none"`` pins thinking OFF) the per-task reasoning + effort. Independent of the model override so clearing one never resets the + other; applies on the NEXT dispatch, so settable while running.""" effort = normalize_reasoning_effort(effort) return _set_task_override( conn, task_id, @@ -1937,12 +1739,7 @@ def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: def _would_cycle(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: - """Return True if adding parent->child creates a cycle. - - A cycle exists iff ``parent_id`` is already a descendant of - ``child_id`` via existing parent->child links. We walk downward - from ``child_id`` and check whether we reach ``parent_id``. - """ + """True iff ``parent_id`` is already a descendant of ``child_id``.""" seen = set() stack = [child_id] while stack: @@ -2059,13 +1856,8 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]: def list_comments_after( conn: sqlite3.Connection, task_id: str, *, after_id: int = 0 ) -> list[Comment]: - """Return comments on ``task_id`` with ``id > after_id`` (ascending). - - Keyed on the monotonic rowid rather than ``created_at`` so a same-second - burst can't be skipped. Used by the live worker bridge to fold new - operator notes into a running task without a restart (see - ``tools.kanban_tools.inject_new_comments_from_env``). - """ + """Comments with ``id > after_id`` — keyed on rowid, not ``created_at``, so a + same-second burst is never skipped (live worker comment bridge).""" rows = conn.execute( "SELECT id, task_id, author, body, created_at FROM task_comments " "WHERE task_id = ? AND id > ? ORDER BY id ASC", @@ -2085,28 +1877,14 @@ def list_comments_after( class AttachmentTooLarge(ValueError): - """Raised when an attachment exceeds the configured size cap. - - Subclasses :class:`ValueError` so generic ``except ValueError`` handlers - (e.g. the dashboard's 400 fallback) still catch it, while callers that - want a distinct user-facing message (the tool/CLI 413-equivalent) can - catch it specifically. - """ + """Attachment over the size cap. A ``ValueError`` so generic 400 handlers + still catch it while the tool/CLI can give a 413-style message.""" def _safe_attachment_name(raw: str) -> str: - """Reduce a client-supplied filename to a safe basename. - - Strips any directory components (both separators) so a malicious - ``../../etc/passwd`` or ``C:\\x`` collapses to its leaf. Drops control - chars and leading dots so we never write a dotfile or a name with - embedded NULs/newlines. Rejects empty / dotfile-only names. The result - is only ever joined under the per-task attachments dir, never used - verbatim as a path from the client. - - Raises :class:`ValueError` on an unusable name; HTTP callers map that - to a 400. - """ + """Client filename -> safe basename: strip directories (both separators), + control chars and leading dots (no dotfiles, no traversal); ValueError when + nothing usable remains. Only ever joined under the per-task attachments dir.""" name = (raw or "").replace("\\", "/").split("/")[-1].strip() name = "".join(ch for ch in name if ch.isprintable() and ch not in "\x00").strip() name = name.lstrip(".").strip() @@ -2116,11 +1894,7 @@ def _safe_attachment_name(raw: str) -> str: def _collision_free_path(dest_dir: Path, safe_name: str) -> Path: - """Return a path under ``dest_dir`` that doesn't clobber an existing file. - - ``foo.pdf`` → ``foo.pdf``, then ``foo (1).pdf``, ``foo (2).pdf``, … - ``safe_name`` must already be sanitised via :func:`_safe_attachment_name`. - """ + """``foo.pdf`` -> ``foo.pdf``, ``foo (1).pdf``, ... first one that doesn't exist.""" stem, dot, ext = safe_name.partition(".") candidate = safe_name n = 1 @@ -2141,23 +1915,10 @@ def store_attachment_bytes( board: Optional[str] = None, max_bytes: Optional[int] = None, ) -> int: - """Validate, size-check, persist a blob, and record its metadata row. - - This is the single write path shared by the dashboard endpoint, the - agent toolset (``kanban_attach`` / ``kanban_attach_url``), and the CLI - (``hermes kanban attach``) so name-sanitisation, the size cap, and the - collision-resolution all behave identically everywhere. - - Steps: enforce ``max_bytes``, sanitise ``filename`` to a safe basename, - write the bytes under :func:`task_attachments_dir` with a - collision-free name, then insert the ``task_attachments`` row via - :func:`add_attachment`. Returns the new attachment id. - - Raises :class:`AttachmentTooLarge` when ``data`` exceeds ``max_bytes``, - or :class:`ValueError` for a bad filename / unknown task. On any failure - after the blob is written (e.g. the task disappeared) the orphaned blob - is removed before re-raising. - """ + """Single attachment write path (dashboard, tools, CLI): size cap, safe + basename, collision-free blob under :func:`task_attachments_dir`, then the + metadata row. Raises :class:`AttachmentTooLarge` / ``ValueError``; a blob + whose row insert fails is removed before re-raising. Returns the new id.""" if max_bytes is None: max_bytes = KANBAN_ATTACHMENT_MAX_BYTES if len(data) > max_bytes: @@ -2195,12 +1956,7 @@ def add_attachment( size: int = 0, uploaded_by: Optional[str] = None, ) -> int: - """Record a file attachment for a task. Returns the new attachment id. - - The caller is responsible for writing the blob to ``stored_path`` - first (under :func:`task_attachments_dir`); this only persists the - metadata row and appends an ``attached`` event. - """ + """Record the metadata row (+ ``attached`` event) for a blob the caller already wrote.""" if not filename or not filename.strip(): raise ValueError("attachment filename is required") if not stored_path or not stored_path.strip(): @@ -2233,12 +1989,7 @@ def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Att def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]: - """Delete an attachment row and its on-disk blob. Returns the removed row. - - Returns ``None`` when no row matched. The blob is removed best-effort - (a missing file is not an error); the metadata row is the source of - truth for whether an attachment "exists". - """ + """Delete the row (source of truth) and best-effort its blob; None when no row matched.""" with write_txn(conn): att = get_attachment(conn, attachment_id) if att is None: @@ -2259,11 +2010,8 @@ def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]: def _insert_comment( conn: sqlite3.Connection, task_id: str, author: str, body: str, created_at: int, ) -> None: - """Raw ``task_comments`` INSERT for callers already inside a write txn. - - ``add_comment`` opens its own ``write_txn`` (raises on nesting) and emits - a ``commented`` event; transitions that record their own event use this. - """ + """Raw comment INSERT for callers already inside a write txn (``add_comment`` + opens its own txn and emits ``commented``).""" conn.execute( "INSERT INTO task_comments (task_id, author, body, created_at) " "VALUES (?, ?, ?, ?)", @@ -2279,13 +2027,7 @@ def _append_event( *, run_id: Optional[int] = None, ) -> None: - """Record an event row. Called from within an already-open txn. - - ``run_id`` is optional: pass the current run id so UIs can group - events by attempt. For events that aren't scoped to a single run - (task created/edited/archived, dependency promotion) leave it None - and the row carries NULL. - """ + """Insert an event row inside the caller's txn; ``run_id`` groups it by attempt (NULL = task-scoped).""" conn.execute( "INSERT INTO task_events (task_id, run_id, kind, payload, created_at) " "VALUES (?, ?, ?, ?, ?)", @@ -2303,15 +2045,8 @@ def _end_run( metadata: Optional[dict] = None, status: Optional[str] = None, ) -> Optional[int]: - """Close the currently-active run for ``task_id`` and clear the pointer. - - ``outcome`` is the semantic result (completed / blocked / crashed / - timed_out / spawn_failed / gave_up / reclaimed). ``status`` is the - run-row status (usually just ``outcome``, but callers can pass it - explicitly). Returns the closed run_id or ``None`` if no active run - existed (e.g. a CLI user calling ``hermes kanban complete`` on a - task that was never claimed). - """ + """Close the active run (``status`` defaults to ``outcome``) and clear + ``current_run_id``; None when no run was active (never-claimed task).""" now = int(time.time()) run_id = _current_run_id(conn, task_id) if run_id is None: @@ -2385,21 +2120,10 @@ def _synthesize_ended_run( error: Optional[str] = None, metadata: Optional[dict] = None, ) -> int: - """Insert a zero-duration, already-closed run row. - - Used when a terminal transition happens on a task that was never - claimed (CLI user calling ``hermes kanban complete - --summary X``, or dashboard "mark done" on a ready task). Without - this, the handoff fields (summary / metadata / error) would be - silently dropped: ``_end_run`` is a no-op because there's no - current run. - - The synthetic run has ``started_at == ended_at == now`` so it - shows up in attempt history as "instant" and doesn't skew elapsed - stats. Caller is responsible for leaving ``current_run_id`` NULL - (or for clearing it elsewhere in the same txn) since this - function does NOT touch the tasks row. - """ + """Zero-duration closed run for a terminal transition on a never-claimed + task, so the handoff fields aren't silently dropped (``_end_run`` is a + no-op then). ``started_at == ended_at`` keeps elapsed stats honest. Does + NOT touch the tasks row.""" now = int(time.time()) trow = conn.execute( "SELECT assignee, current_step_key FROM tasks WHERE id = ?", @@ -2432,19 +2156,10 @@ def _synthesize_ended_run( # --------------------------------------------------------------------------- def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool: - """Return True when ``task_id`` is sticky-blocked by an explicit - worker/operator ``kanban_block`` call. - - A ``blocked`` status has two sources: a deliberate worker/operator - ``kanban_block`` handoff (emits a ``"blocked"`` event; must stay blocked - until an operator unblocks it) and a circuit-breaker trip in - ``_record_task_failure`` (emits ``"gave_up"``, NOT ``"blocked"``; meant to - recover once conditions change). The cheapest discriminator is the most - recent ``"blocked"``/``"unblocked"`` event: if it is ``"blocked"`` the task - is sticky and ``recompute_ready`` must not auto-promote it. No such event - at all (breaker trip, direct DB edit) returns ``False`` — the legacy - auto-recover path. - """ + """True when the newest ``blocked``/``unblocked`` event is ``blocked`` — an + explicit ``kanban_block`` that must wait for an operator. A breaker trip + emits ``gave_up`` (not ``blocked``) and so auto-recovers, as does a task + with no such event at all (direct DB edit).""" row = conn.execute( "SELECT kind FROM task_events " "WHERE task_id = ? AND kind IN ('blocked', 'unblocked') " @@ -2467,13 +2182,8 @@ def _latest_event( def _resume_status_from_events(conn: sqlite3.Connection, task_id: str) -> str: - """Return the durable phase a blocked/dependency-wait task should resume. - - Events written by review workers carry ``source_status``/``retry_status``; - an explicit unblock that must wait for parents carries ``resume_status``. - Legacy events omit these fields and therefore retain the historical - ``ready`` behavior. - """ + """``review`` when the newest lifecycle event carries a review + ``resume_status``/``retry_status``/``source_status``, else ``ready`` (legacy).""" row = conn.execute( "SELECT payload FROM task_events " "WHERE task_id = ? AND kind IN (" @@ -2491,23 +2201,13 @@ def _resume_status_from_events(conn: sqlite3.Connection, task_id: str) -> str: def recompute_ready(conn: sqlite3.Connection, failure_limit: int = None) -> int: - """Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``. + """Promote ``todo``/``blocked`` tasks whose parents are all done/archived; + returns the count. Opens its own IMMEDIATE txn — call OUTSIDE any write txn. - Returns the number of tasks promoted. Opens its own IMMEDIATE txn, so it - MUST be called OUTSIDE any open write transaction (plain ``write_txn`` - raises on nesting); call it after the enclosing txn commits. - - ``blocked`` tasks are also considered (a task blocked purely by a parent - dependency unblocks itself when the parent completes), *except* when the - most recent block event was a worker-initiated ``kanban_block`` (stays - blocked until explicit ``kanban_unblock``) or ``consecutive_failures`` has - reached the effective limit (otherwise the counter would reset on every - recovery cycle and the breaker could never trip). - - The effective limit resolves in the same order as ``_record_task_failure`` - so the two never disagree: per-task ``max_retries``, then the caller's - ``failure_limit`` (``kanban.failure_limit`` via ``dispatch_once``), then - ``DEFAULT_FAILURE_LIMIT``. + ``blocked`` is skipped when sticky (explicit ``kanban_block``) or when + ``consecutive_failures`` reached the limit (else the breaker could never + trip). Limit order matches ``_record_task_failure``: ``max_retries`` > + ``failure_limit`` > ``DEFAULT_FAILURE_LIMIT``. """ if failure_limit is None: failure_limit = DEFAULT_FAILURE_LIMIT @@ -2594,11 +2294,8 @@ def _claim_and_open_run( *, event_extra: Optional[dict] = None, ) -> Optional[int]: - """CAS ``source_status -> running``, open a ``task_runs`` row and emit ``claimed``. - - Caller holds the write transaction. Returns the new run id, or ``None`` - when the CAS lost (task already claimed / not in ``source_status``). - """ + """CAS ``source_status -> running``, open a run row, emit ``claimed``; None + when the CAS lost. Caller holds the txn.""" cur = conn.execute( f""" UPDATE tasks @@ -2704,11 +2401,9 @@ def claim_review_task( ttl_seconds: Optional[int] = None, claimer: Optional[str] = None, ) -> Optional[Task]: - """Atomically ``review -> running``; the claimed ``Task`` or None when - already claimed / not in review. Parents are re-checked (one may have - been reopened while the task waited) and a NEW run is opened so the - review agent's lifecycle is tracked apart from the implementer's run. - """ + """Atomic ``review -> running`` (None when lost). Parents are re-checked + (one may have reopened meanwhile) and a NEW run tracks the reviewer + separately from the implementer.""" now = int(time.time()) lock = claimer or _claimer_id() expires = now + _resolve_claim_ttl_seconds(ttl_seconds) @@ -2744,13 +2439,9 @@ def _retry_status_for_run( task_id: str, run_id: Optional[int] = None, ) -> str: - """Return the non-running phase an interrupted run must resume from. - - Review claims record ``source_status=review`` on their claimed event. All - other and legacy runs retry from ``ready``. Keeping this decision in one - place prevents crash/timeout/reclaim paths from silently converting a - reviewer run into an implementation run. - """ + """``review`` when the run's ``claimed`` event says ``source_status=review``, + else ``ready`` — one place, so crash/timeout/reclaim can't silently turn a + reviewer run into an implementation run.""" if run_id is None: run_id = _current_run_id(conn, task_id) if run_id is None: @@ -2775,13 +2466,9 @@ def goal_run_status( task_id: str, expected_run_id: Optional[int] = None, ) -> Optional[str]: - """Resolve lifecycle status from the perspective of one worker run. - - A successor may claim the task immediately after this run hands it off. - Returning the task's live ``running`` status in that case lets the old goal - loop mutate the successor. Bind terminal handoffs to the original run and - report any other ownership loss as ``superseded``. - """ + """Lifecycle status as seen by ONE run: terminal handoffs bind to that run, + any other ownership loss is ``superseded`` — otherwise an old goal loop + would read the successor's live ``running`` and mutate it.""" task = get_task(conn, task_id) if task is None: return None @@ -2814,11 +2501,7 @@ def heartbeat_claim( ttl_seconds: Optional[int] = None, claimer: Optional[str] = None, ) -> bool: - """Extend a running claim. Returns True if we still own it. - - Workers that know they'll exceed 15 minutes should call this every - few minutes to keep ownership. - """ + """Extend a running claim; True if we still own it.""" expires = int(time.time()) + _resolve_claim_ttl_seconds(ttl_seconds) lock = claimer or _claimer_id() with write_txn(conn): @@ -2842,23 +2525,13 @@ def _extend_run_claim(conn: sqlite3.Connection, task_id: str, expires: int) -> O def release_stale_claims(conn: sqlite3.Connection, *, signal_fn=None) -> int: - """Reset any ``running`` task whose claim has expired. + """Reclaim ``running`` tasks whose claim expired; returns the count reclaimed. - A stale-by-TTL claim whose host-local worker PID is still alive is - *extended* (``claim_extended`` event) instead of reclaimed: reclaiming a - live worker mid-flight causes a spawn-then-reclaim loop on slow models - that spend longer than ``DEFAULT_CLAIM_TTL_SECONDS`` inside one tool-free - LLM call (no tool calls means no ``kanban_heartbeat``). - - Backstop: a live PID whose ``last_heartbeat_at`` is older than - ``DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS`` is reclaimed anyway — the - wedged-in-a-logic-loop case. ``_touch_activity`` (run_agent.py) bridges - chunk-level liveness into ``last_heartbeat_at``, so any genuinely active - worker stays fresh via normal API traffic. ``enforce_max_runtime`` and - ``detect_crashed_workers`` remain the upper bounds for wedged/dead workers. - - Returns the number of stale claims actually reclaimed (live-pid - extensions don't count). Safe to call often. + A host-local worker that is still alive gets its claim *extended* instead + (a slow model can sit longer than the TTL inside one tool-free call, so no + heartbeat) — unless ``last_heartbeat_at`` is older than + ``DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS`` (wedged; ``_touch_activity`` + keeps any genuinely active worker fresh). Safe to call often. """ now = int(time.time()) reclaimed = 0 @@ -2987,11 +2660,8 @@ def reclaim_task( reason: Optional[str] = None, signal_fn=None, ) -> bool: - """Operator-driven reclaim regardless of TTL (unlike - :func:`release_stale_claims`): release the claim and restore the source - phase so a running worker can be aborted on demand. False when the task - is missing or not running. - """ + """Operator reclaim regardless of TTL: release the claim, restore the source + phase, reset the failure counter. False when not running.""" row = conn.execute( "SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?", (task_id,), @@ -3035,11 +2705,8 @@ def reassign_task( reclaim_first: bool = False, reason: Optional[str] = None, ) -> bool: - """Reassign (``profile=None`` unassigns). A running task is refused - (False) unless ``reclaim_first`` releases its claim via - :func:`reclaim_task` first — the "this profile's model is broken, try - another" recovery path. - """ + """Reassign (None unassigns); a running task is refused unless + ``reclaim_first`` releases its claim — the "this profile's model is broken" path.""" if reclaim_first: # Safe to call even if nothing to reclaim. reclaim_task(conn, task_id, reason=reason or "reassign") @@ -3057,26 +2724,10 @@ def _verify_created_cards( completing_task_id: str, claimed_ids: Iterable[str], ) -> tuple[list[str], list[str]]: - """Partition ``claimed_ids`` into (verified, phantom). - - A card is "verified" iff a row exists in ``tasks`` AND at least one - of the following holds: - - * ``created_by`` matches the completing task's ``assignee`` profile - (the common case: worker A spawns a card via ``kanban_create``, - which stamps ``created_by=A``). - * ``created_by`` matches the completing task's id (edge case where - a worker passed its own task id as the ``created_by`` value). - * The card is linked as a ``task_links.child`` of the completing - task — i.e. the worker explicitly called ``kanban_create`` with - ``parents=[]``. This accepts cards created through - the dashboard/CLI by a different principal but then attached to - the completing task by the worker. - - ``phantom`` returns ids that either don't exist at all, or exist - but don't satisfy any of the three trust conditions. The caller - decides what to do with each bucket; this helper never mutates. - """ + """Partition ``claimed_ids`` into (verified, phantom). Verified = the row + exists AND ``created_by`` is the completing task's assignee or id, OR the + card is linked as its child (created elsewhere, attached by the worker). + Never mutates.""" ordered = list(dict.fromkeys(str(x).strip() for x in (claimed_ids or []) if str(x).strip())) if not ordered: return [], [] @@ -3125,27 +2776,16 @@ _TASK_ID_PROSE_RE = re.compile(r"\bt_[a-f0-9]{8,}\b") def _scan_prose_for_phantom_ids(conn: sqlite3.Connection, text: str) -> list[str]: - """Regex-scan free-form text for ``t_`` references; return the - ones that don't exist in ``tasks``. - - Used as a non-blocking advisory check on completion summaries. An - empty return means "no suspicious references found" — either the - text had no IDs at all, or every ID it mentioned resolves to a real - task. Duplicates are deduped. - """ + """``t_`` references in ``text`` that don't resolve to a task (deduped; advisory).""" if not text: return [] return _missing_task_ids(conn, dict.fromkeys(_TASK_ID_PROSE_RE.findall(text))) class HallucinatedCardsError(ValueError): - """Raised by ``complete_task`` when ``created_cards`` contains ids - that don't exist or weren't created by the completing worker. - - The phantom list is attached as ``.phantom`` for callers that want - structured access. Kept as ``ValueError`` subclass so existing - tool-error handlers treat it as a recoverable user error. - """ + """``complete_task`` refused: ``created_cards`` has ids that don't exist or + weren't created by this worker (``.phantom``). A ``ValueError`` so tool + error handlers treat it as recoverable.""" def __init__(self, phantom: list[str], completing_task_id: str): self.phantom = list(phantom) @@ -3171,37 +2811,15 @@ def complete_task( expected_run_id: Optional[int] = None, fire_lifecycle_hook: bool = True, ) -> bool: - """Transition ``running|ready|blocked|review -> done`` and record ``result``. + """``running|ready|blocked|review -> done``; records ``result``. - Accepts a task that is merely ``ready`` too, so a manual CLI - completion (``hermes kanban complete ``) works without requiring - a claim/start/complete sequence. ``review`` is accepted so a human - (or reviewer) can approve a task parked in the review lane by - :func:`request_review` — even when it has no active run - (``current_run_id IS NULL``), the handoff fields are preserved via - :func:`_synthesize_ended_run`. - - ``summary`` and ``metadata`` are stored on the closing run (if any) - and surfaced to downstream children via :func:`build_worker_context`. - When ``summary`` is omitted we fall back to ``result`` so single-run - callers do not have to pass both. ``metadata`` is a free-form dict - (e.g. ``{"changed_files": [...], "tests_run": [...]}``) — workers - are encouraged to use it for structured handoff facts. - - ``created_cards`` is an optional list of task ids the completing - worker claims to have created. Each id is verified against - ``tasks.created_by``. If any id is phantom (does not exist or was - not created by this worker's assignee profile), completion is blocked - with a ``HallucinatedCardsError`` and a - ``completion_blocked_hallucination`` event is emitted so the rejected - attempt is auditable. When all ids verify, they are recorded on the - ``completed`` event payload. - - After a successful completion, ``summary`` and ``result`` are scanned - for prose references like ``t_deadbeefcafe`` that do not resolve. - Any suspected phantom references are recorded as a - ``suspected_hallucinated_references`` event. This pass is advisory - and never blocks. + ``ready`` is accepted for manual CLI completion, ``review`` for human + approval; with no active run the handoff fields survive via + :func:`_synthesize_ended_run`. ``summary`` (defaults to ``result``) and + ``metadata`` land on the closing run for :func:`build_worker_context`. + ``created_cards`` are verified first — a phantom id raises + :class:`HallucinatedCardsError` after an auditable event; afterwards the + prose is scanned for unresolvable ``t_`` refs (advisory event only). """ now = int(time.time()) # Fail before validating cards or staging artifacts; re-checked inside the @@ -3284,12 +2902,9 @@ _REVIEW_APPROVED_NOTE = "Review approved without additional evidence." def _gate_created_cards( conn: sqlite3.Connection, task_id: str, created_cards: Optional[Iterable[str]], preview_text: Optional[str], ) -> list[str]: - """Verify ``created_cards`` BEFORE the main write txn; returns the verified ids. - - A phantom id blocks completion: the rejection is recorded in its own tiny - txn (auditable) and raised as :class:`HallucinatedCardsError` without - touching task state. - """ + """Verify ``created_cards`` BEFORE the main write txn; returns the verified + ids. A phantom id is recorded in its own tiny txn (auditable) then raised + as :class:`HallucinatedCardsError` without touching task state.""" if not created_cards: return [] verified_cards, phantom_cards = _verify_created_cards(conn, task_id, created_cards) @@ -3373,12 +2988,9 @@ def _merge_completion_prose_artifacts( summary: Optional[str], result: Optional[str], ) -> Optional[dict]: - """Promote existing scratch files named in legacy completion prose. - - ``artifacts=[...]`` is preferred. Older workers only wrote an absolute - deliverable path in ``summary``/``result``; discover it while scratch still - exists so cleanup cannot erase the file the user was promised. - """ + """Legacy workers named deliverables only by absolute path in prose; add + those that exist under the scratch workspace to ``metadata["artifacts"]`` + before cleanup can erase them.""" workspace = _scratch_workspace(conn, task_id) if workspace is None: return metadata @@ -3615,18 +3227,9 @@ def block_task( kind: Optional[str] = None, expected_run_id: Optional[int] = None, ) -> bool: - """Transition ``running``/``ready`` → ``blocked`` (or route elsewhere). - - ``kind`` (:data:`VALID_BLOCK_KINDS` or ``None`` = legacy un-typed) routes: - ``dependency`` -> ``todo`` (parent gating / ``recompute_ready`` promotes - it; never parked where a cron would keep "unblocking" it); everything - else -> ``blocked`` for a human, EXCEPT that a re-block for the SAME kind - after an unblock bumps ``block_recurrences`` and at - :data:`BLOCK_RECURRENCE_LIMIT` routes to ``triage`` instead, breaking the - cron-unblock ↔ worker-re-block loop. ``transient`` signals "may clear on - its own" but still counts toward the loop breaker so a forever-flaky task - escalates. Returns True on any transition, False when not blockable. - """ + """``running``/``ready`` -> ``blocked`` (or ``todo`` / ``triage``, see + :func:`_route_block`). ``transient`` still counts toward the loop breaker + so a forever-flaky task escalates. True on any transition.""" if kind is not None and kind not in VALID_BLOCK_KINDS: raise ValueError(f"block kind must be one of {sorted(VALID_BLOCK_KINDS)} or None") with write_txn(conn): @@ -3729,24 +3332,13 @@ def request_review( force: bool = False, with_reason: bool = False, ): - """Transition implementation work into the first-class review phase. + """``running``/``ready`` -> ``review``; never touches block recurrence accounting. - Unlike :func:`block_task`, this transition never touches block recurrence - accounting. The current implementer and resolved reviewer are recorded on - the event so an autonomous reviewer can route requested changes back to the - right profile. Supplying ``reviewer`` reassigns the task before it is - exposed to the review dispatcher. On re-review, omitting it reuses the - reviewer provenance persisted by the latest ``changes_requested`` event. - - When the task is ``running`` under a live claim, a caller that supplies no - ``expected_run_id`` must pass ``force=True`` (explicit human/CLI override) - — otherwise the request is refused instead of silently clearing the live - worker's ``claim_lock``/``worker_pid``. Workers prove ownership by passing - their own run id as ``expected_run_id`` (unchanged). - - Returns ``bool`` by default. With ``with_reason=True`` returns - ``(ok, reason)`` mirroring :func:`request_changes` — ``reason`` is a - diagnostic string on failure, ``None`` on success. + Implementer and reviewer are recorded on the event so requested changes + route back to the right profile; ``reviewer`` reassigns the task, and on + re-review defaults to the latest ``changes_requested`` provenance. A live + claim is only cleared with proof of ownership (``expected_run_id``) or + ``force=True``. Returns ``bool``, or ``(ok, reason)`` with ``with_reason``. """ def _ret(ok: bool, reason: Optional[str] = None): @@ -3860,14 +3452,9 @@ def request_changes( reason: str, expected_run_id: Optional[int] = None, ) -> tuple[bool, Optional[str]]: - """Finish an active review run and route the task back for rework. - - The transition is valid only for a run claimed from ``review``. It closes - that reviewer run, restores the implementer recorded by the latest - ``review_requested`` event, reapplies parent gating, and emits an auditable - ``changes_requested`` event. The second tuple item is the implementer on - success or a diagnostic reason on failure. - """ + """Close an active reviewer run (claimed from ``review``) and hand the task + back to the implementer from the latest ``review_requested`` event, parent + gating reapplied. Returns ``(ok, implementer | reason)``.""" reason = str(redact_review_value(reason or "")).strip() if not reason: return False, "reason is required" @@ -3948,16 +3535,9 @@ def promote_task( force: bool = False, dry_run: bool = False, ) -> tuple[bool, Optional[str]]: - """Manually promote a `todo` or `blocked` task to `ready`. - - Mirrors the automatic promotion done by ``recompute_ready`` but - drives it from a deliberate operator action with an audit-trail - entry. Refuses to promote if any parent dep is not in a terminal - state (`done`/`archived`) unless ``force=True``. Does NOT change - assignee or claim state. Returns ``(True, None)`` on success and - ``(False, reason)`` if refused. ``dry_run=True`` validates the - promotion would succeed without mutating state. - """ + """Operator promotion ``todo``/``blocked`` -> ``ready`` with an audit event. + Refused while a parent is unfinished unless ``force``; ``dry_run`` only + validates. Returns ``(ok, reason)``.""" cur_status = _task_status(conn, task_id) if cur_status is None: return False, f"task {task_id} not found" @@ -4006,12 +3586,8 @@ def promote_task( def _reclaim_dangling_run( conn: sqlite3.Connection, task_id: str, *, statuses, now: int, note: str, ) -> None: - """Close a leaked ``current_run_id`` (run row still open) before a status - flip, preserving the runs invariant (``current_run_id IS NULL`` ⇔ run row - terminal). No-op in the common path where the prior transition already - closed the run. Shared by :func:`unblock_task` and - :func:`reopen_review_task` so the recovery can't drift. - """ + """Close a leaked open run before a status flip so the invariant + ``current_run_id IS NULL <=> run row terminal`` holds; no-op normally.""" placeholders = ", ".join("?" for _ in statuses) stale = conn.execute( f"SELECT current_run_id FROM tasks WHERE id = ? AND status IN ({placeholders})", @@ -4032,29 +3608,14 @@ def _reclaim_dangling_run( def _landing_status_after_parents(conn: sqlite3.Connection, task_id: str) -> str: - """Return ``'todo'`` if any parent isn't ``done`` yet, else ``'ready'``. - - The parent-completion re-gate shared by :func:`unblock_task` and - :func:`reopen_review_task`: flipping straight to ``ready`` would bypass the - parent-completion invariant the dispatcher trusts (it would spawn a child - whose upstream work isn't finished). If parents are still in progress the - task waits in ``todo`` until ``recompute_ready`` picks it up. RCA: Bug 2 at - kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md. Kept in one place - so the two transitions can't drift. - """ + """``ready`` if every parent is terminal else ``todo`` — the re-gate shared by + unblock/reopen so neither can spawn a child whose upstream is unfinished.""" return "ready" if _parents_satisfied(conn, task_id) else "todo" def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool: - """Transition ``blocked``/``scheduled`` to its safe resumable phase. - - Defensively closes any stale ``current_run_id`` pointer before flipping - status. In the common path (``block_task`` closed the run already) this - is a no-op. If a future or external write left the pointer dangling, - the leaked run is closed as ``reclaimed`` inside the same txn so the - runs invariant (``current_run_id IS NULL`` ⇔ run row in terminal - state) holds for the rest of this function's lifetime. - """ + """``blocked``/``scheduled`` -> its resumable phase (parent re-gated; ``review`` + when that is where it left off), closing any leaked run first.""" now = int(time.time()) with write_txn(conn): resume_status = ( @@ -4103,19 +3664,10 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool: def reopen_review_task(conn: sqlite3.Connection, task_id: str) -> bool: - """Transition ``review`` -> ready (or todo) so the implementer re-runs. - - The "changes requested" counterpart of :func:`request_review`: sends the - task back out of the review lane so the dispatcher re-runs the implementer - on the new comments. Mirrors :func:`unblock_task` (parent re-gating, - defensive stale-run close, ``consecutive_failures`` preserved) and emits a - ``review_reopened`` event. - - Deliberately does NOT touch ``block_recurrences``/``block_kind``: review is - not a block, so there is no loop counter to reset. (A stale counter from a - genuine block *before* review is left intact — only :func:`complete_task` - clears it.) Returns False when the task is missing or not in ``review``. - """ + """``review`` -> ``ready``/``todo`` so the implementer re-runs on the new + comments; restores the implementer from the ``review_requested`` event. + Preserves ``consecutive_failures`` and the block loop counter (review is + not a block; only :func:`complete_task` clears them).""" now = int(time.time()) with write_txn(conn): _reclaim_dangling_run( @@ -4156,41 +3708,21 @@ def invalidate_descendants_for_parent_reopen( *, author: str, ) -> dict[str, Any]: - """Retract every dispatchable/completed descendant of a reopened ancestor. + """THE done-reopen invalidation: every ``ready``/``review``/``running``/``done`` + descendant of a reopened ancestor is demoted to ``todo`` and re-gated. + Every surface that reopens a done task (dashboard PATCH/drag) routes here. - THE single implementation of done-reopen descendant invalidation. When a - ``done``/``archived`` ancestor is reopened, every descendant whose state - assumed its result — ``ready``, ``review``, ``running`` or ``done`` — is - demoted to ``todo`` and re-gated on the graph. The CLI has no done-reopen - verb (``reopen-review`` is the review-phase transition), so every surface - that reopens a done task (dashboard drag-drop / PATCH via - ``_set_status_direct``) must route through here. + Composes under the caller's txn (``allow_nested=True``) so the flip and the + retractions commit atomically. Each descendant gets a + ``descendant_invalidated`` event, the legacy ``status`` event the live feed + renders, and a comment naming the ancestor. Running descendants are closed + ``reclaimed`` and their workers killed strictly post-commit (audit trail + before death) — when composed, the CALLER must drain ``terminations`` + after its own commit. ``consecutive_failures`` resets (deliberate operator + action), the opposite of :func:`reopen_review_task`. - Transactionality: composes under the caller's open transaction via - ``write_txn(conn, allow_nested=True)`` so the ancestor's status flip and - the retractions commit atomically; standalone it opens its own. All SQL - is inline (no txn-opening helpers). - - Non-silent contract — every invalidated descendant gets a - ``descendant_invalidated`` event (``ancestor, prior_status, new_status, - resume_status``), the legacy ``status`` event - (``reason=ancestor_reopened``) the live feed renders, and a comment - naming the ancestor so operators see WHY a card moved. - - Live ``running`` descendants are wasted spend: their run is closed - ``reclaimed`` and the worker killed via :func:`_terminate_reclaimed_worker` - strictly post-commit, so the audit trail exists BEFORE the worker dies. - When composing under a caller's transaction the caller MUST drain the - returned ``terminations`` after its own commit. - - ``consecutive_failures`` resets to 0: ancestor reopen is a deliberate - operator action, so demoted work gets a fresh breaker budget. This is the - OPPOSITE of :func:`reopen_review_task` (preserves the counter) because the - autonomous review loop must not launder its own failure streak. - - Returns ``{"invalidated": [...], "terminations": [...]}`` where each - invalidated entry is ``{id, prior_status, new_status, resume_status}`` - and each termination is a ``(worker_pid, claim_lock)`` tuple. + Returns ``{"invalidated": [{id, prior_status, new_status, resume_status}], + "terminations": [(worker_pid, claim_lock)]}``. """ caller_owns_txn = bool(getattr(conn, "in_transaction", False)) now = int(time.time()) @@ -4281,21 +3813,9 @@ def specify_triage_task( assignee: Optional[str] = None, author: Optional[str] = None, ) -> bool: - """Flesh out a triage task and promote it to ``todo``. - - Atomically updates ``title`` / ``body`` / ``assignee`` (when provided) - and transitions ``status: triage -> todo`` in a single write txn. Returns - False when the task is missing or not in the ``triage`` column — callers - should surface that as "nothing to specify" rather than an error. - - ``todo`` (not ``ready``) is the correct landing column: ``recompute_ready`` - promotes parent-free / parent-done todos to ``ready`` on the next - dispatcher tick, which keeps the normal parent-gating behaviour intact - for specified tasks that happen to have open parents. - - ``author`` is recorded on an audit comment only when at least one of - ``title`` / ``body`` / ``assignee`` actually changed — avoids noisy - comment spam for status-only promotions. + """Update title/body/assignee (when given) and move ``triage -> todo`` in one + txn; False when not in triage. Lands in ``todo`` (not ``ready``) so parent + gating still applies; the audit comment is written only when a field changed. """ if title is not None and not title.strip(): raise ValueError("title cannot be blank") @@ -4357,13 +3877,8 @@ def specify_triage_task( def _validate_children_graph(children: list) -> None: - """Shape-check ``decompose_triage_task`` children and reject cycles. - - Cheap, DB-free, so bad input aborts before the txn. The sibling parent - graph is checked whole (Kahn's sort) rather than edge-by-edge like - ``link_tasks``/``_would_cycle``: a cycle silently deadlocks every involved - child in ``todo`` because ``recompute_ready`` can never promote them. - """ + """DB-free shape check + Kahn's cycle check on the sibling graph (a cycle + would deadlock every involved child in ``todo`` forever).""" for idx, child in enumerate(children): if not isinstance(child, dict): raise ValueError(f"child[{idx}] is not a dict") @@ -4406,19 +3921,13 @@ def decompose_triage_task( author: Optional[str] = None, auto_promote: bool = True, ) -> Optional[list[str]]: - """Fan a triage task out into child tasks and promote the root to ``todo``. + """Fan a triage task out into children and move the root to ``todo``; the root + waits on every child and wakes (``ready``) when all are done. - The root task stays alive and becomes the parent of every child — - when all children reach ``done``, the root promotes to ``ready`` and - its assignee (typically the orchestrator profile) wakes back up to - judge completion or spawn more work. - - ``children``: dicts of ``title`` (required), ``body``, ``assignee`` (None - -> default fallback) and ``parents`` (indices into this same list). - Returns the created child ids in input order, or ``None`` when the root - is missing / not in ``triage`` / the graph has a cycle. Title/assignee - validation runs inside the same write_txn as the inserts so a malformed - entry aborts the whole decomposition (no orphan children). + ``children``: dicts of ``title`` (required), ``body``, ``assignee``, + ``parents`` (indices into this list), optional workspace overrides. + Returns child ids in input order, or None when the root is missing / not + in triage. Atomic: a malformed entry aborts the whole fan-out. """ if not children: return None @@ -4559,12 +4068,8 @@ def _delete_task_relations(conn: sqlite3.Connection, task_id: str) -> None: def delete_archived_task(conn: sqlite3.Connection, task_id: str) -> bool: - """Permanently remove an already-archived task and its related rows. - - Safety guard: only archived tasks can be deleted. Active / blocked / done - tasks must be explicitly archived first so accidental data loss requires a - second deliberate action. - """ + """Hard-delete an ARCHIVED task (+ related rows); anything else must be + archived first so data loss takes two deliberate actions.""" with write_txn(conn): if _task_status(conn, task_id) != "archived": return False @@ -4574,15 +4079,7 @@ def delete_archived_task(conn: sqlite3.Connection, task_id: str) -> bool: def delete_task(conn: sqlite3.Connection, task_id: str) -> bool: - """Hard-delete a task and cascade to all related rows. - - Because the schema does not use ``ON DELETE CASCADE`` foreign keys, - we explicitly delete from child tables first, then the task row. - This keeps the operation atomic (single ``write_txn``). - - Returns ``True`` if the task existed and was deleted, ``False`` - if the task was not found. - """ + """Hard-delete a task and its related rows in one txn; False when not found.""" with write_txn(conn): cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) if cur.rowcount != 1: @@ -4600,12 +4097,8 @@ def schedule_task( reason: Optional[str] = None, expected_run_id: Optional[int] = None, ) -> bool: - """Park a task in ``scheduled`` so it is waiting on time, not human input. - - ``scheduled`` tasks are intentionally not dispatchable; an external cron, - human action, or automation can later call ``unblock_task`` to re-gate them - to ``ready`` (or ``todo`` if parents are still incomplete). - """ + """Park in ``scheduled`` (waiting on time, not a human; not dispatchable) + until ``unblock_task`` re-gates it.""" with write_txn(conn): params: list[Any] = [task_id] sql = """ @@ -4639,16 +4132,10 @@ def schedule_task( # --------------------------------------------------------------------------- def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str: - """Return the full text a worker should read to understand its task. - - Sections in order: header, body, attachments, prior attempts on this task, - done-parent handoffs (``run.summary``/``metadata``, falling back to - ``task.result`` for pre-runs data), the assignee's recent completed runs - on other tasks, comment thread. Every list is tail-capped (``_CTX_MAX_*``) - with the omitted head summarised, and every field is char-capped, so the - prompt stays bounded on pathological boards (retry storms, comment - storms, a single 1 MB summary). - """ + """Everything a worker should read about its task: header, body, + attachments, prior attempts, done-parent handoffs, the assignee's recent + work, comments. Lists are tail-capped and fields char-capped + (``_CTX_MAX_*``) so the prompt stays bounded on pathological boards.""" task = get_task(conn, task_id) if not task: raise ValueError(f"unknown task {task_id}") @@ -4860,9 +4347,7 @@ def _ctx_comments(lines: list[str], comments: list[Comment], now: int) -> None: # --------------------------------------------------------------------------- def board_stats(conn: sqlite3.Connection) -> dict: - """Per-status + per-assignee counts, plus the oldest ``ready`` age in - seconds (the clearest staleness signal for a router or HUD). - """ + """Per-status + per-assignee counts and the oldest ``ready`` age (staleness signal).""" by_status: dict[str, int] = {} for row in conn.execute( "SELECT status, COUNT(*) AS n FROM tasks " @@ -4902,11 +4387,7 @@ def _counts_by_assignee(conn: sqlite3.Connection) -> dict[str, dict[str, int]]: def _to_epoch(val) -> Optional[int]: - """Normalise a timestamp to unix epoch seconds. - - Accepts ints (pass-through), numeric strings, and ISO-8601 strings. - Returns ``None`` for ``None`` / empty values. - """ + """Epoch seconds from int/float/numeric string/ISO-8601; None for empty/invalid.""" if val is None: return None if isinstance(val, (int, float)): @@ -4945,10 +4426,7 @@ def task_age(task: Task) -> dict: # --------------------------------------------------------------------------- def gc_events(conn: sqlite3.Connection, *, older_than_seconds: int = 30 * 24 * 3600) -> int: - """Delete task_events rows older than ``older_than_seconds`` for tasks - in a terminal state (``done`` or ``archived``). Returns the number of - rows deleted. Running / ready / blocked tasks keep their full event - history.""" + """Delete events older than the cutoff on done/archived tasks only; returns the count.""" cutoff = int(time.time()) - int(older_than_seconds) with write_txn(conn): cur = conn.execute( @@ -4960,11 +4438,7 @@ def gc_events(conn: sqlite3.Connection, *, older_than_seconds: int = 30 * 24 * 3 def gc_worker_logs(*, older_than_seconds: int = 30 * 24 * 3600, board: Optional[str] = None) -> int: - """Delete worker log files older than ``older_than_seconds``. Returns - the number of files removed. Kept separate from ``gc_events`` because - log files live on disk, not in SQLite. Scoped to ``board`` (defaults - to the active board) — per-board isolation means deleting logs from - board A cannot touch board B's logs.""" + """Delete worker log files older than the cutoff on one board; returns the count.""" log_dir = worker_logs_dir(board=board) if not log_dir.exists(): return 0 @@ -4983,13 +4457,8 @@ def gc_worker_logs(*, older_than_seconds: int = 30 * 24 * 3600, board: Optional[ # --------------------------------------------------------------------------- def worker_log_path(task_id: str, *, board: Optional[str] = None) -> Path: - """Return the path to a worker's log file. The file may not exist - (task never spawned, or log already GC'd). - - When ``board`` is None, resolves via the active board (env var → - current-board file → default). The dispatcher always passes the - board explicitly to avoid any resolution ambiguity when multiple - boards exist.""" + """Worker log path (may not exist). The dispatcher always passes ``board`` + explicitly to avoid resolution ambiguity.""" return worker_logs_dir(board=board) / f"{task_id}.log" @@ -4997,9 +4466,7 @@ def read_worker_log( task_id: str, *, tail_bytes: Optional[int] = None, board: Optional[str] = None, ) -> Optional[str]: - """Read the worker log for ``task_id``. Returns None if the file - doesn't exist. If ``tail_bytes`` is set, only the last N bytes are - returned (useful for the dashboard drawer which shouldn't page megabytes).""" + """Worker log text (last ``tail_bytes`` when set); None when the file is missing.""" path = worker_log_path(task_id, board=board) if not path.exists(): return None @@ -5026,10 +4493,8 @@ def read_worker_log( # --------------------------------------------------------------------------- def list_profiles_on_disk() -> list[str]: - """Profile names on disk: ``/profiles//config.yaml`` - plus the implicit ``default`` when the root exists. Reads paths directly - to avoid importing ``hermes_cli.profiles`` (a large chunk of CLI startup). - """ + """Profiles with a ``config.yaml`` plus the implicit ``default``; reads paths + directly to avoid importing ``hermes_cli.profiles`` at startup.""" try: from hermes_constants import get_default_hermes_root default_root = get_default_hermes_root() @@ -5049,10 +4514,8 @@ def list_profiles_on_disk() -> list[str]: def known_assignees(conn: sqlite3.Connection) -> list[dict]: - """Every assignee that is a profile on disk OR assigned to a non-archived - task, as ``{"name", "on_disk", "counts": {status: n}}`` — so a fresh - profile appears in pickers before it has any task. - """ + """``{"name", "on_disk", "counts"}`` for every on-disk profile or task + assignee, so a fresh profile appears in pickers before it has a task.""" on_disk = set(list_profiles_on_disk()) counts = _counts_by_assignee(conn) return [ @@ -5073,10 +4536,8 @@ def list_runs( state_type: Optional[str] = None, state_name: Optional[str] = None, ) -> list[Run]: - """All runs for ``task_id`` in start order. ``include_active=False`` returns - only closed runs; ``state_type`` (``status``/``outcome``) + ``state_name`` - filter on that column and must be passed together. - """ + """Runs in start order; ``include_active=False`` = closed only; ``state_type`` + (``status``/``outcome``) + ``state_name`` filter together.""" if (state_type is None) ^ (state_name is None): raise ValueError("state_type and state_name must both be set or both omitted") if state_type is not None and state_type not in ("status", "outcome"): @@ -5109,11 +4570,8 @@ def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]: def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]: - """Latest non-null ``task_runs.summary`` (newest ``ended_at``, ``id`` for - ties), or None. Workers hand off via ``complete_task(summary=...)`` and - leave ``tasks.result`` NULL, so show/dashboard views need this or a - completed task looks like a no-op. - """ + """Newest non-empty run summary, or None. Workers hand off via ``summary`` and + leave ``tasks.result`` NULL, so views need this or a done task looks empty.""" row = conn.execute( "SELECT summary FROM task_runs " "WHERE task_id = ? AND summary IS NOT NULL AND summary != '' " @@ -5124,11 +4582,8 @@ def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]: def latest_summaries(conn: sqlite3.Connection, task_ids: Iterable[str]) -> dict[str, str]: - """``{task_id: latest non-null run summary}`` for many tasks in one query - (dashboard board endpoint; avoids N+1 :func:`latest_summary` calls). - Window function -> needs SQLite >= 3.25 (default on every supported - platform). Tasks without a summary are omitted. - """ + """``{task_id: newest non-empty run summary}`` in one query (window function, + SQLite >= 3.25); tasks without a summary are omitted.""" ids = list(task_ids) if not ids: return {} From 679dfec93c85725bd9793260cc4ba23c57a091f3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:53:24 -0700 Subject: [PATCH 30/38] =?UTF-8?q?refactor(kanban-cli):=20specify=20?= =?UTF-8?q?=E2=80=94=20join=20short=20multi-line=20calls=20(AST-identical)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/kanban_specify.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/hermes_cli/kanban_specify.py b/hermes_cli/kanban_specify.py index 0067c601d8..db2206ac7f 100644 --- a/hermes_cli/kanban_specify.py +++ b/hermes_cli/kanban_specify.py @@ -23,10 +23,7 @@ from hermes_cli import kanban_db as kb from utils import env_int -HERMES_KANBAN_SPECIFY_MAX_TOKENS = max( - 1500, - env_int("HERMES_KANBAN_SPECIFY_MAX_TOKENS", 6000), -) +HERMES_KANBAN_SPECIFY_MAX_TOKENS = max(1500, env_int("HERMES_KANBAN_SPECIFY_MAX_TOKENS", 6000)) logger = logging.getLogger(__name__) @@ -159,10 +156,7 @@ def _call_aux(verb: str, task_id: str, *, aux_task: str, system: str, user: str, try: resp = call_llm( task=aux_task, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], + messages=[{"role": "system", "content": system}, {"role": "user", "content": user}], temperature=0.3, max_tokens=max_tokens, timeout=timeout, @@ -227,10 +221,5 @@ 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: - tasks = kb.list_tasks( - conn, - status="triage", - tenant=tenant, - include_archived=False, - ) + tasks = kb.list_tasks(conn, status="triage", tenant=tenant, include_archived=False) return [t.id for t in tasks] From f875e802c262a4908c558b69560cd4f18fc71fd5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:53:37 -0700 Subject: [PATCH 31/38] refactor(kanban_db): compact comments by hand; one-line section banners; drop stale headers --- hermes_cli/kanban_db.py | 342 +++++++++++----------------------------- 1 file changed, 92 insertions(+), 250 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 6c21c538ab..cc7e4c8fb5 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -31,9 +31,7 @@ from toolsets import get_toolset_names _log = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Shared micro-helpers (row access, JSON, env, git) -# --------------------------------------------------------------------------- +# --- Shared micro-helpers (row access, JSON, env, git) --- def _row_get(row: Any, col: str, default: Any = None) -> Any: """``row[col]`` tolerant of the column being absent from the SELECT / schema.""" @@ -86,23 +84,16 @@ def _git_out(cwd: Path, *args: str, timeout: int = 30) -> Optional[str]: return (result.stdout or "").strip() or None -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- +# --- Constants --- VALID_STATUSES = {"triage", "todo", "scheduled", "ready", "running", "blocked", "review", "done", "archived"} VALID_INITIAL_STATUSES = {"running", "blocked"} -# Typed block reasons (routing rules live on ``block_task``): ``dependency`` -# -> ``todo`` (parent gating promotes it, no human/cron/retry storm); -# ``needs_input`` / ``capability`` -> ``blocked`` for a human; ``transient`` = -# may clear on retry. ``None`` = legacy un-typed block (generic human blocker). +# Typed block reasons (routing in ``_route_block``); ``None`` = legacy un-typed. VALID_BLOCK_KINDS = {"dependency", "needs_input", "capability", "transient"} -# Same-reason block -> unblock -> re-block cycles tolerated before the loop -# breaker stops trusting the unblocker (usually a cron) and routes to ``triage`` -# for a human decision. Counts manual unblock recurrences, NOT dispatcher -# spawn/crash/timeout failures (that is ``DEFAULT_FAILURE_LIMIT``). +# Same-reason block -> unblock -> re-block cycles before routing to ``triage``. +# Counts unblock recurrences, NOT dispatcher failures (``DEFAULT_FAILURE_LIMIT``). BLOCK_RECURRENCE_LIMIT = 2 VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} @@ -124,7 +115,7 @@ def normalize_reasoning_effort(effort: Optional[str]) -> Optional[str]: KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names()) _IS_WINDOWS = sys.platform == "win32" -KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024 +KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024 # one cap for dashboard, tools and CLI def _assert_not_delegated_child_mutation() -> None: @@ -288,16 +279,12 @@ def _fire_dispatch_tick_hook( # ``heartbeat_claim`` or raise it via HERMES_KANBAN_CLAIM_TTL_SECONDS. DEFAULT_CLAIM_TTL_SECONDS = 15 * 60 -# A live PID whose ``last_heartbeat_at`` is older than this is treated as -# wedged (logic loop, no observable progress) and reclaimed regardless of -# liveness. ``_touch_activity`` bridges chunk-level API liveness into the -# heartbeat, so a genuinely active worker never trips this. +# A live PID with a heartbeat older than this is wedged and reclaimed anyway +# (``_touch_activity`` keeps genuinely active workers fresh). DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS = 60 * 60 -# Grace added when a reclaim is deferred because the host-local worker -# survived a termination attempt (cgroup memory.high throttle parking it in -# D state, SIGKILL pending). Releasing the claim then would spawn a duplicate -# beside the survivor; holding it a tick lets the signal land. +# Grace when a host-local worker survived termination (e.g. parked in D state +# under memory.high, SIGKILL pending): releasing now would spawn a duplicate. RECLAIM_DEFER_GRACE_SECONDS = 120 @@ -309,15 +296,12 @@ def _resolve_claim_ttl_seconds(ttl_seconds: Optional[int] = None) -> int: return _env_int("HERMES_KANBAN_CLAIM_TTL_SECONDS", DEFAULT_CLAIM_TTL_SECONDS, minimum=1) -# After ``running`` starts, ``detect_crashed_workers`` skips ``_pid_alive`` for -# this long: the fork() -> /proc-visibility window can transiently report a -# fresh worker dead. The claim TTL still catches real crashes. +# ``detect_crashed_workers`` skips ``_pid_alive`` this long after start: the +# fork -> /proc window can report a fresh worker dead. DEFAULT_CRASH_GRACE_SECONDS = 30 -# Worker exit code meaning "provider rate-limited / quota exhausted, not a task -# failure": the reap classifier maps it to ``rate_limited`` so the task is -# released WITHOUT counting a failure (the breaker must never trip on a -# throttle). 75 == BSD EX_TEMPFAIL, clear of the worker's 0/1/2 codes. +# Worker exit "provider rate-limited": released WITHOUT counting a failure (the +# breaker must never trip on a throttle). 75 == BSD EX_TEMPFAIL. KANBAN_RATE_LIMIT_EXIT_CODE = 75 @@ -331,8 +315,7 @@ def _resolve_rate_limit_cooldown_seconds() -> int: return _env_int("HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS", DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS) -# build_worker_context() caps (independently tunable) so the prompt stays -# bounded on pathological boards; sized for a ~100k-char prompt with headroom. +# build_worker_context() caps, sized for a ~100k-char prompt with headroom. _CTX_MAX_PRIOR_ATTEMPTS = 10 # most recent N prior runs shown in full _CTX_MAX_COMMENTS = 30 # most recent N comments shown in full _CTX_MAX_FIELD_BYTES = 4 * 1024 # per summary/error/metadata/result @@ -362,9 +345,7 @@ def _relative_age(ts: Optional[int], now: Optional[int] = None) -> str: return f"{delta // 86400}d ago" -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- +# --- Paths --- DEFAULT_BOARD = "default" _CURRENT_BOARD_OVERRIDE: ContextVar[str | None] = ContextVar( @@ -383,11 +364,8 @@ def scoped_current_board(slug: str): _CURRENT_BOARD_OVERRIDE.reset(token) -# Slug validator: lowercase alphanumerics, digits, hyphens; 1–64 chars. -# Strict enough to stop traversal (`..`) and embedded path separators, loose -# enough that kebab-case names like ``atm10-server`` or ``hermes-agent`` -# pass without fuss. Board names with display formatting (spaces, emoji) -# live in ``board.json``; the slug is just the directory name. +# Slug = directory name: strict enough to stop traversal / separators, loose +# enough for kebab-case. Display names (spaces, emoji) live in board.json. _BOARD_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$") @@ -573,10 +551,7 @@ def read_board_metadata(board: Optional[str] = None) -> dict: "icon": "", "color": "", "default_workdir": None, - # Optional first-class Project this board is scoped to. When set, new - # tasks inherit it (deterministic worktree + branch under the project's - # primary repo) and ``default_workdir`` mirrors the project's primary - # path so the persistent-workspace inheritance path keeps working. + # Project scope: new tasks inherit it (deterministic worktree + branch). "project_id": None, "created_at": None, "archived": False, @@ -710,9 +685,8 @@ def remove_board(slug: str, *, archive: bool = True) -> dict: if get_current_board() == normed: clear_current_board() - # A concurrent connect(board=normed) after the rename/delete recreates - # an empty sqlite file via mkdir(exist_ok=True); the cache entry must be - # dropped first so the schema init pass re-runs on that fresh file. + # A concurrent connect() after the rename recreates an empty DB file; drop + # the init cache first so the schema pass re-runs on it. _INITIALIZED_PATHS.discard(str((d / "kanban.db").resolve())) if archive: @@ -732,9 +706,7 @@ def remove_board(slug: str, *, archive: bool = True) -> dict: return {"slug": normed, "action": "deleted", "new_path": ""} -# --------------------------------------------------------------------------- -# Data classes -# --------------------------------------------------------------------------- +# --- Data classes --- @dataclass class Task: @@ -759,9 +731,7 @@ class Task: project_id: Optional[str] = None result: Optional[str] = None idempotency_key: Optional[str] = None - # Column semantics are documented on SCHEMA_SQL. Pre-rename columns: - # ``spawn_failures`` -> consecutive_failures, ``last_spawn_error`` -> - # last_failure_error (see ``from_row`` fallbacks). + # Column semantics: see SCHEMA_SQL. consecutive_failures: int = 0 worker_pid: Optional[int] = None last_failure_error: Optional[str] = None @@ -774,20 +744,14 @@ class Task: model_override: Optional[str] = None provider_override: Optional[str] = None # provider ``model_override`` belongs to reasoning_effort: Optional[str] = None # VALID_REASONING_EFFORTS | "none"; NULL = profile's - # Failure count at which the breaker trips (1 = block on first failure); - # None -> ``kanban.failure_limit`` config -> DEFAULT_FAILURE_LIMIT. + # Breaker trip count; None -> ``kanban.failure_limit`` -> DEFAULT_FAILURE_LIMIT. max_retries: Optional[int] = None - # Ralph-style goal loop (same engine as ``/goal``): a judge model re-checks - # the worker's response against title/body each turn and feeds a - # continuation prompt IN THE SAME SESSION until done, budget exhausted - # (-> kanban_block) or explicit block/complete. ``goal_max_turns`` None -> - # ``goals.DEFAULT_MAX_TURNS``. + # ``/goal``-style loop: a judge re-checks each turn IN THE SAME SESSION until + # done / budget exhausted (-> kanban_block); ``goal_max_turns`` None -> goals default. goal_mode: bool = False goal_max_turns: Optional[int] = None - # Originating agent session (``HERMES_SESSION_ID``); NULL from CLI/dashboard. - session_id: Optional[str] = None - # Typed block reason (VALID_BLOCK_KINDS) or None for legacy blocks; kept - # across unblock so a same-kind re-block is recognisable as a loop. + session_id: Optional[str] = None # originating HERMES_SESSION_ID; NULL from CLI/dashboard + # VALID_BLOCK_KINDS or None (legacy); kept across unblock so a same-kind re-block reads as a loop. block_kind: Optional[str] = None block_recurrences: int = 0 # unblock-loop counter, see BLOCK_RECURRENCE_LIMIT @@ -923,9 +887,7 @@ class Event: ) -# --------------------------------------------------------------------------- -# Schema -# --------------------------------------------------------------------------- +# --- Schema --- SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS tasks ( @@ -1125,9 +1087,7 @@ CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_ """ -# --------------------------------------------------------------------------- -# ID generation -# --------------------------------------------------------------------------- +# --- ID generation --- def _new_task_id() -> str: """``t_`` + 4 hex bytes (collision ~1e-3 at 100k tasks; 2 bytes would hit 50% @@ -1150,9 +1110,7 @@ def _host_prefix() -> str: return f"{_claimer_id().split(':', 1)[0]}:" -# --------------------------------------------------------------------------- -# Task creation / mutation -# --------------------------------------------------------------------------- +# --- Task creation / mutation --- def _validate_model_override(model: Optional[str], provider: Optional[str]) -> tuple[Optional[str], Optional[str]]: """Strip both; a provider without a model is rejected (a bare ``--provider`` @@ -1378,9 +1336,8 @@ def create_task( parents = tuple(p for p in parents if p) skills_list = _normalize_task_skills(skills) - # Idempotency check BEFORE the write txn (fast path, no write lock held). - # Race is acceptable: two concurrent creators may both insert; the next - # lookup stabilises on the newest. + # Idempotency check BEFORE the write txn (no lock held); a concurrent-create + # race may insert twice, the next lookup stabilises on the newest. if idempotency_key: row = conn.execute( "SELECT id FROM tasks WHERE idempotency_key = ? " @@ -1393,11 +1350,8 @@ def create_task( now = int(time.time()) - # Board ``default_workdir`` is a persistent checkout, so only persistent - # kinds inherit it. A scratch task must stay under the per-board scratch - # root: inheriting the default would point cleanup at the user's source - # tree (#28818). ``_cleanup_workspace`` containment is the safety rail; - # this stops the bad state from being created at all. + # Only persistent kinds inherit the board ``default_workdir``: a scratch + # task inheriting it would point cleanup at the user's source tree. if workspace_path is None and project_repo is None and workspace_kind in {"dir", "worktree"}: board_default = _board_meta_for(board).get("default_workdir") if board_default: @@ -1407,15 +1361,12 @@ def create_task( for attempt in range(2): task_id = _new_task_id() try: - # ``allow_nested=True``: graph builders (kanban_swarm.create_swarm) - # compose create_task calls under one outer commit so the - # dispatcher can never observe a partially constructed graph. + # allow_nested: graph builders compose create_task under one outer + # commit so the dispatcher never sees a half-built graph. with write_txn(conn, allow_nested=True): task_status = _initial_task_status(conn, parents, initial_status, triage) - # Project-linked worktree: a fresh worktree dir under the repo - # plus a deterministic branch (project slug + task id) — kills the - # random ``wt/`` worker fallback and the unanchored - # ``.worktrees/`` under the dispatcher's cwd. + # Project worktree: fresh dir under the repo + deterministic + # branch, instead of the random ``wt/`` worker fallback. if project_obj is not None and workspace_kind == "worktree": if project_repo and not workspace_path: workspace_path = os.path.join(project_repo, ".worktrees", task_id) @@ -1466,8 +1417,7 @@ def create_task( "provider_override": provider_override, }, ) - # ACK-edge: the originating channel still hears about a child - # that BLOCKs, not just the final fan-in. + # ACK-edge: the originating channel hears a child BLOCK, not just the fan-in. _inherit_notify_subs(conn, task_id, parents, created_at=now) return task_id except sqlite3.IntegrityError: @@ -1645,9 +1595,7 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) "Wait for completion or reclaim the stale lock first." ) if row["assignee"] != profile: - # The retry guard is scoped to the task/profile combination. A - # human reassigning the task is an explicit recovery action, so the - # new profile should not inherit the previous profile's streak. + # The failure streak is per task/profile; a new profile starts fresh. conn.execute( "UPDATE tasks SET assignee = ?, consecutive_failures = 0, " "last_failure_error = NULL WHERE id = ?", @@ -1656,8 +1604,7 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) else: conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id)) _append_event(conn, task_id, "assigned", {"assignee": profile}) - # Task-mutation observer (RFC #58548), fired AFTER the assignment txn - # has committed so subscribers always observe durable board state. + # Observer fires AFTER commit so subscribers see durable state. notify_task_updated(conn, task_id, ("assignee",)) return True @@ -1711,9 +1658,7 @@ def set_reasoning_effort(conn: sqlite3.Connection, task_id: str, effort: Optiona ) -# --------------------------------------------------------------------------- -# Links -# --------------------------------------------------------------------------- +# --- Links --- def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: if parent_id == child_id: @@ -1766,10 +1711,8 @@ def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> boo if removed: _append_event(conn, child_id, "unlinked", {"parent": parent_id, "child": child_id}) if removed: - # Dependency edge removed — re-evaluate promotion eligibility for the - # child immediately. Matches the contract of complete_task and - # unblock_task; without this the child stays stuck in todo until the - # next dispatcher tick or a manual `hermes kanban recompute` (issue #22459). + # Re-gate the child now (as complete_task/unblock_task do) instead of + # leaving it in todo until the next tick. recompute_ready(conn) return removed @@ -1815,9 +1758,7 @@ def task_graph_context(conn: sqlite3.Connection, task_id: str) -> dict: return task_graph_contexts(conn, [task_id])[task_id] -# --------------------------------------------------------------------------- -# Comments & events -# --------------------------------------------------------------------------- +# --- Comments & events --- def add_comment(conn: sqlite3.Connection, task_id: str, author: str, body: str) -> int: if not body or not body.strip(): @@ -1866,15 +1807,7 @@ def list_comments_after( return [Comment.from_row(r) for r in rows] -# --------------------------------------------------------------------------- -# Attachments -# --------------------------------------------------------------------------- - -# The attachment size cap is the module-level ``KANBAN_ATTACHMENT_MAX_BYTES`` -# (defined near the top of this file) — one constant shared by the dashboard -# HTTP endpoint, the agent toolset, and the CLI so the limit cannot drift -# between surfaces. - +# --- Attachments --- class AttachmentTooLarge(ValueError): """Attachment over the size cap. A ``ValueError`` so generic 400 handlers @@ -2151,9 +2084,7 @@ def _synthesize_ended_run( return int(cur.lastrowid or 0) -# --------------------------------------------------------------------------- -# Dependency resolution (todo -> ready) -# --------------------------------------------------------------------------- +# --- Dependency resolution (todo -> ready) --- def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool: """True when the newest ``blocked``/``unblocked`` event is ``blocked`` — an @@ -2221,10 +2152,7 @@ def recompute_ready(conn: sqlite3.Connection, failure_limit: int = None) -> int: task_id = row["id"] cur_status = row["status"] if cur_status == "blocked" and _has_sticky_block(conn, task_id): - # Worker / operator asked for explicit human intervention — do not - # silently auto-recover. ``unblock_task`` is the only - # legitimate exit (it emits ``"unblocked"`` which flips - # this predicate back). + # Explicit human-intervention block; only ``unblock_task`` may exit it. continue parents = conn.execute( "SELECT t.status FROM tasks t " @@ -2235,14 +2163,9 @@ def recompute_ready(conn: sqlite3.Connection, failure_limit: int = None) -> int: if all(p["status"] in ("done", "archived") for p in parents): resume_status = _resume_status_from_events(conn, task_id) if cur_status == "blocked": - # Don't auto-recover tasks that have hit the - # circuit-breaker failure limit. Without this - # guard, a task that repeatedly exhausts its - # iteration budget would cycle forever: - # block → auto-recover → respawn → budget - # exhausted → block → … The counter must also - # be preserved so the breaker can accumulate - # across recovery cycles. + # At the breaker limit, no auto-recovery (else block -> + # recover -> respawn -> exhaust -> block forever). The + # counter is preserved so it accumulates across cycles. failures = int(row["consecutive_failures"] or 0) task_limit = row["max_retries"] effective_limit = ( @@ -2269,9 +2192,7 @@ def recompute_ready(conn: sqlite3.Connection, failure_limit: int = None) -> int: return promoted -# --------------------------------------------------------------------------- -# Claim / complete / block -# --------------------------------------------------------------------------- +# --- Claim / complete / block --- def _parents_satisfied(conn: sqlite3.Connection, task_id: str) -> bool: """Return whether every direct parent is terminal for dependency gating.""" @@ -2361,14 +2282,9 @@ def claim_task( lock = claimer or _claimer_id() expires = now + _resolve_claim_ttl_seconds(ttl_seconds) with write_txn(conn): - # Structural invariant: never transition ready -> running while any - # parent is not yet 'done'. This is the single enforcement point - # regardless of which writer (create_task, link_tasks, unblock_task, - # release_stale_claims, manual SQL) set status='ready'. If a racy - # writer promoted a task with undone parents, demote it back to - # 'todo' here — recompute_ready will re-promote when the parents - # actually finish. See RCA at - # kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md. + # Single enforcement point: never ready -> running with an undone + # parent, whichever writer set 'ready'. Demote to 'todo'; + # recompute_ready re-promotes when the parents finish. if not _parents_satisfied(conn, task_id): conn.execute( "UPDATE tasks SET status = 'todo' " @@ -2380,8 +2296,7 @@ def claim_task( {"reason": "parents_not_done"}, ) return None - # Defensive: close a leaked prior run as 'reclaimed' so the CAS below - # doesn't strand it. No-op when the runs invariant holds. + # Close a leaked prior run so the CAS below doesn't strand it. _reclaim_dangling_run( conn, task_id, statuses=("ready",), now=now, note="invariant recovery on re-claim", @@ -2557,8 +2472,7 @@ def release_stale_claims(conn: sqlite3.Connection, *, signal_fn=None) -> int: termination = _terminate_reclaimed_worker( row["worker_pid"], row["claim_lock"], signal_fn=signal_fn, ) - # Never release a claim while our own worker is still alive: that would - # spawn a duplicate beside it. Hold the claim and retry next tick. + # A live worker of ours must keep its claim (else a duplicate spawns beside it). if _worker_survived_termination(termination): _defer_reclaim_for_live_worker( conn, row["id"], row["claim_lock"], now, termination, @@ -2591,10 +2505,7 @@ def release_stale_claims(conn: sqlite3.Connection, *, signal_fn=None) -> int: }, ) reclaimed += 1 - # Worker-lifecycle observer (RFC #58548): the reclaim txn above has - # committed. The ``continue`` branches (rowcount mismatch, claim - # extension, deferred reclaim) never reach this point, so only a - # genuinely reclaimed stale claim fires. + # Post-commit observer; every non-reclaim branch ``continue``d above. if _kanban_observer_consumed("on_kanban_worker_stale_claim"): _fire_kanban_lifecycle_hook( "on_kanban_worker_stale_claim", @@ -2689,10 +2600,7 @@ def reclaim_task( error=f"manual_reclaim: {reason}" if reason else f"manual_reclaim lock={prev_lock}", payload={"manual": True, "reason": reason, "prev_lock": prev_lock, "retry_status": retry_status}, ) - # Operator intervention — they've looked at the task, so the - # consecutive-failures counter is now stale. Give the next retry - # a fresh budget. (_clear_failure_counter opens its own write_txn, - # so it runs after the enclosing one commits.) + # Operator intervention = fresh retry budget (own txn, runs after commit). _clear_failure_counter(conn, task_id) return True @@ -2769,9 +2677,7 @@ def _verify_created_cards( return verified, phantom -# Task-id pattern used both by ``kanban_create`` (``t_<12 hex>``) and -# ``_new_task_id`` below. Kept permissive on length for forward compat: -# accept 8+ hex chars after the ``t_`` prefix. +# Matches ``kanban_create`` (12 hex) and ``_new_task_id`` (8 hex) ids; 8+ for forward compat. _TASK_ID_PROSE_RE = re.compile(r"\bt_[a-f0-9]{8,}\b") @@ -2822,8 +2728,7 @@ def complete_task( prose is scanned for unresolvable ``t_`` refs (advisory event only). """ now = int(time.time()) - # Fail before validating cards or staging artifacts; re-checked inside the - # write txn below to close the parent-reopen race. + # Cheap pre-check; re-checked inside the txn to close the parent-reopen race. if not _parents_satisfied(conn, task_id): return False verified_cards = _gate_created_cards(conn, task_id, created_cards, summary or result) @@ -2832,9 +2737,8 @@ def complete_task( ) handoff_summary = summary if summary is not None else result with write_txn(conn): - # Parent completion is a hard invariant even for direct human review - # approval: a parent may have been reopened while this task sat in - # ``review`` / ``running``. + # Hard invariant even for human review approval: a parent may have + # reopened while this task waited. if not _parents_satisfied(conn, task_id): return False prior_status = _task_status(conn, task_id) @@ -2865,9 +2769,7 @@ def complete_task( summary=handoff_summary, metadata=metadata, ) - # Never-claimed task (ready/blocked/review -> done with no run in - # flight): synthesize a zero-duration run so the handoff fields land in - # attempt history instead of being silently lost. + # Never-claimed task: synthesize a run so the handoff fields survive. if run_id is None and (summary or metadata or result or prior_status == "review"): synth_summary, synth_metadata = handoff_summary, metadata if prior_status == "review" and not synth_summary and not synth_metadata: @@ -2885,8 +2787,7 @@ def complete_task( run_id=run_id, ) _flag_phantom_prose_refs(conn, task_id, run_id, summary, result, verified_cards) - # Success wipes the consecutive-failures counter; history stays on the - # event log, the counter only tracks the *current* pathology. + # Success wipes the breaker counter (history stays on the event log). _clear_failure_counter(conn, task_id) recompute_ready(conn) # separate txn so children see ``done`` _cleanup_workspace(conn, task_id) @@ -2975,11 +2876,6 @@ def _flag_phantom_prose_refs( ) -# --------------------------------------------------------------------------- -# Workspace / tmux cleanup -# --------------------------------------------------------------------------- - - def _merge_completion_prose_artifacts( conn: sqlite3.Connection, task_id: str, @@ -3157,17 +3053,6 @@ def _unique_attachment_path(directory: Path, filename: str, used: set[Path]) -> idx += 1 -# --------------------------------------------------------------------------- -# First-use tip for scratch workspaces -# --------------------------------------------------------------------------- -# -# Scratch workspaces are intentionally ephemeral (``_cleanup_workspace`` removes -# them on ``complete_task``); new users lose worker output without warning. On -# the FIRST scratch materialization per install: log a warning, append a -# ``tip_scratch_workspace`` event on the task, and touch a sentinel under -# ``kanban_home()`` so the tip never repeats. Per-install, not per-board. - - def edit_completed_task_result( conn: sqlite3.Connection, task_id: str, @@ -3486,10 +3371,8 @@ def request_changes( reviewer = _canonical_assignee(_nonblank_str(task_row["assignee"])) new_status = _landing_status_after_parents(conn, task_id) - # NOTE: consecutive_failures is deliberately PRESERVED (neither - # reset nor incremented). Review transitions are not evidence the - # pathology cleared — only complete_task's success path resets the - # breaker counter (mirrors unblock_task, #35072). + # consecutive_failures deliberately PRESERVED: a review transition is + # not evidence the pathology cleared; only complete_task resets it. cur = conn.execute( """ UPDATE tasks @@ -3634,16 +3517,11 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool: if landing_status == "ready" and resume_status == "review" else landing_status ) - # NOTE: deliberately does NOT touch ``block_recurrences`` or - # ``block_kind``. Resetting the recurrence counter on unblock is exactly - # the amnesia that let a cron unblock → worker re-block loop run - # unbounded (Dale's report). The counter survives the unblock so that a - # subsequent same-cause ``block_task`` can detect the loop and route to - # triage at ``BLOCK_RECURRENCE_LIMIT``. It is reset to 0 only on a - # successful completion (see ``complete_task``). ``consecutive_failures`` - # (the *dispatcher* spawn/crash/timeout counter — a different signal) is - # still reset here, which is correct: a deliberate unblock is a fresh - # start for the dispatcher's retry budget. + # ``block_kind``/``block_recurrences`` deliberately survive the unblock: + # resetting them is the amnesia that let cron-unblock <-> re-block loop + # unbounded; only complete_task clears them. ``consecutive_failures`` + # (the dispatcher's spawn/crash counter) IS reset — a deliberate unblock + # is a fresh start for the retry budget. cur = conn.execute( "UPDATE tasks SET status = ?, current_run_id = NULL, " "consecutive_failures = 0, last_failure_error = NULL " @@ -3796,9 +3674,8 @@ def invalidate_descendants_for_parent_reopen( ) invalidated.append(entry) if not caller_owns_txn: - # Standalone call: we committed above, so the audit trail is durable - # — safe to kill workers now. Composed calls leave this to the - # caller (post-commit), preserving events-before-termination. + # Standalone: committed above, audit trail durable, safe to kill now. + # Composed calls leave this to the caller post-commit. for pid, claim_lock in terminations: _terminate_reclaimed_worker(pid, claim_lock) return {"invalidated": invalidated, "terminations": terminations} @@ -3851,11 +3728,7 @@ def specify_triage_task( if cur.rowcount != 1: return False if changed_fields and author and author.strip(): - # Inline INSERT (rather than ``add_comment``) because we're - # already inside this function's write_txn — nested BEGIN - # IMMEDIATE would raise OperationalError. We also skip the - # 'commented' event that ``add_comment`` emits, since the - # 'specified' event below already records the change. + # Not add_comment (own txn + 'commented' event); 'specified' below records it. _insert_comment( conn, task_id, author.strip(), "Specified — updated " + ", ".join(changed_fields) + " and promoted to todo.", @@ -3867,11 +3740,8 @@ def specify_triage_task( "specified", {"changed_fields": changed_fields} if changed_fields else None, ) - # Outside the write_txn above, so we don't nest BEGIN IMMEDIATE — the - # ready-promotion pass opens its own IMMEDIATE txn. This runs the same - # logic the dispatcher would on its next tick, so a specified task - # with no open parents flips straight to 'ready' here instead of - # idling in 'todo' until the next sweep. + # Own IMMEDIATE txn (outside the one above): a parent-free specified task + # flips to 'ready' now instead of idling until the next tick. recompute_ready(conn) return True @@ -3935,10 +3805,8 @@ def decompose_triage_task( root_assignee = _canonical_assignee(root_assignee) _validate_children_graph(children) - # ONE write_txn so the fan-out is atomic (every child + the root flip, or - # nothing). Helpers that open their own write_txn (create_task, - # link_tasks, add_comment) must not be called in here — INSERTs and - # _append_event are inlined instead. + # ONE txn so the fan-out is atomic; helpers that open their own write_txn + # (create_task, link_tasks, add_comment) must not be called in here. now = int(time.time()) with write_txn(conn): root_row = conn.execute( @@ -4041,21 +3909,16 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: ) if cur.rowcount != 1: return False - # If archive happened while a run was still in flight (e.g. user - # archived a running task from the dashboard), close that run with - # outcome='reclaimed' so attempt history isn't orphaned. + # Archived mid-run (dashboard): close the run so history isn't orphaned. run_id = _end_run( conn, task_id, outcome="reclaimed", status="reclaimed", summary="task archived with run still active", ) _append_event(conn, task_id, "archived", None, run_id=run_id) - # ``archived`` parents no longer block children, same as ``done``. - # Promote newly-unblocked dependents immediately instead of waiting - # for a later dispatcher tick. + # ``archived`` parents no longer block children; promote them now. recompute_ready(conn) - # Reap the workspace on archive too — tasks archived without ever - # completing previously kept their scratch dir / worktree forever. + # Reap the workspace on archive too (never-completed tasks kept it forever). _cleanup_workspace(conn, task_id) return True @@ -4089,7 +3952,6 @@ def delete_task(conn: sqlite3.Connection, task_id: str) -> bool: return True -# --------------------------------------------------------------------------- def schedule_task( conn: sqlite3.Connection, task_id: str, @@ -4123,13 +3985,7 @@ def schedule_task( return True -# Dispatcher (one-shot pass) -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# Worker context builder (what a spawned worker sees) -# --------------------------------------------------------------------------- +# --- Worker context builder (what a spawned worker sees) --- def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str: """Everything a worker should read about its task: header, body, @@ -4342,9 +4198,7 @@ def _ctx_comments(lines: list[str], comments: list[Comment], now: int) -> None: lines.append("") -# --------------------------------------------------------------------------- -# Stats + SLA helpers -# --------------------------------------------------------------------------- +# --- Stats + SLA helpers --- def board_stats(conn: sqlite3.Connection) -> dict: """Per-status + per-assignee counts and the oldest ``ready`` age (staleness signal).""" @@ -4421,9 +4275,7 @@ def task_age(task: Task) -> dict: } -# --------------------------------------------------------------------------- -# Retention + garbage collection -# --------------------------------------------------------------------------- +# --- Retention + garbage collection --- def gc_events(conn: sqlite3.Connection, *, older_than_seconds: int = 30 * 24 * 3600) -> int: """Delete events older than the cutoff on done/archived tasks only; returns the count.""" @@ -4452,9 +4304,7 @@ def gc_worker_logs(*, older_than_seconds: int = 30 * 24 * 3600, board: Optional[ return removed -# --------------------------------------------------------------------------- -# Worker log accessor -# --------------------------------------------------------------------------- +# --- Worker log accessor --- def worker_log_path(task_id: str, *, board: Optional[str] = None) -> Path: """Worker log path (may not exist). The dispatcher always passes ``board`` @@ -4477,9 +4327,8 @@ def read_worker_log( with open(path, "rb") as f: if size > tail_bytes: f.seek(size - tail_bytes) - # Skip the partial first line — unless the window has no - # newline at all (one giant line), where readline() would eat - # everything; then return the raw tail. + # Skip the partial first line unless the window has no newline + # at all (readline() would eat everything). probe = f.tell() if not f.readline().endswith(b"\n") and f.tell() >= size: f.seek(probe) @@ -4488,9 +4337,7 @@ def read_worker_log( return None -# --------------------------------------------------------------------------- -# Assignee enumeration (known profiles + per-profile board stats) -# --------------------------------------------------------------------------- +# --- Assignee enumeration (known profiles + per-profile board stats) --- def list_profiles_on_disk() -> list[str]: """Profiles with a ``config.yaml`` plus the implicit ``default``; reads paths @@ -4524,9 +4371,7 @@ def known_assignees(conn: sqlite3.Connection) -> list[dict]: ] -# --------------------------------------------------------------------------- -# Runs (attempt history on a task) -# --------------------------------------------------------------------------- +# --- Runs (attempt history on a task) --- def list_runs( conn: sqlite3.Connection, @@ -4606,10 +4451,7 @@ 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.`` keeps resolving (and -# stays the single monkeypatch target). -# --------------------------------------------------------------------------- +# --- Split modules, re-exported so ``kanban_db.`` stays the monkeypatch target --- from hermes_cli.kanban_db_connect import ( # noqa: E402,F401 KanbanDbCorruptError, _BUSY_RETRY_MAX_S, From c5dcfe155c3d890ee4e51d99c561e04e8045cc2b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:57:13 -0700 Subject: [PATCH 32/38] refactor(kanban_db): AST-neutral packing of one-item-per-line signatures and call args --- hermes_cli/kanban_db.py | 398 ++++++++++------------------------------ 1 file changed, 94 insertions(+), 304 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index cc7e4c8fb5..32facc56a4 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -177,11 +177,7 @@ def _kanban_observer_consumed(event: str) -> bool: def _fire_worker_spawned_hook( - conn: sqlite3.Connection, - task: "Task", - workspace_path: str, - pid: Optional[int], - *, + conn: sqlite3.Connection, task: "Task", workspace_path: str, pid: Optional[int], *, board: Optional[str] = None, ) -> None: """``on_kanban_worker_spawned`` AFTER the PID is durably persisted; best-effort.""" @@ -202,10 +198,7 @@ def _fire_worker_spawned_hook( def notify_task_updated( - conn: sqlite3.Connection, - task_id: str, - changed_fields: Iterable[str], - *, + conn: sqlite3.Connection, task_id: str, changed_fields: Iterable[str], *, board: Optional[str] = None, ) -> None: """``on_kanban_task_updated`` AFTER a non-lifecycle task mutation commits @@ -240,10 +233,7 @@ _TICK_ACTIVITY_FIELDS = ( def _fire_dispatch_tick_hook( - result: "DispatchResult", - *, - board: Optional[str] = None, - dry_run: bool = False, + result: "DispatchResult", *, board: Optional[str] = None, dry_run: bool = False, ) -> None: """``on_kanban_dispatch_tick`` — strictly AFTER ``_dispatch_tick_lock`` is released so a slow subscriber cannot stall a sibling dispatcher.""" @@ -572,15 +562,9 @@ def read_board_metadata(board: Optional[str] = None) -> dict: def write_board_metadata( - board: Optional[str], - *, - name: Optional[str] = None, - description: Optional[str] = None, - icon: Optional[str] = None, - color: Optional[str] = None, - archived: Optional[bool] = None, - default_workdir: Optional[str] = None, - project_id: Optional[str] = None, + board: Optional[str], *, name: Optional[str] = None, description: Optional[str] = None, + icon: Optional[str] = None, color: Optional[str] = None, archived: Optional[bool] = None, + default_workdir: Optional[str] = None, project_id: Optional[str] = None, ) -> dict: """Create/update ``board.json``; unmentioned fields are preserved, ``created_at`` set on first write. ``project_id``/``default_workdir``: ``None`` = unchanged, @@ -605,33 +589,22 @@ def write_board_metadata( path = board_metadata_path(slug) path.parent.mkdir(parents=True, exist_ok=True) path.write_text( - json.dumps(meta, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", + json.dumps(meta, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) meta["db_path"] = str(kanban_db_path(slug)) return meta def create_board( - slug: str, - *, - name: Optional[str] = None, - description: Optional[str] = None, - icon: Optional[str] = None, - color: Optional[str] = None, - default_workdir: Optional[str] = None, + slug: str, *, name: Optional[str] = None, description: Optional[str] = None, + icon: Optional[str] = None, color: Optional[str] = None, default_workdir: Optional[str] = None, project_id: Optional[str] = None, ) -> dict: """Create board dir + DB + metadata (``mkdir -p`` semantics: existing board returns its metadata).""" normed = _require_slug(slug) meta = write_board_metadata( - normed, - name=name, - description=description, - icon=icon, - color=color, - default_workdir=default_workdir, - project_id=project_id, + normed, name=name, description=description, icon=icon, color=color, + default_workdir=default_workdir, project_id=project_id, ) # Touch the DB so list_boards() sees it immediately. init_db(board=normed) @@ -863,8 +836,7 @@ class Attachment: return cls( id=r["id"], task_id=r["task_id"], filename=r["filename"], stored_path=r["stored_path"], content_type=r["content_type"], - size=r["size"] or 0, uploaded_by=r["uploaded_by"], - created_at=r["created_at"], + size=r["size"] or 0, uploaded_by=r["uploaded_by"], created_at=r["created_at"], ) @@ -882,8 +854,7 @@ class Event: run_id = _row_get(row, "run_id") return cls( id=row["id"], task_id=row["task_id"], kind=row["kind"], - payload=_json_or(row["payload"]), created_at=row["created_at"], - run_id=_opt_int(run_id), + payload=_json_or(row["payload"]), created_at=row["created_at"], run_id=_opt_int(run_id), ) @@ -1133,11 +1104,8 @@ def _canonical_assignee(assignee: Optional[str]) -> Optional[str]: def _resolve_project_link( - conn: sqlite3.Connection, - project_id: Optional[str], - project_source_task_id: Optional[str], - workspace_kind: str, - workspace_path: Optional[str], + conn: sqlite3.Connection, project_id: Optional[str], project_source_task_id: Optional[str], + workspace_kind: str, workspace_path: Optional[str], ) -> tuple[Optional[str], Any, Optional[str], str]: """``(project_id, project_obj, project_repo, workspace_kind)`` for ``create_task``. @@ -1266,32 +1234,16 @@ def _normalize_task_skills(skills: Optional[Iterable[str]]) -> Optional[list[str def create_task( - conn: sqlite3.Connection, - *, - title: str, - body: Optional[str] = None, - assignee: Optional[str] = None, - created_by: Optional[str] = None, - workspace_kind: str = "scratch", - workspace_path: Optional[str] = None, - branch_name: Optional[str] = None, - tenant: Optional[str] = None, - priority: int = 0, - parents: Iterable[str] = (), - triage: bool = False, - idempotency_key: Optional[str] = None, - max_runtime_seconds: Optional[int] = None, - skills: Optional[Iterable[str]] = None, - max_retries: Optional[int] = None, - model_override: Optional[str] = None, - provider_override: Optional[str] = None, - reasoning_effort: Optional[str] = None, - goal_mode: bool = False, - goal_max_turns: Optional[int] = None, - initial_status: str = "running", - session_id: Optional[str] = None, - board: Optional[str] = None, - project_id: Optional[str] = None, + conn: sqlite3.Connection, *, title: str, body: Optional[str] = None, + assignee: Optional[str] = None, created_by: Optional[str] = None, + workspace_kind: str = "scratch", workspace_path: Optional[str] = None, + branch_name: Optional[str] = None, tenant: Optional[str] = None, priority: int = 0, + parents: Iterable[str] = (), triage: bool = False, idempotency_key: Optional[str] = None, + max_runtime_seconds: Optional[int] = None, skills: Optional[Iterable[str]] = None, + max_retries: Optional[int] = None, model_override: Optional[str] = None, + provider_override: Optional[str] = None, reasoning_effort: Optional[str] = None, + goal_mode: bool = False, goal_max_turns: Optional[int] = None, initial_status: str = "running", + session_id: Optional[str] = None, board: Optional[str] = None, project_id: Optional[str] = None, project_source_task_id: Optional[str] = None, ) -> str: """Create a task (optionally under ``parents``); returns its id. @@ -1391,8 +1343,7 @@ def create_task( branch_name, project_id, tenant, idempotency_key, _opt_int(max_runtime_seconds), json.dumps(skills_list) if skills_list is not None else None, - _opt_int(max_retries), model_override, provider_override, - reasoning_effort, + _opt_int(max_retries), model_override, provider_override, reasoning_effort, 1 if goal_mode else 0, _opt_int(goal_max_turns), session_id, ), ) @@ -1483,10 +1434,7 @@ def _missing_task_ids(conn: sqlite3.Connection, ids: Iterable[str]) -> list[str] def _inherit_notify_subs( - conn: sqlite3.Connection, - child_id: str, - parents: Iterable[str], - *, + conn: sqlite3.Connection, child_id: str, parents: Iterable[str], *, created_at: Optional[int] = None, ) -> None: """Copy parents' notify subscriptions to a child, cursor caught up to the @@ -1541,17 +1489,10 @@ VALID_SORT_ORDERS: dict[str, str] = { def list_tasks( - conn: sqlite3.Connection, - *, - assignee: Optional[str] = None, - status: Optional[str] = None, - tenant: Optional[str] = None, - session_id: Optional[str] = None, - include_archived: bool = False, - limit: Optional[int] = None, - order_by: Optional[str] = None, - workflow_template_id: Optional[str] = None, - current_step_key: Optional[str] = None, + conn: sqlite3.Connection, *, assignee: Optional[str] = None, status: Optional[str] = None, + tenant: Optional[str] = None, session_id: Optional[str] = None, include_archived: bool = False, + limit: Optional[int] = None, order_by: Optional[str] = None, + workflow_template_id: Optional[str] = None, current_step_key: Optional[str] = None, ) -> list[Task]: if status is not None and status not in VALID_STATUSES: raise ValueError(f"status must be one of {sorted(VALID_STATUSES)}") @@ -1610,10 +1551,7 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) def set_model_override( - conn: sqlite3.Connection, - task_id: str, - model: Optional[str], - provider: Optional[str] = None, + conn: sqlite3.Connection, task_id: str, model: Optional[str], provider: Optional[str] = None, ) -> bool: """Set (empty ``model`` clears BOTH) the per-task model/provider override. Allowed while ``running``: it applies on the NEXT dispatch, which is the @@ -1677,8 +1615,7 @@ def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: (child_id,), ) _append_event( - conn, child_id, "linked", - {"parent": parent_id, "child": child_id}, + conn, child_id, "linked", {"parent": parent_id, "child": child_id}, ) _inherit_notify_subs(conn, child_id, (parent_id,)) @@ -1838,15 +1775,9 @@ def _collision_free_path(dest_dir: Path, safe_name: str) -> Path: def store_attachment_bytes( - conn: sqlite3.Connection, - task_id: str, - filename: str, - data: bytes, - *, - content_type: Optional[str] = None, - uploaded_by: Optional[str] = None, - board: Optional[str] = None, - max_bytes: Optional[int] = None, + conn: sqlite3.Connection, task_id: str, filename: str, data: bytes, *, + content_type: Optional[str] = None, uploaded_by: Optional[str] = None, + board: Optional[str] = None, max_bytes: Optional[int] = None, ) -> int: """Single attachment write path (dashboard, tools, CLI): size cap, safe basename, collision-free blob under :func:`task_attachments_dir`, then the @@ -1863,13 +1794,8 @@ def store_attachment_bytes( dest_path.write_bytes(data) try: return add_attachment( - conn, - task_id, - filename=dest_path.name, - stored_path=str(dest_path.resolve()), - content_type=content_type, - size=len(data), - uploaded_by=uploaded_by, + conn, task_id, filename=dest_path.name, stored_path=str(dest_path.resolve()), + content_type=content_type, size=len(data), uploaded_by=uploaded_by, ) except Exception: # Don't leave an orphan blob if the metadata insert fails (most @@ -1880,14 +1806,8 @@ def store_attachment_bytes( def add_attachment( - conn: sqlite3.Connection, - task_id: str, - *, - filename: str, - stored_path: str, - content_type: Optional[str] = None, - size: int = 0, - uploaded_by: Optional[str] = None, + conn: sqlite3.Connection, task_id: str, *, filename: str, stored_path: str, + content_type: Optional[str] = None, size: int = 0, uploaded_by: Optional[str] = None, ) -> int: """Record the metadata row (+ ``attached`` event) for a blob the caller already wrote.""" if not filename or not filename.strip(): @@ -1953,11 +1873,7 @@ def _insert_comment( def _append_event( - conn: sqlite3.Connection, - task_id: str, - kind: str, - payload: Optional[dict] = None, - *, + conn: sqlite3.Connection, task_id: str, kind: str, payload: Optional[dict] = None, *, run_id: Optional[int] = None, ) -> None: """Insert an event row inside the caller's txn; ``run_id`` groups it by attempt (NULL = task-scoped).""" @@ -1969,14 +1885,8 @@ def _append_event( def _end_run( - conn: sqlite3.Connection, - task_id: str, - *, - outcome: str, - summary: Optional[str] = None, - error: Optional[str] = None, - metadata: Optional[dict] = None, - status: Optional[str] = None, + conn: sqlite3.Connection, task_id: str, *, outcome: str, summary: Optional[str] = None, + error: Optional[str] = None, metadata: Optional[dict] = None, status: Optional[str] = None, ) -> Optional[int]: """Close the active run (``status`` defaults to ``outcome``) and clear ``current_run_id``; None when no run was active (never-claimed task).""" @@ -2045,13 +1955,8 @@ def _end_or_synthesize_run( def _synthesize_ended_run( - conn: sqlite3.Connection, - task_id: str, - *, - outcome: str, - summary: Optional[str] = None, - error: Optional[str] = None, - metadata: Optional[dict] = None, + conn: sqlite3.Connection, task_id: str, *, outcome: str, summary: Optional[str] = None, + error: Optional[str] = None, metadata: Optional[dict] = None, ) -> int: """Zero-duration closed run for a terminal transition on a never-claimed task, so the handoff fields aren't silently dropped (``_end_run`` is a @@ -2074,10 +1979,7 @@ def _synthesize_ended_run( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - task_id, profile, step_key, - outcome, outcome, - summary, error, - _json_or_null(metadata), + task_id, profile, step_key, outcome, outcome, summary, error, _json_or_null(metadata), now, now, ), ) @@ -2206,14 +2108,8 @@ def _parents_satisfied(conn: sqlite3.Connection, task_id: str) -> bool: def _claim_and_open_run( - conn: sqlite3.Connection, - task_id: str, - source_status: str, - lock: str, - expires: int, - now: int, - *, - event_extra: Optional[dict] = None, + conn: sqlite3.Connection, task_id: str, source_status: str, lock: str, expires: int, now: int, + *, event_extra: Optional[dict] = None, ) -> Optional[int]: """CAS ``source_status -> running``, open a run row, emit ``claimed``; None when the CAS lost. Caller holds the txn.""" @@ -2247,30 +2143,21 @@ def _claim_and_open_run( ) VALUES (?, ?, ?, 'running', ?, ?, ?, ?) """, ( - task_id, - trow["assignee"] if trow else None, - trow["current_step_key"] if trow else None, - lock, - expires, - trow["max_runtime_seconds"] if trow else None, - now, + task_id, trow["assignee"] if trow else None, trow["current_step_key"] if trow else None, + lock, expires, trow["max_runtime_seconds"] if trow else None, now, ), ) run_id = run_cur.lastrowid conn.execute("UPDATE tasks SET current_run_id = ? WHERE id = ?", (run_id, task_id)) _append_event( conn, task_id, "claimed", - {"lock": lock, "expires": expires, "run_id": run_id, **(event_extra or {})}, - run_id=run_id, + {"lock": lock, "expires": expires, "run_id": run_id, **(event_extra or {})}, run_id=run_id, ) return run_id def claim_task( - conn: sqlite3.Connection, - task_id: str, - *, - ttl_seconds: Optional[int] = None, + conn: sqlite3.Connection, task_id: str, *, ttl_seconds: Optional[int] = None, claimer: Optional[str] = None, ) -> Optional[Task]: """Atomically transition ``ready -> running``. @@ -2292,14 +2179,12 @@ def claim_task( (task_id,), ) _append_event( - conn, task_id, "claim_rejected", - {"reason": "parents_not_done"}, + conn, task_id, "claim_rejected", {"reason": "parents_not_done"}, ) return None # Close a leaked prior run so the CAS below doesn't strand it. _reclaim_dangling_run( - conn, task_id, statuses=("ready",), now=now, - note="invariant recovery on re-claim", + conn, task_id, statuses=("ready",), now=now, note="invariant recovery on re-claim", ) run_id = _claim_and_open_run(conn, task_id, "ready", lock, expires, now) if run_id is None: @@ -2310,10 +2195,7 @@ def claim_task( def claim_review_task( - conn: sqlite3.Connection, - task_id: str, - *, - ttl_seconds: Optional[int] = None, + conn: sqlite3.Connection, task_id: str, *, ttl_seconds: Optional[int] = None, claimer: Optional[str] = None, ) -> Optional[Task]: """Atomic ``review -> running`` (None when lost). Parents are re-checked @@ -2341,8 +2223,7 @@ def claim_review_task( ) return None run_id = _claim_and_open_run( - conn, task_id, "review", lock, expires, now, - event_extra={"source_status": "review"}, + conn, task_id, "review", lock, expires, now, event_extra={"source_status": "review"}, ) if run_id is None: return None @@ -2350,9 +2231,7 @@ def claim_review_task( def _retry_status_for_run( - conn: sqlite3.Connection, - task_id: str, - run_id: Optional[int] = None, + conn: sqlite3.Connection, task_id: str, run_id: Optional[int] = None, ) -> str: """``review`` when the run's ``claimed`` event says ``source_status=review``, else ``ready`` — one place, so crash/timeout/reclaim can't silently turn a @@ -2377,9 +2256,7 @@ _RUN_OUTCOME_TERMINAL_STATUS = { def goal_run_status( - conn: sqlite3.Connection, - task_id: str, - expected_run_id: Optional[int] = None, + conn: sqlite3.Connection, task_id: str, expected_run_id: Optional[int] = None, ) -> Optional[str]: """Lifecycle status as seen by ONE run: terminal handoffs bind to that run, any other ownership loss is ``superseded`` — otherwise an old goal loop @@ -2410,10 +2287,7 @@ def goal_run_status( def heartbeat_claim( - conn: sqlite3.Connection, - task_id: str, - *, - ttl_seconds: Optional[int] = None, + conn: sqlite3.Connection, task_id: str, *, ttl_seconds: Optional[int] = None, claimer: Optional[str] = None, ) -> bool: """Extend a running claim; True if we still own it.""" @@ -2565,11 +2439,7 @@ def _extend_live_stale_claim(conn: sqlite3.Connection, row: sqlite3.Row, now: in def reclaim_task( - conn: sqlite3.Connection, - task_id: str, - *, - reason: Optional[str] = None, - signal_fn=None, + conn: sqlite3.Connection, task_id: str, *, reason: Optional[str] = None, signal_fn=None, ) -> bool: """Operator reclaim regardless of TTL: release the claim, restore the source phase, reset the failure counter. False when not running.""" @@ -2606,11 +2476,7 @@ def reclaim_task( def reassign_task( - conn: sqlite3.Connection, - task_id: str, - profile: Optional[str], - *, - reclaim_first: bool = False, + conn: sqlite3.Connection, task_id: str, profile: Optional[str], *, reclaim_first: bool = False, reason: Optional[str] = None, ) -> bool: """Reassign (None unassigns); a running task is refused unless @@ -2628,9 +2494,7 @@ def reassign_task( def _verify_created_cards( - conn: sqlite3.Connection, - completing_task_id: str, - claimed_ids: Iterable[str], + conn: sqlite3.Connection, completing_task_id: str, claimed_ids: Iterable[str], ) -> tuple[list[str], list[str]]: """Partition ``claimed_ids`` into (verified, phantom). Verified = the row exists AND ``created_by`` is the completing task's assignee or id, OR the @@ -2707,14 +2571,9 @@ class ArtifactPreservationError(RuntimeError): def complete_task( - conn: sqlite3.Connection, - task_id: str, - *, - result: Optional[str] = None, - summary: Optional[str] = None, - metadata: Optional[dict] = None, - created_cards: Optional[Iterable[str]] = None, - expected_run_id: Optional[int] = None, + conn: sqlite3.Connection, task_id: str, *, result: Optional[str] = None, + summary: Optional[str] = None, metadata: Optional[dict] = None, + created_cards: Optional[Iterable[str]] = None, expected_run_id: Optional[int] = None, fire_lifecycle_hook: bool = True, ) -> bool: """``running|ready|blocked|review -> done``; records ``result``. @@ -2764,9 +2623,7 @@ def complete_task( if isinstance(metadata, dict): _stage_completion_artifacts(conn, task_id, metadata, now) run_id = _end_run( - conn, task_id, - outcome="completed", status="done", - summary=handoff_summary, + conn, task_id, outcome="completed", status="done", summary=handoff_summary, metadata=metadata, ) # Never-claimed task: synthesize a run so the handoff fields survive. @@ -2871,17 +2728,12 @@ def _flag_phantom_prose_refs( with write_txn(conn): _append_event( conn, task_id, "suspected_hallucinated_references", - {"phantom_refs": phantom_refs, "source": "completion_summary"}, - run_id=run_id, + {"phantom_refs": phantom_refs, "source": "completion_summary"}, run_id=run_id, ) def _merge_completion_prose_artifacts( - conn: sqlite3.Connection, - task_id: str, - metadata: Optional[dict], - *, - summary: Optional[str], + conn: sqlite3.Connection, task_id: str, metadata: Optional[dict], *, summary: Optional[str], result: Optional[str], ) -> Optional[dict]: """Legacy workers named deliverables only by absolute path in prose; add @@ -2917,9 +2769,7 @@ def _merge_completion_prose_artifacts( def _persist_scratch_completion_artifacts( - conn: sqlite3.Connection, - task_id: str, - metadata: dict, + conn: sqlite3.Connection, task_id: str, metadata: dict, ) -> None: """Copy scratch-workspace completion artifacts before cleanup removes them.""" raw_artifacts = metadata.get("artifacts") @@ -3013,12 +2863,7 @@ def _persist_scratch_completion_artifacts( def _insert_completion_attachment( - conn: sqlite3.Connection, - task_id: str, - *, - filename: str, - stored_path: str, - size: int, + conn: sqlite3.Connection, task_id: str, *, filename: str, stored_path: str, size: int, created_at: int, ) -> None: """Record a worker-produced artifact in the existing attachment table.""" @@ -3054,11 +2899,7 @@ def _unique_attachment_path(directory: Path, filename: str, used: set[Path]) -> def edit_completed_task_result( - conn: sqlite3.Connection, - task_id: str, - *, - result: str, - summary: Optional[str] = None, + conn: sqlite3.Connection, task_id: str, *, result: str, summary: Optional[str] = None, metadata: Optional[dict] = None, ) -> bool: """Backfill the user-visible result for an already completed task.""" @@ -3080,10 +2921,7 @@ def edit_completed_task_result( run_id = int(run["id"]) if run else None if run_id is None: run_id = _synthesize_ended_run( - conn, task_id, - outcome="completed", - summary=handoff_summary, - metadata=metadata, + conn, task_id, outcome="completed", summary=handoff_summary, metadata=metadata, ) else: conn.execute("UPDATE task_runs SET summary = ? WHERE id = ?", (handoff_summary, run_id)) @@ -3105,12 +2943,8 @@ def edit_completed_task_result( def block_task( - conn: sqlite3.Connection, - task_id: str, - *, - reason: Optional[str] = None, - kind: Optional[str] = None, - expected_run_id: Optional[int] = None, + conn: sqlite3.Connection, task_id: str, *, reason: Optional[str] = None, + kind: Optional[str] = None, expected_run_id: Optional[int] = None, ) -> bool: """``running``/``ready`` -> ``blocked`` (or ``todo`` / ``triage``, see :func:`_route_block`). ``transient`` still counts toward the loop breaker @@ -3130,8 +2964,7 @@ def block_task( else "ready" ) new_status, event_kind, set_sql, params, payload = _route_block( - kind, reason, source_status, - prev_kind=_row_get(cur_row, "block_kind"), + kind, reason, source_status, prev_kind=_row_get(cur_row, "block_kind"), prev_recurrences=int(_row_get(cur_row, "block_recurrences") or 0), ) sql = f""" @@ -3207,15 +3040,9 @@ def redact_review_value(value: Any) -> Any: def request_review( - conn: sqlite3.Connection, - task_id: str, - *, - summary: Optional[str] = None, - metadata: Optional[dict] = None, - reviewer: Optional[str] = None, - expected_run_id: Optional[int] = None, - force: bool = False, - with_reason: bool = False, + conn: sqlite3.Connection, task_id: str, *, summary: Optional[str] = None, + metadata: Optional[dict] = None, reviewer: Optional[str] = None, + expected_run_id: Optional[int] = None, force: bool = False, with_reason: bool = False, ): """``running``/``ready`` -> ``review``; never touches block recurrence accounting. @@ -3268,8 +3095,7 @@ def request_review( assignee_sql = ", assignee = ?" if reviewer is not None else "" run_guard = "" if expected_run_id is None else " AND current_run_id = ?" params: tuple[Any, ...] = ( - *(() if reviewer is None else (reviewer,)), - task_id, + *(() if reviewer is None else (reviewer,)), task_id, *(() if expected_run_id is None else (int(expected_run_id),)), ) cur = conn.execute( @@ -3331,11 +3157,7 @@ def _nonblank_str(value: Any) -> Optional[str]: def request_changes( - conn: sqlite3.Connection, - task_id: str, - *, - reason: str, - expected_run_id: Optional[int] = None, + conn: sqlite3.Connection, task_id: str, *, reason: str, expected_run_id: Optional[int] = None, ) -> tuple[bool, Optional[str]]: """Close an active reviewer run (claimed from ``review``) and hand the task back to the implementer from the latest ``review_requested`` event, parent @@ -3388,11 +3210,7 @@ def request_changes( if cur.rowcount != 1: return False, "task changed during review handoff" run_id = _end_run( - conn, - task_id, - outcome="changes_requested", - status=new_status, - summary=reason, + conn, task_id, outcome="changes_requested", status=new_status, summary=reason, ) _append_event( conn, @@ -3410,13 +3228,8 @@ def request_changes( def promote_task( - conn: sqlite3.Connection, - task_id: str, - *, - actor: str, - reason: Optional[str] = None, - force: bool = False, - dry_run: bool = False, + conn: sqlite3.Connection, task_id: str, *, actor: str, reason: Optional[str] = None, + force: bool = False, dry_run: bool = False, ) -> tuple[bool, Optional[str]]: """Operator promotion ``todo``/``blocked`` -> ``ready`` with an audit event. Refused while a parent is unfinished unless ``force``; ``dry_run`` only @@ -3581,10 +3394,7 @@ def reopen_review_task(conn: sqlite3.Connection, task_id: str) -> bool: def invalidate_descendants_for_parent_reopen( - conn: sqlite3.Connection, - task_id: str, - *, - author: str, + conn: sqlite3.Connection, task_id: str, *, author: str, ) -> dict[str, Any]: """THE done-reopen invalidation: every ``ready``/``review``/``running``/``done`` descendant of a reopened ancestor is demoted to ``todo`` and re-gated. @@ -3682,13 +3492,8 @@ def invalidate_descendants_for_parent_reopen( def specify_triage_task( - conn: sqlite3.Connection, - task_id: str, - *, - title: Optional[str] = None, - body: Optional[str] = None, - assignee: Optional[str] = None, - author: Optional[str] = None, + conn: sqlite3.Connection, task_id: str, *, title: Optional[str] = None, + body: Optional[str] = None, assignee: Optional[str] = None, author: Optional[str] = None, ) -> bool: """Update title/body/assignee (when given) and move ``triage -> todo`` in one txn; False when not in triage. Lands in ``todo`` (not ``ready``) so parent @@ -3783,13 +3588,8 @@ def _validate_children_graph(children: list) -> None: def decompose_triage_task( - conn: sqlite3.Connection, - task_id: str, - *, - root_assignee: Optional[str], - children: list[dict], - author: Optional[str] = None, - auto_promote: bool = True, + conn: sqlite3.Connection, task_id: str, *, root_assignee: Optional[str], children: list[dict], + author: Optional[str] = None, auto_promote: bool = True, ) -> Optional[list[str]]: """Fan a triage task out into children and move the root to ``todo``; the root waits on every child and wakes (``ready``) when all are done. @@ -3911,8 +3711,7 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: return False # Archived mid-run (dashboard): close the run so history isn't orphaned. run_id = _end_run( - conn, task_id, - outcome="reclaimed", status="reclaimed", + conn, task_id, outcome="reclaimed", status="reclaimed", summary="task archived with run still active", ) _append_event(conn, task_id, "archived", None, run_id=run_id) @@ -3953,10 +3752,7 @@ def delete_task(conn: sqlite3.Connection, task_id: str) -> bool: def schedule_task( - conn: sqlite3.Connection, - task_id: str, - *, - reason: Optional[str] = None, + conn: sqlite3.Connection, task_id: str, *, reason: Optional[str] = None, expected_run_id: Optional[int] = None, ) -> bool: """Park in ``scheduled`` (waiting on time, not a human; not dispatchable) @@ -4054,8 +3850,7 @@ def _ctx_header(lines: list[str], task: Task) -> None: lines.append(f"Workspace: {task.workspace_kind} @ {task.workspace_path or '(unresolved)'}") if task.max_runtime_seconds is not None: terminal_timeout = _worker_terminal_timeout_env( - task.max_runtime_seconds, - os.environ.get("TERMINAL_TIMEOUT"), + task.max_runtime_seconds, os.environ.get("TERMINAL_TIMEOUT"), ) effective_terminal_timeout = terminal_timeout or os.environ.get("TERMINAL_TIMEOUT") lines.append(f"Max runtime: {task.max_runtime_seconds}s") @@ -4313,8 +4108,7 @@ def worker_log_path(task_id: str, *, board: Optional[str] = None) -> Path: def read_worker_log( - task_id: str, *, tail_bytes: Optional[int] = None, - board: Optional[str] = None, + task_id: str, *, tail_bytes: Optional[int] = None, board: Optional[str] = None, ) -> Optional[str]: """Worker log text (last ``tail_bytes`` when set); None when the file is missing.""" path = worker_log_path(task_id, board=board) @@ -4374,12 +4168,8 @@ def known_assignees(conn: sqlite3.Connection) -> list[dict]: # --- Runs (attempt history on a task) --- def list_runs( - conn: sqlite3.Connection, - task_id: str, - *, - include_active: bool = True, - state_type: Optional[str] = None, - state_name: Optional[str] = None, + conn: sqlite3.Connection, task_id: str, *, include_active: bool = True, + state_type: Optional[str] = None, state_name: Optional[str] = None, ) -> list[Run]: """Runs in start order; ``include_active=False`` = closed only; ``state_type`` (``status``/``outcome``) + ``state_name`` filter together.""" From ea6d0b625de88f3b630145cea0d80be552667933 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:57:36 -0700 Subject: [PATCH 33/38] =?UTF-8?q?refactor(kanban-cli):=20parser=20?= =?UTF-8?q?=E2=80=94=20drop=20explicit=20default=3DNone=20on=20optional=20?= =?UTF-8?q?flags=20(argparse=20default;=20action=20dump=20identical)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/kanban_parser.py | 140 ++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/hermes_cli/kanban_parser.py b/hermes_cli/kanban_parser.py index 1944cfc415..d64f009c2f 100644 --- a/hermes_cli/kanban_parser.py +++ b/hermes_cli/kanban_parser.py @@ -37,13 +37,13 @@ def _json_flag(**kw): def _reason(help: str): - return _arg("--reason", default=None, help=help) + return _arg("--reason", help=help) def _run_state_args(type_help: str): return ( - _arg("--state-type", choices=("status", "outcome"), default=None, help=f"With --state-name: {type_help}"), - _arg("--state-name", default=None, metavar="VALUE", + _arg("--state-type", choices=("status", "outcome"), help=f"With --state-name: {type_help}"), + _arg("--state-name", metavar="VALUE", help="With --state-type: keep runs whose column equals this value"), ) @@ -51,33 +51,33 @@ def _run_state_args(type_help: str): def _triage_sweep_args(verb: str, Verb: str, noun: str): """Shared ``specify`` / ``decompose`` arguments.""" return ( - _arg("task_id", nargs="?", default=None, help=f"Task id to {verb} (required unless --all is given)"), + _arg("task_id", nargs="?", help=f"Task id to {verb} (required unless --all is given)"), _arg("--all", dest="all_triage", action="store_true", help=f"{Verb} every task currently in the triage column"), - _arg("--tenant", default=None, help="When used with --all, restrict the sweep to this tenant"), - _arg("--author", default=None, + _arg("--tenant", help="When used with --all, restrict the sweep to this tenant"), + _arg("--author", help=f"Author name recorded on the audit comment (default: $HERMES_PROFILE or '{noun}')"), _json_flag(help="Emit one JSON object per task on stdout"), ) def _bulk_ids(verb: str): - return _arg("--ids", nargs="+", default=None, help=f"Additional task ids to {verb} with the same reason (bulk mode)") + return _arg("--ids", nargs="+", help=f"Additional task ids to {verb} with the same reason (bulk mode)") _TASK_ID = _arg("task_id") _TASK_IDS = _arg("task_ids", nargs="+") _SLUG = _arg("slug") -_TENANT = _arg("--tenant", default=None, help="Tenant namespace") +_TENANT = _arg("--tenant", help="Tenant namespace") _PRIORITY = _arg("--priority", type=int, default=0, help="Priority tiebreaker") _RECLAIM_REASON = _reason("Human-readable reason (recorded on the reclaimed event)") _NOTIFY_TARGET = ( _arg("--platform", required=True), _arg("--chat-id", required=True), - _arg("--thread-id", default=None), + _arg("--thread-id"), ) _STEP_HANDOFF = ( - _arg("--summary", default=None, help="Structured handoff summary. Falls back to --result if omitted."), - _arg("--metadata", default=None, help="JSON dict of structured facts to store on the latest completed run."), + _arg("--summary", help="Structured handoff summary. Falls back to --result if omitted."), + _arg("--metadata", help="JSON dict of structured facts to store on the latest completed run."), ) _BOARD_SPECS = [ @@ -87,12 +87,12 @@ _BOARD_SPECS = [ ], aliases=["ls"], help="List all boards with task counts"), _cmd("create", [ _arg("slug", help="Board slug (kebab-case, e.g. atm10-server)"), - _arg("--name", default=None, help="Human-readable display name (defaults to Title Case of slug)"), - _arg("--description", default=None, help="Optional description"), - _arg("--icon", default=None, help="Optional emoji or single-character icon for the dashboard"), - _arg("--color", default=None, help="Optional hex color (e.g. '#8b5cf6') for the dashboard"), + _arg("--name", help="Human-readable display name (defaults to Title Case of slug)"), + _arg("--description", help="Optional description"), + _arg("--icon", help="Optional emoji or single-character icon for the dashboard"), + _arg("--color", help="Optional hex color (e.g. '#8b5cf6') for the dashboard"), _arg("--switch", action="store_true", help="Switch to the new board after creating it"), - _arg("--default-workdir", default=None, help="Default workspace path for tasks created on this board"), + _arg("--default-workdir", help="Default workspace path for tasks created on this board"), ], aliases=["new"], help="Create a new board"), _cmd("rm", [ _SLUG, @@ -106,11 +106,11 @@ _BOARD_SPECS = [ help="Change a board's human-readable display name (slug is immutable)"), _cmd("set-default-workdir", [ _SLUG, - _arg("path", nargs="?", default=None, help="Absolute path to use as default workdir. Omit to clear."), + _arg("path", nargs="?", help="Absolute path to use as default workdir. Omit to clear."), ], help="Set the default workspace path for tasks on a board"), _cmd("export", [ - _arg("slug", nargs="?", default=None, help="Board to export (default: the current board)"), - _arg("-o", "--output", default=None, help="Archive path (default: ./.tar.gz)"), + _arg("slug", nargs="?", help="Board to export (default: the current board)"), + _arg("-o", "--output", help="Archive path (default: ./.tar.gz)"), _arg("--no-attachments", action="store_true", help="Skip attachment files, keeping the archive small"), _arg("--include-logs", action="store_true", help="Include per-task worker logs"), _json_flag(), @@ -122,7 +122,7 @@ _BOARD_SPECS = [ )), _cmd("import", [ _arg("archive", help="Path to the .tar.gz archive"), - _arg("--as", dest="as_slug", default=None, help="Slug for the imported board (default: from the archive)"), + _arg("--as", dest="as_slug", help="Slug for the imported board (default: from the archive)"), _arg("--switch", action="store_true", help="Switch to the imported board afterwards"), _json_flag(), ], help="Import a board archive as a new board", description=( @@ -145,13 +145,13 @@ _SPECS = [ )), _cmd("create", [ _arg("title", help="Task title"), - _arg("--body", default=None, help="Optional opening post"), - _arg("--assignee", default=None, help="Profile name to assign"), + _arg("--body", help="Optional opening post"), + _arg("--assignee", help="Profile name to assign"), _arg("--parent", action="append", default=[], help="Parent task id (repeatable)"), _arg("--workspace", default="scratch", help="scratch | worktree | worktree: | dir: (default: scratch)"), - _arg("--branch", default=None, help="Branch name for worktree tasks, e.g. wt/t6-wire"), - _arg("--project", default=None, + _arg("--branch", help="Branch name for worktree tasks, e.g. wt/t6-wire"), + _arg("--project", help="Link to a project (id or slug). Anchors the task's " "worktree under the project's primary repo with a " "deterministic branch. See `hermes project list`."), @@ -159,10 +159,10 @@ _SPECS = [ _PRIORITY, _arg("--triage", action="store_true", help="Park in triage — a specifier will flesh out the spec and promote to todo"), - _arg("--idempotency-key", default=None, + _arg("--idempotency-key", help="Dedup key. If a non-archived task with this key exists, " "its id is returned instead of creating a duplicate."), - _arg("--max-runtime", default=None, + _arg("--max-runtime", help="Per-task runtime cap. Accepts seconds (300) or durations (90s, " "30m, 2h, 1d). When exceeded, the dispatcher SIGTERMs (then " "SIGKILLs) the worker and re-queues the task."), @@ -171,17 +171,17 @@ _SPECS = [ help="Skill to force-load into the worker (repeatable). The kanban " "lifecycle is already injected automatically. Example: --skill " "translation --skill github-code-review"), - _arg("--max-retries", type=int, default=None, metavar="N", + _arg("--max-retries", type=int, metavar="N", help="Per-task override for the consecutive-failure " f"circuit breaker. Trip on the Nth failure — e.g. --max-retries 1 blocks on the " f"first failure (no retries), --max-retries 3 allows two retries. Omit to use " f"the dispatcher's kanban.failure_limit config (default " f"{kb.DEFAULT_FAILURE_LIMIT})."), - _arg("--model", default=None, dest="model_override", + _arg("--model", dest="model_override", help="Pin the worker to this model (passed as -m ) without " "changing the profile's configured model. Combine with --provider " "when the model belongs to a different backend than the profile's default."), - _arg("--provider", default=None, dest="provider_override", + _arg("--provider", dest="provider_override", help="Provider the --model belongs to (passed as --provider to " "the worker). Requires --model."), _arg("--goal", action="store_true", dest="goal_mode", @@ -190,7 +190,7 @@ _SPECS = [ "keeps going in the same session until the judge agrees it's " "complete (or the turn budget runs out, which blocks the card for " "review). Best for open-ended cards one shot rarely finishes."), - _arg("--goal-max-turns", type=int, default=None, metavar="N", dest="goal_max_turns", + _arg("--goal-max-turns", type=int, metavar="N", dest="goal_max_turns", help="Turn budget for --goal workers (default 20). Ignored without --goal."), _arg("--initial-status", choices=sorted(kb.VALID_INITIAL_STATUSES), default="running", help="Initial card status. Use 'blocked' for cards " @@ -206,23 +206,23 @@ _SPECS = [ _arg("--synthesizer", required=True, help="Synthesizer/writer profile"), _TENANT, _PRIORITY, - _arg("--created-by", default=None, help="Creator/anchor profile"), - _arg("--idempotency-key", default=None, help="Dedup key for the root card"), + _arg("--created-by", help="Creator/anchor profile"), + _arg("--idempotency-key", help="Dedup key for the root card"), _json_flag(help="Emit JSON output"), ], help="Create a Kanban Swarm v1 graph (parallel workers → verifier → synthesizer)"), _cmd("list", [ _arg("--mine", action="store_true", help="Filter by $HERMES_PROFILE as assignee"), - _arg("--assignee", default=None), - _arg("--status", default=None, choices=sorted(kb.VALID_STATUSES)), - _arg("--tenant", default=None), - _arg("--session", default=None, + _arg("--assignee"), + _arg("--status", choices=sorted(kb.VALID_STATUSES)), + _arg("--tenant"), + _arg("--session", help="Filter by originating chat/agent session id (set on tasks created from inside an ACP loop)"), _arg("--archived", action="store_true", help="Include archived tasks"), _json_flag(), - _arg("--sort", default=None, choices=sorted(kb.VALID_SORT_ORDERS.keys()), + _arg("--sort", choices=sorted(kb.VALID_SORT_ORDERS.keys()), help="Sort order for listed tasks (default: priority)"), - _arg("--workflow-template-id", default=None, metavar="ID", help="Restrict to tasks with this workflow_template_id"), - _arg("--step-key", default=None, dest="current_step_key", metavar="KEY", + _arg("--workflow-template-id", metavar="ID", help="Restrict to tasks with this workflow_template_id"), + _arg("--step-key", dest="current_step_key", metavar="KEY", help="Restrict to tasks with this current_step_key"), ], aliases=["ls"], help="List tasks"), _cmd("show", [_TASK_ID, _json_flag(), *_run_state_args("filter listed runs by task_runs column")], @@ -231,8 +231,8 @@ _SPECS = [ help="Assign or reassign a task"), _cmd("set-model", [ _TASK_ID, - _arg("model", nargs="?", default=None, help="Model to pin the worker to (or 'none' to clear the override)"), - _arg("--provider", default=None, + _arg("model", nargs="?", help="Model to pin the worker to (or 'none' to clear the override)"), + _arg("--provider", help="Provider the model belongs to (worker is spawned with " "--provider ). Cleared together with the model."), ], help="Set or clear a task's model/provider override (takes effect on the next dispatch)"), @@ -245,9 +245,9 @@ _SPECS = [ _RECLAIM_REASON, ], help="Reassign a task to a different profile, optionally reclaiming first"), _cmd("diagnostics", [ - _arg("--severity", choices=["warning", "error", "critical"], default=None, + _arg("--severity", choices=["warning", "error", "critical"], help="Only show diagnostics at or above this severity"), - _arg("--task", default=None, help="Only show diagnostics for one task id"), + _arg("--task", help="Only show diagnostics for one task id"), _json_flag(help="Emit JSON (structured) instead of the default human table"), ], aliases=["diag"], help="List active diagnostics on the current board"), _cmd("link", [_arg("parent_id"), _arg("child_id")], help="Add a parent->child dependency"), @@ -259,24 +259,24 @@ _SPECS = [ _cmd("comment", [ _TASK_ID, _arg("text", nargs="+", help="Comment body"), - _arg("--author", default=None, help="Author name (default: $HERMES_PROFILE or 'user')"), - _arg("--max-len", type=int, default=None, help="Trim the stored comment body to this many characters"), + _arg("--author", help="Author name (default: $HERMES_PROFILE or 'user')"), + _arg("--max-len", type=int, help="Trim the stored comment body to this many characters"), ], help="Append a comment"), _cmd("attach", [ _TASK_ID, _arg("path", help="Path to the local file to attach"), - _arg("--content-type", default=None, help="MIME type (default: guessed from the file extension)"), - _arg("--name", default=None, help="Stored filename (default: the source file's basename)"), - _arg("--author", default=None, help="uploaded_by label (default: $HERMES_PROFILE or 'user')"), + _arg("--content-type", help="MIME type (default: guessed from the file extension)"), + _arg("--name", help="Stored filename (default: the source file's basename)"), + _arg("--author", help="uploaded_by label (default: $HERMES_PROFILE or 'user')"), ], help="Attach a local file to a task"), _cmd("attachments", [_TASK_ID, _json_flag()], help="List a task's attachments"), _cmd("attach-rm", [_arg("attachment_id", type=int)], help="Delete an attachment by id"), _cmd("complete", [ _arg("task_ids", nargs="+", help="One or more task ids (only --result applies to all of them)"), - _arg("--result", default=None, help="Result summary"), - _arg("--summary", default=None, + _arg("--result", help="Result summary"), + _arg("--summary", help="Structured handoff summary for downstream tasks. Falls back to --result if omitted."), - _arg("--metadata", default=None, + _arg("--metadata", help='JSON dict of structured facts (e.g. \'{"changed_files": [...], ' '"tests_run": 12}\'). Stored on the closing run.'), ], help="Mark one or more tasks done"), @@ -289,7 +289,7 @@ _SPECS = [ _TASK_ID, _arg("reason", nargs="*", help="Reason (also appended as a comment)"), _bulk_ids("block"), - _arg("--kind", default=None, choices=sorted(kb.VALID_BLOCK_KINDS), + _arg("--kind", choices=sorted(kb.VALID_BLOCK_KINDS), help="Typed block reason. 'dependency' waits in todo (auto-promoted when " "parents finish, no human); 'needs_input'/'capability' go to " "blocked for a human; 'transient' marks a maybe-flaky failure. " @@ -307,9 +307,9 @@ _SPECS = [ ], help="Return blocked/scheduled tasks to ready, or todo while parents remain open"), _cmd("request-review", [ _TASK_ID, - _arg("--summary", default=None, help="What was implemented and how it was verified — shown to the reviewer."), - _arg("--reviewer", default=None, help="Optional reviewer profile; reassigns the task before review dispatch."), - _arg("--metadata", default=None, help="JSON object with structured reviewer handoff facts."), + _arg("--summary", help="What was implemented and how it was verified — shown to the reviewer."), + _arg("--reviewer", help="Optional reviewer profile; reassigns the task before review dispatch."), + _arg("--metadata", help="JSON object with structured reviewer handoff facts."), _arg("--force", action="store_true", help="Override the live-claim guard: move a running, claimed " "task to review even without owning its run (clears the worker's claim)."), @@ -330,13 +330,13 @@ _SPECS = [ ], help="Manually move one or more todo/blocked tasks to ready (recovery path)"), _cmd("archive", [ _arg("task_ids", nargs="*", help="Task ids to archive (default mode)"), - _arg("--rm", dest="purge_ids", nargs="+", default=None, + _arg("--rm", dest="purge_ids", nargs="+", help="Permanently delete already-archived task ids from the board"), ], help="Archive one or more tasks"), _cmd("tail", [_TASK_ID, _arg("--interval", type=float, default=1.0)], help="Follow a task's event stream"), _cmd("dispatch", [ _arg("--dry-run", action="store_true", help="Don't actually spawn processes; just print what would happen"), - _arg("--max", type=int, default=None, help="Cap number of spawns this pass"), + _arg("--max", type=int, help="Cap number of spawns this pass"), _arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_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})"), @@ -344,18 +344,18 @@ _SPECS = [ ], 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, default=None, help="Cap number of spawns per tick"), + _arg("--max", type=int, help="Cap number of spawns per tick"), _arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT), - _arg("--pidfile", default=None, help="Write the daemon's PID to this file on start"), + _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 # --help so nobody casually keeps the double-dispatcher pattern alive. _arg("--force", action="store_true", help=argparse.SUPPRESS), ], help="DEPRECATED — dispatcher now runs in the gateway. Use `hermes gateway start`."), _cmd("watch", [ - _arg("--assignee", default=None, help="Only show events for tasks assigned to this profile"), - _arg("--tenant", default=None, help="Only show events from tasks in this tenant"), - _arg("--kinds", default=None, + _arg("--assignee", help="Only show events for tasks assigned to this profile"), + _arg("--tenant", help="Only show events from tasks in this tenant"), + _arg("--kinds", help="Comma-separated event kinds to include (e.g. 'completed,blocked,gave_up,crashed,timed_out')"), _arg("--interval", type=float, default=0.5, help="Poll interval in seconds (default: 0.5)"), ], help="Live-stream task_events to the terminal (Ctrl+C to exit)"), @@ -363,16 +363,16 @@ _SPECS = [ _cmd("notify-subscribe", [ _TASK_ID, *_NOTIFY_TARGET, - _arg("--user-id", default=None), - _arg("--user-id-alt", default=None), - _arg("--chat-type", choices=("dm", "group", "channel", "thread"), default=None, + _arg("--user-id"), + _arg("--user-id-alt"), + _arg("--chat-type", choices=("dm", "group", "channel", "thread"), help="Originating source chat_type, recorded so the active-wake delivery " "modes resolve the operator's real session. Omit to leave an " "existing sub unchanged (new subs default to 'dm')."), - _arg("--notifier-profile", default=None, + _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, default=None, + _arg("--delivery-mode", choices=kb._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 " @@ -380,16 +380,16 @@ _SPECS = [ "'wake' (wake the agent only, no passive message). Omit to leave an " "existing subscription's mode unchanged (new subs default to 'notify')."), ], help="Subscribe a gateway source to a task's terminal events (used by /kanban subscribe in the gateway adapter)"), - _cmd("notify-list", [_arg("task_id", nargs="?", default=None), _json_flag()], + _cmd("notify-list", [_arg("task_id", nargs="?"), _json_flag()], help="List notification subscriptions (optionally for a single task)"), _cmd("notify-unsubscribe", [_TASK_ID, *_NOTIFY_TARGET], help="Remove a gateway subscription from a task"), - _cmd("log", [_TASK_ID, _arg("--tail", type=int, default=None, help="Only print the last N bytes")], + _cmd("log", [_TASK_ID, _arg("--tail", type=int, help="Only print the last N bytes")], help="Print the worker log for a task (from /kanban/logs/)"), _cmd("runs", [_TASK_ID, _json_flag(), *_run_state_args("filter runs by task_runs column")], help="Show attempt history for a task (one row per run: profile, outcome, elapsed, summary)"), _cmd("heartbeat", [ _TASK_ID, - _arg("--note", default=None, help="Optional short note attached to the heartbeat event"), + _arg("--note", help="Optional short note attached to the heartbeat event"), ], help="Emit a heartbeat event for a running task (worker liveness signal)"), _cmd("assignees", [_json_flag()], help="List known profiles + per-profile task counts (union of ~/.hermes/profiles/ and current assignees on the board)"), From fb0f19d4fd8b99a77fc3c8d23dd1e60ecbee13e5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:00:43 -0700 Subject: [PATCH 34/38] =?UTF-8?q?refactor(kanban-cli):=20ops=20=E2=80=94?= =?UTF-8?q?=20dispatch=20--json=20scalar=20fields=20via=20one=20comprehens?= =?UTF-8?q?ion=20(output=20byte-identical)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/kanban_ops.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/hermes_cli/kanban_ops.py b/hermes_cli/kanban_ops.py index 83aa2e6ab1..3aa871d983 100644 --- a/hermes_cli/kanban_ops.py +++ b/hermes_cli/kanban_ops.py @@ -65,7 +65,9 @@ def _cmd_dispatch(args: argparse.Namespace) -> int: default_assignee = (_kanban_cfg.get("default_assignee") or "").strip() or None max_in_progress_per_profile = kb._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 = kb.resolve_max_in_progress( + kb._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) @@ -84,16 +86,9 @@ def _cmd_dispatch(args: argparse.Namespace) -> int: ) if getattr(args, "json", False): _print_json({ - "reclaimed": res.reclaimed, - "crashed": res.crashed, - "timed_out": res.timed_out, - "stale": res.stale, - "auto_blocked": res.auto_blocked, - "promoted": res.promoted, - "spawned": [ - {"task_id": tid, "assignee": who, "workspace": ws} - for (tid, who, ws) in res.spawned - ], + **{k: getattr(res, k) + for k in ("reclaimed", "crashed", "timed_out", "stale", "auto_blocked", "promoted")}, + "spawned": [{"task_id": tid, "assignee": who, "workspace": ws} for (tid, who, ws) in res.spawned], "skipped_unassigned": res.skipped_unassigned, "skipped_nonspawnable": res.skipped_nonspawnable, "skipped_per_profile_capped": [ From 432de4272087493a9e3ef985f8ca16787b84a0c2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:03:37 -0700 Subject: [PATCH 35/38] refactor(kanban_db): _copy_capped, single-loop unique path, rowcount inlines, list_boards tightening --- hermes_cli/kanban_db.py | 111 ++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 73 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 32facc56a4..c194935d6f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -317,8 +317,6 @@ def _relative_age(ts: Optional[int], now: Optional[int] = None) -> str: """``just now`` / ``18h ago`` / ``3d ago``; "" for a missing/invalid ts. An LLM reads a bare absolute timestamp as current fact — the relative age is what prompts a worker to re-verify stale sibling work.""" - if ts is None: - return "" try: ts = int(ts) except (TypeError, ValueError): @@ -614,23 +612,15 @@ def create_board( def list_boards(*, include_archived: bool = True) -> list[dict]: """Metadata for every board: ``default`` first (always present), then ``boards//`` dirs holding a ``kanban.db`` or ``board.json``, sorted.""" - entries: list[dict] = [] - seen: set[str] = set() - - # Default board is always first. - entries.append(read_board_metadata(DEFAULT_BOARD)) - seen.add(DEFAULT_BOARD) - + entries = [read_board_metadata(DEFAULT_BOARD)] + seen = {DEFAULT_BOARD} root = boards_root() if root.is_dir(): for child in sorted(root.iterdir(), key=lambda p: p.name.lower()): if not child.is_dir(): continue - slug = child.name - # Keep slug normalisation soft for discovery — but skip dirs - # that don't parse as valid slugs so we don't surface junk. try: - normed = _normalize_board_slug(slug) + normed = _normalize_board_slug(child.name) # skip junk dirs, don't raise except ValueError: continue if not normed or normed in seen or not _dir_holds_board(child): @@ -667,9 +657,8 @@ def remove_board(slug: str, *, archive: bool = True) -> dict: archive_root.mkdir(parents=True, exist_ok=True) ts = int(time.time()) target = archive_root / f"{normed}-{ts}" - # Avoid collision on rapid double-archives. suffix = 1 - while target.exists(): + while target.exists(): # rapid double-archive target = archive_root / f"{normed}-{ts}-{suffix}" suffix += 1 d.rename(target) @@ -1175,15 +1164,11 @@ def _project_from_source_task( if source_task.branch_name: prefix, separator, leaf = source_task.branch_name.partition("/") if separator and (leaf == source_task.id or leaf.startswith(f"{source_task.id}-")): - try: + with contextlib.suppress(ValueError): project_slug = _pdb.normalize_slug(prefix) - except ValueError: - project_slug = None if project_slug is None: - try: + with contextlib.suppress(ValueError): project_slug = _pdb.normalize_slug(project_id) - except ValueError: - return None, None if not project_slug: return None, None project_repo = str(source_path.parent.parent) @@ -2299,10 +2284,10 @@ def heartbeat_claim( "WHERE id = ? AND status = 'running' AND claim_lock = ?", (expires, task_id, lock), ) - if cur.rowcount == 1: - _extend_run_claim(conn, task_id, expires) - return True - return False + if cur.rowcount != 1: + return False + _extend_run_claim(conn, task_id, expires) + return True def _extend_run_claim(conn: sqlite3.Connection, task_id: str, expires: int) -> Optional[int]: @@ -2526,18 +2511,12 @@ def _verify_created_cards( phantom: list[str] = [] for cid in ordered: created_by = found.get(cid) - if created_by is None: - phantom.append(cid) - continue - # Accept if any of the three trust conditions holds. - if ( + trusted = created_by is not None and ( (completing_assignee and created_by == completing_assignee) or created_by == completing_task_id or cid in linked_children - ): - verified.append(cid) - else: - phantom.append(cid) + ) + (verified if trusted else phantom).append(cid) return verified, phantom @@ -2831,15 +2810,7 @@ def _persist_scratch_completion_artifacts( try: attachment_dir.mkdir(parents=True, exist_ok=True) dest = _unique_attachment_path(attachment_dir, resolved_src.name, used_destinations) - with resolved_src.open("rb") as source_file, dest.open("xb") as destination_file: - copied = 0 - while chunk := source_file.read(1024 * 1024): - copied += len(chunk) - if copied > KANBAN_ATTACHMENT_MAX_BYTES: - raise ArtifactPreservationError( - f"declared scratch artifact grew beyond the size limit: {artifact}" - ) - destination_file.write(chunk) + _copy_capped(resolved_src, dest, artifact) except Exception as exc: if dest is not None: with contextlib.suppress(OSError): @@ -2850,7 +2821,6 @@ def _persist_scratch_completion_artifacts( raise ArtifactPreservationError( f"could not preserve declared scratch artifact {artifact}: {exc}" ) from exc - used_destinations.add(dest) persisted.append(str(dest.resolve())) changed = True @@ -2862,6 +2832,19 @@ def _persist_scratch_completion_artifacts( ] +def _copy_capped(src: Path, dest: Path, artifact: str) -> None: + """Chunked copy that aborts if the file grows past the attachment cap mid-copy.""" + with src.open("rb") as source_file, dest.open("xb") as destination_file: + copied = 0 + while chunk := source_file.read(1024 * 1024): + copied += len(chunk) + if copied > KANBAN_ATTACHMENT_MAX_BYTES: + raise ArtifactPreservationError( + f"declared scratch artifact grew beyond the size limit: {artifact}" + ) + destination_file.write(chunk) + + def _insert_completion_attachment( conn: sqlite3.Connection, task_id: str, *, filename: str, stored_path: str, size: int, created_at: int, @@ -2873,29 +2856,19 @@ def _insert_completion_attachment( "VALUES (?, ?, ?, NULL, ?, 'kanban_complete', ?)", (task_id, filename, stored_path, size, created_at), ) - _append_event( - conn, - task_id, - "attached", - {"filename": filename, "size": size, "by": "kanban_complete"}, - ) + _append_event(conn, task_id, "attached", {"filename": filename, "size": size, "by": "kanban_complete"}) def _unique_attachment_path(directory: Path, filename: str, used: set[Path]) -> Path: """Return a non-conflicting path under ``directory`` for ``filename``.""" safe_name = Path(filename).name or "artifact" + stem, suffix = Path(safe_name).stem or "artifact", Path(safe_name).suffix candidate = directory / safe_name - if candidate not in used and not candidate.exists(): - return candidate - - stem = Path(safe_name).stem or "artifact" - suffix = Path(safe_name).suffix idx = 1 - while True: + while candidate in used or candidate.exists(): candidate = directory / f"{stem}_{idx}{suffix}" - if candidate not in used and not candidate.exists(): - return candidate idx += 1 + return candidate def edit_completed_task_result( @@ -2918,12 +2891,12 @@ def edit_completed_task_result( """, (task_id,), ).fetchone() - run_id = int(run["id"]) if run else None - if run_id is None: + if run is None: run_id = _synthesize_ended_run( conn, task_id, outcome="completed", summary=handoff_summary, metadata=metadata, ) else: + run_id = int(run["id"]) conn.execute("UPDATE task_runs SET summary = ? WHERE id = ?", (handoff_summary, run_id)) if metadata is not None: conn.execute( @@ -2958,11 +2931,7 @@ def block_task( ).fetchone() if cur_row is None: return False - source_status = ( - _retry_status_for_run(conn, task_id) - if cur_row["status"] == "running" - else "ready" - ) + source_status = _retry_status_for_run(conn, task_id) if cur_row["status"] == "running" else "ready" new_status, event_kind, set_sql, params, payload = _route_block( kind, reason, source_status, prev_kind=_row_get(cur_row, "block_kind"), prev_recurrences=int(_row_get(cur_row, "block_recurrences") or 0), @@ -2981,8 +2950,7 @@ def block_task( if expected_run_id is not None: sql += " AND current_run_id = ?" params = (*params, int(expected_run_id)) - cur = conn.execute(sql, params) - if cur.rowcount != 1: + if conn.execute(sql, params).rowcount != 1: return False run_id = _end_or_synthesize_run( conn, task_id, outcome="blocked", status="blocked", summary=reason, synthesize=bool(reason), @@ -3113,9 +3081,7 @@ def request_review( ) if cur.rowcount != 1: return _ret( - False, - "task is not in running/ready (or expected_run_id did not " - "match the current run)", + False, "task is not in running/ready (or expected_run_id did not match the current run)", ) run_id = _end_or_synthesize_run( conn, task_id, outcome="review_requested", status="review", @@ -3412,7 +3378,7 @@ def invalidate_descendants_for_parent_reopen( Returns ``{"invalidated": [{id, prior_status, new_status, resume_status}], "terminations": [(worker_pid, claim_lock)]}``. """ - caller_owns_txn = bool(getattr(conn, "in_transaction", False)) + caller_owns_txn = bool(conn.in_transaction) now = int(time.time()) invalidated: list[dict[str, Any]] = [] terminations: list[tuple[Optional[int], Optional[str]]] = [] @@ -3771,8 +3737,7 @@ def schedule_task( if expected_run_id is not None: sql += " AND current_run_id = ?" params.append(int(expected_run_id)) - cur = conn.execute(sql, params) - if cur.rowcount != 1: + if conn.execute(sql, params).rowcount != 1: return False run_id = _end_or_synthesize_run( conn, task_id, outcome="scheduled", status="scheduled", summary=reason, synthesize=bool(reason), From cf515673e3393f1d5151ee36979d81ffd5b77ade Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:07:23 -0700 Subject: [PATCH 36/38] refactor(kanban_db): AST-neutral packing of hook/event call args and trailing SQL params --- hermes_cli/kanban_db.py | 215 ++++++++++++---------------------------- 1 file changed, 66 insertions(+), 149 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index c194935d6f..991e327b4e 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -185,13 +185,9 @@ def _fire_worker_spawned_hook( return try: _fire_kanban_lifecycle_hook( - "on_kanban_worker_spawned", - task.id, - board=board or get_current_board(), - assignee=task.assignee, - run_id=_current_run_id(conn, task.id), - worker_pid=int(pid) if pid else None, - workspace_path=str(workspace_path), + "on_kanban_worker_spawned", task.id, board=board or get_current_board(), + assignee=task.assignee, run_id=_current_run_id(conn, task.id), + worker_pid=int(pid) if pid else None, workspace_path=str(workspace_path), ) except Exception as exc: # pragma: no cover - defensive _log.debug("kanban worker spawned hook failed: %s", exc) @@ -208,16 +204,12 @@ def notify_task_updated( return try: row = conn.execute( - "SELECT assignee, current_run_id FROM tasks WHERE id = ?", - (task_id,), + "SELECT assignee, current_run_id FROM tasks WHERE id = ?", (task_id,), ).fetchone() _fire_kanban_lifecycle_hook( - "on_kanban_task_updated", - task_id, - board=board or get_current_board(), + "on_kanban_task_updated", task_id, board=board or get_current_board(), assignee=row["assignee"] if row else None, - run_id=row["current_run_id"] if row else None, - changed_fields=list(changed_fields), + run_id=row["current_run_id"] if row else None, changed_fields=list(changed_fields), ) except Exception as exc: # pragma: no cover - defensive _log.debug("kanban task updated hook failed: %s", exc) @@ -254,12 +246,8 @@ def _fire_dispatch_tick_hook( elif not any(getattr(result, f) for f in _TICK_ACTIVITY_FIELDS): outcome = "idle" invoke_hook( - "on_kanban_dispatch_tick", - board=board, - profile_name=profile_name, - dry_run=bool(dry_run), - outcome=outcome, - result=result, + "on_kanban_dispatch_tick", board=board, profile_name=profile_name, + dry_run=bool(dry_run), outcome=outcome, result=result, ) except Exception as exc: # pragma: no cover - defensive _log.debug("kanban dispatch tick hook failed: %s", exc) @@ -337,8 +325,7 @@ def _relative_age(ts: Optional[int], now: Optional[int] = None) -> str: DEFAULT_BOARD = "default" _CURRENT_BOARD_OVERRIDE: ContextVar[str | None] = ContextVar( - "hermes_kanban_current_board_override", - default=None, + "hermes_kanban_current_board_override", default=None, ) @@ -1279,8 +1266,7 @@ def create_task( row = conn.execute( "SELECT id FROM tasks WHERE idempotency_key = ? " "AND status != 'archived' " - "ORDER BY created_at DESC LIMIT 1", - (idempotency_key,), + "ORDER BY created_at DESC LIMIT 1", (idempotency_key,), ).fetchone() if row: return row["id"] @@ -1383,8 +1369,7 @@ def _initial_task_status( if parents: rows = conn.execute( "SELECT status FROM tasks WHERE id IN " - "(" + ",".join("?" * len(parents)) + ")", - parents, + "(" + ",".join("?" * len(parents)) + ")", parents, ).fetchall() if any(r["status"] != "done" for r in rows): return "todo" @@ -1433,8 +1418,7 @@ def _inherit_notify_subs( if not parent_ids: return row = conn.execute( - "SELECT COALESCE(MAX(id), 0) AS cursor FROM task_events WHERE task_id = ?", - (child_id,), + "SELECT COALESCE(MAX(id), 0) AS cursor FROM task_events WHERE task_id = ?", (child_id,), ).fetchone() cursor = int(row["cursor"] if row is not None else 0) placeholders = ",".join("?" * len(parent_ids)) @@ -1524,8 +1508,7 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) # The failure streak is per task/profile; a new profile starts fresh. conn.execute( "UPDATE tasks SET assignee = ?, consecutive_failures = 0, " - "last_failure_error = NULL WHERE id = ?", - (profile, task_id), + "last_failure_error = NULL WHERE id = ?", (profile, task_id), ) else: conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id)) @@ -1574,8 +1557,7 @@ def set_reasoning_effort(conn: sqlite3.Connection, task_id: str, effort: Optiona other; applies on the NEXT dispatch, so settable while running.""" effort = normalize_reasoning_effort(effort) return _set_task_override( - conn, task_id, - "UPDATE tasks SET reasoning_effort = ? WHERE id = ?", (effort,), + conn, task_id, "UPDATE tasks SET reasoning_effort = ? WHERE id = ?", (effort,), "reasoning_effort_set", {"reasoning_effort": effort}, ("reasoning_effort",), archived_msg="cannot set reasoning effort", ) @@ -1596,8 +1578,7 @@ def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: # If child was ready but parent is not yet done, demote child to todo. if _task_status(conn, parent_id) != "done": conn.execute( - "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", - (child_id,), + "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", (child_id,), ) _append_event( conn, child_id, "linked", {"parent": parent_id, "child": child_id}, @@ -1626,8 +1607,7 @@ def _would_cycle(conn: sqlite3.Connection, parent_id: str, child_id: str) -> boo def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: with write_txn(conn): cur = conn.execute( - "DELETE FROM task_links WHERE parent_id = ? AND child_id = ?", - (parent_id, child_id), + "DELETE FROM task_links WHERE parent_id = ? AND child_id = ?", (parent_id, child_id), ) removed = cur.rowcount > 0 if removed: @@ -1666,8 +1646,7 @@ def task_graph_contexts(conn: sqlite3.Connection, task_ids: Iterable[str]) -> di for row in conn.execute( f"SELECT l.{own} AS owner_id, t.id, t.title, t.status " f"FROM task_links l JOIN tasks t ON t.id = l.{other} " - f"WHERE l.{own} IN ({placeholders}) ORDER BY l.{own}, t.id", - tuple(ordered_ids), + f"WHERE l.{own} IN ({placeholders}) ORDER BY l.{own}, t.id", tuple(ordered_ids), ).fetchall(): contexts[row["owner_id"]][bucket].append( {"id": row["id"], "title": row["title"], "status": row["status"]} @@ -1694,8 +1673,7 @@ def add_comment(conn: sqlite3.Connection, task_id: str, author: str, body: str) _require_task(conn, task_id) cur = conn.execute( "INSERT INTO task_comments (task_id, author, body, created_at) " - "VALUES (?, ?, ?, ?)", - (task_id, author.strip(), body.strip(), now), + "VALUES (?, ?, ?, ?)", (task_id, author.strip(), body.strip(), now), ) _append_event(conn, task_id, "commented", {"author": author, "len": len(body)}) return int(cur.lastrowid or 0) @@ -1723,8 +1701,7 @@ def list_comments_after( same-second burst is never skipped (live worker comment bridge).""" rows = conn.execute( "SELECT id, task_id, author, body, created_at FROM task_comments " - "WHERE task_id = ? AND id > ? ORDER BY id ASC", - (task_id, int(after_id)), + "WHERE task_id = ? AND id > ? ORDER BY id ASC", (task_id, int(after_id)), ).fetchall() return [Comment.from_row(r) for r in rows] @@ -1809,9 +1786,7 @@ def add_attachment( (task_id, filename.strip(), stored_path, content_type, int(size), uploaded_by, now), ) _append_event( - conn, - task_id, - "attached", + conn, task_id, "attached", {"filename": filename.strip(), "size": int(size), "by": uploaded_by}, ) return int(cur.lastrowid or 0) @@ -1852,8 +1827,7 @@ def _insert_comment( opens its own txn and emits ``commented``).""" conn.execute( "INSERT INTO task_comments (task_id, author, body, created_at) " - "VALUES (?, ?, ?, ?)", - (task_id, author, body, created_at), + "VALUES (?, ?, ?, ?)", (task_id, author, body, created_at), ) @@ -1864,8 +1838,7 @@ def _append_event( """Insert an event row inside the caller's txn; ``run_id`` groups it by attempt (NULL = task-scoped).""" conn.execute( "INSERT INTO task_events (task_id, run_id, kind, payload, created_at) " - "VALUES (?, ?, ?, ?, ?)", - (task_id, run_id, kind, _json_or_null(payload), int(time.time())), + "VALUES (?, ?, ?, ?, ?)", (task_id, run_id, kind, _json_or_null(payload), int(time.time())), ) @@ -1949,8 +1922,7 @@ def _synthesize_ended_run( NOT touch the tasks row.""" now = int(time.time()) trow = conn.execute( - "SELECT assignee, current_step_key FROM tasks WHERE id = ?", - (task_id,), + "SELECT assignee, current_step_key FROM tasks WHERE id = ?", (task_id,), ).fetchone() profile = trow["assignee"] if trow else None step_key = trow["current_step_key"] if trow else None @@ -1981,8 +1953,7 @@ def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool: row = conn.execute( "SELECT kind FROM task_events " "WHERE task_id = ? AND kind IN ('blocked', 'unblocked') " - "ORDER BY id DESC LIMIT 1", - (task_id,), + "ORDER BY id DESC LIMIT 1", (task_id,), ).fetchone() return bool(row) and row["kind"] == "blocked" @@ -2008,8 +1979,7 @@ def _resume_status_from_events(conn: sqlite3.Connection, task_id: str) -> str: "'blocked', 'block_loop_detected', 'dependency_wait', 'gave_up', " "'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', " "'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited'" - ") ORDER BY id DESC LIMIT 1", - (task_id,), + ") ORDER BY id DESC LIMIT 1", (task_id,), ).fetchone() payload = _json_dict(_row_get(row, "payload")) for key in ("resume_status", "retry_status", "source_status"): @@ -2044,8 +2014,7 @@ def recompute_ready(conn: sqlite3.Connection, failure_limit: int = None) -> int: parents = conn.execute( "SELECT t.status FROM tasks t " "JOIN task_links l ON l.parent_id = t.id " - "WHERE l.child_id = ?", - (task_id,), + "WHERE l.child_id = ?", (task_id,), ).fetchall() if all(p["status"] in ("done", "archived") for p in parents): resume_status = _resume_status_from_events(conn, task_id) @@ -2063,8 +2032,7 @@ def recompute_ready(conn: sqlite3.Connection, failure_limit: int = None) -> int: continue conn.execute( "UPDATE tasks SET status = ? " - "WHERE id = ? AND status = 'blocked'", - (resume_status, task_id), + "WHERE id = ? AND status = 'blocked'", (resume_status, task_id), ) else: conn.execute( @@ -2087,8 +2055,7 @@ def _parents_satisfied(conn: sqlite3.Connection, task_id: str) -> bool: "SELECT 1 FROM task_links l " "JOIN tasks p ON p.id = l.parent_id " "WHERE l.child_id = ? " - "AND p.status NOT IN ('done', 'archived') LIMIT 1", - (task_id,), + "AND p.status NOT IN ('done', 'archived') LIMIT 1", (task_id,), ).fetchone() is None @@ -2113,11 +2080,9 @@ def _claim_and_open_run( ) if cur.rowcount != 1: return None - # Populate the run with the task's assignee / step / runtime cap. trow = conn.execute( "SELECT assignee, max_runtime_seconds, current_step_key " - "FROM tasks WHERE id = ?", - (task_id,), + "FROM tasks WHERE id = ?", (task_id,), ).fetchone() run_cur = conn.execute( """ @@ -2160,12 +2125,9 @@ def claim_task( if not _parents_satisfied(conn, task_id): conn.execute( "UPDATE tasks SET status = 'todo' " - "WHERE id = ? AND status = 'ready'", - (task_id,), - ) - _append_event( - conn, task_id, "claim_rejected", {"reason": "parents_not_done"}, + "WHERE id = ? AND status = 'ready'", (task_id,), ) + _append_event(conn, task_id, "claim_rejected", {"reason": "parents_not_done"}) return None # Close a leaked prior run so the CAS below doesn't strand it. _reclaim_dangling_run( @@ -2193,18 +2155,12 @@ def claim_review_task( if not _parents_satisfied(conn, task_id): demoted = conn.execute( "UPDATE tasks SET status = 'todo' " - "WHERE id = ? AND status = 'review' AND claim_lock IS NULL", - (task_id,), + "WHERE id = ? AND status = 'review' AND claim_lock IS NULL", (task_id,), ) if demoted.rowcount == 1: _append_event( - conn, - task_id, - "dependency_wait", - { - "reason": "parent_reopened", - "source_status": "review", - }, + conn, task_id, "dependency_wait", + {"reason": "parent_reopened", "source_status": "review"}, ) return None run_id = _claim_and_open_run( @@ -2263,8 +2219,7 @@ def goal_run_status( if task.status in {"ready", "todo"}: event = conn.execute( "SELECT kind FROM task_events WHERE task_id = ? " - "ORDER BY id DESC LIMIT 1", - (task_id,), + "ORDER BY id DESC LIMIT 1", (task_id,), ).fetchone() if event and event["kind"] == "changes_requested": return "changes_requested" @@ -2281,8 +2236,7 @@ def heartbeat_claim( with write_txn(conn): cur = conn.execute( "UPDATE tasks SET claim_expires = ? " - "WHERE id = ? AND status = 'running' AND claim_lock = ?", - (expires, task_id, lock), + "WHERE id = ? AND status = 'running' AND claim_lock = ?", (expires, task_id, lock), ) if cur.rowcount != 1: return False @@ -2315,8 +2269,7 @@ def release_stale_claims(conn: sqlite3.Connection, *, signal_fn=None) -> int: " assignee " "FROM tasks " "WHERE status = 'running' AND claim_expires IS NOT NULL " - " AND claim_expires < ?", - (now,), + " AND claim_expires < ?", (now,), ).fetchall() for row in stale: host_local = (row["claim_lock"] or "").startswith(host_prefix) @@ -2367,14 +2320,9 @@ def release_stale_claims(conn: sqlite3.Connection, *, signal_fn=None) -> int: # Post-commit observer; every non-reclaim branch ``continue``d above. if _kanban_observer_consumed("on_kanban_worker_stale_claim"): _fire_kanban_lifecycle_hook( - "on_kanban_worker_stale_claim", - row["id"], - board=get_current_board(), - assignee=row["assignee"], - run_id=run_id, - worker_pid=_opt_int(row["worker_pid"]), - heartbeat_stale=bool(heartbeat_stale), - retry_status=retry_status, + "on_kanban_worker_stale_claim", row["id"], board=get_current_board(), + assignee=row["assignee"], run_id=run_id, worker_pid=_opt_int(row["worker_pid"]), + heartbeat_stale=bool(heartbeat_stale), retry_status=retry_status, ) return reclaimed @@ -2403,8 +2351,7 @@ def _extend_live_stale_claim(conn: sqlite3.Connection, row: sqlite3.Row, now: in "WHERE id = ? AND status = 'running' " " AND claim_lock IS ? " " AND claim_expires IS NOT NULL " - " AND claim_expires < ?", - (new_expires, row["id"], row["claim_lock"], now), + " AND claim_expires < ?", (new_expires, row["id"], row["claim_lock"], now), ) if cur.rowcount != 1: return @@ -2429,8 +2376,7 @@ def reclaim_task( """Operator reclaim regardless of TTL: release the claim, restore the source phase, reset the failure counter. False when not running.""" row = conn.execute( - "SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?", - (task_id,), + "SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?", (task_id,), ).fetchone() if not row: return False @@ -2445,8 +2391,7 @@ def reclaim_task( "UPDATE tasks SET status = ?, claim_lock = NULL, " "claim_expires = NULL, worker_pid = NULL " "WHERE id = ? AND status IN ('running', 'ready', 'blocked') " - "AND claim_lock IS ?", - (retry_status, task_id, prev_lock), + "AND claim_lock IS ?", (retry_status, task_id, prev_lock), ) if cur.rowcount != 1: return False @@ -2498,8 +2443,7 @@ def _verify_created_cards( # Batch-fetch existence + created_by in one query. placeholders = ",".join(["?"] * len(ordered)) rows = conn.execute( - f"SELECT id, created_by FROM tasks WHERE id IN ({placeholders})", - tuple(ordered), + f"SELECT id, created_by FROM tasks WHERE id IN ({placeholders})", tuple(ordered), ).fetchall() found = {r["id"]: r["created_by"] for r in rows} @@ -2926,8 +2870,7 @@ def block_task( raise ValueError(f"block kind must be one of {sorted(VALID_BLOCK_KINDS)} or None") with write_txn(conn): cur_row = conn.execute( - "SELECT status, block_kind, block_recurrences FROM tasks WHERE id = ?", - (task_id,), + "SELECT status, block_kind, block_recurrences FROM tasks WHERE id = ?", (task_id,), ).fetchone() if cur_row is None: return False @@ -3044,8 +2987,7 @@ def request_review( and trow["claim_lock"] is not None ): return _ret( - False, - "task is running under a live claim; pass expected_run_id " + False, "task is running under a live claim; pass expected_run_id " "(worker ownership) or force=True (explicit operator " "override) instead of clearing the live run's claim", ) @@ -3054,8 +2996,7 @@ def request_review( reviewer = _prior_reviewer(conn, task_id) if reviewer is False: return _ret( - False, - "re-review has no durable reviewer provenance (the " + False, "re-review has no durable reviewer provenance (the " "latest changes_requested event is missing or " "malformed); pass reviewer= explicitly", ) @@ -3108,8 +3049,7 @@ def _prior_reviewer(conn: sqlite3.Connection, task_id: str): changes_run = conn.execute( "SELECT id FROM task_runs " "WHERE task_id = ? AND outcome = 'changes_requested' " - "ORDER BY id DESC LIMIT 1", - (task_id,), + "ORDER BY id DESC LIMIT 1", (task_id,), ).fetchone() if changes_run is None: return None @@ -3134,8 +3074,7 @@ def request_changes( with write_txn(conn): task_row = conn.execute( - "SELECT status, assignee, current_run_id FROM tasks WHERE id = ?", - (task_id,), + "SELECT status, assignee, current_run_id FROM tasks WHERE id = ?", (task_id,), ).fetchone() if task_row is None: return False, "task not found" @@ -3214,8 +3153,7 @@ def promote_task( parents = conn.execute( "SELECT t.id, t.status FROM tasks t " "JOIN task_links l ON l.parent_id = t.id " - "WHERE l.child_id = ?", - (task_id,), + "WHERE l.child_id = ?", (task_id,), ).fetchall() unsatisfied = [p["id"] for p in parents if p["status"] not in ("done", "archived")] if unsatisfied: @@ -3230,16 +3168,12 @@ def promote_task( with write_txn(conn): upd = conn.execute( "UPDATE tasks SET status = 'ready' " - "WHERE id = ? AND status IN ('todo', 'blocked')", - (task_id,), + "WHERE id = ? AND status IN ('todo', 'blocked')", (task_id,), ) if upd.rowcount != 1: return False, f"task {task_id} status changed during promotion" _append_event( - conn, - task_id, - "promoted_manual", - {"actor": actor, "reason": reason, "forced": force}, + conn, task_id, "promoted_manual", {"actor": actor, "reason": reason, "forced": force}, ) return True, None @@ -3304,8 +3238,7 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool: cur = conn.execute( "UPDATE tasks SET status = ?, current_run_id = NULL, " "consecutive_failures = 0, last_failure_error = NULL " - "WHERE id = ? AND status IN ('blocked', 'scheduled')", - (new_status, task_id), + "WHERE id = ? AND status IN ('blocked', 'scheduled')", (new_status, task_id), ) if cur.rowcount != 1: return False @@ -3351,10 +3284,7 @@ def reopen_review_task(conn: sqlite3.Connection, task_id: str) -> bool: if implementer: payload["implementer"] = implementer _append_event( - conn, - task_id, - "review_reopened", - payload if payload != {"status": "ready"} else None, + conn, task_id, "review_reopened", payload if payload != {"status": "ready"} else None, ) return True @@ -3419,8 +3349,7 @@ def invalidate_descendants_for_parent_reopen( conn.execute( "UPDATE tasks SET status = 'todo', completed_at = NULL, " "claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, " - "current_run_id = NULL, consecutive_failures = 0 WHERE id = ?", - (row["id"],), + "current_run_id = NULL, consecutive_failures = 0 WHERE id = ?", (row["id"],), ) entry = { "id": row["id"], "prior_status": previous_status, @@ -3442,11 +3371,9 @@ def invalidate_descendants_for_parent_reopen( run_id=run_id, ) _insert_comment( - conn, row["id"], author, - f"Invalidated: ancestor {task_id} was reopened; " + conn, row["id"], author, f"Invalidated: ancestor {task_id} was reopened; " f"retracted from '{previous_status}' to 'todo' " - f"(will resume via '{resume_status}').", - now, + f"(will resume via '{resume_status}').", now, ) invalidated.append(entry) if not caller_owns_txn: @@ -3493,8 +3420,7 @@ def specify_triage_task( params.append(task_id) cur = conn.execute( f"UPDATE tasks SET {', '.join(sets)} " - f"WHERE id = ? AND status = 'triage'", - tuple(params), + f"WHERE id = ? AND status = 'triage'", tuple(params), ) if cur.rowcount != 1: return False @@ -3506,9 +3432,7 @@ def specify_triage_task( int(time.time()), ) _append_event( - conn, - task_id, - "specified", + conn, task_id, "specified", {"changed_fields": changed_fields} if changed_fields else None, ) # Own IMMEDIATE txn (outside the one above): a parent-free specified task @@ -3577,8 +3501,7 @@ def decompose_triage_task( with write_txn(conn): root_row = conn.execute( "SELECT id, status, tenant, workspace_kind, workspace_path " - "FROM tasks WHERE id = ?", - (task_id,), + "FROM tasks WHERE id = ?", (task_id,), ).fetchone() if root_row is None or root_row["status"] != "triage": return None @@ -3670,8 +3593,7 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: cur = conn.execute( "UPDATE tasks SET status = 'archived', " " claim_lock = NULL, claim_expires = NULL, worker_pid = NULL " - "WHERE id = ? AND status != 'archived'", - (task_id,), + "WHERE id = ? AND status != 'archived'", (task_id,), ) if cur.rowcount != 1: return False @@ -3880,8 +3802,7 @@ def _ctx_parent_results(lines: list[str], conn: sqlite3.Connection, task_id: str falling back to ``task.result`` for pre-runs-table data. Stamped with a relative age so the worker re-verifies stale upstream results.""" parent_rows = conn.execute( - "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id", - (task_id,), + "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id", (task_id,), ).fetchall() wrote_header = False for pid in (r["parent_id"] for r in parent_rows): @@ -3926,8 +3847,7 @@ def _ctx_role_history(lines: list[str], conn: sqlite3.Connection, task: Task, no "FROM task_runs r JOIN tasks t ON r.task_id = t.id " "WHERE r.profile = ? AND r.task_id != ? " " AND r.outcome = 'completed' " - "ORDER BY r.ended_at DESC LIMIT 5", - (task.assignee, task.id), + "ORDER BY r.ended_at DESC LIMIT 5", (task.assignee, task.id), ).fetchall() if not role_rows: return @@ -4043,8 +3963,7 @@ def gc_events(conn: sqlite3.Connection, *, older_than_seconds: int = 30 * 24 * 3 with write_txn(conn): cur = conn.execute( "DELETE FROM task_events WHERE created_at < ? AND task_id IN " - "(SELECT id FROM tasks WHERE status IN ('done', 'archived'))", - (cutoff,), + "(SELECT id FROM tasks WHERE status IN ('done', 'archived'))", (cutoff,), ) return int(cur.rowcount or 0) @@ -4163,8 +4082,7 @@ def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]: """Return the most recent run regardless of outcome (active or closed).""" row = conn.execute( "SELECT * FROM task_runs WHERE task_id = ? " - "ORDER BY started_at DESC, id DESC LIMIT 1", - (task_id,), + "ORDER BY started_at DESC, id DESC LIMIT 1", (task_id,), ).fetchone() return Run.from_row(row) if row else None @@ -4175,8 +4093,7 @@ def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]: row = conn.execute( "SELECT summary FROM task_runs " "WHERE task_id = ? AND summary IS NOT NULL AND summary != '' " - "ORDER BY COALESCE(ended_at, started_at) DESC, id DESC LIMIT 1", - (task_id,), + "ORDER BY COALESCE(ended_at, started_at) DESC, id DESC LIMIT 1", (task_id,), ).fetchone() return row["summary"] if row else None From edfbd774439956ce3520c2e2713c92fe9e1e8481 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:11:18 -0700 Subject: [PATCH 37/38] refactor(kanban-cli): drop redundant parens on kwarg string concatenations; wrap to 100 cols (AST-identical) --- hermes_cli/kanban_diagnostics.py | 89 +++++++++++++++----------------- hermes_cli/kanban_ops.py | 48 ++++++++--------- hermes_cli/kanban_parser.py | 12 ++--- hermes_cli/kanban_swarm.py | 8 ++- 4 files changed, 74 insertions(+), 83 deletions(-) diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 571aad57f9..f957491556 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -113,7 +113,8 @@ def _latest_event_ts(events: Iterable[Any], kinds: set[str]) -> int: def _cli_hint(label: str, command: str, *, suggested: bool = False) -> DiagnosticAction: - return DiagnosticAction(kind="cli_hint", label=label, payload={"command": command}, suggested=suggested) + return DiagnosticAction(kind="cli_hint", label=label, payload={"command": command}, + suggested=suggested) def _log_hint_action(task_id: str) -> DiagnosticAction: @@ -200,7 +201,9 @@ def _main_model_visible(raw_config: Any) -> bool: model_cfg = raw_config.get("model") if isinstance(model_cfg, dict): provider = str(model_cfg.get("provider") or "").strip() - model = str(model_cfg.get("default") or model_cfg.get("model") or model_cfg.get("name") or "").strip() + model = str( + model_cfg.get("default") or model_cfg.get("model") or model_cfg.get("name") or "" + ).strip() return bool(provider and model) return bool(str(model_cfg or "").strip()) @@ -244,17 +247,16 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: hits = _active_hallucination_events(events, "completion_blocked_hallucination") if not hits: return [] - actions = [DiagnosticAction(kind="comment", label="Add a comment explaining what to do", suggested=False)] + actions = [DiagnosticAction(kind="comment", label="Add a comment explaining what to do", + suggested=False)] actions += _generic_recovery_actions(task, running=_is_running(task)) return [Diagnostic( kind="hallucinated_cards", severity="error", title="Worker claimed cards that don't exist", - detail=( - "The completing worker declared created_cards that either didn't exist or weren't " - "created by its profile. The completion was blocked and the task stayed in its prior " - "state. Usually means the worker hallucinated ids instead of capturing return values " - "from kanban_create." - ), + detail="The completing worker declared created_cards that either didn't exist or weren't " + "created by its profile. The completion was blocked and the task stayed in its prior " + "state. Usually means the worker hallucinated ids instead of capturing return values " + "from kanban_create.", actions=actions, first_seen_at=_event_ts(hits[0]), last_seen_at=_event_ts(hits[-1]), count=len(hits), data={"phantom_ids": _unique_payload_ids(hits, "phantom_cards")}, @@ -289,7 +291,8 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti if status is None: return [] - auto_decompose, main_visible = bool(status.get("auto_decompose")), bool(status.get("main_model_visible")) + auto_decompose = bool(status.get("auto_decompose")) + main_visible = bool(status.get("main_model_visible")) decomposer_explicit = bool(status.get("decomposer_explicit")) specifier_explicit = bool(status.get("specifier_explicit")) primary_slot, fallback_slot, primary_desc, detail_path = _TRIAGE_SLOTS[auto_decompose] @@ -315,12 +318,10 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti return [Diagnostic( kind="triage_aux_unavailable", severity="warning", title=f"Triage {primary_desc} has no usable model", - detail=( - f"This task is still in triage and no working auxiliary model is visible to the " - f"dispatcher. {detail_path} The default slot uses `provider: auto` which falls back to " - f"the main model, but no main model is configured either. Configure the slot directly " - f"or set a main model so the auto fallback can take over." - ), + detail=f"This task is still in triage and no working auxiliary model is visible to the " + f"dispatcher. {detail_path} The default slot uses `provider: auto` which falls back to " + f"the main model, but no main model is configured either. Configure the slot directly " + f"or set a main model so the auto fallback can take over.", actions=actions, first_seen_at=now, last_seen_at=now, count=1, data={"task_id": task_id, "auto_decompose": auto_decompose, @@ -337,11 +338,9 @@ def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]: return [Diagnostic( kind="prose_phantom_refs", severity="warning", title="Completion summary references unknown task ids", - detail=( - "The completion summary mentions task ids that don't resolve in this board's database. " - "The completion itself succeeded, but downstream consumers parsing the summary may be " - "pointed at cards that never existed." - ), + detail="The completion summary mentions task ids that don't resolve in this board's database. " + "The completion itself succeeded, but downstream consumers parsing the summary may be " + "pointed at cards that never existed.", actions=_generic_recovery_actions(task, running=_is_running(task)), first_seen_at=_event_ts(hits[0]), last_seen_at=_event_ts(hits[-1]), count=len(hits), data={"phantom_refs": _unique_payload_ids(hits, "phantom_refs")}, @@ -512,7 +511,8 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn actions: list[DiagnosticAction] = [] if task_id: actions.append(_cli_hint( - "Complete the finished implementation phase", f"hermes kanban complete {task_id}", suggested=True, + "Complete the finished implementation phase", f"hermes kanban complete {task_id}", + suggested=True, )) if task_id and child_ids: actions.append(_cli_hint( @@ -523,12 +523,10 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn return [Diagnostic( kind="review_dependency_deadlock", severity="error", title=f"Review handoff blocks {len(child_ids)} dependent task(s)", - detail=( - "This implementation is sticky-blocked for review while its downstream task(s) require " - "the implementation to be done or archived before they can run. Complete the finished " - "phase, unlink the incorrect dependency, or migrate this workflow to the first-class " - "review lifecycle." - ), + detail="This implementation is sticky-blocked for review while its downstream task(s) require " + "the implementation to be done or archived before they can run. Complete the finished " + "phase, unlink the incorrect dependency, or migrate this workflow to the first-class " + "review lifecycle.", actions=actions, first_seen_at=blocked_at, last_seen_at=blocked_at, count=len(child_ids), data={"blocked_parent_id": task_id, "waiting_child_ids": child_ids, "block_reason": reason}, @@ -554,12 +552,11 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: return [Diagnostic( kind="stuck_in_blocked", severity="warning", title=f"Task has been blocked for {int(age_hours)}h", - detail=( - f"This task transitioned to blocked {int(age_hours)}h ago and has had no comments or " - f"unblock attempts since. Blocked tasks are waiting for human input — check the block " - f"reason and either unblock with feedback or answer with a comment." - ), - actions=[DiagnosticAction(kind="comment", label="Add a comment / unblock the task", suggested=True)], + detail=f"This task transitioned to blocked {int(age_hours)}h ago and has had no comments or " + f"unblock attempts since. Blocked tasks are waiting for human input — check the block " + f"reason and either unblock with feedback or answer with a comment.", + actions=[DiagnosticAction(kind="comment", label="Add a comment / unblock the task", + suggested=True)], first_seen_at=last_blocked_ts, last_seen_at=last_blocked_ts, count=1, data={"blocked_at": last_blocked_ts, "age_hours": round(age_hours, 1)}, )] @@ -607,12 +604,10 @@ def _rule_block_unblock_cycling(task, events, runs, now, cfg) -> list[Diagnostic return [Diagnostic( kind="block_unblock_cycling", severity="warning", title=f"Task block→unblock cycled {cycles}x in {int(window_seconds/3600)}h", - detail=( - f"This task has been blocked {cycles} times after being unblocked, suggesting the " - f"unblock is not addressing the root cause and the worker keeps hitting the same wall. " - f"Review the block reasons in the event history; a different intervention (reassign, " - f"change scope, archive) may be needed." - ), + detail=f"This task has been blocked {cycles} times after being unblocked, suggesting the " + f"unblock is not addressing the root cause and the worker keeps hitting the same wall. " + f"Review the block reasons in the event history; a different intervention (reassign, " + f"change scope, archive) may be needed.", actions=actions, first_seen_at=int(initial_blocked_ts) if initial_blocked_ts else int(now), last_seen_at=int(last_cycle_blocked_ts) if last_cycle_blocked_ts else int(now), @@ -667,12 +662,10 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: return [Diagnostic( kind="stranded_in_ready", severity=severity, title=f"Ready for {age_str} with no worker", - detail=( - f"This task has been ready for {age_str} but nothing has claimed it. Common causes: " - f"assignee {assignee!r} is misspelled, the profile was deleted, or the external worker " - f"pool for this lane is down. Confirm the assignee is correct and that a worker is " - f"actually polling for it." - ), + detail=f"This task has been ready for {age_str} but nothing has claimed it. Common causes: " + f"assignee {assignee!r} is misspelled, the profile was deleted, or the external worker " + f"pool for this lane is down. Confirm the assignee is correct and that a worker is " + f"actually polling for it.", actions=actions, first_seen_at=last_ready_ts, last_seen_at=last_ready_ts, count=1, data={"ready_since": last_ready_ts, "age_seconds": int(age_seconds), @@ -718,7 +711,9 @@ def config_from_kanban_config(kanban_cfg: Optional[dict]) -> dict: ``kanban.failure_limit`` so diagnostics match the dispatcher's breaker.""" kanban_cfg = kanban_cfg or {} diag_cfg = dict(kanban_cfg.get("diagnostics") or {}) - diag_cfg.setdefault("failure_limit", kanban_cfg.get("failure_limit", DEFAULT_CONFIG["failure_threshold"])) + diag_cfg.setdefault( + "failure_limit", kanban_cfg.get("failure_limit", DEFAULT_CONFIG["failure_threshold"]), + ) if not _has_explicit_threshold(diag_cfg): diag_cfg["failure_threshold"] = diag_cfg["failure_limit"] return diag_cfg diff --git a/hermes_cli/kanban_ops.py b/hermes_cli/kanban_ops.py index 3aa871d983..7b2def47bd 100644 --- a/hermes_cli/kanban_ops.py +++ b/hermes_cli/kanban_ops.py @@ -63,14 +63,18 @@ 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(_kanban_cfg.get("max_in_progress_per_profile"), None) + max_in_progress_per_profile = kb._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) ) # 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) + max_spawn = ( + cli_max if cli_max is not None else kb._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) @@ -88,7 +92,9 @@ def _cmd_dispatch(args: argparse.Namespace) -> int: _print_json({ **{k: getattr(res, k) for k in ("reclaimed", "crashed", "timed_out", "stale", "auto_blocked", "promoted")}, - "spawned": [{"task_id": tid, "assignee": who, "workspace": ws} for (tid, who, ws) in res.spawned], + "spawned": [ + {"task_id": tid, "assignee": who, "workspace": ws} for (tid, who, ws) in res.spawned + ], "skipped_unassigned": res.skipped_unassigned, "skipped_nonspawnable": res.skipped_nonspawnable, "skipped_per_profile_capped": [ @@ -131,22 +137,14 @@ def _cmd_dispatch(args: argparse.Namespace) -> int: _DAEMON_DEPRECATED = ( - "hermes kanban daemon: DEPRECATED — the dispatcher now runs\n" - "inside the gateway. To use kanban:\n" - "\n" - " hermes gateway start # starts the gateway + embedded dispatcher\n" - "\n" - "Ready tasks will be picked up on the next dispatcher tick\n" - "(default: every 60 seconds). Configure via config.yaml:\n" - "\n" - " kanban:\n" - " dispatch_in_gateway: true # default\n" - " dispatch_interval_seconds: 60\n" - " failure_limit: 2 # consecutive non-success attempts before auto-block\n" - "\n" - "Running both the gateway AND this standalone daemon will\n" - "race for claims. If you truly need the old standalone\n" - "daemon (no gateway available), rerun with --force." + "hermes kanban daemon: DEPRECATED — the dispatcher now runs\ninside the gateway. To use " + "kanban:\n\n hermes gateway start # starts the gateway + embedded dispatcher\n\nReady " + "tasks will be picked up on the next dispatcher tick\n(default: every 60 seconds). Configure " + "via config.yaml:\n\n kanban:\n dispatch_in_gateway: true # default\n " + "dispatch_interval_seconds: 60\n failure_limit: 2 # consecutive non-success " + "attempts before auto-block\n\nRunning both the gateway AND this standalone daemon will\nrace " + "for claims. If you truly need the old standalone\ndaemon (no gateway available), rerun with " + "--force." ) @@ -196,7 +194,10 @@ def _cmd_daemon(args: argparse.Namespace) -> int: def _on_tick(res): ready_pending = bool(res.skipped_unassigned) or _ready_queue_nonempty() - health_state["bad_ticks"] = health_state["bad_ticks"] + 1 if ready_pending and not res.spawned else 0 + if ready_pending and not res.spawned: + health_state["bad_ticks"] += 1 + else: + health_state["bad_ticks"] = 0 # Warn once per HEALTH_WINDOW bad ticks, at most every 5 minutes. if health_state["bad_ticks"] >= HEALTH_WINDOW: now = int(time.time()) @@ -254,10 +255,9 @@ def _cmd_watch(args: argparse.Namespace) -> int: nonlocal cursor with kb.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 > ? ORDER BY e.id ASC LIMIT 200", + "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 > ? " + "ORDER BY e.id ASC LIMIT 200", (cursor,), ).fetchall() for r in rows: diff --git a/hermes_cli/kanban_parser.py b/hermes_cli/kanban_parser.py index d64f009c2f..520452049f 100644 --- a/hermes_cli/kanban_parser.py +++ b/hermes_cli/kanban_parser.py @@ -428,13 +428,11 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu kanban_parser = parent_subparsers.add_parser( "kanban", help="Multi-profile collaboration board (tasks, links, comments)", - description=( - "Durable SQLite-backed task board shared across Hermes profiles. " - "Tasks are claimed atomically, can depend on other tasks, and " - "are executed by a named profile in an isolated workspace. " - "See https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban " - "or docs/hermes-kanban-v1-spec.pdf for the full design." - ), + description="Durable SQLite-backed task board shared across Hermes profiles. " + "Tasks are claimed atomically, can depend on other tasks, and " + "are executed by a named profile in an isolated workspace. " + "See https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban " + "or docs/hermes-kanban-v1-spec.pdf for the full design.", ) # --board scopes every subcommand to one board's DB; when omitted the # resolution is HERMES_KANBAN_BOARD, then the persisted current-board diff --git a/hermes_cli/kanban_swarm.py b/hermes_cli/kanban_swarm.py index 8551ae3289..0dc3fa7e8c 100644 --- a/hermes_cli/kanban_swarm.py +++ b/hermes_cli/kanban_swarm.py @@ -208,11 +208,9 @@ def _create_swarm_uncommitted( root = kb.create_task( conn, title=root_title or f"Swarm: {goal.splitlines()[0][:80]}", - body=( - "Kanban Swarm v1 planning/root card. This card is completed " - "immediately so parallel workers can start while it remains the " - f"shared blackboard and audit anchor.\n\nGoal:\n{goal}" - ), + body="Kanban Swarm v1 planning/root card. This card is completed " + "immediately so parallel workers can start while it remains the " + f"shared blackboard and audit anchor.\n\nGoal:\n{goal}", assignee=created_by, priority=priority, idempotency_key=idempotency_key, From cf3e78316b833ab825e7fc9764b05110c0ca4d3f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:17:47 -0700 Subject: [PATCH 38/38] =?UTF-8?q?refactor(kanban-cli):=20swarm=20=E2=80=94?= =?UTF-8?q?=20pack=20private=20graph-builder=20signature/call=20(defaults?= =?UTF-8?q?=20were=20unused)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/kanban_swarm.py | 38 +++++++++----------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/hermes_cli/kanban_swarm.py b/hermes_cli/kanban_swarm.py index 0dc3fa7e8c..e450f6d43e 100644 --- a/hermes_cli/kanban_swarm.py +++ b/hermes_cli/kanban_swarm.py @@ -125,20 +125,11 @@ def create_swarm( activated = False with kb.write_txn(conn): created = _create_swarm_uncommitted( - conn, - goal=goal, - workers=workers, - verifier_assignee=verifier_assignee, - synthesizer_assignee=synthesizer_assignee, - root_title=root_title, - verifier_title=verifier_title, - synthesizer_title=synthesizer_title, - tenant=tenant, - created_by=created_by, - workspace_kind=workspace_kind, - workspace_path=workspace_path, - priority=priority, - idempotency_key=idempotency_key, + conn, goal=goal, workers=workers, verifier_assignee=verifier_assignee, + synthesizer_assignee=synthesizer_assignee, root_title=root_title, + verifier_title=verifier_title, synthesizer_title=synthesizer_title, tenant=tenant, + created_by=created_by, workspace_kind=workspace_kind, workspace_path=workspace_path, + priority=priority, idempotency_key=idempotency_key, ) root = kb.get_task(conn, created.root_id) if root is not None and root.status == "blocked": @@ -172,21 +163,10 @@ def create_swarm( def _create_swarm_uncommitted( - conn: sqlite3.Connection, - *, - goal: str, - workers: Iterable[SwarmWorkerSpec], - verifier_assignee: str, - synthesizer_assignee: str, - root_title: Optional[str] = None, - verifier_title: str = "Verify swarm outputs", - synthesizer_title: str = "Synthesize swarm outputs", - tenant: Optional[str] = None, - created_by: str = "swarm-orchestrator", - workspace_kind: str = "scratch", - workspace_path: Optional[str] = None, - priority: int = 0, - idempotency_key: Optional[str] = None, + conn: sqlite3.Connection, *, goal: str, workers: Iterable[SwarmWorkerSpec], + verifier_assignee: str, synthesizer_assignee: str, root_title: Optional[str], + verifier_title: str, synthesizer_title: str, tenant: Optional[str], created_by: str, + workspace_kind: str, workspace_path: Optional[str], priority: int, idempotency_key: Optional[str], ) -> SwarmCreated: """Create the swarm graph inside the caller's transaction: planning root (``blocked`` until the caller activates it), parallel workers, a verifier