From ec64ec0d24cd76ba6e6af3dc1e9b55c3f0fa9267 Mon Sep 17 00:00:00 2001 From: KoNit-K <124019182+KoNit-K@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:39:37 -0700 Subject: [PATCH] fix(kanban): `dispatch --json` reports respawn_guarded and other suppression reasons `hermes kanban dispatch --json` only emitted `spawned` and the skip buckets it already knew about, so a ready card held by the respawn guard (`active_pr`, `recent_success`, ...), a quota-released worker, a lost dispatch lock or a memory-pressure hold all looked like `spawned: []` with no reason. Emit `respawn_guarded`, `rate_limited`, `skipped_locked` and `memory_pressure` from the DispatchResult the tick already returns. Salvaged from #111917 by @KoNit-K. Dropped hunk: the `_ACTIVE_PR_RECOVERY_LANES = frozenset({"review"})` rename in kanban_db_dispatch.py, which is behaviour- identical to the existing `lane == "review"` check and does not implement the role-aware exemption the issue asks for. Part of #111910 --- hermes_cli/kanban_ops.py | 7 +++++ .../test_kanban_review_lifecycle.py | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/hermes_cli/kanban_ops.py b/hermes_cli/kanban_ops.py index 078fc44bba..8689124aa9 100644 --- a/hermes_cli/kanban_ops.py +++ b/hermes_cli/kanban_ops.py @@ -105,6 +105,13 @@ def _cmd_dispatch(args: argparse.Namespace) -> int: for (tid, who, current) in res.skipped_per_profile_capped ], "auto_assigned_default": res.auto_assigned_default, + "respawn_guarded": [ + {"task_id": tid, "reason": reason} + for (tid, reason) in res.respawn_guarded + ], + "rate_limited": res.rate_limited, + "skipped_locked": res.skipped_locked, + "memory_pressure": res.memory_pressure, }, ascii=True) return 0 print(f"Reclaimed: {res.reclaimed}") diff --git a/tests/hermes_cli/test_kanban_review_lifecycle.py b/tests/hermes_cli/test_kanban_review_lifecycle.py index dcfcc6da44..283f7fdd6c 100644 --- a/tests/hermes_cli/test_kanban_review_lifecycle.py +++ b/tests/hermes_cli/test_kanban_review_lifecycle.py @@ -22,6 +22,7 @@ from __future__ import annotations import json from pathlib import Path +from types import SimpleNamespace import pytest @@ -29,6 +30,7 @@ from hermes_cli import kanban_db as kb from hermes_cli import kanban_db_connect as kbc from hermes_cli import kanban_db_notify as kbn from hermes_cli import kanban_db_dispatch as kbd +from hermes_cli import kanban_ops @pytest.fixture @@ -478,6 +480,32 @@ def test_active_pr_guard_skipped_for_review_lane_but_defers_ready_lane( ) == "rate_limit_cooldown" +def test_dispatch_json_exposes_suppression_reasons( + kanban_home: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Operators can distinguish an active PR, quota cooldown, lock, and pressure.""" + monkeypatch.setattr( + kanban_ops.kbd, + "dispatch_once", + lambda *args, **kwargs: kbd.DispatchResult( + respawn_guarded=[("active-pr-task", "active_pr")], + rate_limited=["quota-task"], + skipped_locked=True, + memory_pressure="elevated", + ), + ) + + assert kanban_ops._cmd_dispatch( + SimpleNamespace(dry_run=True, max=None, failure_limit=kbd.DEFAULT_FAILURE_LIMIT, json=True) + ) == 0 + + payload = json.loads(capsys.readouterr().out) + assert payload["respawn_guarded"] == [{"task_id": "active-pr-task", "reason": "active_pr"}] + assert payload["rate_limited"] == ["quota-task"] + assert payload["skipped_locked"] is True + assert payload["memory_pressure"] == "elevated" + + def test_review_dispatch_preserves_task_skills_and_adds_reviewer_skill( kanban_home: Path, monkeypatch: pytest.MonkeyPatch ) -> None: