From 502d03d5a3c071be7a8856d0ce9b5dc10894e0be Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Mon, 18 May 2026 17:56:23 +0300 Subject: [PATCH] fix(kanban): detect cycles in decompose_triage_task sibling-link pre-validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decompose_triage_task inlines SQL INSERTs for atomicity and intentionally bypasses link_tasks() — which calls _would_cycle() per edge. If the LLM emits a cyclic parent graph (e.g. A.parents=[1], B.parents=[0]) the DB write succeeds but every involved child deadlocks in 'todo' forever: recompute_ready() requires all parents to be done, which is impossible when A waits for B and B waits for A. Add a Kahn topological sort over the sibling parent indices in the pre-validation block, before any DB writes. Mirrors the cycle-safety guarantee that link_tasks() provides for manually linked tasks. --- hermes_cli/kanban_db.py | 23 ++++++++++++++++++++ tests/hermes_cli/test_kanban_decompose_db.py | 16 ++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 4def6fc5d59..5c2d0324083 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2849,6 +2849,29 @@ def decompose_triage_task( if p == idx: raise ValueError(f"child[{idx}] cannot list itself as a parent") + # Detect cycles in the sibling parent graph (Kahn's topological sort). + # link_tasks() calls _would_cycle() for every new edge; here we check + # the entire sibling graph before touching the DB. A cycle silently + # deadlocks every involved child in 'todo' because recompute_ready() + # can never promote them. + _in_deg = [0] * len(children) + _adj: list[list[int]] = [[] for _ in range(len(children))] + for _i, _c in enumerate(children): + for _p in (_c.get("parents") or []): + _adj[_p].append(_i) + _in_deg[_i] += 1 + _queue = [_i for _i in range(len(children)) if _in_deg[_i] == 0] + _seen = 0 + while _queue: + _node = _queue.pop() + _seen += 1 + for _nb in _adj[_node]: + _in_deg[_nb] -= 1 + if _in_deg[_nb] == 0: + _queue.append(_nb) + if _seen != len(children): + raise ValueError("cyclic dependency detected in decomposed children list") + # We do the full decomposition in a SINGLE write_txn so it's # atomic: either every child is created AND the root flips to # ``todo``, or nothing changes. We deliberately do NOT call any diff --git a/tests/hermes_cli/test_kanban_decompose_db.py b/tests/hermes_cli/test_kanban_decompose_db.py index 236fb1fff1b..85026fd5a97 100644 --- a/tests/hermes_cli/test_kanban_decompose_db.py +++ b/tests/hermes_cli/test_kanban_decompose_db.py @@ -132,6 +132,22 @@ def test_decompose_rejects_out_of_range_parent(kanban_home): ) +def test_decompose_rejects_cyclic_parents(kanban_home): + with kb.connect() as conn: + tid = _create_triage(conn) + with pytest.raises(ValueError, match="cyclic dependency"): + kb.decompose_triage_task( + conn, + tid, + root_assignee="orch", + children=[ + {"title": "A", "parents": [1]}, + {"title": "B", "parents": [0]}, + ], + author="me", + ) + + def test_decompose_records_audit_comment_and_event(kanban_home): with kb.connect() as conn: tid = _create_triage(conn)