mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
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.
168 lines
4.9 KiB
Python
168 lines
4.9 KiB
Python
"""Tests for kb.decompose_triage_task — the DB-layer atomic fan-out
|
|
from the triage column. LLM-free by design.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
|
|
@pytest.fixture
|
|
def kanban_home(tmp_path, monkeypatch):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
kb.init_db()
|
|
return home
|
|
|
|
|
|
def _create_triage(conn, title="rough idea", body=None, assignee=None, tenant=None):
|
|
return kb.create_task(
|
|
conn,
|
|
title=title,
|
|
body=body,
|
|
assignee=assignee,
|
|
tenant=tenant,
|
|
triage=True,
|
|
)
|
|
|
|
|
|
def test_decompose_creates_children_and_promotes_root(kanban_home):
|
|
with kb.connect() as conn:
|
|
tid = _create_triage(conn, title="ship a feature")
|
|
assert kb.get_task(conn, tid).status == "triage"
|
|
|
|
children = [
|
|
{"title": "research", "body": "look at prior art", "assignee": "researcher", "parents": []},
|
|
{"title": "build it", "body": "write code", "assignee": "engineer", "parents": [0]},
|
|
]
|
|
with kb.connect() as conn:
|
|
child_ids = kb.decompose_triage_task(
|
|
conn,
|
|
tid,
|
|
root_assignee="orchestrator",
|
|
children=children,
|
|
author="decomposer",
|
|
)
|
|
assert child_ids is not None
|
|
assert len(child_ids) == 2
|
|
|
|
with kb.connect() as conn:
|
|
root = kb.get_task(conn, tid)
|
|
c0 = kb.get_task(conn, child_ids[0])
|
|
c1 = kb.get_task(conn, child_ids[1])
|
|
|
|
# Root flipped to todo with orchestrator assignee, gated by children.
|
|
assert root.status == "todo"
|
|
assert root.assignee == "orchestrator"
|
|
# First child has no internal parents → ready on recompute_ready.
|
|
assert c0.status == "ready"
|
|
assert c0.assignee == "researcher"
|
|
# Second child has parents=[0] → stays in todo until c0 completes.
|
|
assert c1.status == "todo"
|
|
assert c1.assignee == "engineer"
|
|
|
|
|
|
def test_decompose_returns_none_when_task_missing(kanban_home):
|
|
with kb.connect() as conn:
|
|
result = kb.decompose_triage_task(
|
|
conn,
|
|
"nonexistent",
|
|
root_assignee="orch",
|
|
children=[{"title": "x"}],
|
|
author="me",
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_decompose_returns_none_when_task_not_in_triage(kanban_home):
|
|
with kb.connect() as conn:
|
|
tid = kb.create_task(conn, title="already a real task") # not triage
|
|
result = kb.decompose_triage_task(
|
|
conn,
|
|
tid,
|
|
root_assignee="orch",
|
|
children=[{"title": "x"}],
|
|
author="me",
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_decompose_empty_children_returns_none(kanban_home):
|
|
with kb.connect() as conn:
|
|
tid = _create_triage(conn)
|
|
result = kb.decompose_triage_task(
|
|
conn,
|
|
tid,
|
|
root_assignee="orch",
|
|
children=[],
|
|
author="me",
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_decompose_rejects_self_parent(kanban_home):
|
|
with kb.connect() as conn:
|
|
tid = _create_triage(conn)
|
|
with pytest.raises(ValueError, match="cannot list itself"):
|
|
kb.decompose_triage_task(
|
|
conn,
|
|
tid,
|
|
root_assignee="orch",
|
|
children=[{"title": "x", "parents": [0]}],
|
|
author="me",
|
|
)
|
|
|
|
|
|
def test_decompose_rejects_out_of_range_parent(kanban_home):
|
|
with kb.connect() as conn:
|
|
tid = _create_triage(conn)
|
|
with pytest.raises(ValueError, match="not a valid index"):
|
|
kb.decompose_triage_task(
|
|
conn,
|
|
tid,
|
|
root_assignee="orch",
|
|
children=[{"title": "x", "parents": [5]}],
|
|
author="me",
|
|
)
|
|
|
|
|
|
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)
|
|
child_ids = kb.decompose_triage_task(
|
|
conn,
|
|
tid,
|
|
root_assignee="orch",
|
|
children=[{"title": "task A", "assignee": "researcher"}],
|
|
author="alice",
|
|
)
|
|
assert child_ids is not None
|
|
|
|
with kb.connect() as conn:
|
|
comments = kb.list_comments(conn, tid)
|
|
events = kb.list_events(conn, tid)
|
|
|
|
assert any("Decomposed into" in (c.body or "") for c in comments)
|
|
assert any(ev.kind == "decomposed" for ev in events)
|