From 42c64f65a22836821f58660b7ceb91cc53d9df1d Mon Sep 17 00:00:00 2001 From: "ang-fleet-workers[bot]" <333956806+ang-fleet-workers[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:53:48 -0700 Subject: [PATCH 1/2] fix(kanban): refuse a receipt-less worker completion (no_receipt, rc 4) A dispatcher-owned worker handoff (expected_run_id set) that closes a running card done with prose only -- no PR ref, survivor claim, attachment, or structured metadata beyond bookkeeping (worker_session_id/pool/no_pr/ no_pr_reason/review_skipped) -- is refused before any mutation with ReceiptRequiredError (reason code no_receipt) and a completion_blocked_no_receipt event. `hermes kanban complete` and `request-review` (milestone_only in-place completion) exit 4 on it. Operator closes, superseded closes and reviewer approvals are not gated. Board knob kanban.receipt_gate (default true). hermes_cli/kanban_receipt.py also lints a board: python -m hermes_cli.kanban_receipt --days 7 [--db PATH] [--json] rc 0 none / 1 found / 2 unreadable. Verified: tests/hermes_cli/test_kanban_receipt_gate.py 10/10; 3 go red with the kernel gate reverted. 20 complete_task/expected_run_id kanban test files + tools/plugins/gateway kanban suites: 1025 passed, 6 failed, all 6 also fail on fork/main 264dceffdb. Six existing worker-path tests now pass a minimal metadata receipt. Card t_e21aa11c. --- hermes_cli/config_defaults.py | 5 + hermes_cli/kanban.py | 12 ++ hermes_cli/kanban_db.py | 36 ++++ hermes_cli/kanban_receipt.py | 190 ++++++++++++++++++ .../test_kanban_complete_refusal.py | 3 +- tests/hermes_cli/test_kanban_link_kinds.py | 3 +- tests/hermes_cli/test_kanban_open_pr_route.py | 1 + tests/hermes_cli/test_kanban_receipt_gate.py | 140 +++++++++++++ .../test_kanban_review_lifecycle.py | 1 + tests/tools/test_kanban_tool_survivor.py | 3 +- tests/tools/test_kanban_tools.py | 3 +- 11 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 hermes_cli/kanban_receipt.py create mode 100644 tests/hermes_cli/test_kanban_receipt_gate.py diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 85b61ef6b8d06..cfdfc2da4870f 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -3273,6 +3273,11 @@ # DEPLOYED", "STOP finding", outcome=partial, ...) goes to review # (human:apollo) instead of done. See kanban_negative_handoff.py. "negative_handoff_review": False, + # True: a worker's completion of a running card with no receipt (no + # PR, survivor, attachment or structured metadata beyond bookkeeping) + # is refused with reason code no_receipt (t_e21aa11c). Lint the board: + # python -m hermes_cli.kanban_receipt --days 7 + "receipt_gate": True, # "all" (default): every request_review routes to review_assignee. # "milestone_only": only cards whose title/body carry "[milestone]" # or "qa:required" get a reviewer session (being a task_links parent diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 5f16d25546250..93f9e8b688817 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -33,6 +33,7 @@ from hermes_cli.kanban_pr_freshness import DraftPrError from hermes_cli.kanban_branch_base import StaleBaseError from hermes_cli.kanban_open_pr import ClosedUnmergedPrError +from hermes_cli.kanban_receipt import EXIT_NO_RECEIPT, ReceiptRequiredError from hermes_cli.kanban_identity import safe_comment_provenance from hermes_constants import get_default_hermes_root @@ -4935,6 +4936,7 @@ def _cmd_complete(args: argparse.Namespace) -> int: ) return 2 failed: list[str] = [] + no_receipt = False with kb.connect_closing() as conn: for tid in ids: # Goal-mode judge gate (mirrors tools/kanban_tools.py). Apply it @@ -4973,6 +4975,11 @@ def _cmd_complete(args: argparse.Namespace) -> int: superseded_by=superseded_by, draft_ok=draft_ok, ) + except ReceiptRequiredError as receipt_err: + failed.append(tid) + no_receipt = True + print(f"cannot complete {tid}: {receipt_err}.", file=sys.stderr) + continue except (kb.EmptySupersedeError, kb.EmptyDraftOverrideError) as supersede_err: failed.append(tid) print(f"cannot complete {tid}: {supersede_err}.", file=sys.stderr) @@ -5000,6 +5007,8 @@ def _cmd_complete(args: argparse.Namespace) -> int: if override: print(f" draft override recorded for {', '.join(override['prs'])}: " f"{override['reason']}") + if no_receipt: + return EXIT_NO_RECEIPT return 0 if not failed else 1 @@ -5437,6 +5446,9 @@ def _cmd_request_review(args: argparse.Namespace) -> int: allow_same_actor=bool(getattr(args, "allow_same_actor", False)), with_reason=True, ) + except ReceiptRequiredError as receipt_err: + print(f"cannot request review for {tid}: {receipt_err}", file=sys.stderr) + return EXIT_NO_RECEIPT except (DraftPrError, StaleBaseError, ClosedUnmergedPrError) as draft_err: print(f"cannot request review for {tid}: {draft_err}", file=sys.stderr) return 1 diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index c0804bdc212b1..3a61ea7653e47 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -11512,6 +11512,31 @@ def complete_task( and candidate.current_run_id is not None and _retry_status_for_run(conn, task_id, candidate.current_run_id) == "review" ) + # Receipt gate (t_e21aa11c): a dispatcher-owned worker handoff + # (``expected_run_id`` set) that closes ``done`` with prose only -- no PR, + # survivor, attachment or structured metadata -- is refused before any + # mutation. Operator closes are not gated. Knob ``kanban.receipt_gate``. + if ( + expected_run_id is not None + and candidate.status == 'running' and not review_claimed + and not approve_head_sha and not superseded_by + and configured_receipt_gate() + ): + from hermes_cli import kanban_receipt as _receipt + if _receipt.missing( + summary=summary, result=result, metadata=metadata, + survivor_pr=survivor_pr, survivor_ref=survivor_ref, + survivor_none=survivor_none, + attachments=_receipt.attachment_count(conn, task_id), + ): + with write_txn(conn): + _append_event( + conn, task_id, _receipt.EVENT, + {"reason": _receipt.REASON_CODE, + "metadata_keys": sorted((metadata or {}).keys()) + if isinstance(metadata, dict) else []}, + ) + raise _receipt.ReceiptRequiredError(task_id) negative_trigger: Optional[str] = None if ( candidate.status == 'running' and not review_claimed @@ -14025,6 +14050,17 @@ def configured_negative_handoff_review() -> bool: return value is True or str(value).strip().casefold() in ("1", "true", "yes", "on") +def configured_receipt_gate() -> bool: + """``kanban.receipt_gate`` — refuse a receipt-less implementer completion (default on).""" + try: + value, _source = _kanban_review_setting("receipt_gate", True) + except Exception: + return True + if isinstance(value, bool): + return value + return str(value).strip().casefold() not in ("0", "false", "no", "off") + + def configured_review_policy() -> str: """``kanban.review_policy`` — ``all`` (default), ``milestone_only`` or ``none``. diff --git a/hermes_cli/kanban_receipt.py b/hermes_cli/kanban_receipt.py new file mode 100644 index 0000000000000..f2a78dfbc0c7f --- /dev/null +++ b/hermes_cli/kanban_receipt.py @@ -0,0 +1,190 @@ +"""Completion receipt gate: a worker's ``done`` handoff must carry evidence. + +Incident 2026-10-01 (t_e21aa11c): deploy cards closed ``done`` with nothing a +reader could check but prose, and the session overview printed "no receipt on +card" for them. A receipt is one of: + +* a PR the handoff owns or names (``owner/repo#N`` / PR URL in summary, result, + ``metadata.pr_url|pr_urls|pr`` or ``survivor_pr``), +* a survivor claim (``survivor_ref`` / ``survivor_none``), +* an attachment on the card, or declared ``metadata.artifacts``, +* structured handoff metadata: any key outside :data:`BOOKKEEPING_KEYS` + (what was deployed, a sha table, a read-back). + +:func:`missing` is the single predicate. ``complete_task`` refuses a worker's +dispatcher-owned (``expected_run_id``) completion with +:class:`ReceiptRequiredError` (reason code ``no_receipt``; CLI exit +:data:`EXIT_NO_RECEIPT`) when the board knob ``kanban.receipt_gate`` is on, and +``python -m hermes_cli.kanban_receipt --days 7`` lists the receipt-less +``done`` cards already on a board. +""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +import time +from typing import Any, Iterable, Optional + +SETTING = "receipt_gate" +REASON_CODE = "no_receipt" +# ``hermes kanban complete`` / ``request-review`` exit status for this refusal +# (0 ok, 1 other refusal, 2 usage error). +EXIT_NO_RECEIPT = 4 +EVENT = "completion_blocked_no_receipt" + +# Keys the runtime stamps, or that only say "no PR" without saying what was +# done. A handoff whose metadata holds nothing else has no structured receipt. +BOOKKEEPING_KEYS = frozenset({ + "worker_session_id", + "pool", + "review_skipped", + "no_pr", + "no_pr_reason", +}) + + +class ReceiptRequiredError(ValueError): + """``complete_task`` refused a worker handoff that carries no receipt. + + ``ValueError`` so tool-error handlers treat it as recoverable: nothing was + mutated except the ``completion_blocked_no_receipt`` audit event. + """ + + code = REASON_CODE + + def __init__(self, task_id: str): + self.task_id = task_id + super().__init__( + f"completion blocked ({REASON_CODE}): {task_id} has no receipt. Prose " + f"alone is not one. Attach the evidence (kanban_attach / artifacts=[...]), " + f"or put a structured handback in metadata (what was deployed, a " + f"before/after sha table, the read-back), or name the PR. " + f"{task_id} is still in-flight (no state change)" + ) + + +def _has_pr(summary, result, metadata, survivor_pr) -> bool: + from hermes_cli import kanban_open_pr as _open_pr + + return bool(_open_pr.extract_pr_refs( + result, summary, metadata=metadata if isinstance(metadata, dict) else None, + survivor_pr=survivor_pr, + )) + + +def structured_keys(metadata: Any) -> list: + """Metadata keys that count as a structured receipt (sorted).""" + if not isinstance(metadata, dict): + return [] + return sorted( + k for k, v in metadata.items() + if k not in BOOKKEEPING_KEYS and v not in (None, "", [], {}) + ) + + +def missing( + *, + summary: Optional[str] = None, + result: Optional[str] = None, + metadata: Any = None, + survivor_pr=None, + survivor_ref=None, + survivor_none: bool = False, + attachments: int = 0, +) -> bool: + """True when the handoff carries no receipt (see module docstring).""" + if attachments or survivor_ref or survivor_none: + return False + if structured_keys(metadata): + return False + return not _has_pr(summary, result, metadata, survivor_pr) + + +def attachment_count(conn: sqlite3.Connection, task_id: str) -> int: + row = conn.execute( + "SELECT count(*) FROM task_attachments WHERE task_id = ?", (task_id,) + ).fetchone() + return int(row[0]) if row else 0 + + +# ---------------------------------------------------------------- lint +def lint(conn: sqlite3.Connection, *, days: float = 7, now: Optional[int] = None) -> list: + """Receipt-less ``done`` cards completed in the last ``days`` days. + + Reads each card's latest ENDED run (the closing handoff). Superseded closes + carry their pointer as evidence and are skipped. Returns dicts sorted by + completion time. + """ + now = int(now if now is not None else time.time()) + since = now - int(days * 86400) + rows = conn.execute( + """ + SELECT t.id, t.title, t.result, t.completed_at, + r.profile, r.outcome, r.summary, r.metadata, + (SELECT count(*) FROM task_attachments a WHERE a.task_id = t.id) + FROM tasks t + LEFT JOIN task_runs r ON r.id = ( + SELECT max(id) FROM task_runs + WHERE task_id = t.id AND ended_at IS NOT NULL) + WHERE t.status = 'done' AND t.completed_at >= ? + ORDER BY t.completed_at, t.id + """, + (since,), + ).fetchall() + out = [] + for tid, title, result, completed_at, profile, outcome, summary, meta, att in rows: + if outcome == "superseded": + continue + try: + metadata = json.loads(meta) if meta else None + except (TypeError, ValueError): + metadata = None + if not missing(summary=summary, result=result, metadata=metadata, attachments=att): + continue + out.append({ + "id": tid, + "completed_at": completed_at, + "closed_by": profile or "-", + "worker_handoff": isinstance(metadata, dict) and "worker_session_id" in metadata, + "title": (title or "").strip(), + "summary": ((summary or result or "").strip().splitlines() or [""])[0], + }) + return out + + +def _main(argv: Optional[Iterable[str]] = None) -> int: + ap = argparse.ArgumentParser( + prog="python -m hermes_cli.kanban_receipt", + description="List receipt-less done cards (rc 0 none, 1 some found, 2 error).", + ) + ap.add_argument("--days", type=float, default=7) + ap.add_argument("--db", default=None, help="board DB (default: the active board)") + ap.add_argument("--json", action="store_true") + args = ap.parse_args(list(argv) if argv is not None else None) + try: + if args.db: + conn = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True) + else: + from hermes_cli import kanban_db as kb + conn = kb.connect() + with conn: + found = lint(conn, days=args.days) + except (sqlite3.Error, OSError) as exc: + print(f"kanban_receipt: cannot read board: {exc}", file=sys.stderr) + return 2 + if args.json: + print(json.dumps(found, indent=1)) + else: + for c in found: + ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(c["completed_at"])) + who = c["closed_by"] + (" (worker)" if c["worker_handoff"] else "") + print(f"{ts} {c['id']} {who:<22} {c['title'][:70]} | {c['summary'][:80]}") + print(f"{len(found)} receipt-less done card(s) in the last {args.days:g} day(s)") + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tests/hermes_cli/test_kanban_complete_refusal.py b/tests/hermes_cli/test_kanban_complete_refusal.py index b919d433d4225..1e13b57e08995 100644 --- a/tests/hermes_cli/test_kanban_complete_refusal.py +++ b/tests/hermes_cli/test_kanban_complete_refusal.py @@ -33,7 +33,8 @@ def test_already_done_names_who_and_when(kanban_home): t = kb.create_task(conn, title="x", assignee="daedalus") claimed = kb.claim_task(conn, t) assert claimed is not None - assert kb.complete_task(conn, t, summary="done", expected_run_id=claimed.current_run_id) + assert kb.complete_task(conn, t, summary="done", metadata={"tests_run": 1}, + expected_run_id=claimed.current_run_id) assert not kb.complete_task(conn, t, summary="again") msg = kb.explain_complete_refusal(conn, t) assert msg.startswith("already done by daedalus at 20"), msg diff --git a/tests/hermes_cli/test_kanban_link_kinds.py b/tests/hermes_cli/test_kanban_link_kinds.py index 8f9840294f792..b88441fbf0bf6 100644 --- a/tests/hermes_cli/test_kanban_link_kinds.py +++ b/tests/hermes_cli/test_kanban_link_kinds.py @@ -181,7 +181,8 @@ def test_running_child_completes_while_derived_from_parent_is_open(kanban_home): run_id = kb.get_task(conn, child).current_run_id assert kb.complete_task( - conn, child, summary="shipped", expected_run_id=run_id, + conn, child, summary="shipped", metadata={"tests_run": 1}, + expected_run_id=run_id, ) is True assert kb.get_task(conn, child).status == "done" assert kb.get_task(conn, parent).status == "ready" diff --git a/tests/hermes_cli/test_kanban_open_pr_route.py b/tests/hermes_cli/test_kanban_open_pr_route.py index e15acd4b6e22b..5189145d7e8cb 100644 --- a/tests/hermes_cli/test_kanban_open_pr_route.py +++ b/tests/hermes_cli/test_kanban_open_pr_route.py @@ -329,6 +329,7 @@ def test_milestone_only_in_place_completion_uses_worker_run_id(kanban_home, monk assert ok is False and "expected_run_id" in reason assert _status(conn, tid) == "running" ok, reason = kb.request_review(conn, tid, summary="done", + metadata={"tests_run": 1}, expected_run_id=run_id, with_reason=True) assert ok is True, reason assert _status(conn, tid) == "done" diff --git a/tests/hermes_cli/test_kanban_receipt_gate.py b/tests/hermes_cli/test_kanban_receipt_gate.py new file mode 100644 index 0000000000000..c29cfc17c1d5b --- /dev/null +++ b/tests/hermes_cli/test_kanban_receipt_gate.py @@ -0,0 +1,140 @@ +"""A worker handoff that closes ``done`` must carry a receipt (t_e21aa11c). + +Incident 2026-10-01: deploy cards closed ``done`` and the session overview read +"no receipt on card". The gate refuses a prose-only worker completion with reason +code ``no_receipt``; a receipt is a PR, a survivor claim, an attachment or +structured handoff metadata. +""" + +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path + +import pytest + +from hermes_cli import kanban as kcli +from hermes_cli import kanban_db as kb +from hermes_cli import kanban_open_pr as op +from hermes_cli import kanban_receipt as rc + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr(op, "_default_query", lambda: (lambda repo, number: {"state": "MERGED"})) + kb.init_db() + return home + + +def _claimed(conn): + tid = kb.create_task(conn, title="deploy pcv #609 to ACE-AI", assignee="daedalus") + run = kb.claim_task(conn, tid) + assert run is not None + return tid, run.current_run_id + + +def _status(conn, tid): + return kb.get_task(conn, tid).status + + +def _kinds(conn, tid): + return [e.kind for e in kb.list_events(conn, tid)] + + +def test_prose_only_worker_completion_is_refused_with_named_code(kanban_home): + with kb.connect() as conn: + tid, run_id = _claimed(conn) + with pytest.raises(rc.ReceiptRequiredError) as err: + kb.complete_task(conn, tid, summary="Deployed and live on ACE-AI, and verified.", + metadata={"worker_session_id": "s1", "no_pr": True, + "no_pr_reason": "deploy-only"}, + expected_run_id=run_id) + assert err.value.code == "no_receipt" + assert "no_receipt" in str(err.value) + assert _status(conn, tid) == "running" + assert rc.EVENT in _kinds(conn, tid) + + +def test_receipt_attachment_is_accepted(kanban_home, tmp_path): + with kb.connect() as conn: + tid, run_id = _claimed(conn) + blob = tmp_path / "deploy-receipt.txt" + blob.write_text("pcv 64eb851e -> 7fceff2a; read-back ok\n") + kb.add_attachment(conn, tid, filename=blob.name, stored_path=str(blob), + size=blob.stat().st_size, uploaded_by="agent") + assert kb.complete_task(conn, tid, summary="Deployed and live.", + expected_run_id=run_id) is True + assert _status(conn, tid) == "done" + + +@pytest.mark.parametrize("kwargs", [ + {"metadata": {"pcv_before": "64eb851e", "pcv_after": "7fceff2a"}}, + {"metadata": {"artifacts": ["/tmp/x.log"]}}, + {"summary": "merged ANG-Ventures/pipecat-house-voice#609 and deployed"}, +]) +def test_structured_handback_or_pr_is_accepted(kanban_home, kwargs): + kwargs = {"summary": "Deployed and live.", **kwargs} + with kb.connect() as conn: + tid, run_id = _claimed(conn) + assert kb.complete_task(conn, tid, expected_run_id=run_id, **kwargs) is True + assert _status(conn, tid) == "done" + + +def test_operator_close_and_superseded_are_not_gated(kanban_home): + with kb.connect() as conn: + tid, _run = _claimed(conn) + assert kb.complete_task(conn, tid, summary="closed by hand") is True + tid2, run2 = _claimed(conn) + assert kb.complete_task(conn, tid2, superseded_by="t_c18e4162", + expected_run_id=run2) is True + + +def test_knob_off_disables_the_gate(kanban_home, monkeypatch): + monkeypatch.setattr(kb, "configured_receipt_gate", lambda: False) + with kb.connect() as conn: + tid, run_id = _claimed(conn) + assert kb.complete_task(conn, tid, summary="done", expected_run_id=run_id) is True + + +def test_cli_complete_exits_with_named_rc(kanban_home, monkeypatch, capsys): + with kb.connect() as conn: + tid, run_id = _claimed(conn) + monkeypatch.setattr(kcli, "_worker_run_id_for", lambda _t: run_id) + args = argparse.Namespace(task_ids=[tid], summary="Deployed and live.", result=None, + metadata=None, superseded_by=None, draft_ok=None) + assert kcli._cmd_complete(args) == rc.EXIT_NO_RECEIPT == 4 + assert "no_receipt" in capsys.readouterr().err + with kb.connect() as conn: + assert _status(conn, tid) == "running" + + +def test_lint_lists_receipt_less_done_cards(kanban_home): + with kb.connect() as conn: + bare = kb.create_task(conn, title="bare", assignee="daedalus") + kb.complete_task(conn, bare, summary="done") # operator close, no receipt + good = kb.create_task(conn, title="good", assignee="daedalus") + kb.complete_task(conn, good, summary="done", metadata={"sha_after": "abc"}) + sup = kb.create_task(conn, title="sup", assignee="daedalus") + kb.complete_task(conn, sup, superseded_by="t_c18e4162") + old = kb.create_task(conn, title="old", assignee="daedalus") + kb.complete_task(conn, old, summary="done") + conn.execute("UPDATE tasks SET completed_at = ? WHERE id = ?", + (int(time.time()) - 9 * 86400, old)) + conn.commit() + found = [c["id"] for c in rc.lint(conn, days=7)] + assert found == [bare] + + +def test_lint_cli_rc(kanban_home, capsys): + with kb.connect() as conn: + tid = kb.create_task(conn, title="bare", assignee="daedalus") + kb.complete_task(conn, tid, summary="done") + path = conn.execute("PRAGMA database_list").fetchone()[2] + assert rc._main(["--db", path, "--json"]) == 1 + assert [c["id"] for c in json.loads(capsys.readouterr().out)] == [tid] diff --git a/tests/hermes_cli/test_kanban_review_lifecycle.py b/tests/hermes_cli/test_kanban_review_lifecycle.py index 13978037d3158..51f028a1c9f9f 100644 --- a/tests/hermes_cli/test_kanban_review_lifecycle.py +++ b/tests/hermes_cli/test_kanban_review_lifecycle.py @@ -572,6 +572,7 @@ def test_review_dispatch_honors_global_and_per_profile_caps( assert kb.complete_task( conn, running_id, + metadata={"tests_run": 1}, expected_run_id=running.current_run_id, ) global_dry_run = kb.dispatch_once( diff --git a/tests/tools/test_kanban_tool_survivor.py b/tests/tools/test_kanban_tool_survivor.py index 7dfa8ed935b97..695b3b4e48b76 100644 --- a/tests/tools/test_kanban_tool_survivor.py +++ b/tests/tools/test_kanban_tool_survivor.py @@ -380,7 +380,8 @@ def test_tool_all_blank_survivor_list_normalizes_to_none(worker_env, remote_mult two_vanished_repos(worker_env) for blank in ([], [" "], ["", " "]): - out = complete(summary="approved", survivor_pr=blank) + out = complete(summary="approved", survivor_pr=blank, + metadata={"verified": "receipt gate satisfied"}) # No claim was made, so this is the plain fail-closed refusal — never # a completion, and never a bogus [""] claim sent to the remote. assert "survivor" in out.get("error", "").lower(), (blank, out) diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index 623a0ea93a11d..3c77e75d7e32a 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -270,7 +270,8 @@ def judge(**kwargs): return ("done", "deliverable verified", False, None, False) monkeypatch.setattr(kt, "judge_goal", judge) - result = json.loads(kt._handle_complete({"summary": "CANARY_PHASE1_NEW and CANARY_PHASE2_NEW; both exit 0"})) + result = json.loads(kt._handle_complete({"summary": "CANARY_PHASE1_NEW and CANARY_PHASE2_NEW; both exit 0", + "metadata": {"tests_run": 2}})) assert result.get("ok") is True, result with kb.connect() as conn: assert kb.get_task(conn, tid).status == "done" From fbed8e7b88dd8b08d8b4d6143f0a2a712e7a7b33 Mon Sep 17 00:00:00 2001 From: "ang-fleet-workers[bot]" <333956806+ang-fleet-workers[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:07:29 -0700 Subject: [PATCH 2/2] test(kanban): give remaining worker-path completions a metadata receipt (t_e21aa11c) CI on 42c64f65a2 caught 8 more worker-path tests completing with prose only; each now passes metadata={tests_run: 1}. Verified: the 4 files pass locally. --- tests/agent/test_kanban_stop.py | 3 ++- tests/hermes_cli/test_kanban_cli_non_owner_complete.py | 1 + tests/hermes_cli/test_kanban_review_load_gates.py | 8 +++++--- tests/hermes_cli/test_kanban_review_policy_home.py | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/agent/test_kanban_stop.py b/tests/agent/test_kanban_stop.py index b35c26b002e79..cf382d2e27c65 100644 --- a/tests/agent/test_kanban_stop.py +++ b/tests/agent/test_kanban_stop.py @@ -366,7 +366,8 @@ def test_non_worker_does_not_nudge(clear_kanban_env): def test_closed_run_needs_no_tool_history(worker_run, handoff): conn, task = worker_run if handoff == "complete": - assert kb.complete_task(conn, task.id, expected_run_id=task.current_run_id) + assert kb.complete_task(conn, task.id, metadata={"tests_run": 1}, + expected_run_id=task.current_run_id) elif handoff == "block": assert kb.block_task( conn, task.id, reason="Missing input", expected_run_id=task.current_run_id, diff --git a/tests/hermes_cli/test_kanban_cli_non_owner_complete.py b/tests/hermes_cli/test_kanban_cli_non_owner_complete.py index eca2d5e581850..684aca79f1270 100644 --- a/tests/hermes_cli/test_kanban_cli_non_owner_complete.py +++ b/tests/hermes_cli/test_kanban_cli_non_owner_complete.py @@ -115,6 +115,7 @@ def test_owner_still_completes_its_own_card(home): task_id, run_id = _running_card(home) out = _run(home, "complete", task_id, "--summary", "owner done", + "--metadata", '{"tests_run": 1}', extra=_worker_env(task_id, run_id, "pending")) assert out.returncode == 0, out.stdout + out.stderr diff --git a/tests/hermes_cli/test_kanban_review_load_gates.py b/tests/hermes_cli/test_kanban_review_load_gates.py index 3532cb123af67..91e9c197a45cf 100644 --- a/tests/hermes_cli/test_kanban_review_load_gates.py +++ b/tests/hermes_cli/test_kanban_review_load_gates.py @@ -209,7 +209,8 @@ def test_milestone_only_completes_slice_cards_in_place(kanban_home, monkeypatch) tid = kb.create_task(conn, title="slice: add flag", assignee="builder") claimed = kb.claim_task(conn, tid) ok, reason = kb.request_review( - conn, tid, summary="PR #1 green", expected_run_id=claimed.current_run_id, + conn, tid, summary="PR #1 green", metadata={"tests_run": 1}, + expected_run_id=claimed.current_run_id, with_reason=True, ) assert ok is True and "review skipped" in reason @@ -245,6 +246,7 @@ def test_milestone_only_routes_marker_and_parent_cards(kanban_home, monkeypatch) leaf = kb.create_task(conn, title="slice with explicit reviewer", assignee="builder") claimed = kb.claim_task(conn, leaf) assert kb.request_review(conn, leaf, summary="s", reviewer="argus", + metadata={"tests_run": 1}, expected_run_id=claimed.current_run_id) is True assert kb.get_task(conn, leaf).status == "done" # the explicit human sentinel still parks the card for a person @@ -286,8 +288,8 @@ def _claim_and_request(conn, tid, **kw): claimed = kb.claim_task(conn, tid) assert claimed is not None return kb.request_review( - conn, tid, summary="PR green", expected_run_id=claimed.current_run_id, - with_reason=True, **kw, + conn, tid, summary="PR green", metadata={"tests_run": 1}, + expected_run_id=claimed.current_run_id, with_reason=True, **kw, ) diff --git a/tests/hermes_cli/test_kanban_review_policy_home.py b/tests/hermes_cli/test_kanban_review_policy_home.py index d0c2de4ce5a78..50c5057f42cf2 100644 --- a/tests/hermes_cli/test_kanban_review_policy_home.py +++ b/tests/hermes_cli/test_kanban_review_policy_home.py @@ -125,8 +125,8 @@ def test_request_review_from_worker_profile_completes_in_place_under_root_none( claimed = kb.claim_task(conn, tid) assert claimed is not None ok, reason = kb.request_review( - conn, tid, summary="PR green", expected_run_id=claimed.current_run_id, - with_reason=True, + conn, tid, summary="PR green", metadata={"tests_run": 1}, + expected_run_id=claimed.current_run_id, with_reason=True, ) # reviewer omitted assert ok is True and "review skipped" in reason, reason assert kb.get_task(conn, tid).status == "done"