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

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