Skip to content
Merged
30 changes: 25 additions & 5 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,35 @@ def _honcho_is_configured_for_doctor() -> bool:
return False


def _is_kanban_worker_env_gate(item: dict) -> bool:
"""Return True when Kanban is unavailable only because this is not a worker process."""
if item.get("name") != "kanban":
return False
if os.environ.get("HERMES_KANBAN_TASK"):
return False

tools = item.get("tools") or []
return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools)


def _doctor_tool_availability_detail(toolset: str) -> str:
"""Optional explanatory suffix for toolsets whose doctor status needs context."""
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
return ""


def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
"""Adjust runtime-gated tool availability for doctor diagnostics."""
if not _honcho_is_configured_for_doctor():
return available, unavailable

updated_available = list(available)
updated_unavailable = []
for item in unavailable:
if item.get("name") == "honcho":
name = item.get("name")
if _is_kanban_worker_env_gate(item):
if "kanban" not in updated_available:
updated_available.append("kanban")
continue
if name == "honcho" and _honcho_is_configured_for_doctor():
if "honcho" not in updated_available:
updated_available.append("honcho")
continue
Expand Down Expand Up @@ -1278,7 +1298,7 @@ def run_doctor(args):

for tid in available:
info = TOOLSET_REQUIREMENTS.get(tid, {})
check_ok(info.get("name", tid))
check_ok(info.get("name", tid), _doctor_tool_availability_detail(tid))

for item in unavailable:
env_vars = item.get("missing_vars") or item.get("env_vars") or []
Expand Down
13 changes: 13 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -1071,10 +1071,16 @@ def _cmd_show(args: argparse.Namespace) -> int:
parents = kb.parent_ids(conn, args.task_id)
children = kb.child_ids(conn, args.task_id)
runs = kb.list_runs(conn, args.task_id)
# Workers hand off via ``task_runs.summary`` (kanban-worker skill);
# ``tasks.result`` is left NULL unless the caller explicitly passed
# ``result=``. Surfacing the latest summary here keeps ``show`` from
# looking like a no-op when the worker actually did real work.
latest_summary = kb.latest_summary(conn, args.task_id)

if getattr(args, "json", False):
payload = {
"task": _task_to_dict(task),
"latest_summary": latest_summary,
"parents": parents,
"children": children,
"comments": [
Expand Down Expand Up @@ -1161,6 +1167,13 @@ def _cmd_show(args: argparse.Namespace) -> int:
print()
print("Result:")
print(task.result)
elif latest_summary:
# Worker handoff lives on the latest run, not on tasks.result.
# Surface it at top-level so a glance at ``hermes kanban show <id>``
# tells you what the worker did even if tasks.result is empty.
print()
print("Latest summary:")
print(latest_summary)
if comments:
print()
print(f"Comments ({len(comments)}):")
Expand Down
110 changes: 94 additions & 16 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1978,14 +1978,23 @@ def _verify_created_cards(
) -> tuple[list[str], list[str]]:
"""Partition ``claimed_ids`` into (verified, phantom).

A card is "verified" iff a row exists in ``tasks`` with the given id
AND ``created_by`` matches the completing task's ``assignee`` (or
the completing task itself — workers that create children of their
own task also qualify).

``phantom`` returns ids that either don't exist at all or exist but
were not created by the completing worker. The caller decides what
to do with each bucket; this helper never mutates.
A card is "verified" iff a row exists in ``tasks`` AND at least one
of the following holds:

* ``created_by`` matches the completing task's ``assignee`` profile
(the common case: worker A spawns a card via ``kanban_create``,
which stamps ``created_by=A``).
* ``created_by`` matches the completing task's id (edge case where
a worker passed its own task id as the ``created_by`` value).
* The card is linked as a ``task_links.child`` of the completing
task — i.e. the worker explicitly called ``kanban_create`` with
``parents=[<current_task>]``. This accepts cards created through
the dashboard/CLI by a different principal but then attached to
the completing task by the worker.

``phantom`` returns ids that either don't exist at all, or exist
but don't satisfy any of the three trust conditions. The caller
decides what to do with each bucket; this helper never mutates.
"""
claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()]
if not claimed:
Expand Down Expand Up @@ -2014,20 +2023,24 @@ def _verify_created_cards(
).fetchall()
found = {r["id"]: r["created_by"] for r in rows}

# Pull the set of cards linked as children of the completing task.
# Cheap: one query, indexed on parent_id.
linked_children: set[str] = set(child_ids(conn, completing_task_id))

verified: list[str] = []
phantom: list[str] = []
for cid in ordered:
created_by = found.get(cid)
if created_by is None:
phantom.append(cid)
continue
# Accept if created_by matches the completing task's assignee
# profile, OR the task itself (workers whose created_by happens
# to match their task id are unusual but harmless to accept).
# Accept if any of the three trust conditions holds.
if completing_assignee and created_by == completing_assignee:
verified.append(cid)
elif created_by == completing_task_id:
verified.append(cid)
elif cid in linked_children:
verified.append(cid)
else:
phantom.append(cid)
return verified, phantom
Expand Down Expand Up @@ -2699,16 +2712,23 @@ def enforce_max_runtime(
host_prefix = f"{_claimer_id().split(':', 1)[0]}:"

rows = conn.execute(
"SELECT id, worker_pid, started_at, max_runtime_seconds, claim_lock "
"FROM tasks "
"WHERE status = 'running' AND max_runtime_seconds IS NOT NULL "
" AND started_at IS NOT NULL AND worker_pid IS NOT NULL"
"SELECT t.id, t.worker_pid, "
" COALESCE(r.started_at, t.started_at) AS active_started_at, "
" t.max_runtime_seconds, t.claim_lock "
"FROM tasks t "
"LEFT JOIN task_runs r ON r.id = t.current_run_id "
"WHERE t.status = 'running' AND t.max_runtime_seconds IS NOT NULL "
" AND COALESCE(r.started_at, t.started_at) IS NOT NULL "
" AND t.worker_pid IS NOT NULL"
).fetchall()
for row in rows:
lock = row["claim_lock"] or ""
if not lock.startswith(host_prefix):
continue
elapsed = now - int(row["started_at"])
# Runtime is per attempt, not lifetime-of-task. ``tasks.started_at``
# intentionally records the first time a task ever started, so retries
# must be measured from the active task_runs row when present.
elapsed = now - int(row["active_started_at"])
if elapsed < int(row["max_runtime_seconds"]):
continue

Expand Down Expand Up @@ -3993,3 +4013,61 @@ def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
(task_id,),
).fetchone()
return Run.from_row(row) if row else None


def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]:
"""Return the latest non-null ``task_runs.summary`` for ``task_id``.

The kanban-worker skill writes its handoff to ``task_runs.summary``
via ``complete_task(summary=...)``; ``tasks.result`` is left empty
unless the caller passes ``result=`` explicitly. Dashboards and CLI
"show" views need this value to surface what a worker actually did
— without it, ``tasks.result`` is NULL and the task looks like a
no-op even when the run completed.

Picks the most recent run by ``ended_at`` (falling back to ``id``
for ties or unfinished rows). Returns None if no run has a summary.
"""
row = conn.execute(
"SELECT summary FROM task_runs "
"WHERE task_id = ? AND summary IS NOT NULL AND summary != '' "
"ORDER BY COALESCE(ended_at, started_at) DESC, id DESC LIMIT 1",
(task_id,),
).fetchone()
return row["summary"] if row else None


