{"repo":"NousResearch/hermes-agent","public_id":"22375","display_label":"Kanban unblock_task bypassing parent-dependency gate","summary":"The members concern the kanban feature's unblock (and in one case claim) action possibly skipping the parent-dependency check. One is a bug report, and the others are fix proposals that route unblock through the gate; one also covers claim and references salvaging an earlier change (#22581). Titles alone do not show the outcome of any of them.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22671,"merged_at":"2026-05-09T18:07:37+00:00"},"resolution":{"state":"resolved","by":"pr-22671"},"freshness":{"state":"verified","last_verified_at":"2026-05-09T18:07:37+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":22375,"pr_number":22671,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-09T18:07:37+00:00"}],"members":[{"kind":"issue","number":22375,"title":"[Bug]: unblock_task bypasses parent-dependency gate","url":null,"state":"closed"},{"kind":"pr","number":22372,"title":"fix(kanban): route unblock through dependency gate","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cda20eec0","number":0},{"kind":"pr","value":"#22375","number":22375}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":22405,"title":"fix(kanban): route unblock_task through parent-dependency gate","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cda20eec0","number":0},{"kind":"commit","value":"0c22434f0","number":0},{"kind":"pr","value":"#22375","number":22375},{"kind":"pr","value":"#22627","number":22627}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":22671,"title":"fix(kanban): gate claim+unblock on parent completion (salvage #22581)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/kanban_db.py","scripts/release.py","tests/hermes_cli/test_kanban_db.py","tests/stress/test_concurrency_parent_gate.py"],"fix_hunks":[{"file":"hermes_cli/kanban_db.py","hunks":["@@ -1804,6 +1804,31 @@ def claim_task(\n     lock = claimer or _claimer_id()\n     expires = now + int(ttl_seconds)\n     with write_txn(conn):\n+        # Structural invariant: never transition ready -> running while any\n+        # parent is not yet 'done'. This is the single enforcement point\n+        # regardless of which writer (create_task, link_tasks, unblock_task,\n+        # release_stale_claims, manual SQL) set status='ready'. If a racy\n+        # writer promoted a task with undone parents, demote it back to\n+        # 'todo' here — recompute_ready will re-promote when the parents\n+        # actually finish. See RCA at\n+        # kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md.\n+        undone = conn.execute(\n+            \"SELECT 1 FROM task_links l \"\n+            \"JOIN tasks p ON p.id = l.parent_id \"\n+            \"WHERE l.child_id = ? AND p.status != 'done' LIMIT 1\",\n+            (task_id,),\n+        ).fetchone()\n+        if undone:\n+            conn.execute(\n+                \"UPDATE tasks SET status = 'todo' \"\n+                \"WHERE id = ? AND status = 'ready'\",\n+                (task_id,),\n+            )\n+            _append_event(\n+                conn, task_id, \"claim_rejected\",\n+                {\"reason\": \"parents_not_done\"},\n+            )\n+            return None\n         # Defensive: if a prior run somehow leaked (invariant violation from\n         # an unknown code path), close it as 'reclaimed' so we don't strand\n         # it when the CAS resets the pointer below. No-op when the invariant","@@ -2503,14 +2528,30 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:\n                 \"\"\",\n                 (now, int(stale[\"current_run_id\"])),\n             )\n+        # Re-gate on parent completion before flipping 'blocked' back to\n+        # 'ready'. Unconditionally setting status='ready' here bypasses the\n+        # parent-completion invariant (the dispatcher trusts that column);\n+        # if parents are still in progress the task must wait in 'todo'\n+        # until recompute_ready picks it up. RCA: Bug 2 at\n+        # kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md.\n+        undone_parents = conn.execute(\n+            \"SELECT 1 FROM task_links l \"\n+            \"JOIN tasks p ON p.id = l.parent_id \"\n+            \"WHERE l.child_id = ? AND p.status != 'done' LIMIT 1\",\n+            (task_id,),\n+        ).fetchone()\n+        new_status = \"todo\" if undone_parents else \"ready\"\n         cur = conn.execute(\n-            \"UPDATE tasks SET status = 'ready', current_run_id = NULL \"\n+            \"UPDATE tasks SET status = ?, current_run_id = NULL \"\n             \"WHERE id = ? AND status = 'blocked'\",\n-            (task_id,),\n+            (new_status, task_id),\n         )\n         if cur.rowcount != 1:\n             return False\n-        _append_event(conn, task_id, \"unblocked\", None)\n+        _append_event(\n+            conn, task_id, \"unblocked\",\n+            {\"status\": new_status} if new_status != \"ready\" else None,\n+        )\n         return True\n \n "]},{"file":"scripts/release.py","hunks":["@@ -65,6 +65,7 @@\n     \"[redacted:email]\": \"subtract0\",\n     \"[redacted:email]\": \"qWaitCrypto\",\n     \"[redacted:email]\": \"wesleysimplicio\",\n+    \"[redacted:email]\": \"SiliconID\",\n     \"[redacted:email]\": \"uzunkuyruk\",\n     \"[redacted:email]\": \"Zhekinmaksim\",\n     \"[redacted:email]\": \"KvnGz\","]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -298,6 +298,122 @@ def test_block_then_unblock(kanban_home):\n         assert kb.get_task(conn, t).status == \"ready\"\n \n \n+# ---------------------------------------------------------------------------\n+# Parent-completion invariant at the claim gate (RCA t_a6acd07d)\n+# ---------------------------------------------------------------------------\n+\n+def test_claim_rejects_when_parents_not_done(kanban_home):\n+    \"\"\"claim_task must refuse ready->running if any parent isn't 'done'.\n+\n+    Simulates the create-then-link race: a task gets status='ready' via a\n+    racy writer while it still has undone parents. The claim gate must\n+    detect the violation, demote the child back to 'todo', append a\n+    'claim_rejected' event, and return None. Covers Fix 1 of the RCA.\n+    \"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\", assignee=\"a\")\n+        child = kb.create_task(\n+            conn, title=\"child\", assignee=\"a\", parents=[parent],\n+        )\n+        # Child correctly starts 'todo' because parent is not 'done'.\n+        assert kb.get_task(conn, child).status == \"todo\"\n+        # Simulate the race: a racy writer force-promotes the child to\n+        # 'ready' while parent is still pending.\n+        conn.execute(\n+            \"UPDATE tasks SET status='ready' WHERE id=?\", (child,),\n+        )\n+        conn.commit()\n+        assert kb.get_task(conn, child).status == \"ready\"\n+\n+        result = kb.claim_task(conn, child, claimer=\"host:1\")\n+\n+    assert result is None\n+    with kb.connect() as conn:\n+        assert kb.get_task(conn, child).status == \"todo\"\n+        events = conn.execute(\n+            \"SELECT kind, payload FROM task_events \"\n+            \"WHERE task_id = ? ORDER BY id\",\n+            (child,),\n+        ).fetchall()\n+    kinds = [e[\"kind\"] for e in events]\n+    assert \"claim_rejected\" in kinds\n+    # No 'claimed' event was emitted for the blocked attempt.\n+    assert \"claimed\" not in kinds\n+\n+\n+def test_claim_succeeds_once_parents_done(kanban_home):\n+    \"\"\"After parents complete, recompute_ready -> claim_task must succeed.\"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\", assignee=\"a\")\n+        child = kb.create_task(\n+            conn, title=\"child\", assignee=\"a\", parents=[parent],\n+        )\n+        kb.claim_task(conn, parent)\n+        assert kb.complete_task(conn, parent, result=\"ok\")\n+        kb.recompute_ready(conn)\n+        assert kb.get_task(conn, child).status == \"ready\"\n+        claimed = kb.claim_task(conn, child, claimer=\"host:1\")\n+    assert claimed is not None\n+    assert claimed.status == \"running\"\n+\n+\n+def test_create_with_parents_stays_todo_until_parents_done(kanban_home):\n+    \"\"\"kanban_create(parents=[...]) must land in 'todo' and only promote on parent done.\"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\", assignee=\"a\")\n+        child = kb.create_task(\n+            conn, title=\"child\", assignee=\"a\", parents=[parent],\n+        )\n+        assert kb.get_task(conn, child).status == \"todo\"\n+        # Dispatcher tick between create and some later event must NOT\n+        # produce a winner for this child.\n+        promoted = kb.recompute_ready(conn)\n+        assert promoted == 0\n+        assert kb.get_task(conn, child).status == \"todo\"\n+        # Complete parent; complete_task internally runs recompute_ready,\n+        # which promotes the child to 'ready'.\n+        kb.claim_task(conn, parent)\n+        kb.complete_task(conn, parent, result=\"ok\")\n+        assert kb.get_task(conn, child).status == \"ready\"\n+\n+\n+def test_unblock_with_pending_parents_goes_to_todo(kanban_home):\n+    \"\"\"unblock_task must re-gate on parent completion (Fix 3).\n+\n+    A task blocked while parents are still in progress must return to\n+    'todo' (not 'ready') on unblock. Otherwise the dispatcher will claim\n+    it immediately, repeating Bug 2 from the RCA.\n+    \"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\", assignee=\"a\")\n+        child = kb.create_task(\n+            conn, title=\"child\", assignee=\"a\", parents=[parent],\n+        )\n+        # Force child into 'blocked' regardless of parent progress\n+        # (simulates a worker that self-blocked, or an operator block).\n+        conn.execute(\n+            \"UPDATE tasks SET status='blocked' WHERE id=?\", (child,),\n+        )\n+        conn.commit()\n+        assert kb.unblock_task(conn, child)\n+        assert kb.get_task(conn, child).status == \"todo\"\n+        # After parent completes + recompute, the child is ready.\n+        kb.claim_task(conn, parent)\n+        kb.complete_task(conn, parent, result=\"ok\")\n+        kb.recompute_ready(conn)\n+        assert kb.get_task(conn, child).status == \"ready\"\n+\n+\n+def test_unblock_without_parents_goes_to_ready(kanban_home):\n+    \"\"\"Parent-free unblock still produces 'ready' (behavior preserved).\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"lone\", assignee=\"a\")\n+        kb.claim_task(conn, t)\n+        assert kb.block_task(conn, t, reason=\"need input\")\n+        assert kb.unblock_task(conn, t)\n+        assert kb.get_task(conn, t).status == \"ready\"\n+\n+\n def test_assign_refuses_while_running(kanban_home):\n     with kb.connect() as conn:\n         t = kb.create_task(conn, title=\"x\", assignee=\"a\")"]},{"file":"tests/stress/test_concurrency_parent_gate.py","hunks":["@@ -0,0 +1,183 @@\n+\"\"\"Stress test for parent-completion invariant at the claim gate.\n+\n+Simulates the create-then-link race described in RCA t_a6acd07d:\n+\n+  Thread A: repeatedly inserts a child row with status='ready' (racy\n+            writer) and a split-second-later inserts the parent link,\n+            emulating the pre-fix _kanban_create path.\n+  Thread B: repeatedly runs claim_task against every ready task.\n+\n+Pass criteria: no task is ever 'claimed' while any of its parents is\n+not 'done'. The claim_task gate added in hermes_cli/kanban_db.py must\n+demote such tasks back to 'todo' and emit a 'claim_rejected' event\n+instead of spawning.\n+\n+Run as a script (`python tests/stress/test_concurrency_parent_gate.py`)\n+or via `pytest --run-stress`. The default pytest collection in\n+tests/stress/conftest.py ignores *.py globs, so this is a script.\n+\"\"\"\n+from __future__ import annotations\n+\n+import os\n+import random\n+import sys\n+import tempfile\n+import threading\n+import time\n+from pathlib import Path\n+\n+WT = str(Path(__file__).resolve().parents[2])\n+sys.path.insert(0, WT)\n+\n+NUM_CREATE_ROUNDS = 200\n+WORKERS_RUN_DURATION_S = 8\n+\n+\n+def run() -> int:\n+    home = tempfile.mkdtemp(prefix=\"hermes_parent_gate_stress_\")\n+    os.environ[\"HERMES_HOME\"] = home\n+    os.environ[\"HOME\"] = home\n+\n+    from hermes_cli import kanban_db as kb\n+\n+    kb.init_db()\n+\n+    # Seed N parents in 'ready' state. They stay ready for the whole run\n+    # (never 'done'), so every child linked to one of them must remain\n+    # unclaimable.\n+    parent_ids: list[str] = []\n+    conn = kb.connect()\n+    try:\n+        for i in range(10):\n+            parent_ids.append(\n+                kb.create_task(conn, title=f\"parent-{i}\", assignee=\"a\")\n+            )\n+    finally:\n+        conn.close()\n+\n+    created_children: list[str] = []\n+    created_lock = threading.Lock()\n+    stop = threading.Event()\n+    violations: list[str] = []\n+\n+    def racy_creator() -> None:\n+        \"\"\"Inserts child rows with status='ready' and links them after.\n+\n+        This is the pre-fix _kanban_create behavior — the very race\n+        the gate in claim_task must catch.\n+        \"\"\"\n+        conn = kb.connect()\n+        try:\n+            for _ in range(NUM_CREATE_ROUNDS):\n+                if stop.is_set():\n+                    return\n+                parents = random.sample(parent_ids, k=2)\n+                # Step 1: insert child WITHOUT parents (ends up ready).\n+                child = kb.create_task(\n+                    conn, title=\"child\", assignee=\"a\", parents=[],\n+                )\n+                # Tiny delay so worker threads get a chance to see the\n+                # ready row before the links are inserted.\n+                time.sleep(random.uniform(0.0001, 0.002))\n+                # Step 2: add the parent links after the fact.\n+                for p in parents:\n+                    try:\n+                        kb.link_tasks(conn, parent_id=p, child_id=child)\n+                    except Exception:\n+                        pass\n+                with created_lock:\n+                    created_children.append(child)\n+        finally:\n+            conn.close()\n+\n+    def worker_loop() -> None:\n+        conn = kb.connect()\n+        try:\n+            end = time.monotonic() + WORKERS_RUN_DURATION_S\n+            while time.monotonic() < end and not stop.is_set():\n+                row = conn.execute(\n+                    \"SELECT id FROM tasks WHERE status='ready' \"\n+                    \"AND claim_lock IS NULL ORDER BY RANDOM() LIMIT 1\"\n+                ).fetchone()\n+                if row is None:\n+                    time.sleep(0.002)\n+                    continue\n+                tid = row[\"id\"]\n+                try:\n+                    claimed = kb.claim_task(conn, tid, claimer=\"w\")\n+                except Exception:\n+                    continue\n+                if claimed is None:\n+                    continue\n+                # Invariant: a successful claim on `tid` must mean all\n+                # parents are 'done'. Check in the same connection txn\n+                # so we see the post-claim state.\n+                undone = conn.execute(\n+                    \"SELECT l.parent_id, p.status FROM task_links l \"\n+                    \"JOIN tasks p ON p.id = l.parent_id \"\n+                    \"WHERE l.child_id = ? AND p.status != 'done'\",\n+                    (tid,),\n+                ).fetchall()\n+                if undone:\n+                    violations.append(\n+                        f\"claimed {tid} while parents not done: \"\n+                        + \",\".join(f\"{r['parent_id']}={r['status']}\" for r in undone)\n+                    )\n+                # Release so the run doesn't leak and the next round sees ready.\n+                kb.complete_task(conn, tid, result=\"stress-ok\")\n+        finally:\n+            conn.close()\n+\n+    creator = threading.Thread(target=racy_creator, daemon=True)\n+    workers = [threading.Thread(target=worker_loop, daemon=True)\n+               for _ in range(4)]\n+    creator.start()\n+    for w in workers:\n+        w.start()\n+    creator.join()\n+    # Give the workers a chance to fully drain ready rows before we stop.\n+    time.sleep(0.5)\n+    stop.set()\n+    for w in workers:\n+        w.join(timeout=WORKERS_RUN_DURATION_S + 2)\n+\n+    # Post-run audit: the DB event log must show no 'claimed' event on any\n+    # task whose parents were not 'done' at the time of the claim.\n+    conn = kb.connect()\n+    try:\n+        bad = conn.execute(\n+            \"\"\"\n+            WITH claims AS (\n+              SELECT task_id, created_at AS t\n+              FROM task_events WHERE kind='claimed'\n+            )\n+            SELECT c.task_id, l.parent_id, p.status, p.completed_at\n+            FROM claims c\n+            JOIN task_links l ON l.child_id = c.task_id\n+            JOIN tasks p ON p.id = l.parent_id\n+            WHERE p.completed_at IS NULL OR p.completed_at > c.t\n+            \"\"\"\n+        ).fetchall()\n+        rejections = conn.execute(\n+            \"SELECT COUNT(*) FROM task_events WHERE kind='claim_rejected'\"\n+        ).fetchone()[0]\n+    finally:\n+        conn.close()\n+\n+    print(f\"children created:  {len(created_children)}\")\n+    print(f\"violations:        {len(violations)}\")\n+    print(f\"event-log bad:     {len(bad)}\")\n+    print(f\"claim_rejected:    {rejections}\")\n+\n+    if violations or bad:\n+        for v in violations[:10]:\n+            print(\"  VIOLATION:\", v)\n+        for row in list(bad)[:10]:\n+            print(\"  EVENT-LOG BAD:\", dict(row))\n+        return 1\n+    print(\"PARENT-GATE INVARIANT HELD UNDER RACE\")\n+    return 0\n+\n+\n+if __name__ == \"__main__\":\n+    sys.exit(run())\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}