fix(kanban): detect cycles in decompose_triage_task sibling-link pre-validation

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.
This commit is contained in:
EloquentBrush0x 2026-05-18 17:56:23 +03:00 committed by Teknium
parent a86d2ad557
commit 502d03d5a3
2 changed files with 39 additions and 0 deletions

View file

@ -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

View file

@ -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)