def latest_summaries(
conn: sqlite3.Connection, task_ids: Iterable[str]
) -> dict[str, str]:
"""Batch-fetch latest non-null summaries for a list of task ids.

Used by the dashboard board endpoint to attach ``latest_summary`` to
every card in a single SQL query, avoiding the N+1 pattern of
calling :func:`latest_summary` per task. Returns a dict mapping
``task_id`` → summary string, omitting tasks with no summary.

Approach: a window function picks the newest non-null-summary row
per ``task_id``; works against SQLite ≥ 3.25 (default on every
supported platform).
"""
ids = list(task_ids)
if not ids:
return {}
placeholders = ",".join("?" for _ in ids)
rows = conn.execute(
f"""
SELECT task_id, summary FROM (
SELECT task_id, summary,
ROW_NUMBER() OVER (
PARTITION BY task_id
ORDER BY COALESCE(ended_at, started_at) DESC, id DESC
) AS rn
FROM task_runs
WHERE task_id IN ({placeholders})
AND summary IS NOT NULL AND summary != ''
) WHERE rn = 1
""",
ids,
).fetchall()
return {r["task_id"]: r["summary"] for r in rows}
10 changes: 4 additions & 6 deletions plugins/kanban/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2416,11 +2416,10 @@
),
),
h("div", { className: "hermes-kanban-deps-row" },
h(Select, {
h(Select, Object.assign({
value: newParent,
onChange: function (e) { setNewParent(e.target.value); },
className: "h-7 text-xs flex-1",
},
}, selectChangeHandler(setNewParent)),
h(SelectOption, { value: "" }, "— add parent —"),
candidatesFor(parentExclude).map(function (t) {
return h(SelectOption, { key: t.id, value: t.id },
Expand Down Expand Up @@ -2455,11 +2454,10 @@
),
),
h("div", { className: "hermes-kanban-deps-row" },
h(Select, {
h(Select, Object.assign({
value: newChild,
onChange: function (e) { setNewChild(e.target.value); },
className: "h-7 text-xs flex-1",
},
}, selectChangeHandler(setNewChild)),
h(SelectOption, { value: "" }, "— add child —"),
candidatesFor(childExclude).map(function (t) {
return h(SelectOption, { key: t.id, value: t.id },
Expand Down
48 changes: 45 additions & 3 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,23 @@ def _conn(board: Optional[str] = None):
]


def _task_dict(task: kanban_db.Task) -> dict[str, Any]:
_CARD_SUMMARY_PREVIEW_CHARS = 200


def _task_dict(
task: kanban_db.Task,
*,
latest_summary: Optional[str] = None,
) -> dict[str, Any]:
d = asdict(task)
# Add derived age metrics so the UI can colour stale cards without
# computing deltas client-side.
d["age"] = kanban_db.task_age(task)
# Surface the latest non-null run summary so dashboards don't show
# blank cards/drawers for tasks where the worker handed off via
# ``task_runs.summary`` (the kanban-worker pattern) instead of
# ``tasks.result``. ``None`` when no run has produced a summary yet.
d["latest_summary"] = latest_summary
# Keep body short on list endpoints; full body comes from /tasks/:id.
return d

Expand Down Expand Up @@ -381,8 +393,18 @@ def get_board(
if include_archived:
columns["archived"] = []

# Batch-fetch the latest non-null run summary per task in one
# window-function query (avoids N+1 ``latest_summary`` calls
# for boards with hundreds of tasks). Truncated to a card-size
# preview here — the full text is available via /tasks/:id.
summary_map = kanban_db.latest_summaries(conn, [t.id for t in tasks])

for t in tasks:
d = _task_dict(t)
full = summary_map.get(t.id)
preview = (
full[:_CARD_SUMMARY_PREVIEW_CHARS] if full else None
)
d = _task_dict(t, latest_summary=preview)
d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0})
d["comment_count"] = comment_counts.get(t.id, 0)
d["progress"] = progress.get(t.id) # None when the task has no children
Expand Down Expand Up @@ -440,7 +462,11 @@ def get_task(task_id: str, board: Optional[str] = Query(None)):
task = kanban_db.get_task(conn, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
task_d = _task_dict(task)
# Drawer/detail view returns the FULL summary (no truncation) so
# operators can read the complete worker handoff without making
# a second round-trip. Cards on /board carry a 200-char preview.
full_summary = kanban_db.latest_summary(conn, task_id)
task_d = _task_dict(task, latest_summary=full_summary)
# Attach diagnostics so the drawer's Diagnostics section can
# render recovery actions without a second round-trip.
diags = _compute_task_diagnostics(conn, task_ids=[task_id])
Expand Down Expand Up @@ -662,6 +688,22 @@ def _set_status_direct(
).fetchone()
if prev is None:
return False

# Guard: don't allow promoting to 'ready' unless all parents are done.
# Prevents the dispatcher from spawning a child whose upstream work
# hasn't completed (e.g. T4 dispatched while T3 is still blocked).
if new_status == "ready":
parent_statuses = conn.execute(
"SELECT t.status FROM tasks t "
"JOIN task_links l ON l.parent_id = t.id "
"WHERE l.child_id = ?",
(task_id,),
).fetchall()
if parent_statuses and not all(
p["status"] == "done" for p in parent_statuses
):
return False

was_running = prev["status"] == "running"

cur = conn.execute(
Expand Down
2 changes: 2 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@
"momowind@gmail.com": "momowind",
"clockwork-codex@users.noreply.github.com": "misery-hl",
"207811921+misery-hl@users.noreply.github.com": "misery-hl",
"suncokret@protonmail.com": "suncokret12",
"mio.imoto.ai@gmail.com": "mioimotoai-lgtm",
"aamirjawaid@microsoft.com": "heyitsaamir",
"johnnncenaaa77@gmail.com": "johnncenae",
"thomasjhon6666@gmail.com": "ThomassJonax",
Expand Down
41 changes: 41 additions & 0 deletions tests/hermes_cli/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,47 @@ def test_leaves_honcho_unavailable_when_not_configured(self, monkeypatch):
assert available == []
assert unavailable == [honcho_entry]

def test_marks_kanban_available_only_when_missing_worker_env_gate(self, monkeypatch):
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: False)
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)

available, unavailable = doctor._apply_doctor_tool_availability_overrides(
[],
[{"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}],
)

assert available == ["kanban"]
assert unavailable == []

def test_leaves_kanban_unavailable_when_worker_env_is_set(self, monkeypatch):
monkeypatch.setenv("HERMES_KANBAN_TASK", "probe")
kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}

available, unavailable = doctor._apply_doctor_tool_availability_overrides(
[],
[kanban_entry],
)

assert available == []
assert unavailable == [kanban_entry]

def test_leaves_non_worker_kanban_failure_unavailable(self, monkeypatch):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show", "not_a_kanban_tool"]}

available, unavailable = doctor._apply_doctor_tool_availability_overrides(
[],
[kanban_entry],
)

assert available == []
assert unavailable == [kanban_entry]

def test_kanban_doctor_detail_explains_worker_gate(self, monkeypatch):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)

assert doctor._doctor_tool_availability_detail("kanban") == "(runtime-gated; loaded only for dispatcher-spawned workers)"


class TestHonchoDoctorConfigDetection:
def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):
Expand Down
Loading
Loading