feat(kanban): surface task_runs.summary on dashboard cards + `kanban show`

The kanban-worker skill (built into the gateway dispatcher's spawn
prompt) instructs every worker to hand off via
``kanban_complete(summary=..., metadata=...)``. That writes the summary
onto the closing ``task_runs`` row, NOT onto ``tasks.result`` — the
latter is left NULL unless the caller passes ``result=`` explicitly.

Result: a glance at the dashboard or ``hermes kanban show <id>`` shows
a blank "Result:" section even when the worker did real work, which
on 2026-05-05 caused a Mac false-alarm ("Hermes did nothing") on a
task that had a 10-line completion summary on its run.

This patch surfaces the latest non-null run summary as
``latest_summary`` so the worker's actual handoff lands in front of
operators.

* New helpers ``kanban_db.latest_summary(conn, task_id)`` and
  ``kanban_db.latest_summaries(conn, task_ids)``. The batch variant
  uses a single window-function SELECT so the dashboard board endpoint
  doesn't pay an N+1 cost on multi-hundred-task boards.
* CLI ``hermes kanban show <id>`` prints a "Latest summary:" block
  when ``tasks.result`` is empty but a run has produced a summary
  (the existing "Result:" section still wins when populated, so the
  back-compat path for hand-edited results is untouched). JSON output
  gains a top-level ``latest_summary`` field.
* Dashboard ``/board`` and ``/tasks/{id}`` now include a
  ``latest_summary`` field on every task. Cards on /board carry a
  200-character preview (cheap to render, plenty for "what did this
  worker do?" at a glance); the drawer/detail endpoint returns the
  full summary.
* Five new tests cover: empty-runs case, post-complete surface,
  newest-of-multiple selection, empty-string skip, batch with
  missing tasks + empty input.

Smoke-tested locally against the live profile DB on the three
acceptance-criterion targets (t_f08fef91 cron-hygiene-audit,
t_007b7f1c EMA-analysis, t_05746fa4 self-assessment) — all three now
return their populated summaries via both ``latest_summary`` and
``latest_summaries``.

Test plan: 255/255 kanban tests pass + 91/91 dashboard plugin tests
pass. No regression on tasks where ``tasks.result`` is explicitly
populated (the existing "Result:" branch is preserved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brecht-H 2026-05-05 14:14:25 +00:00 committed by Teknium
parent d2c6eceed9
commit 3f97297413
4 changed files with 177 additions and 3 deletions

View file

@ -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 <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)}):")

View file

@ -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}

View file

@ -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])

View file

@ -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, []) == {}