mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-04 01:37:34 +08:00
fix(kanban): audit pass — close orphaned runs on archive / dashboard direct-status / drag-drop
Integration audit of the runs-as-first-class work (0146cb2bd) found five
bugs where structured runs got orphaned or dashboard parity was missing.
All behavioral fixes; no schema change needed.
Kernel
- archive_task: when called on a running task, now closes the
in-flight run with outcome='reclaimed' and clears current_run_id.
Previously, dashboard bulk-archive or CLI `kanban archive <running>`
would leave the task_runs row open with ended_at=NULL forever and
strand the pointer. Adds the claim_lock / claim_expires / worker_pid
clearing to the UPDATE so the task row is clean too.
- complete_task: embeds the first-line handoff summary in the
`completed` event payload (capped at 400 chars). Notifier can now
render `✔ task done — <title>\n<summary>` without a second SQL hit,
and the full summary still lives on the run row.
Dashboard plugin
- _set_status_direct: drag-drop OFF 'running' (to 'ready', 'todo',
'triage', 'done' — anywhere except back to 'running') now closes
the active run with outcome='reclaimed'. Clears worker_pid too.
Snapshots previous status + current_run_id before the UPDATE so
the decision has the right before-state. status event rows now
carry run_id when closing a run, NULL otherwise.
- UpdateTaskBody: adds `summary` and `metadata` fields. PATCH
/tasks/:id with status='done' now forwards them to complete_task,
giving the dashboard parity with `hermes kanban complete --summary
... --metadata ...`. Previously these fields only existed on the
CLI.
CLI
- `hermes kanban complete a b c --summary X` or `--metadata Y`:
refused with a clear stderr message instead of silently applying
the same handoff to every task. Bulk-close without handoff flags
still works. (Note: hermes_cli.main discards subcommand exit
codes via `args.func(args)` without propagating; tracked
separately. Side-effect check is the real guard.)
Gateway notifier
- Completion message prefers run.summary (carried in event payload)
over task.result. task.result remains the fallback for legacy rows
written before runs shipped.
- Docstring: renamed stale `spawn_auto_blocked` reference to
`gave_up` / `timed_out` — matches the actual TERMINAL_KINDS
tuple, which was already correct in code.
Tests (+8 in core functionality, +3 in dashboard plugin)
- archive_of_running_task_closes_run
- archive_of_ready_task_does_not_create_spurious_run
- dashboard_direct_status_change_off_running_closes_run
- dashboard_direct_status_change_within_same_state_is_noop_for_runs
- cli_bulk_complete_with_summary_rejects (side-effect assertion)
- cli_bulk_complete_without_summary_still_works
- completed_event_payload_carries_summary
- completed_event_payload_summary_none_when_missing
- patch_status_done_with_summary_and_metadata
- patch_status_done_without_summary_still_works (legacy path)
- patch_status_archive_closes_running_run (E2E through FastAPI TestClient)
164/164 kanban suite pass under scripts/run_tests.sh. Live smoke
(execute_code with isolated HERMES_HOME) covered all five fixed paths
plus a re-claim-after-drag-drop to confirm the fresh run is tracked
correctly after the orphan close.
This commit is contained in:
@@ -672,3 +672,88 @@ def test_task_detail_runs_empty_before_claim(client):
|
||||
r = client.post("/api/plugins/kanban/tasks", json={"title": "fresh"}).json()
|
||||
d = client.get(f"/api/plugins/kanban/tasks/{r['task']['id']}").json()
|
||||
assert d["runs"] == []
|
||||
|
||||
|
||||
def test_patch_status_done_with_summary_and_metadata(client):
|
||||
"""PATCH /tasks/:id with status=done + summary + metadata must
|
||||
reach complete_task, so the dashboard has CLI parity."""
|
||||
# Create + claim.
|
||||
r = client.post("/api/plugins/kanban/tasks", json={"title": "x", "assignee": "worker"})
|
||||
tid = r.json()["task"]["id"]
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
kb.claim_task(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{tid}",
|
||||
json={
|
||||
"status": "done",
|
||||
"summary": "shipped the thing",
|
||||
"metadata": {"changed_files": ["a.py", "b.py"], "tests_run": 7},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The run must have the summary + metadata attached.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
run = kb.latest_run(conn, tid)
|
||||
assert run.outcome == "completed"
|
||||
assert run.summary == "shipped the thing"
|
||||
assert run.metadata == {"changed_files": ["a.py", "b.py"], "tests_run": 7}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_patch_status_done_without_summary_still_works(client):
|
||||
"""Back-compat: PATCH without the new fields still completes."""
|
||||
r = client.post("/api/plugins/kanban/tasks", json={"title": "y", "assignee": "worker"})
|
||||
tid = r.json()["task"]["id"]
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
kb.claim_task(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{tid}",
|
||||
json={"status": "done", "result": "legacy shape"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
conn = kb.connect()
|
||||
try:
|
||||
run = kb.latest_run(conn, tid)
|
||||
assert run.outcome == "completed"
|
||||
assert run.summary == "legacy shape" # falls back to result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_patch_status_archive_closes_running_run(client):
|
||||
"""PATCH to archived while running must close the in-flight run."""
|
||||
r = client.post("/api/plugins/kanban/tasks", json={"title": "z", "assignee": "worker"})
|
||||
tid = r.json()["task"]["id"]
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
kb.claim_task(conn, tid)
|
||||
open_run = kb.latest_run(conn, tid)
|
||||
assert open_run.ended_at is None
|
||||
finally:
|
||||
conn.close()
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{tid}",
|
||||
json={"status": "archived"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn, tid)
|
||||
assert task.status == "archived"
|
||||
assert task.current_run_id is None
|
||||
assert kb.latest_run(conn, tid).outcome == "reclaimed"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user