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)