Merge branch 'simp/r3-20' into simp/integration3
This commit is contained in:
+1404
-2908
File diff suppressed because it is too large
Load Diff
+148
-204
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user