fix(kanban): failure diagnostics exempt done/archived tasks

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-08 22:23:38 -05:00
parent 111544d544
commit 5829fe1378
2 changed files with 32 additions and 3 deletions

View file

@ -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),

View file

@ -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