From 5829fe1378eb1083c77c02198016f052b886abb9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Jul 2026 22:23:38 -0500 Subject: [PATCH] fix(kanban): failure diagnostics exempt done/archived tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manual done (dashboard/desktop drag) runs complete_task but ends no run, so a trailing crashed/crashed run history never gains the 'completed' outcome that breaks the repeated_crashes streak — the card kept flagging "needs attention" forever after being finished. repeated_failures had the same hole via a stale counter. Terminal statuses are now exempt from both: done means done; the history stays on the event log for audit. Regression test included. --- hermes_cli/kanban_diagnostics.py | 15 +++++++++++++++ tests/hermes_cli/test_kanban_diagnostics.py | 20 +++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 81da4c19156..1ee680f1e40 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -530,7 +530,14 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: Accepts the legacy ``spawn_failure_threshold`` config key for back-compat. + + Terminal statuses are exempt: a done/archived card has nothing left + to retry, so a lingering failure streak is history, not a signal. + (``complete_task`` resets the counter, but a manual done — e.g. a + dashboard drag — ends no run and used to leave the flag stuck.) """ + if _task_field(task, "status") in ("done", "archived"): + return [] threshold = _positive_int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), @@ -649,7 +656,15 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: total failures) so the operator gets a crash-specific heads-up before the unified rule kicks in. Suppresses itself when the unified rule is also about to fire, to avoid double-flagging. + + Terminal statuses are exempt for the same reason as + ``repeated_failures`` — with one extra wrinkle: this rule reads run + history, and a manual done (dashboard drag) appends no ``completed`` + run to break the crash streak, so the flag was permanent (#kanban + desktop dogfood). Done means done. """ + if _task_field(task, "status") in ("done", "archived"): + return [] failure_threshold = int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py index 2de4933dc63..c040bbdfeef 100644 --- a/tests/hermes_cli/test_kanban_diagnostics.py +++ b/tests/hermes_cli/test_kanban_diagnostics.py @@ -272,6 +272,16 @@ def test_repeated_crashes_escalates_on_many_crashes(): assert diags[0].severity == "critical" +def test_failure_rules_exempt_terminal_statuses(): + # A manual done (dashboard drag) ends no run, so the trailing crash + # streak survives in run history — but done means done: neither + # failure rule may keep flagging a terminal card. + runs = [_run(outcome="crashed", run_id=1), _run(outcome="crashed", run_id=2)] + for status in ("done", "archived"): + task = _task(status=status, assignee="crashy", consecutive_failures=3) + assert kd.compute_task_diagnostics(task, [], runs) == [] + + def test_stuck_in_blocked_fires_past_threshold(): now = int(time.time()) task = _task(status="blocked") @@ -368,15 +378,19 @@ def test_repeated_crashes_truncates_huge_tracebacks(): def test_diagnostics_sorted_critical_first(): """A task with both a critical (many spawn failures) and a warning - (prose phantoms) diagnostic should list the critical one first.""" - task = _task(status="done", consecutive_failures=10, + (prose phantoms) diagnostic should list the critical one first. + + Status must be non-terminal: done/archived are exempt from the + failure rules (done means done). ``now=300`` keeps the synthetic + timestamps from tripping stranded_in_ready — same dodge as above.""" + task = _task(status="ready", consecutive_failures=10, last_failure_error="nope") events = [ _event("completed", ts=100, summary="referenced t_missing"), _event("suspected_hallucinated_references", ts=101, phantom_refs=["t_missing11"]), ] - diags = kd.compute_task_diagnostics(task, events, []) + diags = kd.compute_task_diagnostics(task, events, [], now=300) kinds = [d.kind for d in diags] assert kinds[0] == "repeated_failures" # critical assert "prose_phantom_refs" in kinds