Merge branch 'simp/r3-20' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 21:57:20 -07:00
14 changed files with 2908 additions and 5354 deletions
+1404 -2908
View File
File diff suppressed because it is too large Load Diff
+148 -204
View File
@@ -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.<key>`` 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]
+201 -427
View File
@@ -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,29 +57,14 @@ 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)
# ---------------------------------------------------------------------------
# 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."""
@@ -105,8 +83,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 +98,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 +109,17 @@ 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 +128,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,26 +141,38 @@ 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
# ---------------------------------------------------------------------------
# Rule implementations
# ---------------------------------------------------------------------------
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 ---
# Each rule: (task, events, runs, now_ts, config) -> list[Diagnostic].
# ``events``/``runs`` are kanban_db rows/dataclasses or same-shaped dicts.
@@ -208,10 +189,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:
@@ -224,10 +202,7 @@ def _main_model_visible(raw_config: Any) -> bool:
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 ""
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 +214,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,39 +247,37 @@ 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)
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 "
"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=first,
last_seen_at=last,
count=len(hits),
data={"phantom_ids": phantom_ids},
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")},
)]
# (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,93 +287,45 @@ 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 []
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"))
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 "<task_id>"
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",
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 "
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."
),
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,
"primary_slot": primary_slot,
"main_model_visible": main_visible,
},
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},
)]
@@ -426,30 +335,26 @@ 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",
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."
),
actions=_generic_recovery_actions(task, running=running),
first_seen_at=_event_ts(hits[0]),
last_seen_at=_event_ts(hits[-1]),
count=len(hits),
data={"phantom_refs": phantom_refs},
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")},
)]
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 +366,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,71 +375,46 @@ 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"},
))
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 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 = (
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"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,
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,
@@ -559,42 +436,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)
@@ -611,14 +476,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},
)]
@@ -629,11 +489,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,55 +500,36 @@ 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}"},
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
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 "
"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,
},
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},
)]
@@ -700,8 +537,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,30 +546,18 @@ 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",
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."
),
actions=actions,
first_seen_at=last_blocked_ts,
last_seen_at=last_blocked_ts,
count=1,
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)},
)]
@@ -775,31 +599,20 @@ 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",
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."
),
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),
count=cycles,
data={
"cycles": cycles,
"window_seconds": int(window_seconds),
},
data={"cycles": cycles, "window_seconds": int(window_seconds)},
)]
@@ -809,11 +622,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 +632,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 +645,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"
@@ -853,39 +655,21 @@ 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="cli_hint",
label="Check dispatcher status",
payload={"command": "hermes kanban diagnostics"},
),
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 "
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."
),
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),
"assignee": assignee,
"threshold_seconds": int(threshold_seconds),
},
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)},
)]
@@ -917,6 +701,10 @@ 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
@@ -924,13 +712,9 @@ def config_from_kanban_config(kanban_cfg: Optional[dict]) -> dict:
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"]),
"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
):
if not _has_explicit_threshold(diag_cfg):
diag_cfg["failure_threshold"] = diag_cfg["failure_limit"]
return diag_cfg
@@ -970,14 +754,9 @@ 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
):
if not _has_explicit_threshold(config) and "failure_limit" in config:
cfg["failure_threshold"] = _positive_int(
config.get("failure_limit"),
DEFAULT_CONFIG["failure_threshold"],
config.get("failure_limit"), DEFAULT_CONFIG["failure_threshold"],
)
out: list[Diagnostic] = []
for rule in _RULES:
@@ -987,10 +766,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
+108 -154
View File
@@ -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,20 @@ 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"))
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(
@@ -92,15 +90,10 @@ 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,
**{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
{"task_id": tid, "assignee": who, "workspace": ws} for (tid, who, ws) in res.spawned
],
"skipped_unassigned": res.skipped_unassigned,
"skipped_nonspawnable": res.skipped_nonspawnable,
@@ -143,34 +136,26 @@ 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\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."
)
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.
@@ -186,11 +171,9 @@ 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"dispatch_in_gateway=true (default), you have two dispatchers "
f"racing for claims.",
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,
)
@@ -200,10 +183,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:
if ready_pending and not res.spawned:
health_state["bad_ticks"] += 1
else:
health_state["bad_ticks"] = 0
@@ -212,13 +203,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 "
f"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
@@ -230,23 +219,13 @@ 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,
)
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 +245,38 @@ 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 +318,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 +340,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 +359,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
+6 -13
View File
@@ -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
+378 -452
View File
@@ -32,11 +32,18 @@ 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", 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"),
)
@@ -44,455 +51,376 @@ 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("--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"),
_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", 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 _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="+", 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: ./<slug>.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", 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"),
)
_STEP_HANDOFF = (
_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 = [
_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", 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", 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="?", 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="?", help="Board to export (default: the current board)"),
_arg("-o", "--output", help="Archive path (default: ./<slug>.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", 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:<path> | dir:<path> (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 <model>) 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 <name> 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 <name>). 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-root>/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 <name>' / 'row N missing from "
"index <name>'), the corrupt file is quarantined to a "
".corrupt.<hash>.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 <action>`` records, in ``--help`` order."""
return _task_specs() + _lifecycle_specs()
# Top-level ``hermes kanban <action>`` 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", 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:<path> | dir:<path> (default: scratch)"),
_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`."),
_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",
help="Dedup key. If a non-archived task with this key exists, "
"its id is returned instead of creating a duplicate."),
_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."),
_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, 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", dest="model_override",
help="Pin the worker to this model (passed as -m <model>) 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", dest="provider_override",
help="Provider the --model belongs to (passed as --provider <name> 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, 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", 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"),
_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", choices=sorted(kb.VALID_SORT_ORDERS.keys()),
help="Sort order for listed tasks (default: priority)"),
_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")],
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="?", 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 <name>). 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"],
help="Only show diagnostics at or above this severity"),
_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"),
_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", 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", 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", help="Result summary"),
_arg("--summary",
help="Structured handoff summary for downstream tasks. Falls back to --result if omitted."),
_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"),
_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", 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", 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)."),
], 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="+",
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, 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, help="Cap number of spawns per tick"),
_arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT),
_arg("--pidfile", help="Write the daemon's PID to this file on start"),
_arg("--verbose", "-v", action="store_true", help="Log each tick's outcome to stdout"),
# Escape hatch for hosts that truly cannot run the gateway; hidden from
# --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", 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)"),
_cmd("stats", [_json_flag()], help="Per-status + per-assignee counts + oldest-ready age"),
_cmd("notify-subscribe", [
_TASK_ID,
*_NOTIFY_TARGET,
_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",
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,
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="?"), _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, help="Only print the last N bytes")],
help="Print the worker log for a task (from <kanban-root>/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", 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 <name>' / "
"'row N missing from index <name>'), the corrupt file is quarantined to a "
".corrupt.<hash>.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:
@@ -500,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
@@ -516,6 +442,6 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
"via `hermes kanban boards switch <slug>` 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
+68 -72
View File
@@ -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__)
@@ -99,9 +96,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 +120,57 @@ 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.<aux_task>.*`` 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 +180,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,19 +214,12 @@ 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)
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]
+42 -120
View File
@@ -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:
@@ -66,12 +60,10 @@ 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"
"- 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"
)
@@ -89,7 +81,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 +92,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,26 +121,15 @@ 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(
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":
@@ -193,29 +163,14 @@ 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 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,23 +181,21 @@ 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]}",
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}"
),
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,
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 +206,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 +223,6 @@ def _create_swarm_uncommitted(
)
for spec in worker_specs
]
verifier = kb.create_task(
conn,
title=verifier_title,
@@ -295,7 +238,6 @@ def _create_swarm_uncommitted(
skills=["requesting-code-review"],
**common,
)
synthesizer = kb.create_task(
conn,
title=synthesizer_title,
@@ -312,26 +254,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 +268,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 +293,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)
+48 -118
View File
@@ -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 (``<slug>.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/<task>/…`` (unless --no-attachments), ``logs/<task>.log`` (only
with --include-logs).
Archive layout (``<slug>.tar.gz``, one top-level directory named for the
source board's slug)::
<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/<task>/… attachment blobs (unless --no-attachments)
logs/<task>.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,65 +248,39 @@ 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 "
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():
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 +300,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 +313,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 "
+8 -7
View File
@@ -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)
+216 -374
View File
@@ -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."""
@@ -28,34 +31,29 @@ 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:
"""Where the ``hermes.desktop`` entry lives."""
return _xdg_data_home() / "applications" / DESKTOP_ENTRY_NAME
def icon_path(project_root: Path) -> Path:
"""The app icon shipped in the desktop workspace."""
return project_root / "apps" / "desktop" / "assets" / "icon.png"
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,412 +63,283 @@ _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)
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,
)
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.
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,
)
if ok is None:
return True
_probe_cache[key] = ok
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 ``<python> -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()
argv = [interpreter, "-m", "hermes_cli.main", "desktop"]
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.
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
(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:
"""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
# `<python> 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
# `<python> 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:
return rerouted
if not primary:
return primary
if rerouted is not None:
return rerouted or 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.
# 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 _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
return primary
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:
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"#!"):
# 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 (``<checkout>-old``, ``<checkout>.bak``), so the root must end at a quote /
whitespace / EOL or continue INTO the tree (``<root>/``). 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:
head = _read_head(wrapper)
if head is None:
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.
if _is_native_binary(head):
# 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 `<checkout>-old` or `<checkout>.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
# <root>/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 ``<checkout>/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.
"""
"""``<checkout>`` 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)."""
try:
with open(bin_path, "rb") as fh:
head = fh.readline(256)
except OSError:
"""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.
"""
head = _read_head(bin_path, 256)
if head is None or not head.startswith(b"#!"):
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()
shebang = head.decode("utf-8", errors="replace").splitlines()[0].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: ``<venv>/bin-extra/python`` is not inside ``<venv>/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: ``<venv>/bin-extra/python`` is not inside
``<venv>/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 <abs-interpreter>`` 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 <abs>`), 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
@@ -510,41 +379,40 @@ 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
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
# 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 +423,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 +436,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():
removed = stale.is_file()
if removed:
stale.unlink()
return True
return removed
except OSError:
return False
return False
def _refresh_hicolor_cache() -> None:
@@ -596,12 +458,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 +470,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 +492,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 +501,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 +513,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 +533,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
+43 -106
View File
@@ -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,36 +158,19 @@ def tail_log(
if not follow:
return
# Follow mode — poll for new content
try:
_follow_log(log_path, **filters)
except KeyboardInterrupt:
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)
# 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
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:
@@ -224,68 +179,66 @@ 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:]
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:
# 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, **filters):
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 +253,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:
+120 -238
View File
@@ -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,12 @@ WAKEUP_PROMPT_WITH_UNTIL_TEMPLATE = (
)
# ──────────────────────────────────────────────────────────────────────
# 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,22 +82,18 @@ def parse_interval_token(token: str) -> Optional[int]:
def parse_loop_args(text: str) -> Dict[str, Any]:
"""Parse the argument string of ``/loop [interval] <prompt> [flags]``.
"""Parse ``/loop [interval] <prompt> [--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}
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. 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:
@@ -126,8 +102,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)
@@ -141,29 +116,20 @@ 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] <prompt>)"
return result
result.update(interval_seconds=interval, prompt=raw, times=times, until=until)
return result
return {**result, "error": "missing prompt (usage: /loop [interval] <prompt>)"}
return {**result, "interval_seconds": interval, "prompt": raw, "times": times, "until": until}
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 +137,7 @@ def format_interval(seconds: float) -> str:
return "".join(parts)
# ──────────────────────────────────────────────────────────────────────
# 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,11 +172,6 @@ def self_paced_ceiling_seconds() -> int:
return _config_int("self_paced_ceiling_seconds", max(floor, DEFAULT_SELF_PACED_CEILING_SECONDS), floor)
# ──────────────────────────────────────────────────────────────────────
# Dataclass
# ──────────────────────────────────────────────────────────────────────
@dataclass
class LoopState:
"""Serializable /loop state stored per session."""
@@ -233,18 +188,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:
@@ -258,20 +209,17 @@ 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 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:
# 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())
kwargs[f.name] = casts[f.type](data.get(f.name, f.default) or casts[f.type]())
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,15 +230,9 @@ 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)
# ──────────────────────────────────────────────────────────────────────
_META_PREFIX = "loop:"
@@ -299,13 +241,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
@@ -323,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:
@@ -356,17 +295,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:
@@ -374,31 +311,23 @@ 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
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,28 +341,23 @@ def migrate_loop_to_session(old_session_id: str, new_session_id: str, *, reason:
return False
# ──────────────────────────────────────────────────────────────────────
# Response evaluation helpers
# ──────────────────────────────────────────────────────────────────────
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
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 +365,18 @@ def _digest_response(response: str) -> str:
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()
# ──────────────────────────────────────────────────────────────────────
# LoopManager — the orchestration surface CLI + gateway + TUI talk to
# ──────────────────────────────────────────────────────────────────────
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
@@ -475,6 +391,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":
@@ -494,15 +414,11 @@ 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}"
# --- mutation -----------------------------------------------------
def set(
self,
prompt: str,
@@ -533,54 +449,47 @@ class LoopManager:
route=dict(route or {}),
)
self._state = state
save_loop(self.session_id, state)
return state
return self._save()
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
# --- 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
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.
"""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,13 +497,10 @@ 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.
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)
# 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.
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()
@@ -609,7 +515,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."""
@@ -619,15 +525,14 @@ 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]:
"""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 +558,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,47 +580,33 @@ 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
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": ""}
# ──────────────────────────────────────────────────────────────────────
# /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)
# ──────────────────────────────────────────────────────────────────────
LOOP_HELP = (
"Usage: /loop [interval] <prompt> [--times N] [--until <condition>]\n"
" /loop 5m check the deploy status — first run now, then every 5m\n"
@@ -740,16 +630,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),
}
@@ -760,12 +646,10 @@ def dispatch_loop_command(
*,
route: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""Surface-agnostic handler for ``/loop <args>``.
"""Surface-agnostic handler for ``/loop <args>`` → ``{"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())
@@ -791,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 — "
@@ -807,12 +693,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.")
if replacing:
lines.insert(1, "(replaced the previous loop for this session)")
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.")
return {"output": "\n".join(lines), "created": True}
+118 -161
View File
@@ -1,15 +1,17 @@
"""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
import contextlib
import errno
import logging
import os
@@ -17,6 +19,7 @@ import platform
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
@@ -29,19 +32,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 +53,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,17 +61,12 @@ 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
root = Path(project_root) if project_root is not None else Path(__file__).resolve().parents[1]
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:
@@ -91,57 +76,63 @@ 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:
"""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
home = ""
try:
for line in cfg.read_text(encoding="utf-8").splitlines():
if line.lower().startswith("home"):
_, _, home = line.partition("=")
home = home.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
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 +145,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 +179,52 @@ 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:
with contextlib.suppress(OSError):
tmp_path.unlink(missing_ok=True)
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 +236,6 @@ def _materialize_aliases(
ok = _copy_alias(venv_bin, name, anchor) and ok
except OSError:
ok = False
continue
return ok
@@ -256,8 +250,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 +287,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 +296,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 +309,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 +349,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)