fix(kanban): align recompute_ready guard with breaker's configured failure_limit
Follow-up to the budget-exhaustion recovery fix. recompute_ready's new circuit-breaker guard resolved its effective limit from per-task max_retries -> DEFAULT_FAILURE_LIMIT, skipping the dispatcher's configured kanban.failure_limit. _record_task_failure resolves max_retries -> failure_limit(config) -> DEFAULT, so the two disagreed whenever an operator set kanban.failure_limit != 2: - config > 2: a task could get stuck at DEFAULT(2) before reaching its allowed retry count. - config < 2: a task the breaker already blocked could be auto-recovered back to ready, defeating the stricter limit. Thread the dispatcher's failure_limit through dispatch_once into recompute_ready so the guard and the breaker share one resolution order. Updated test_circuit_breaker_block_still_auto_promotes (it asserted a failures=5 block auto-recovers and resets the counter — that's the pre-#35072 behavior the loop fix removes); it now exercises a below-limit transient block, with the at-limit case covered in test_kanban_db.py. Added two tests for the config-tier and per-task override resolution.
This commit is contained in:
@@ -892,6 +892,73 @@ def test_recompute_ready_recovers_below_limit(kanban_home):
|
||||
assert task.consecutive_failures == 1
|
||||
|
||||
|
||||
def test_recompute_ready_honours_dispatcher_failure_limit(kanban_home):
|
||||
"""The guard's effective limit must follow the same resolution order
|
||||
as the circuit breaker (#35072): per-task max_retries → dispatcher
|
||||
failure_limit → DEFAULT_FAILURE_LIMIT.
|
||||
|
||||
Without threading the dispatcher's ``kanban.failure_limit`` through,
|
||||
the guard falls back to DEFAULT_FAILURE_LIMIT and disagrees with the
|
||||
breaker — sticking a task prematurely (config limit > default) or
|
||||
letting a tripped task escape (config limit < default).
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
# Config allows MORE retries than the default. A task blocked
|
||||
# with failures below the configured limit must still recover.
|
||||
t = kb.create_task(conn, title="lenient", assignee="a")
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='blocked', consecutive_failures=? "
|
||||
"WHERE id=?",
|
||||
(kb.DEFAULT_FAILURE_LIMIT, t),
|
||||
)
|
||||
conn.commit()
|
||||
# Default-limit call would stick it (failures >= default).
|
||||
assert kb.recompute_ready(conn) == 0
|
||||
assert kb.get_task(conn, t).status == "blocked"
|
||||
# Dispatcher configured a higher limit → recover, preserve counter.
|
||||
promoted = kb.recompute_ready(
|
||||
conn, failure_limit=kb.DEFAULT_FAILURE_LIMIT + 2
|
||||
)
|
||||
assert promoted == 1
|
||||
task = kb.get_task(conn, t)
|
||||
assert task.status == "ready"
|
||||
assert task.consecutive_failures == kb.DEFAULT_FAILURE_LIMIT
|
||||
|
||||
# Config allows FEWER retries than the default. A task at the
|
||||
# stricter limit must stay blocked even though it's below default.
|
||||
t2 = kb.create_task(conn, title="strict", assignee="a")
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='blocked', consecutive_failures=1 "
|
||||
"WHERE id=?",
|
||||
(t2,),
|
||||
)
|
||||
conn.commit()
|
||||
# Default-limit (2) would recover it (1 < 2).
|
||||
# Stricter config limit (1) must keep it blocked (1 >= 1).
|
||||
assert kb.recompute_ready(conn, failure_limit=1) == 0
|
||||
assert kb.get_task(conn, t2).status == "blocked"
|
||||
|
||||
|
||||
def test_recompute_ready_per_task_max_retries_overrides_dispatcher(kanban_home):
|
||||
"""A per-task ``max_retries`` wins over the dispatcher failure_limit,
|
||||
matching ``_record_task_failure``'s resolution order."""
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="per-task", assignee="a")
|
||||
# Per-task allows 4 retries; dispatcher config says 2.
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='blocked', consecutive_failures=2, "
|
||||
"max_retries=4 WHERE id=?",
|
||||
(t,),
|
||||
)
|
||||
conn.commit()
|
||||
# failures(2) < per-task limit(4) → recover, despite dispatcher=2.
|
||||
promoted = kb.recompute_ready(conn, failure_limit=2)
|
||||
assert promoted == 1
|
||||
task = kb.get_task(conn, t)
|
||||
assert task.status == "ready"
|
||||
assert task.consecutive_failures == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parent-completion invariant at the claim gate (RCA t_a6acd07d)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user