{"repo":"NousResearch/hermes-agent","public_id":"20054","display_label":"Kanban dispatch assignee profile validation fixes","summary":"These issues and pull requests address validation of assignee profile readiness before the kanban dispatcher spawns workers, including handling tasks assigned to non-profile lanes.","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":20165,"merged_at":"2026-05-05T11:13:12+00:00"},"resolution":{"state":"resolved","by":"pr-20165"},"freshness":{"state":"verified","last_verified_at":"2026-05-05T11:13:12+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":20054,"pr_number":20165,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-05T11:13:12+00:00"}],"members":[{"kind":"issue","number":20054,"title":"Kanban dispatcher should validate assignee profile readiness before spawning workers","url":null,"state":"closed"},{"kind":"pr","number":20065,"title":"fix(kanban): validate worker profile before spawn","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20165","number":20165},{"kind":"pr","value":"#20054","number":20054}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":20067,"title":"fix(cli): validate assignee profile readiness before kanban dispatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20065","number":20065}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":20165,"title":"fix(kanban): skip dispatch for tasks assigned to non-profile lanes (salvages #20105, #20134)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/kanban.py","hermes_cli/kanban_db.py","tests/hermes_cli/conftest.py","tests/hermes_cli/test_kanban_core_functionality.py","tests/hermes_cli/test_kanban_db.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -3906,7 +3906,17 @@ def _tick_once() -> \"list[tuple[str, Optional[object]]]\":\n             return out\n \n         def _ready_nonempty() -> bool:\n-            \"\"\"Cheap probe: is there a ready+assigned+unclaimed task on ANY board?\"\"\"\n+            \"\"\"Cheap probe: is there at least one ready+assigned+unclaimed\n+            task on ANY board whose assignee maps to a real Hermes profile\n+            (i.e. one the dispatcher would actually spawn for)?\n+\n+            Tasks assigned to control-plane lanes (e.g. ``orion-cc``,\n+            ``orion-research``) are pulled by terminals via\n+            ``claim_task`` directly and never spawnable, so a queue full\n+            of those is \"correctly idle\", not \"stuck\". Filtering them out\n+            here keeps the stuck-warn fire only on real failures (broken\n+            PATH, missing venv, credential loss for a real Hermes profile).\n+            \"\"\"\n             try:\n                 boards = _kb.list_boards(include_archived=False)\n             except Exception:","@@ -3916,12 +3926,7 @@ def _ready_nonempty() -> bool:\n                 conn = None\n                 try:\n                     conn = _kb.connect(board=slug)\n-                    row = conn.execute(\n-                        \"SELECT 1 FROM tasks \"\n-                        \"WHERE status = 'ready' AND assignee IS NOT NULL \"\n-                        \"    AND claim_lock IS NULL LIMIT 1\"\n-                    ).fetchone()\n-                    if row is not None:\n+                    if _kb.has_spawnable_ready(conn):\n                         return True\n                 except Exception:\n                     continue"]},{"file":"hermes_cli/kanban.py","hunks":["@@ -1274,6 +1274,7 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:\n                 for (tid, who, ws) in res.spawned\n             ],\n             \"skipped_unassigned\": res.skipped_unassigned,\n+            \"skipped_nonspawnable\": res.skipped_nonspawnable,\n         }, indent=2))\n         return 0\n     print(f\"Reclaimed:    {res.reclaimed}\")","@@ -1293,6 +1294,11 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:\n         print(f\"  - {tid}  ->  {who}  @ {ws or '-'}{tag}\")\n     if res.skipped_unassigned:\n         print(f\"Skipped (unassigned): {', '.join(res.skipped_unassigned)}\")\n+    if res.skipped_nonspawnable:\n+        print(\n+            f\"Skipped (non-spawnable assignee — terminal lane, OK): \"\n+            f\"{', '.join(res.skipped_nonspawnable)}\"\n+        )\n     return 0\n \n ","@@ -1404,16 +1410,18 @@ def _on_tick(res):\n             )\n \n     def _ready_queue_nonempty() -> bool:\n-        \"\"\"Cheap SELECT — just asks whether there's at least one ready\n-        task with an assignee that the dispatcher could have picked up.\"\"\"\n+        \"\"\"Cheap probe — is there at least one ready+assigned+unclaimed\n+        task whose assignee maps to a real Hermes profile (i.e. one the\n+        dispatcher would actually try to spawn for)?\n+\n+        Filters out tasks assigned to control-plane lanes\n+        (e.g. ``orion-cc``, ``orion-research``) that are pulled by\n+        terminals via ``claim_task`` directly — those are correctly idle\n+        from the dispatcher's perspective, not stuck.\n+        \"\"\"\n         try:\n             with kb.connect() as conn:\n-                row = conn.execute(\n-                    \"SELECT 1 FROM tasks \"\n-                    \"WHERE status = 'ready' AND assignee IS NOT NULL \"\n-                    \"    AND claim_lock IS NULL LIMIT 1\"\n-                ).fetchone()\n-                return row is not None\n+                return kb.has_spawnable_ready(conn)\n         except Exception:\n             return False\n "]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -2118,6 +2118,15 @@ class DispatchResult:\n     spawned: list[tuple[str, str, str]] = field(default_factory=list)\n     \"\"\"List of ``(task_id, assignee, workspace_path)`` triples.\"\"\"\n     skipped_unassigned: list[str] = field(default_factory=list)\n+    \"\"\"Ready task ids skipped because they have no assignee at all.\n+    Operator-actionable — usually a misfiled task waiting for routing.\"\"\"\n+    skipped_nonspawnable: list[str] = field(default_factory=list)\n+    \"\"\"Ready task ids skipped because their assignee names a control-plane\n+    lane (a Claude Code terminal like ``orion-cc``) rather than a Hermes\n+    profile. Expected steady-state on multi-lane setups; NOT an\n+    operator-actionable failure. Tracked separately so health telemetry\n+    can distinguish \"real stuck\" (nothing spawned but spawnable work\n+    available) from \"correctly idle\" (nothing spawnable in the queue).\"\"\"\n     crashed: list[str] = field(default_factory=list)\n     \"\"\"Task ids reclaimed because their worker PID disappeared.\"\"\"\n     auto_blocked: list[str] = field(default_factory=list)","@@ -2459,6 +2468,38 @@ def _clear_spawn_failures(conn: sqlite3.Connection, task_id: str) -> None:\n         )\n \n \n+def has_spawnable_ready(conn: sqlite3.Connection) -> bool:\n+    \"\"\"Return True iff there is at least one ready+assigned+unclaimed task\n+    whose assignee maps to a real Hermes profile.\n+\n+    Used by the gateway- and CLI-embedded dispatchers' health telemetry to\n+    decide whether ``0 spawned`` is a \"stuck\" condition (real spawnable\n+    work waiting) or a \"correctly idle\" condition (only control-plane\n+    lanes like ``orion-cc`` / ``orion-research`` waiting on terminals\n+    that pull tasks via ``claim_task`` directly).\n+\n+    Falls back to \"any ready+assigned\" if ``profile_exists`` is not\n+    importable (e.g. partial install) — preserves the old behavior so\n+    the warning still fires in degraded environments.\n+    \"\"\"\n+    rows = conn.execute(\n+        \"SELECT DISTINCT assignee FROM tasks \"\n+        \"WHERE status = 'ready' AND assignee IS NOT NULL \"\n+        \"    AND claim_lock IS NULL\"\n+    ).fetchall()\n+    if not rows:\n+        return False\n+    try:\n+        from hermes_cli.profiles import profile_exists  # local import: avoids cycle\n+    except Exception:\n+        # Can't introspect — assume spawnable, preserve legacy behavior.\n+        return True\n+    for row in rows:\n+        if profile_exists(row[\"assignee\"]):\n+            return True\n+    return False\n+\n+\n def dispatch_once(\n     conn: sqlite3.Connection,\n     *,","@@ -2506,6 +2547,29 @@ def dispatch_once(\n         if not row[\"assignee\"]:\n             result.skipped_unassigned.append(row[\"id\"])\n             continue\n+        # Skip ready tasks whose assignee is not a real Hermes profile.\n+        # `_default_spawn` invokes ``hermes -p <assignee>`` which fails\n+        # with \"Profile 'X' does not exist\" when the assignee names a\n+        # control-plane lane (e.g. an interactive Claude Code terminal\n+        # like ``orion-cc`` / ``orion-research``) rather than a Hermes\n+        # profile. Those task lanes are pulled by terminals via\n+        # ``claim_task`` directly and should NEVER auto-spawn — the\n+        # subprocess would crash on startup, get reaped as a zombie,\n+        # the task would loop back to ``ready`` on next tick, and we'd\n+        # burn CPU forever (#kanban-dispatcher-crash-loop 2026-05-05).\n+        try:\n+            from hermes_cli.profiles import profile_exists  # local import: avoids cycle\n+        except Exception:\n+            profile_exists = None  # type: ignore[assignment]\n+        if profile_exists is not None and not profile_exists(row[\"assignee\"]):\n+            # Bucket separately from skipped_unassigned: the operator\n+            # cannot fix this by assigning a profile (the assignee IS the\n+            # intended owner — a terminal lane). Health telemetry uses\n+            # this distinction to suppress spurious \"stuck\" warnings on\n+            # multi-lane setups where the ready queue is steadily full\n+            # of human-pulled work.\n+            result.skipped_nonspawnable.append(row[\"id\"])\n+            continue\n         if dry_run:\n             result.spawned.append((row[\"id\"], row[\"assignee\"], \"\"))\n             continue"]},{"file":"tests/hermes_cli/conftest.py","hunks":["@@ -0,0 +1,19 @@\n+\"\"\"Fixtures shared across hermes_cli kanban tests.\"\"\"\n+\n+from __future__ import annotations\n+\n+import pytest\n+\n+\n[redacted:email]\n+def all_assignees_spawnable(monkeypatch):\n+    \"\"\"Pretend every assignee maps to a real Hermes profile.\n+\n+    Most dispatcher tests use synthetic assignees (\"alice\", \"bob\") that\n+    don't correspond to actual profile directories on disk. Without this\n+    patch, the dispatcher's profile-exists guard (PR #20105) routes\n+    those tasks into ``skipped_nonspawnable`` instead of spawning, which\n+    would break tests that assert spawn behavior.\n+    \"\"\"\n+    from hermes_cli import profiles\n+    monkeypatch.setattr(profiles, \"profile_exists\", lambda name: True)"]},{"file":"tests/hermes_cli/test_kanban_core_functionality.py","hunks":["@@ -80,7 +80,7 @@ def test_no_idempotency_key_never_collides(kanban_home):\n # Spawn-failure circuit breaker\n # ---------------------------------------------------------------------------\n \n-def test_spawn_failure_auto_blocks_after_limit(kanban_home):\n+def test_spawn_failure_auto_blocks_after_limit(kanban_home, all_assignees_spawnable):\n     \"\"\"N consecutive spawn failures on the same task → auto_blocked.\"\"\"\n     def _bad_spawn(task, ws):\n         raise RuntimeError(\"no PATH\")","@@ -109,7 +109,7 @@ def _bad_spawn(task, ws):\n         conn.close()\n \n \n-def test_successful_spawn_resets_failure_counter(kanban_home):\n+def test_successful_spawn_resets_failure_counter(kanban_home, all_assignees_spawnable):\n     \"\"\"A successful spawn clears the counter so past failures don't count\n     against future retries of the same task.\"\"\"\n     calls = [0]","@@ -138,7 +138,7 @@ def _flaky_spawn(task, ws):\n         conn.close()\n \n \n-def test_workspace_resolution_failure_also_counts(kanban_home):\n+def test_workspace_resolution_failure_also_counts(kanban_home, all_assignees_spawnable):\n     \"\"\"`dir:` workspace with no path should fail workspace resolution AND\n     count against the failure budget — not just crash the tick.\"\"\"\n     conn = kb.connect()","@@ -824,7 +824,7 @@ def test_recompute_ready_emits_promoted_not_ready(kanban_home):\n         conn.close()\n \n \n-def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home):\n+def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home, all_assignees_spawnable):\n     def _bad(task, ws):\n         raise RuntimeError(\"nope\")\n     conn = kb.connect()","@@ -840,7 +840,7 @@ def _bad(task, ws):\n         conn.close()\n \n \n-def test_spawned_event_emitted_with_pid(kanban_home):\n+def test_spawned_event_emitted_with_pid(kanban_home, all_assignees_spawnable):\n     \"\"\"Successful spawn must append a ``spawned`` event with the pid in\n     the payload so humans tailing events see pid tracking.\"\"\"\n     def _spawn_returns_pid(task, ws):","@@ -1154,7 +1154,7 @@ def test_run_on_block_with_reason(kanban_home):\n         conn.close()\n \n \n-def test_run_on_spawn_failure_records_failed_runs(kanban_home):\n+def test_run_on_spawn_failure_records_failed_runs(kanban_home, all_assignees_spawnable):\n     \"\"\"Each spawn_failed event closes a run with outcome='spawn_failed',\n     and the Nth failure closes a run with outcome='gave_up'.\"\"\"\n     def _bad(task, ws):"]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -327,7 +327,7 @@ def test_worker_context_includes_parent_results_and_comments(kanban_home):\n # Dispatcher\n # ---------------------------------------------------------------------------\n \n-def test_dispatch_dry_run_does_not_claim(kanban_home):\n+def test_dispatch_dry_run_does_not_claim(kanban_home, all_assignees_spawnable):\n     with kb.connect() as conn:\n         t1 = kb.create_task(conn, title=\"a\", assignee=\"alice\")\n         t2 = kb.create_task(conn, title=\"b\", assignee=\"bob\")","@@ -344,10 +344,58 @@ def test_dispatch_skips_unassigned(kanban_home):\n         t = kb.create_task(conn, title=\"floater\")\n         res = kb.dispatch_once(conn, dry_run=True)\n     assert t in res.skipped_unassigned\n+    assert t not in res.skipped_nonspawnable\n     assert not res.spawned\n \n \n-def test_dispatch_promotes_ready_and_spawns(kanban_home):\n+def test_dispatch_skips_nonspawnable_into_separate_bucket(kanban_home, monkeypatch):\n+    \"\"\"Tasks whose assignee fails profile_exists() must NOT land in\n+    ``skipped_unassigned`` (which is operator-actionable) — they go in\n+    the dedicated ``skipped_nonspawnable`` bucket so health telemetry\n+    can suppress false-positive \"stuck\" warnings.\"\"\"\n+    from hermes_cli import profiles\n+    monkeypatch.setattr(profiles, \"profile_exists\", lambda name: False)\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"for-terminal\", assignee=\"orion-cc\")\n+        res = kb.dispatch_once(conn, dry_run=True)\n+    assert t in res.skipped_nonspawnable\n+    assert t not in res.skipped_unassigned\n+    assert not res.spawned\n+\n+\n+def test_has_spawnable_ready_false_when_only_terminal_lanes(kanban_home, monkeypatch):\n+    \"\"\"``has_spawnable_ready`` returns False when every ready task is\n+    assigned to a control-plane lane — used by gateway/CLI dispatchers\n+    to silence the stuck-warn while terminals still have queued work.\"\"\"\n+    from hermes_cli import profiles\n+    monkeypatch.setattr(profiles, \"profile_exists\", lambda name: False)\n+    with kb.connect() as conn:\n+        kb.create_task(conn, title=\"t1\", assignee=\"orion-cc\")\n+        kb.create_task(conn, title=\"t2\", assignee=\"orion-research\")\n+        assert kb.has_spawnable_ready(conn) is False\n+\n+\n+def test_has_spawnable_ready_true_when_real_profile_present(kanban_home, monkeypatch):\n+    \"\"\"``has_spawnable_ready`` returns True as soon as ANY ready task\n+    has an assignee that maps to a real Hermes profile — preserves the\n+    real \"stuck\" signal when a daily/agent task is queued.\"\"\"\n+    from hermes_cli import profiles\n+    monkeypatch.setattr(\n+        profiles, \"profile_exists\", lambda name: name == \"daily\"\n+    )\n+    with kb.connect() as conn:\n+        kb.create_task(conn, title=\"terminal-task\", assignee=\"orion-cc\")\n+        kb.create_task(conn, title=\"hermes-task\", assignee=\"daily\")\n+        assert kb.has_spawnable_ready(conn) is True\n+\n+\n+def test_has_spawnable_ready_false_on_empty_queue(kanban_home):\n+    \"\"\"Empty queue is the trivial false case — no ready tasks at all.\"\"\"\n+    with kb.connect() as conn:\n+        assert kb.has_spawnable_ready(conn) is False\n+\n+\n+def test_dispatch_promotes_ready_and_spawns(kanban_home, all_assignees_spawnable):\n     spawns = []\n \n     def fake_spawn(task, workspace):","@@ -368,7 +416,7 @@ def fake_spawn(task, workspace):\n         assert kb.get_task(conn, c).status == \"running\"\n \n \n-def test_dispatch_spawn_failure_releases_claim(kanban_home):\n+def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawnable):\n     def boom(task, workspace):\n         raise RuntimeError(\"spawn failed\")\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}