mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
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:
parent
d2c6eceed9
commit
3f97297413
4 changed files with 177 additions and 3 deletions
|
|
@ -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)}):")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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, []) == {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue