mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
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:
parent
a86d2ad557
commit
502d03d5a3
2 changed files with 39 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue