Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -3260,23 +3260,33 @@ def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:
finish, transient infra error clears).

The cheapest signal that distinguishes the two is the most recent
``"blocked"`` / ``"unblocked"`` event for the task. If the most
recent one is ``"blocked"`` (or there is a ``"blocked"`` event and
no ``"unblocked"`` event has fired since), the task is sticky and
``recompute_ready`` must *not* auto-promote it.

Returns ``False`` when there is no such event at all (e.g. the task
was set to ``status='blocked'`` by the circuit breaker or by direct
DB manipulation) — preserves the pre-#28712 auto-recover semantics
for that path.
``"blocked"`` / ``"unblocked"`` event for the task. A ``"created"``
event whose payload records ``status="blocked"`` is also sticky: callers
use ``initial_status="blocked"`` specifically for immediate human gates,
and an empty parent set must not make those gates vacuously ready.

Returns ``False`` when there is no explicit human-block signal at all
(e.g. the task was set to ``status='blocked'`` by the circuit breaker or
by direct DB manipulation) — preserves the pre-#28712 auto-recover
semantics for that path.
"""
row = conn.execute(
"SELECT kind FROM task_events "
"WHERE task_id = ? AND kind IN ('blocked', 'unblocked') "
"SELECT kind, payload FROM task_events "
"WHERE task_id = ? AND kind IN ('created', 'blocked', 'unblocked') "
"ORDER BY id DESC LIMIT 1",
(task_id,),
).fetchone()
return bool(row) and row["kind"] == "blocked"
if not row:
return False
if row["kind"] == "blocked":
return True
if row["kind"] != "created":
return False
try:
payload = json.loads(row["payload"] or "{}")
except (TypeError, UnicodeDecodeError, json.JSONDecodeError):
return False
return isinstance(payload, dict) and payload.get("status") == "blocked"


def recompute_ready(
Expand Down
15 changes: 15 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,21 @@ def test_recompute_ready_cascades_through_chain(kanban_home):
assert kb.get_task(conn, c).status == "ready"


def test_recompute_ready_preserves_initial_human_block(kanban_home):
with kb.connect() as conn:
task_id = kb.create_task(
conn,
title="human gate",
assignee="main",
initial_status="blocked",
)

assert kb.recompute_ready(conn) == 0
task = kb.get_task(conn, task_id)
assert task is not None
assert task.status == "blocked"


def test_recompute_ready_promotes_blocked_with_done_parents(kanban_home):
"""blocked tasks with all parents done should be promoted to ready,
unless the circuit-breaker failure limit has been reached."""
Expand Down