diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 87f3b7f9d1..d8bc47a7d7 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -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": [ @@ -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 `` + # 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)}):") diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 3c6c7a1b92..8440113c25 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4013,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} diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index d1bd227253..3176737a8c 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -124,11 +124,23 @@ BOARD_COLUMNS: list[str] = [ ] -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 @@ -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 @@ -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]) diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 365aa83113..7068e773d1 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -822,3 +822,80 @@ class TestSharedBoardPaths: default_home / "kanban" / "workspaces" ) assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env" + + +# --------------------------------------------------------------------------- +# latest_summary / latest_summaries — surface task_runs.summary handoffs +# --------------------------------------------------------------------------- + +def test_latest_summary_returns_none_when_no_runs(kanban_home): + """A freshly-created task has no runs and therefore no summary.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="fresh", assignee="alice") + assert kb.latest_summary(conn, t) is None + + +def test_latest_summary_returns_summary_after_complete(kanban_home): + """``complete_task(summary=...)`` is the canonical kanban-worker + handoff; ``latest_summary`` must surface it so dashboards/CLI can + render what the worker actually did.""" + handoff = "shipped 3 files, ran tests, opened PR #42" + with kb.connect() as conn: + t = kb.create_task(conn, title="work", assignee="alice") + kb.complete_task(conn, t, summary=handoff) + assert kb.latest_summary(conn, t) == handoff + + +def test_latest_summary_picks_newest_when_multiple_runs(kanban_home): + """When a task has been re-run (block → unblock → complete), the + newest run's summary wins. We unblock to take the task back to + ``ready``, then complete a second time and verify the second + summary surfaces.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="retry", assignee="alice") + kb.complete_task(conn, t, summary="first attempt") + # Move back to ready by direct SQL — block_task / unblock_task + # paths require an active claim, but we just want a second run + # row to exist with a later ended_at. + conn.execute( + "UPDATE tasks SET status='ready', completed_at=NULL WHERE id=?", + (t,), + ) + # Sleep 1s so the second run's ended_at is provably later than + # the first (complete_task uses int(time.time())). + time.sleep(1.05) + kb.complete_task(conn, t, summary="second attempt — final") + assert kb.latest_summary(conn, t) == "second attempt — final" + + +def test_latest_summary_skips_empty_string(kanban_home): + """A run with an empty-string summary should not mask an earlier + populated one — empty strings carry no information.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="t", assignee="alice") + kb.complete_task(conn, t, summary="real handoff") + # Inject a later run with empty summary directly. Workers + # writing "" instead of None is a real shape we want to ignore. + conn.execute( + "INSERT INTO task_runs (task_id, status, started_at, ended_at, " + "outcome, summary) VALUES (?, 'done', ?, ?, 'completed', ?)", + (t, int(time.time()) + 1, int(time.time()) + 2, ""), + ) + conn.commit() + assert kb.latest_summary(conn, t) == "real handoff" + + +def test_latest_summaries_batch_omits_tasks_without_summary(kanban_home): + """``latest_summaries`` is the dashboard's N+1 escape hatch — it + must return only entries for tasks that actually have a summary, + keep the per-task latest, and accept an empty input gracefully.""" + with kb.connect() as conn: + t1 = kb.create_task(conn, title="a", assignee="alice") + t2 = kb.create_task(conn, title="b", assignee="bob") + t3 = kb.create_task(conn, title="c", assignee="carol") + kb.complete_task(conn, t1, summary="alpha") + kb.complete_task(conn, t3, summary="charlie") + out = kb.latest_summaries(conn, [t1, t2, t3]) + assert out == {t1: "alpha", t3: "charlie"} + # Empty input → empty dict, no SQL syntax error from "IN ()". + assert kb.latest_summaries(conn, []) == {}