From 0c22434f033ab0a8ec8c4e9ede319ecb85e4c206 Mon Sep 17 00:00:00 2001 From: Wesley Simplicio Date: Sat, 9 May 2026 12:27:04 -0300 Subject: [PATCH] fix(kanban): call recompute_ready after unlink_tasks removes a dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: unlink_tasks() removes a parent→child dependency edge but does not trigger recompute_ready(). A child whose last blocking parent is unlinked stays stuck in 'todo' indefinitely — it only promotes to 'ready' on the next dispatcher tick or a manual 'hermes kanban recompute'. For CLI-only users without a dispatcher, the child is permanently stuck. Root cause: complete_task() and unblock_task() both call recompute_ready() after their write transaction so downstream children are evaluated immediately. unlink_tasks() was missing this call — removing a dependency is semantically equivalent to completing one, so the same recompute is needed. Fix: Capture the rowcount result before the write_txn exits, then call recompute_ready(conn) outside the transaction when a row was actually deleted (so the child sees the updated task_links state). Tests: Added test_unlink_tasks_triggers_recompute_ready in tests/hermes_cli/test_kanban_db.py: creates parent A (done) + parent C (running), child B with both parents (todo), unlinks C→B, asserts B is ready immediately. Stash-verified: FAILS without fix (child stays todo), PASSES with fix. 62/62 tests green in tests/hermes_cli/test_kanban_db.py. Closes #22459. --- hermes_cli/kanban_db.py | 9 +++++++- tests/hermes_cli/test_kanban_db.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 42bc1ed9bd1..ff2e1cb254b 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1504,7 +1504,14 @@ def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> boo conn, child_id, "unlinked", {"parent": parent_id, "child": child_id}, ) - return cur.rowcount > 0 + removed = cur.rowcount > 0 + if removed: + # Dependency edge removed — re-evaluate promotion eligibility for the + # child immediately. Matches the contract of complete_task and + # unblock_task; without this the child stays stuck in todo until the + # next dispatcher tick or a manual `hermes kanban recompute` (issue #22459). + recompute_ready(conn) + return removed def parent_ids(conn: sqlite3.Connection, task_id: str) -> list[str]: diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 758f0be49e1..324782dad66 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -966,3 +966,37 @@ def test_connect_falls_back_to_delete_on_locking_protocol(kanban_home, caplog): tasks = kb.list_tasks(conn) assert any(row.id == t for row in tasks) conn.close() + + +def test_unlink_tasks_triggers_recompute_ready(kanban_home): + """Regression test for issue #22459. + + Removing a dependency via unlink_tasks must immediately promote the child + to ready when all remaining parents are done — same contract as + complete_task and unblock_task. + + Before the fix, child stayed 'todo' indefinitely after unlink; only the + next dispatcher tick or a manual 'hermes kanban recompute' would promote it. + """ + with kb.connect() as conn: + # A is done. + a = kb.create_task(conn, title="parent-done") + kb.complete_task(conn, a) + + # C is running (not done) — blocks child B. + c = kb.create_task(conn, title="parent-running") + kb.claim_task(conn, c, claimer="worker:1") + + # B depends on both A (done) and C (running) → stays todo. + b = kb.create_task(conn, title="child", parents=[a, c]) + assert kb.get_task(conn, b).status == "todo" + + # Remove the blocking dependency C → B. + removed = kb.unlink_tasks(conn, c, b) + assert removed is True + + # B's only remaining parent is A (done) → must be ready immediately. + assert kb.get_task(conn, b).status == "ready", ( + "child should promote to ready immediately after unlink_tasks " + "removes its last blocking dependency" + )