mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-22 05:22:09 +00:00
fix(kanban): gate claim + unblock on parent completion
Enforce the parent-completion invariant at claim_task (the single ready->running chokepoint) and re-gate unblock_task so blocked->ready only fires when parents are done. Prevents child tasks from running ahead of in-progress parents under the create-then-link race. Also adds a stress test that races concurrent create+link against hammered claim_task and asserts no child runs while any parent is undone. Ref: kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md Refs: t_8d6af9d6
This commit is contained in:
parent
79694018f8
commit
cda20eec0c
3 changed files with 344 additions and 4 deletions
|
|
@ -298,6 +298,122 @@ def test_block_then_unblock(kanban_home):
|
|||
assert kb.get_task(conn, t).status == "ready"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parent-completion invariant at the claim gate (RCA t_a6acd07d)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_claim_rejects_when_parents_not_done(kanban_home):
|
||||
"""claim_task must refuse ready->running if any parent isn't 'done'.
|
||||
|
||||
Simulates the create-then-link race: a task gets status='ready' via a
|
||||
racy writer while it still has undone parents. The claim gate must
|
||||
detect the violation, demote the child back to 'todo', append a
|
||||
'claim_rejected' event, and return None. Covers Fix 1 of the RCA.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
parent = kb.create_task(conn, title="parent", assignee="a")
|
||||
child = kb.create_task(
|
||||
conn, title="child", assignee="a", parents=[parent],
|
||||
)
|
||||
# Child correctly starts 'todo' because parent is not 'done'.
|
||||
assert kb.get_task(conn, child).status == "todo"
|
||||
# Simulate the race: a racy writer force-promotes the child to
|
||||
# 'ready' while parent is still pending.
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='ready' WHERE id=?", (child,),
|
||||
)
|
||||
conn.commit()
|
||||
assert kb.get_task(conn, child).status == "ready"
|
||||
|
||||
result = kb.claim_task(conn, child, claimer="host:1")
|
||||
|
||||
assert result is None
|
||||
with kb.connect() as conn:
|
||||
assert kb.get_task(conn, child).status == "todo"
|
||||
events = conn.execute(
|
||||
"SELECT kind, payload FROM task_events "
|
||||
"WHERE task_id = ? ORDER BY id",
|
||||
(child,),
|
||||
).fetchall()
|
||||
kinds = [e["kind"] for e in events]
|
||||
assert "claim_rejected" in kinds
|
||||
# No 'claimed' event was emitted for the blocked attempt.
|
||||
assert "claimed" not in kinds
|
||||
|
||||
|
||||
def test_claim_succeeds_once_parents_done(kanban_home):
|
||||
"""After parents complete, recompute_ready -> claim_task must succeed."""
|
||||
with kb.connect() as conn:
|
||||
parent = kb.create_task(conn, title="parent", assignee="a")
|
||||
child = kb.create_task(
|
||||
conn, title="child", assignee="a", parents=[parent],
|
||||
)
|
||||
kb.claim_task(conn, parent)
|
||||
assert kb.complete_task(conn, parent, result="ok")
|
||||
kb.recompute_ready(conn)
|
||||
assert kb.get_task(conn, child).status == "ready"
|
||||
claimed = kb.claim_task(conn, child, claimer="host:1")
|
||||
assert claimed is not None
|
||||
assert claimed.status == "running"
|
||||
|
||||
|
||||
def test_create_with_parents_stays_todo_until_parents_done(kanban_home):
|
||||
"""kanban_create(parents=[...]) must land in 'todo' and only promote on parent done."""
|
||||
with kb.connect() as conn:
|
||||
parent = kb.create_task(conn, title="parent", assignee="a")
|
||||
child = kb.create_task(
|
||||
conn, title="child", assignee="a", parents=[parent],
|
||||
)
|
||||
assert kb.get_task(conn, child).status == "todo"
|
||||
# Dispatcher tick between create and some later event must NOT
|
||||
# produce a winner for this child.
|
||||
promoted = kb.recompute_ready(conn)
|
||||
assert promoted == 0
|
||||
assert kb.get_task(conn, child).status == "todo"
|
||||
# Complete parent; complete_task internally runs recompute_ready,
|
||||
# which promotes the child to 'ready'.
|
||||
kb.claim_task(conn, parent)
|
||||
kb.complete_task(conn, parent, result="ok")
|
||||
assert kb.get_task(conn, child).status == "ready"
|
||||
|
||||
|
||||
def test_unblock_with_pending_parents_goes_to_todo(kanban_home):
|
||||
"""unblock_task must re-gate on parent completion (Fix 3).
|
||||
|
||||
A task blocked while parents are still in progress must return to
|
||||
'todo' (not 'ready') on unblock. Otherwise the dispatcher will claim
|
||||
it immediately, repeating Bug 2 from the RCA.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
parent = kb.create_task(conn, title="parent", assignee="a")
|
||||
child = kb.create_task(
|
||||
conn, title="child", assignee="a", parents=[parent],
|
||||
)
|
||||
# Force child into 'blocked' regardless of parent progress
|
||||
# (simulates a worker that self-blocked, or an operator block).
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='blocked' WHERE id=?", (child,),
|
||||
)
|
||||
conn.commit()
|
||||
assert kb.unblock_task(conn, child)
|
||||
assert kb.get_task(conn, child).status == "todo"
|
||||
# After parent completes + recompute, the child is ready.
|
||||
kb.claim_task(conn, parent)
|
||||
kb.complete_task(conn, parent, result="ok")
|
||||
kb.recompute_ready(conn)
|
||||
assert kb.get_task(conn, child).status == "ready"
|
||||
|
||||
|
||||
def test_unblock_without_parents_goes_to_ready(kanban_home):
|
||||
"""Parent-free unblock still produces 'ready' (behavior preserved)."""
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="lone", assignee="a")
|
||||
kb.claim_task(conn, t)
|
||||
assert kb.block_task(conn, t, reason="need input")
|
||||
assert kb.unblock_task(conn, t)
|
||||
assert kb.get_task(conn, t).status == "ready"
|
||||
|
||||
|
||||
def test_assign_refuses_while_running(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="x", assignee="a")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue