{"repo":"NousResearch/hermes-agent","public_id":"19746","display_label":"Kanban dependency gating, deadlock, and lifecycle fixes","summary":"These items cluster around parent-child dependency semantics in a kanban system, including bugs where blocked or archived parents deadlock children, fixes to unblock/completion gating logic, drag-and-drop transition issues, and related lifecycle features like review and scheduled statuses.","subsystem":"cron","counts":{"members":29,"open_issues":3,"open_issues_distinct":3,"open_prs":4,"closed_issues":8,"merged_prs":4,"attempt_prs":14},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":20448},"resolution":{"state":"partially_resolved","by":"pr-22671,pr-23791,pr-83412"},"freshness":{"state":"verified","last_verified_at":"2026-08-10T19:43:47+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.05743842364532019},"fix_edges":[{"issue_number":19746,"pr_number":20448,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":19998,"pr_number":20448,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":22375,"pr_number":22671,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":23180,"pr_number":23791,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":23216,"pr_number":23791,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":23620,"pr_number":23791,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":42896,"pr_number":83412,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54823,"pr_number":83412,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":67963,"pr_number":83412,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":19746,"title":"feat(kanban): add parent dependency guard to direct status writes","url":null,"state":"closed"},{"kind":"issue","number":22375,"title":"[Bug]: unblock_task bypasses parent-dependency gate","url":null,"state":"closed"},{"kind":"issue","number":23180,"title":"[Bug]: archiving a parent task silently deadlocks dependent children in todo","url":null,"state":"closed"},{"kind":"issue","number":24489,"title":"kanban: parent-child deadlock blocks parallel War Room orchestration","url":null,"state":"closed"},{"kind":"issue","number":25408,"title":"feat: kanban task board drag-drop status transitions (hermes parity)","url":null,"state":"closed"},{"kind":"issue","number":26744,"title":"Kanban dashboard silently fails when Ready transition is blocked by parent dependency","url":null,"state":"open"},{"kind":"issue","number":31717,"title":"Kanban approval comments do not resume work; completed status does not satisfy dependencies","url":null,"state":"open"},{"kind":"issue","number":49908,"title":"[Bug]: Drag-and-drop to Done column on  tasks shows completion prompt then silently reverts","url":null,"state":"open"},{"kind":"issue","number":65195,"title":"kanban unblock on a parented task unlinks parent-child edge and promotes children to ready","url":null,"state":"closed"},{"kind":"issue","number":67963,"title":"Dependency-gate semantics gap: blocked parents deadlock dependency children","url":null,"state":"closed"},{"kind":"issue","number":67989,"title":"Hermes kanban: blocked parent creates silent dependency deadlock","url":null,"state":"closed"},{"kind":"pr","number":19743,"title":"feat(kanban): prevent child task dispatch when parent is not done","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20448","number":20448}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":20448,"title":"chore(kanban): tier-2 batch salvage — doctor, started_at, parent-guard, latest_summary, selects, linked-children (closes #18344 #20022 #19473 #19828 #19743 #20251 #20019)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/doctor.py","hermes_cli/kanban.py","hermes_cli/kanban_db.py","plugins/kanban/dashboard/dist/index.js","plugins/kanban/dashboard/plugin_api.py","scripts/release.py","tests/hermes_cli/test_doctor.py","tests/hermes_cli/test_kanban_core_functionality.py","tests/hermes_cli/test_kanban_db.py","tests/plugins/test_kanban_dashboard_plugin.py"],"fix_hunks":[{"file":"hermes_cli/doctor.py","hunks":["@@ -107,15 +107,35 @@ def _honcho_is_configured_for_doctor() -> bool:\n         return False\n \n \n+def _is_kanban_worker_env_gate(item: dict) -> bool:\n+    \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n+    if item.get(\"name\") != \"kanban\":\n+        return False\n+    if os.environ.get(\"HERMES_KANBAN_TASK\"):\n+        return False\n+\n+    tools = item.get(\"tools\") or []\n+    return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n+\n+\n+def _doctor_tool_availability_detail(toolset: str) -> str:\n+    \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n+    if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n+        return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n+    return \"\"\n+\n+\n def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n     \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n-    if not _honcho_is_configured_for_doctor():\n-        return available, unavailable\n-\n     updated_available = list(available)\n     updated_unavailable = []\n     for item in unavailable:\n-        if item.get(\"name\") == \"honcho\":\n+        name = item.get(\"name\")\n+        if _is_kanban_worker_env_gate(item):\n+            if \"kanban\" not in updated_available:\n+                updated_available.append(\"kanban\")\n+            continue\n+        if name == \"honcho\" and _honcho_is_configured_for_doctor():\n             if \"honcho\" not in updated_available:\n                 updated_available.append(\"honcho\")\n             continue","@@ -1278,7 +1298,7 @@ def run_doctor(args):\n         \n         for tid in available:\n             info = TOOLSET_REQUIREMENTS.get(tid, {})\n-            check_ok(info.get(\"name\", tid))\n+            check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n         \n         for item in unavailable:\n             env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []"]},{"file":"hermes_cli/kanban.py","hunks":["@@ -1071,10 +1071,16 @@ def _cmd_show(args: argparse.Namespace) -> int:\n         parents = kb.parent_ids(conn, args.task_id)\n         children = kb.child_ids(conn, args.task_id)\n         runs = kb.list_runs(conn, args.task_id)\n+        # Workers hand off via ``task_runs.summary`` (kanban-worker skill);\n+        # ``tasks.result`` is left NULL unless the caller explicitly passed\n+        # ``result=``. Surfacing the latest summary here keeps ``show`` from\n+        # looking like a no-op when the worker actually did real work.\n+        latest_summary = kb.latest_summary(conn, args.task_id)\n \n     if getattr(args, \"json\", False):\n         payload = {\n             \"task\": _task_to_dict(task),\n+            \"latest_summary\": latest_summary,\n             \"parents\": parents,\n             \"children\": children,\n             \"comments\": [","@@ -1161,6 +1167,13 @@ def _cmd_show(args: argparse.Namespace) -> int:\n         print()\n         print(\"Result:\")\n         print(task.result)\n+    elif latest_summary:\n+        # Worker handoff lives on the latest run, not on tasks.result.\n+        # Surface it at top-level so a glance at ``hermes kanban show <id>``\n+        # tells you what the worker did even if tasks.result is empty.\n+        print()\n+        print(\"Latest summary:\")\n+        print(latest_summary)\n     if comments:\n         print()\n         print(f\"Comments ({len(comments)}):\")"]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -1978,14 +1978,23 @@ def _verify_created_cards(\n ) -> tuple[list[str], list[str]]:\n     \"\"\"Partition ``claimed_ids`` into (verified, phantom).\n \n-    A card is \"verified\" iff a row exists in ``tasks`` with the given id\n-    AND ``created_by`` matches the completing task's ``assignee`` (or\n-    the completing task itself — workers that create children of their\n-    own task also qualify).\n-\n-    ``phantom`` returns ids that either don't exist at all or exist but\n-    were not created by the completing worker. The caller decides what\n-    to do with each bucket; this helper never mutates.\n+    A card is \"verified\" iff a row exists in ``tasks`` AND at least one\n+    of the following holds:\n+\n+    * ``created_by`` matches the completing task's ``assignee`` profile\n+      (the common case: worker A spawns a card via ``kanban_create``,\n+      which stamps ``created_by=A``).\n+    * ``created_by`` matches the completing task's id (edge case where\n+      a worker passed its own task id as the ``created_by`` value).\n+    * The card is linked as a ``task_links.child`` of the completing\n+      task — i.e. the worker explicitly called ``kanban_create`` with\n+      ``parents=[<current_task>]``. This accepts cards created through\n+      the dashboard/CLI by a different principal but then attached to\n+      the completing task by the worker.\n+\n+    ``phantom`` returns ids that either don't exist at all, or exist\n+    but don't satisfy any of the three trust conditions. The caller\n+    decides what to do with each bucket; this helper never mutates.\n     \"\"\"\n     claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()]\n     if not claimed:","@@ -2014,6 +2023,10 @@ def _verify_created_cards(\n     ).fetchall()\n     found = {r[\"id\"]: r[\"created_by\"] for r in rows}\n \n+    # Pull the set of cards linked as children of the completing task.\n+    # Cheap: one query, indexed on parent_id.\n+    linked_children: set[str] = set(child_ids(conn, completing_task_id))\n+\n     verified: list[str] = []\n     phantom: list[str] = []\n     for cid in ordered:","@@ -2021,13 +2034,13 @@ def _verify_created_cards(\n         if created_by is None:\n             phantom.append(cid)\n             continue\n-        # Accept if created_by matches the completing task's assignee\n-        # profile, OR the task itself (workers whose created_by happens\n-        # to match their task id are unusual but harmless to accept).\n+        # Accept if any of the three trust conditions holds.\n         if completing_assignee and created_by == completing_assignee:\n             verified.append(cid)\n         elif created_by == completing_task_id:\n             verified.append(cid)\n+        elif cid in linked_children:\n+            verified.append(cid)\n         else:\n             phantom.append(cid)\n     return verified, phantom","@@ -2699,16 +2712,23 @@ def enforce_max_runtime(\n     host_prefix = f\"{_claimer_id().split(':', 1)[0]}:\"\n \n     rows = conn.execute(\n-        \"SELECT id, worker_pid, started_at, max_runtime_seconds, claim_lock \"\n-        \"FROM tasks \"\n-        \"WHERE status = 'running' AND max_runtime_seconds IS NOT NULL \"\n-        \"  AND started_at IS NOT NULL AND worker_pid IS NOT NULL\"\n+        \"SELECT t.id, t.worker_pid, \"\n+        \"       COALESCE(r.started_at, t.started_at) AS active_started_at, \"\n+        \"       t.max_runtime_seconds, t.claim_lock \"\n+        \"FROM tasks t \"\n+        \"LEFT JOIN task_runs r ON r.id = t.current_run_id \"\n+        \"WHERE t.status = 'running' AND t.max_runtime_seconds IS NOT NULL \"\n+        \"  AND COALESCE(r.started_at, t.started_at) IS NOT NULL \"\n+        \"  AND t.worker_pid IS NOT NULL\"\n     ).fetchall()\n     for row in rows:\n         lock = row[\"claim_lock\"] or \"\"\n         if not lock.startswith(host_prefix):\n             continue\n-        elapsed = now - int(row[\"started_at\"])\n+        # Runtime is per attempt, not lifetime-of-task. ``tasks.started_at``\n+        # intentionally records the first time a task ever started, so retries\n+        # must be measured from the active task_runs row when present.\n+        elapsed = now - int(row[\"active_started_at\"])\n         if elapsed < int(row[\"max_runtime_seconds\"]):\n             continue\n ","@@ -3993,3 +4013,61 @@ def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:\n         (task_id,),\n     ).fetchone()\n     return Run.from_row(row) if row else None\n+\n+\n+def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]:\n+    \"\"\"Return the latest non-null ``task_runs.summary`` for ``task_id``.\n+\n+    The kanban-worker skill writes its handoff to ``task_runs.summary``\n+    via ``complete_task(summary=...)``; ``tasks.result`` is left empty\n+    unless the caller passes ``result=`` explicitly. Dashboards and CLI\n+    \"show\" views need this value to surface what a worker actually did\n+    — without it, ``tasks.result`` is NULL and the task looks like a\n+    no-op even when the run completed.\n+\n+    Picks the most recent run by ``ended_at`` (falling back to ``id``\n+    for ties or unfinished rows). Returns None if no run has a summary.\n+    \"\"\"\n+    row = conn.execute(\n+        \"SELECT summary FROM task_runs \"\n+        \"WHERE task_id = ? AND summary IS NOT NULL AND summary != '' \"\n+        \"ORDER BY COALESCE(ended_at, started_at) DESC, id DESC LIMIT 1\",\n+        (task_id,),\n+    ).fetchone()\n+    return row[\"summary\"] if row else None\n+\n+\n+def latest_summaries(\n+    conn: sqlite3.Connection, task_ids: Iterable[str]\n+) -> dict[str, str]:\n+    \"\"\"Batch-fetch latest non-null summaries for a list of task ids.\n+\n+    Used by the dashboard board endpoint to attach ``latest_summary`` to\n+    every card in a single SQL query, avoiding the N+1 pattern of\n+    calling :func:`latest_summary` per task. Returns a dict mapping\n+    ``task_id`` → summary string, omitting tasks with no summary.\n+\n+    Approach: a window function picks the newest non-null-summary row\n+    per ``task_id``; works against SQLite ≥ 3.25 (default on every\n+    supported platform).\n+    \"\"\"\n+    ids = list(task_ids)\n+    if not ids:\n+        return {}\n+    placeholders = \",\".join(\"?\" for _ in ids)\n+    rows = conn.execute(\n+        f\"\"\"\n+        SELECT task_id, summary FROM (\n+            SELECT task_id, summary,\n+                   ROW_NUMBER() OVER (\n+                       PARTITION BY task_id\n+                       ORDER BY COALESCE(ended_at, started_at) DESC, id DESC\n+                   ) AS rn\n+              FROM task_runs\n+             WHERE task_id IN ({placeholders})\n+               AND summary IS NOT NULL AND summary != ''\n+        ) WHERE rn = 1\n+        \"\"\",\n+        ids,\n+    ).fetchall()\n+    return {r[\"task_id\"]: r[\"summary\"] for r in rows}"]},{"file":"plugins/kanban/dashboard/dist/index.js","hunks":["@@ -2416,11 +2416,10 @@\n         ),\n       ),\n       h(\"div\", { className: \"hermes-kanban-deps-row\" },\n-        h(Select, {\n+        h(Select, Object.assign({\n           value: newParent,\n-          onChange: function (e) { setNewParent(e.target.value); },\n           className: \"h-7 text-xs flex-1\",\n-        },\n+        }, selectChangeHandler(setNewParent)),\n           h(SelectOption, { value: \"\" }, \"— add parent —\"),\n           candidatesFor(parentExclude).map(function (t) {\n             return h(SelectOption, { key: t.id, value: t.id },","@@ -2455,11 +2454,10 @@\n         ),\n       ),\n       h(\"div\", { className: \"hermes-kanban-deps-row\" },\n-        h(Select, {\n+        h(Select, Object.assign({\n           value: newChild,\n-          onChange: function (e) { setNewChild(e.target.value); },\n           className: \"h-7 text-xs flex-1\",\n-        },\n+        }, selectChangeHandler(setNewChild)),\n           h(SelectOption, { value: \"\" }, \"— add child —\"),\n           candidatesFor(childExclude).map(function (t) {\n             return h(SelectOption, { key: t.id, value: t.id },"]},{"file":"plugins/kanban/dashboard/plugin_api.py","hunks":["@@ -124,11 +124,23 @@ def _conn(board: Optional[str] = None):\n ]\n \n \n-def _task_dict(task: kanban_db.Task) -> dict[str, Any]:\n+_CARD_SUMMARY_PREVIEW_CHARS = 200\n+\n+\n+def _task_dict(\n+    task: kanban_db.Task,\n+    *,\n+    latest_summary: Optional[str] = None,\n+) -> dict[str, Any]:\n     d = asdict(task)\n     # Add derived age metrics so the UI can colour stale cards without\n     # computing deltas client-side.\n     d[\"age\"] = kanban_db.task_age(task)\n+    # Surface the latest non-null run summary so dashboards don't show\n+    # blank cards/drawers for tasks where the worker handed off via\n+    # ``task_runs.summary`` (the kanban-worker pattern) instead of\n+    # ``tasks.result``. ``None`` when no run has produced a summary yet.\n+    d[\"latest_summary\"] = latest_summary\n     # Keep body short on list endpoints; full body comes from /tasks/:id.\n     return d\n ","@@ -381,8 +393,18 @@ def get_board(\n         if include_archived:\n             columns[\"archived\"] = []\n \n+        # Batch-fetch the latest non-null run summary per task in one\n+        # window-function query (avoids N+1 ``latest_summary`` calls\n+        # for boards with hundreds of tasks). Truncated to a card-size\n+        # preview here — the full text is available via /tasks/:id.\n+        summary_map = kanban_db.latest_summaries(conn, [t.id for t in tasks])\n+\n         for t in tasks:\n-            d = _task_dict(t)\n+            full = summary_map.get(t.id)\n+            preview = (\n+                full[:_CARD_SUMMARY_PREVIEW_CHARS] if full else None\n+            )\n+            d = _task_dict(t, latest_summary=preview)\n             d[\"link_counts\"] = link_counts.get(t.id, {\"parents\": 0, \"children\": 0})\n             d[\"comment_count\"] = comment_counts.get(t.id, 0)\n             d[\"progress\"] = progress.get(t.id)  # None when the task has no children","@@ -440,7 +462,11 @@ def get_task(task_id: str, board: Optional[str] = Query(None)):\n         task = kanban_db.get_task(conn, task_id)\n         if task is None:\n             raise HTTPException(status_code=404, detail=f\"task {task_id} not found\")\n-        task_d = _task_dict(task)\n+        # Drawer/detail view returns the FULL summary (no truncation) so\n+        # operators can read the complete worker handoff without making\n+        # a second round-trip. Cards on /board carry a 200-char preview.\n+        full_summary = kanban_db.latest_summary(conn, task_id)\n+        task_d = _task_dict(task, latest_summary=full_summary)\n         # Attach diagnostics so the drawer's Diagnostics section can\n         # render recovery actions without a second round-trip.\n         diags = _compute_task_diagnostics(conn, task_ids=[task_id])","@@ -662,6 +688,22 @@ def _set_status_direct(\n         ).fetchone()\n         if prev is None:\n             return False\n+\n+        # Guard: don't allow promoting to 'ready' unless all parents are done.\n+        # Prevents the dispatcher from spawning a child whose upstream work\n+        # hasn't completed (e.g. T4 dispatched while T3 is still blocked).\n+        if new_status == \"ready\":\n+            parent_statuses = conn.execute(\n+                \"SELECT t.status FROM tasks t \"\n+                \"JOIN task_links l ON l.parent_id = t.id \"\n+                \"WHERE l.child_id = ?\",\n+                (task_id,),\n+            ).fetchall()\n+            if parent_statuses and not all(\n+                p[\"status\"] == \"done\" for p in parent_statuses\n+            ):\n+                return False\n+\n         was_running = prev[\"status\"] == \"running\"\n \n         cur = conn.execute("]},{"file":"scripts/release.py","hunks":["@@ -129,6 +129,8 @@\n     \"[redacted:email]\": \"momowind\",\n     \"[redacted:email]\": \"misery-hl\",\n     \"[redacted:email]\": \"misery-hl\",\n+    \"[redacted:email]\": \"suncokret12\",\n+    \"[redacted:email]\": \"mioimotoai-lgtm\",\n     \"[redacted:email]\": \"heyitsaamir\",\n     \"[redacted:email]\": \"johnncenae\",\n     \"[redacted:email]\": \"ThomassJonax\","]},{"file":"tests/hermes_cli/test_doctor.py","hunks":["@@ -126,6 +126,47 @@ def test_leaves_honcho_unavailable_when_not_configured(self, monkeypatch):\n         assert available == []\n         assert unavailable == [honcho_entry]\n \n+    def test_marks_kanban_available_only_when_missing_worker_env_gate(self, monkeypatch):\n+        monkeypatch.setattr(doctor, \"_honcho_is_configured_for_doctor\", lambda: False)\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        available, unavailable = doctor._apply_doctor_tool_availability_overrides(\n+            [],\n+            [{\"name\": \"kanban\", \"env_vars\": [], \"tools\": [\"kanban_show\"]}],\n+        )\n+\n+        assert available == [\"kanban\"]\n+        assert unavailable == []\n+\n+    def test_leaves_kanban_unavailable_when_worker_env_is_set(self, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"probe\")\n+        kanban_entry = {\"name\": \"kanban\", \"env_vars\": [], \"tools\": [\"kanban_show\"]}\n+\n+        available, unavailable = doctor._apply_doctor_tool_availability_overrides(\n+            [],\n+            [kanban_entry],\n+        )\n+\n+        assert available == []\n+        assert unavailable == [kanban_entry]\n+\n+    def test_leaves_non_worker_kanban_failure_unavailable(self, monkeypatch):\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+        kanban_entry = {\"name\": \"kanban\", \"env_vars\": [], \"tools\": [\"kanban_show\", \"not_a_kanban_tool\"]}\n+\n+        available, unavailable = doctor._apply_doctor_tool_availability_overrides(\n+            [],\n+            [kanban_entry],\n+        )\n+\n+        assert available == []\n+        assert unavailable == [kanban_entry]\n+\n+    def test_kanban_doctor_detail_explains_worker_gate(self, monkeypatch):\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        assert doctor._doctor_tool_availability_detail(\"kanban\") == \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n+\n \n class TestHonchoDoctorConfigDetection:\n     def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):"]},{"file":"tests/hermes_cli/test_kanban_core_functionality.py","hunks":["@@ -682,14 +682,21 @@ def _signal_fn(pid, sig):\n                 conn, title=\"long job\", assignee=\"worker\",\n                 max_runtime_seconds=1,  # one second cap\n             )\n-            # Spawn by hand: claim + set pid + set started_at to the past.\n+            # Spawn by hand: claim + set pid + set active run start to the past.\n             kb.claim_task(conn, tid)\n             kb._set_worker_pid(conn, tid, os.getpid())   # any live pid works\n-            # Backdate started_at so elapsed > limit.\n+            # Backdate both the task-level first-start timestamp and the active\n+            # run timestamp so elapsed > limit under the per-run runtime model.\n+            old_started = int(time.time()) - 30\n             with kb.write_txn(conn):\n                 conn.execute(\n                     \"UPDATE tasks SET started_at = ? WHERE id = ?\",\n-                    (int(time.time()) - 30, tid),\n+                    (old_started, tid),\n+                )\n+                conn.execute(\n+                    \"UPDATE task_runs SET started_at = ? \"\n+                    \"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)\",\n+                    (old_started, tid),\n                 )\n \n             timed_out = kb.enforce_max_runtime(conn, signal_fn=_signal_fn)","@@ -769,10 +776,16 @@ def _signal(pid, sig):\n         )\n         kb.claim_task(conn, tid)\n         kb._set_worker_pid(conn, tid, os.getpid())\n+        old_started = int(time.time()) - 30\n         with kb.write_txn(conn):\n             conn.execute(\n                 \"UPDATE tasks SET started_at = ? WHERE id = ?\",\n-                (int(time.time()) - 30, tid),\n+                (old_started, tid),\n+            )\n+            conn.execute(\n+                \"UPDATE task_runs SET started_at = ? \"\n+                \"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)\",\n+                (old_started, tid),\n             )\n         # Use enforce_max_runtime directly with our signal stub — dispatch_once\n         # uses the default os.kill, but integration-wise calling","@@ -2978,6 +2991,46 @@ def test_complete_with_cross_worker_card_is_rejected(kanban_home):\n         conn.close()\n \n \n+def test_complete_accepts_cross_worker_card_when_linked_as_child(kanban_home):\n+    \"\"\"A card created by a different principal but explicitly linked as\n+    a child of the completing task is accepted — the worker took\n+    ownership via ``kanban_create(parents=[current_task])`` or an\n+    explicit ``link_tasks`` call, which proves the relationship even\n+    when ``created_by`` doesn't match.\n+\n+    (Relaxation salvaged from #20022 @LeonSGP43 — stricter version\n+    would incorrectly reject legitimate orchestrator flows where a\n+    specifier creates a card, then a worker picks it up and links it\n+    to its own parent task.)\n+    \"\"\"\n+    conn = kb.connect()\n+    try:\n+        parent = kb.create_task(conn, title=\"parent\", assignee=\"alice\")\n+        # Card created by a DIFFERENT principal (not alice, not parent).\n+        other = kb.create_task(\n+            conn, title=\"other\", assignee=\"x\", created_by=\"bob\",\n+            parents=[parent],  # explicitly links as child of the completing task\n+        )\n+\n+        ok = kb.complete_task(\n+            conn, parent,\n+            summary=\"completed with linked child\",\n+            created_cards=[other],\n+        )\n+        assert ok is True\n+        # The card should appear in the completed event's verified_cards list.\n+        import json as _json\n+        row = conn.execute(\n+            \"SELECT payload FROM task_events \"\n+            \"WHERE task_id=? AND kind='completed' ORDER BY id DESC LIMIT 1\",\n+            (parent,),\n+        ).fetchone()\n+        payload = _json.loads(row[\"payload\"])\n+        assert other in payload.get(\"verified_cards\", [])\n+    finally:\n+        conn.close()\n+\n+\n def test_complete_prose_scan_flags_nonexistent_ids(kanban_home):\n     \"\"\"Successful completion whose summary references a ``t_<hex>`` id\n     that doesn't resolve emits a ``suspected_hallucinated_references``","@@ -3184,10 +3237,21 @@ def _signal(pid, sig):\n         )\n         kb.claim_task(conn, tid)\n         kb._set_worker_pid(conn, tid, os.getpid())\n+        # Since PR #19473 (salvaged) changed enforce_max_runtime to read\n+        # from task_runs.started_at (per-attempt) rather than\n+        # tasks.started_at (lifetime), we need to backdate BOTH to\n+        # guarantee the timeout fires regardless of which column the\n+        # query pulls from.\n         with kb.write_txn(conn):\n+            long_ago = int(time.time()) - 30\n             conn.execute(\n                 \"UPDATE tasks SET started_at = ? WHERE id = ?\",\n-                (int(time.time()) - 30, tid),\n+                (long_ago, tid),\n+            )\n+            conn.execute(\n+                \"UPDATE task_runs SET started_at = ? \"\n+                \"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)\",\n+                (long_ago, tid),\n             )\n         before = kb.get_task(conn, tid)\n         assert before.consecutive_failures == 0"]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -182,6 +182,52 @@ def test_stale_claim_reclaimed(kanban_home):\n         assert kb.get_task(conn, t).status == \"ready\"\n \n \n+def test_max_runtime_uses_current_run_start_after_retry(kanban_home):\n+    \"\"\"A retry should get a fresh max-runtime window.\n+\n+    ``tasks.started_at`` intentionally records the first time the task ever\n+    started. Runtime enforcement must therefore use the active\n+    ``task_runs.started_at`` row; otherwise every retry of an old task is\n+    immediately timed out again.\n+    \"\"\"\n+    with kb.connect() as conn:\n+        host = kb._claimer_id().split(\":\", 1)[0]\n+        t = kb.create_task(\n+            conn, title=\"retry\", assignee=\"a\", max_runtime_seconds=10,\n+        )\n+\n+        kb.claim_task(conn, t, claimer=f\"{host}:first\")\n+        first_run_id = kb.latest_run(conn, t).id\n+        old_started = int(time.time()) - 20\n+        conn.execute(\n+            \"UPDATE tasks SET started_at = ?, worker_pid = ? WHERE id = ?\",\n+            (old_started, 999999, t),\n+        )\n+        conn.execute(\n+            \"UPDATE task_runs SET started_at = ?, worker_pid = ? WHERE id = ?\",\n+            (old_started, 999999, first_run_id),\n+        )\n+\n+        timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None)\n+        assert timed_out == [t]\n+        assert kb.get_task(conn, t).status == \"ready\"\n+\n+        kb.claim_task(conn, t, claimer=f\"{host}:retry\")\n+        retry_run = kb.latest_run(conn, t)\n+        conn.execute(\n+            \"UPDATE tasks SET worker_pid = ? WHERE id = ?\",\n+            (999999, t),\n+        )\n+        conn.execute(\n+            \"UPDATE task_runs SET worker_pid = ? WHERE id = ?\",\n+            (999999, retry_run.id),\n+        )\n+\n+        timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None)\n+        assert timed_out == []\n+        assert kb.get_task(conn, t).status == \"running\"\n+\n+\n def test_heartbeat_extends_claim(kanban_home):\n     with kb.connect() as conn:\n         t = kb.create_task(conn, title=\"x\", assignee=\"a\")","@@ -776,3 +822,80 @@ def __init__(self, cmd, **kwargs):\n             default_home / \"kanban\" / \"workspaces\"\n         )\n         assert env[\"HERMES_KANBAN_TASK\"] == \"t_dispatch_env\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# latest_summary / latest_summaries — surface task_runs.summary handoffs\n+# ---------------------------------------------------------------------------\n+\n+def test_latest_summary_returns_none_when_no_runs(kanban_home):\n+    \"\"\"A freshly-created task has no runs and therefore no summary.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"fresh\", assignee=\"alice\")\n+        assert kb.latest_summary(conn, t) is None\n+\n+\n+def test_latest_summary_returns_summary_after_complete(kanban_home):\n+    \"\"\"``complete_task(summary=...)`` is the canonical kanban-worker\n+    handoff; ``latest_summary`` must surface it so dashboards/CLI can\n+    render what the worker actually did.\"\"\"\n+    handoff = \"shipped 3 files, ran tests, opened PR #42\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"work\", assignee=\"alice\")\n+        kb.complete_task(conn, t, summary=handoff)\n+        assert kb.latest_summary(conn, t) == handoff\n+\n+\n+def test_latest_summary_picks_newest_when_multiple_runs(kanban_home):\n+    \"\"\"When a task has been re-run (block → unblock → complete), the\n+    newest run's summary wins. We unblock to take the task back to\n+    ``ready``, then complete a second time and verify the second\n+    summary surfaces.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"retry\", assignee=\"alice\")\n+        kb.complete_task(conn, t, summary=\"first attempt\")\n+        # Move back to ready by direct SQL — block_task / unblock_task\n+        # paths require an active claim, but we just want a second run\n+        # row to exist with a later ended_at.\n+        conn.execute(\n+            \"UPDATE tasks SET status='ready', completed_at=NULL WHERE id=?\",\n+            (t,),\n+        )\n+        # Sleep 1s so the second run's ended_at is provably later than\n+        # the first (complete_task uses int(time.time())).\n+        time.sleep(1.05)\n+        kb.complete_task(conn, t, summary=\"second attempt — final\")\n+        assert kb.latest_summary(conn, t) == \"second attempt — final\"\n+\n+\n+def test_latest_summary_skips_empty_string(kanban_home):\n+    \"\"\"A run with an empty-string summary should not mask an earlier\n+    populated one — empty strings carry no information.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"t\", assignee=\"alice\")\n+        kb.complete_task(conn, t, summary=\"real handoff\")\n+        # Inject a later run with empty summary directly. Workers\n+        # writing \"\" instead of None is a real shape we want to ignore.\n+        conn.execute(\n+            \"INSERT INTO task_runs (task_id, status, started_at, ended_at, \"\n+            \"outcome, summary) VALUES (?, 'done', ?, ?, 'completed', ?)\",\n+            (t, int(time.time()) + 1, int(time.time()) + 2, \"\"),\n+        )\n+        conn.commit()\n+        assert kb.latest_summary(conn, t) == \"real handoff\"\n+\n+\n+def test_latest_summaries_batch_omits_tasks_without_summary(kanban_home):\n+    \"\"\"``latest_summaries`` is the dashboard's N+1 escape hatch — it\n+    must return only entries for tasks that actually have a summary,\n+    keep the per-task latest, and accept an empty input gracefully.\"\"\"\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\")\n+        t3 = kb.create_task(conn, title=\"c\", assignee=\"carol\")\n+        kb.complete_task(conn, t1, summary=\"alpha\")\n+        kb.complete_task(conn, t3, summary=\"charlie\")\n+        out = kb.latest_summaries(conn, [t1, t2, t3])\n+        assert out == {t1: \"alpha\", t3: \"charlie\"}\n+        # Empty input → empty dict, no SQL syntax error from \"IN ()\".\n+        assert kb.latest_summaries(conn, []) == {}"]},{"file":"tests/plugins/test_kanban_dashboard_plugin.py","hunks":["@@ -203,7 +203,10 @@ def test_patch_block_then_unblock(client):\n \n def test_patch_drag_drop_move_todo_to_ready(client):\n     \"\"\"Direct status write: the drag-drop path for statuses without a\n-    dedicated verb (e.g. manually promoting todo -> ready).\"\"\"\n+    dedicated verb (e.g. manually promoting todo -> ready).\n+\n+    Promoting a child whose parent is not done is rejected (409).\n+    Promoting a child whose parent IS done is accepted (200).\"\"\"\n     parent = client.post(\"/api/plugins/kanban/tasks\", json={\"title\": \"p\"}).json()[\"task\"]\n     child = client.post(\n         \"/api/plugins/kanban/tasks\",","@@ -211,12 +214,23 @@ def test_patch_drag_drop_move_todo_to_ready(client):\n     ).json()[\"task\"]\n     assert child[\"status\"] == \"todo\"\n \n+    # Rejected: parent not done yet.\n     r = client.patch(\n         f\"/api/plugins/kanban/tasks/{child['id']}\",\n         json={\"status\": \"ready\"},\n     )\n+    assert r.status_code == 409\n+\n+    # Complete the parent.\n+    r = client.patch(\n+        f\"/api/plugins/kanban/tasks/{parent['id']}\",\n+        json={\"status\": \"done\"},\n+    )\n     assert r.status_code == 200\n-    assert r.json()[\"task\"][\"status\"] == \"ready\"\n+\n+    # Now child auto-promoted by recompute_ready — already ready.\n+    child_after = client.get(f\"/api/plugins/kanban/tasks/{child['id']}\").json()[\"task\"]\n+    assert child_after[\"status\"] == \"ready\"\n \n \n def test_patch_reassign(client):","@@ -433,13 +447,17 @@ def test_board_progress_rollup(client):\n         \"/api/plugins/kanban/tasks\",\n         json={\"title\": \"b\", \"parents\": [parent[\"id\"]]},\n     ).json()[\"task\"]\n-    # Children start as \"todo\" because the parent isn't done yet; promote\n-    # them to \"ready\" so complete_task will accept the transition.\n+    # Children start as \"todo\" because the parent isn't done yet.  Set the\n+    # parent to done so children auto-promote to ready via recompute_ready.\n+    r = client.patch(\n+        f\"/api/plugins/kanban/tasks/{parent['id']}\",\n+        json={\"status\": \"done\"},\n+    )\n+    assert r.status_code == 200\n+    # Verify children are now ready.\n     for cid in (child_a[\"id\"], child_b[\"id\"]):\n-        r = client.patch(\n-            f\"/api/plugins/kanban/tasks/{cid}\", json={\"status\": \"ready\"},\n-        )\n-        assert r.status_code == 200\n+        t = client.get(f\"/api/plugins/kanban/tasks/{cid}\").json()[\"task\"]\n+        assert t[\"status\"] == \"ready\", f\"{cid} should be ready after parent done\"\n \n     # 0/2 done.\n     r = client.get(\"/api/plugins/kanban/board\")","@@ -604,6 +622,32 @@ def test_dashboard_done_actions_prompt_for_completion_summary():\n     assert \"body: JSON.stringify(finalPatch)\" in bundle\n \n \n+def test_dashboard_dependency_selects_use_value_change_handler():\n+    \"\"\"Regression for the dependency selects in the task drawer: the\n+    add-parent / add-child dropdowns must wire through the shared\n+    selectChangeHandler helper so their value actually lands on the\n+    underlying React state. Salvaged from #20019 @LeonSGP43.\n+    \"\"\"\n+    repo_root = Path(__file__).resolve().parents[2]\n+    bundle = (\n+        repo_root / \"plugins\" / \"kanban\" / \"dashboard\" / \"dist\" / \"index.js\"\n+    ).read_text()\n+\n+    parent_select = (\n+        'value: newParent,\\n'\n+        '          className: \"h-7 text-xs flex-1\",\\n'\n+        '        }, selectChangeHandler(setNewParent))'\n+    )\n+    child_select = (\n+        'value: newChild,\\n'\n+        '          className: \"h-7 text-xs flex-1\",\\n'\n+        '        }, selectChangeHandler(setNewChild))'\n+    )\n+\n+    assert parent_select in bundle\n+    assert child_select in bundle\n+\n+\n def test_bulk_archive(client):\n     a = client.post(\"/api/plugins/kanban/tasks\", json={\"title\": \"a\"}).json()[\"task\"]\n     b = client.post(\"/api/plugins/kanban/tasks\", json={\"title\": \"b\"}).json()[\"task\"]\n"]}]},{"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","implication":"superseded"},"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"]}]},{"kind":"pr","number":23298,"title":"fix(cli): warn before archiving a task with dependent children","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23325","number":23325}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":23325,"title":"fix(kanban): treat archived parent tasks as terminal for dependency resolution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23791","number":23791}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":23791,"title":"fix(kanban): batch-salvage 7 community kanban bug fixes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/kanban.py","hermes_cli/kanban_db.py","plugins/kanban/dashboard/dist/index.js","run_agent.py","scripts/release.py","tests/hermes_cli/test_kanban_cli.py","tests/hermes_cli/test_kanban_notify.py","tests/run_agent/test_run_agent.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -8329,6 +8329,7 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:\n         \"\"\"\n         import asyncio\n         import re\n+        import shlex\n         from hermes_cli.kanban import run_slash\n \n         text = (event.text or \"\").strip()","@@ -8338,7 +8339,26 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:\n         if text.startswith(\"kanban\"):\n             text = text[len(\"kanban\"):].lstrip()\n \n-        is_create = text.split(None, 1)[:1] == [\"create\"]\n+        tokens = [redacted:secret-assignment] if text else []\n+        requested_board = None\n+        action = None\n+        i = 0\n+        while i < len(tokens):\n+            tok = tokens[i]\n+            if tok == \"--board\":\n+                if i + 1 >= len(tokens):\n+                    break\n+                requested_board = tokens[i + 1]\n+                i += 2\n+                continue\n+            if tok.startswith(\"--board=\"):\n+                requested_board = tok.split(\"=\", 1)[1]\n+                i += 1\n+                continue\n+            action = tok\n+            break\n+\n+        is_create = action == \"create\"\n \n         try:\n             output = await asyncio.to_thread(run_slash, text)","@@ -8365,7 +8385,7 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:\n                     if platform_str and chat_id:\n                         def _sub():\n                             from hermes_cli import kanban_db as _kb\n-                            conn = _kb.connect()\n+                            conn = _kb.connect(board=requested_board)\n                             try:\n                                 _kb.add_notify_sub(\n                                     conn, task_id=task_id,"]},{"file":"hermes_cli/kanban.py","hunks":["@@ -652,6 +652,16 @@ def kanban_command(args: argparse.Namespace) -> int:\n     # keeps the patch small and inherits the exact same resolution the\n     # dispatcher uses for workers — consistency is a feature here.\n     board_override = getattr(args, \"board\", None)\n+    prev_board_env = os.environ.get(\"HERMES_KANBAN_BOARD\")\n+    restore_board_env = False\n+\n+    def _restore_board_env() -> None:\n+        if not restore_board_env:\n+            return\n+        if prev_board_env is None:\n+            os.environ.pop(\"HERMES_KANBAN_BOARD\", None)\n+        else:\n+            os.environ[\"HERMES_KANBAN_BOARD\"] = prev_board_env\n     if board_override:\n         try:\n             normed = kb._normalize_board_slug(board_override)","@@ -671,12 +681,16 @@ def kanban_command(args: argparse.Namespace) -> int:\n             )\n             return 1\n         os.environ[\"HERMES_KANBAN_BOARD\"] = normed\n+        restore_board_env = True\n \n     # Boards management doesn't touch the DB at all — dispatch early so\n     # fresh installs that haven't initialized any DB can still use\n     # `hermes kanban boards create …`.\n     if action == \"boards\":\n-        return _dispatch_boards(args)\n+        try:\n+            return _dispatch_boards(args)\n+        finally:\n+            _restore_board_env()\n \n     # Auto-initialize the DB before dispatching any subcommand. init_db\n     # is idempotent, so running it every invocation is cheap (one","@@ -689,6 +703,7 @@ def kanban_command(args: argparse.Namespace) -> int:\n         kb.init_db()\n     except Exception as exc:\n         print(f\"kanban: could not initialize database: {exc}\", file=sys.stderr)\n+        _restore_board_env()\n         return 1\n \n     handlers = {","@@ -730,12 +745,16 @@ def kanban_command(args: argparse.Namespace) -> int:\n     handler = handlers.get(action)\n     if not handler:\n         print(f\"kanban: unknown action {action!r}\", file=sys.stderr)\n+        _restore_board_env()\n         return 2\n     try:\n         return int(handler(args) or 0)\n     except (ValueError, RuntimeError) as exc:\n         print(f\"kanban: {exc}\", file=sys.stderr)\n+        _restore_board_env()\n         return 1\n+    finally:\n+        _restore_board_env()\n \n \n # ---------------------------------------------------------------------------"]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -1826,7 +1826,7 @@ def _synthesize_ended_run(\n # ---------------------------------------------------------------------------\n \n def recompute_ready(conn: sqlite3.Connection) -> int:\n-    \"\"\"Promote ``todo`` tasks to ``ready`` when all parents are ``done``.\n+    \"\"\"Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.\n \n     Returns the number of tasks promoted.  Safe to call inside or outside\n     an existing transaction; it opens its own IMMEDIATE txn.","@@ -1844,7 +1844,7 @@ def recompute_ready(conn: sqlite3.Connection) -> int:\n                 \"WHERE l.child_id = ?\",\n                 (task_id,),\n             ).fetchall()\n-            if all(p[\"status\"] == \"done\" for p in parents):\n+            if all(p[\"status\"] in (\"done\", \"archived\") for p in parents):\n                 conn.execute(\n                     \"UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'\",\n                     (task_id,),","@@ -1885,7 +1885,7 @@ def claim_task(\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+            \"WHERE l.child_id = ? AND p.status NOT IN ('done', 'archived') LIMIT 1\",\n             (task_id,),\n         ).fetchone()\n         if undone:","@@ -3930,6 +3930,25 @@ def _default_spawn(\n \n     prompt = f\"work kanban task {task.id}\"\n     env = dict(os.environ)\n+\n+    # Inject HERMES_HOME so the worker reads the profile-scoped config.yaml\n+    # (fallback_providers, toolsets, agent settings, etc.) instead of the root\n+    # config.  Without this, `env = dict(os.environ)` copies only the parent's\n+    # env, and when the child process starts `hermes -p <name>` the\n+    # _apply_profile_override() runs *before* hermes_constants is imported.\n+    # If HERMES_HOME is absent from the child's env, get_hermes_home() falls\n+    # back to Path.home() / \".hermes\" (the DEFAULT profile root), ignoring the\n+    # profile-specific config entirely.  Fixes profile-scoped fallback_providers\n+    # being invisible to kanban workers.\n+    from hermes_cli.profiles import resolve_profile_env\n+    try:\n+        env[\"HERMES_HOME\"] = resolve_profile_env(profile_arg)\n+    except FileNotFoundError:\n+        # Profile dir doesn't exist — defer resolution to the CLI's\n+        # _apply_profile_override() via HERMES_PROFILE (set below).\n+        # This only happens in test fixtures where the isolated\n+        # HERMES_HOME never had profiles created.\n+        pass\n     if task.tenant:\n         env[\"HERMES_TENANT\"] = task.tenant\n     env[\"HERMES_KANBAN_TASK\"] = task.id"]},{"file":"plugins/kanban/dashboard/dist/index.js","hunks":["@@ -1898,7 +1898,7 @@\n           type: \"checkbox\",\n           className: \"hermes-kanban-col-check\",\n           title: \"Select all tasks in this column\",\n-          \"aria-label\": `Select all tasks in ${COLUMN_LABEL[props.column.name] || props.column.name}`,\n+          \"aria-label\": `Select all tasks in ${colLabel || props.column.name}`,\n           checked: props.column.tasks.length > 0 && props.column.tasks.every(function (t) { return props.selectedIds.has(t.id); }),\n           onChange: function (e) {\n             e.stopPropagation();"]},{"file":"run_agent.py","hunks":["@@ -14987,7 +14987,41 @@ def _stop_spinner():\n                     \"— requesting summary...\"\n                 )\n             final_response = self._handle_max_iterations(messages, api_call_count)\n-        \n+\n+            # If running as a kanban worker, block the task so the dispatcher\n+            # knows the worker could not complete (rather than treating it as a\n+            # protocol violation).  The agent loop strips tools before calling\n+            # _handle_max_iterations, so the model cannot call kanban_block\n+            # itself — we must do it on its behalf.\n+            _kanban_task = os.environ.get(\"HERMES_KANBAN_TASK\")\n+            if _kanban_task:\n+                try:\n+                    handle_function_call(\n+                        \"kanban_block\",\n+                        {\n+                            \"task_id\": _kanban_task,\n+                            \"reason\": (\n+                                f\"Iteration budget exhausted \"\n+                                f\"({api_call_count}/{self.max_iterations}) — \"\n+                                \"task could not complete within the allowed \"\n+                                \"iterations\"\n+                            ),\n+                        },\n+                        task_id=effective_task_id,\n+                    )\n+                    logger.info(\n+                        \"kanban_block called for task %s after iteration \"\n+                        \"exhaustion (%d/%d)\",\n+                        _kanban_task, api_call_count, self.max_iterations,\n+                    )\n+                except Exception:\n+                    logger.warning(\n+                        \"Failed to call kanban_block after iteration \"\n+                        \"exhaustion for task %s\",\n+                        _kanban_task,\n+                        exc_info=True,\n+                    )\n+\n         # Determine if conversation completed successfully\n         completed = final_response is not None and api_call_count < self.max_iterations\n "]},{"file":"scripts/release.py","hunks":["@@ -971,6 +971,12 @@\n     \"[redacted:email]\": \"leehack\",  # PR #22053 salvage (telegram DM topic reply fallback)\n     # pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan\n     \"[redacted:email]\": \"A-kamal\",  # PR #18678 (xAI image resolution fix)\n+    # Kanban bug-fix batch salvage (May 2026)\n+    \"[redacted:email]\": \"Frowtek\",  # salvage of #23206 (gateway --board auto-subscribe)\n+    \"[redacted:email]\": \"Sylw3ster\",  # salvage of #23252 (HERMES_KANBAN_BOARD restore)\n+    \"[redacted:email]\": \"dmnkhorvath\",  # salvage of #23358 (kanban worker send_message)\n+    \"[redacted:email]\": \"smwbev\",  # salvage of #23659 (aria-label colLabel)\n+    \"[redacted:email]\": \"TurgutKural\",  # salvage of #23356 (HERMES_HOME inject)\n }\n \n "]},{"file":"tests/hermes_cli/test_kanban_cli.py","hunks":["@@ -392,3 +392,13 @@ def test_run_slash_missing_required_arg_friendly_error(kanban_home):\n     out = kc.run_slash(\"show\")\n     assert \"/kanban show\" in out\n     assert \"task_id\" in out\n+\n+\n+def test_run_slash_board_override_restores_prior_env(kanban_home, monkeypatch):\n+    kb.create_board(\"alpha\")\n+    kb.create_board(\"beta\")\n+    monkeypatch.setenv(\"HERMES_KANBAN_BOARD\", \"beta\")\n+\n+    kc.run_slash(\"--board alpha list\")\n+\n+    assert os.environ.get(\"HERMES_KANBAN_BOARD\") == \"beta\""]},{"file":"tests/hermes_cli/test_kanban_notify.py","hunks":["@@ -2,6 +2,7 @@\n import pytest\n \n from pathlib import Path\n+from types import SimpleNamespace\n from hermes_cli import kanban_db as kb\n from unittest.mock import AsyncMock, MagicMock, patch\n ","@@ -429,3 +430,52 @@ async def _fast_sleep(_):\n     finally:\n         conn.close()\n     assert subs == []\n+\n+\n[redacted:email]\n+async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home):\n+    \"\"\"`/kanban --board <slug> create ...` must subscribe on that board.\n+\n+    The gateway handler currently auto-subscribes after `/kanban create`,\n+    but the create detection must still work when the shared `--board`\n+    flag appears before the subcommand, and the subscription must land in\n+    that board's DB rather than the ambient/default board.\n+    \"\"\"\n+    from gateway.run import GatewayRunner\n+    from gateway.config import Platform\n+\n+    kb.create_board(\"projx\")\n+\n+    runner = object.__new__(GatewayRunner)\n+    source = SimpleNamespace(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"chat1\",\n+        thread_id=\"th1\",\n+        user_id=\"u1\",\n+    )\n+    event = SimpleNamespace(\n+        text='/kanban --board projx create \"hello\" --assignee alice',\n+        source=source,\n+    )\n+\n+    out = await GatewayRunner._handle_kanban_command(runner, event)\n+\n+    assert \"subscribed\" in out.lower()\n+\n+    conn = kb.connect(board=\"projx\")\n+    try:\n+        subs = kb.list_notify_subs(conn)\n+        tasks = kb.list_tasks(conn)\n+    finally:\n+        conn.close()\n+\n+    assert [t.title for t in tasks] == [\"hello\"]\n+    assert len(subs) == 1\n+    assert subs[0][\"chat_id\"] == \"chat1\"\n+    assert subs[0][\"thread_id\"] == \"th1\"\n+\n+    conn = kb.connect(board=\"default\")\n+    try:\n+        assert kb.list_notify_subs(conn) == []\n+    finally:\n+        conn.close()"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -3344,6 +3344,88 @@ def test_truncated_tool_args_detected_when_finish_reason_not_length(self, agent)\n         assert \"truncated due to output length limit\" in result[\"error\"]\n         mock_handle_function_call.assert_not_called()\n \n+    def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch):\n+        \"\"\"Regression: kanban worker must call kanban_block when iteration\n+        budget is exhausted, otherwise the dispatcher sees a protocol\n+        violation and gives up after 1 failure (issue #23216).\"\"\"\n+        self._setup_agent(agent)\n+        agent.max_iterations = 2\n+\n+        monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"t_test_task_123\")\n+\n+        # Return a tool call for every iteration to exhaust the budget.\n+        tc = _mock_tool_call(name=\"web_search\", arguments=\"{}\", call_id=\"c1\")\n+        tool_resp = _mock_response(\n+            content=\"\", finish_reason=\"tool_calls\", tool_calls=[tc],\n+        )\n+        # Final summary response from _handle_max_iterations.\n+        summary_resp = _mock_response(\n+            content=\"Could not finish — budget exhausted.\", finish_reason=\"stop\",\n+        )\n+        agent.client.chat.completions.create.side_effect = [\n+            tool_resp, tool_resp, summary_resp,\n+        ]\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\", return_value=\"ok\") as mock_hfc,\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"do the kanban work\")\n+\n+        # The agent should have reported the task as not completed.\n+        assert result[\"completed\"] is False\n+\n+        # Among all handle_function_call invocations, one must be\n+        # kanban_block with the correct task_id and a reason mentioning\n+        # iteration exhaustion.\n+        kanban_block_calls = [\n+            c for c in mock_hfc.call_args_list\n+            if c[0][0] == \"kanban_block\"\n+        ]\n+        assert len(kanban_block_calls) == 1, (\n+            f\"Expected exactly 1 kanban_block call, got {len(kanban_block_calls)}. \"\n+            f\"All calls: {mock_hfc.call_args_list}\"\n+        )\n+        call = kanban_block_calls[0]\n+        assert call[0][1][\"task_id\"] == \"t_test_task_123\"\n+        assert \"Iteration budget exhausted\" in call[0][1][\"reason\"]\n+\n+    def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch):\n+        \"\"\"kanban_block must NOT be called when HERMES_KANBAN_TASK is unset.\"\"\"\n+        self._setup_agent(agent)\n+        agent.max_iterations = 2\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        tc = _mock_tool_call(name=\"web_search\", arguments=\"{}\", call_id=\"c1\")\n+        tool_resp = _mock_response(\n+            content=\"\", finish_reason=\"tool_calls\", tool_calls=[tc],\n+        )\n+        summary_resp = _mock_response(\n+            content=\"Summary.\", finish_reason=\"stop\",\n+        )\n+        agent.client.chat.completions.create.side_effect = [\n+            tool_resp, tool_resp, summary_resp,\n+        ]\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\", return_value=\"ok\") as mock_hfc,\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            agent.run_conversation(\"do stuff\")\n+\n+        kanban_block_calls = [\n+            c for c in mock_hfc.call_args_list\n+            if c[0][0] == \"kanban_block\"\n+        ]\n+        assert len(kanban_block_calls) == 0, (\n+            \"kanban_block should not be called outside kanban mode\"\n+        )\n+\n \n class TestRetryExhaustion:\n     \"\"\"Regression: retry_count > max_retries was dead code (off-by-one)."]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -2229,3 +2229,106 @@ async def fake_send(pconfig, chat_id, message, **kwargs):\n         assert result[\"success\"] is True\n         assert result[\"message_id\"] == \"abc-123\"\n         assert result[\"extra_field\"] == \"preserved\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _check_send_message — availability gating\n+# ---------------------------------------------------------------------------\n+\n+class TestCheckSendMessage:\n+    \"\"\"The tool's check_fn governs whether the model sees ``send_message`` as\n+    callable for a given session. The four passing conditions are:\n+\n+    1. ``HERMES_KANBAN_TASK`` is set (worker spawned by the kanban dispatcher\n+       — parent gateway is by definition running, but the worker's\n+       ``HERMES_HOME`` may be a profile dir without a ``gateway.pid``).\n+    2. ``HERMES_SESSION_PLATFORM`` resolves to a non-empty, non-``local`` value\n+       (the session is wired to a messaging platform like Telegram).\n+    3. ``is_gateway_running()`` returns True (CLI / orchestrator profile with\n+       a live gateway colocated under the same ``HERMES_HOME``).\n+    4. None of the above → False, tool is hidden.\n+    \"\"\"\n+\n+    def test_kanban_task_env_grants_access(self, monkeypatch):\n+        \"\"\"Workers spawned by the dispatcher (HERMES_KANBAN_TASK set) must be\n+        allowed regardless of session_platform / gateway-pid state.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"t_abc12345\")\n+        monkeypatch.delenv(\"HERMES_SESSION_PLATFORM\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=False):\n+            assert _check_send_message() is True\n+\n+    def test_kanban_task_env_short_circuits_before_gateway_check(self, monkeypatch):\n+        \"\"\"Honoring HERMES_KANBAN_TASK must not depend on importing or calling\n+        gateway.status — the worker may run with a HERMES_HOME that has no\n+        gateway.pid, and we don't want that import path to be load-bearing.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"t_abc12345\")\n+\n+        with patch(\"gateway.session_context.get_session_env\",\n+                   side_effect=AssertionError(\"session_context not consulted \"\n+                                              \"when HERMES_KANBAN_TASK is set\")), \\\n+             patch(\"gateway.status.is_gateway_running\",\n+                   side_effect=AssertionError(\"gateway.status not consulted \"\n+                                              \"when HERMES_KANBAN_TASK is set\")):\n+            assert _check_send_message() is True\n+\n+    def test_messaging_platform_session_grants_access(self, monkeypatch):\n+        \"\"\"Telegram/Discord/etc. sessions pass via the platform branch even\n+        without HERMES_KANBAN_TASK.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"telegram\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=False):\n+            assert _check_send_message() is True\n+\n+    def test_local_platform_falls_through_to_gateway_check(self, monkeypatch):\n+        \"\"\"``HERMES_SESSION_PLATFORM=local`` means CLI-style — must defer to\n+        is_gateway_running() rather than auto-grant.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"local\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=True) as gw_mock:\n+            assert _check_send_message() is True\n+            gw_mock.assert_called_once()\n+\n+    def test_running_gateway_grants_access(self, monkeypatch):\n+        \"\"\"Plain CLI session (no kanban task, empty platform) with a live\n+        gateway: tool is callable.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=True):\n+            assert _check_send_message() is True\n+\n+    def test_no_signals_means_unavailable(self, monkeypatch):\n+        \"\"\"No kanban task, no platform, no gateway: tool is hidden.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=False):\n+            assert _check_send_message() is False\n+\n+    def test_gateway_status_import_error_is_swallowed(self, monkeypatch):\n+        \"\"\"If gateway.status can't be imported (unusual deployment / partial\n+        install), the check returns False rather than raising.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"\"), \\\n+             patch(\"gateway.status.is_gateway_running\",\n+                   side_effect=ImportError(\"simulated\")):\n+            assert _check_send_message() is False"]},{"file":"tools/send_message_tool.py","hunks":["@@ -1757,7 +1757,20 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No\n \n \n def _check_send_message():\n-    \"\"\"Gate send_message on gateway running (always available on messaging platforms).\"\"\"\n+    \"\"\"Gate send_message on gateway running (always available on messaging platforms).\n+\n+    Also passes for kanban workers — the dispatcher sets ``HERMES_KANBAN_TASK``\n+    on every spawned worker, but those workers run with the assignee profile's\n+    ``HERMES_HOME`` which has no ``gateway.pid``, so the gateway-running check\n+    would fail even though the parent gateway is alive. Honoring the env var\n+    lets workers call ``send_message`` to deliver rich content directly to the\n+    originating chat (paired with ``kanban_complete`` for the short notifier\n+    summary), which is the canonical pattern for any worker that needs to\n+    reply with more than the ~200-char first-line truncation the kanban\n+    notifier applies.\n+    \"\"\"\n+    if os.environ.get(\"HERMES_KANBAN_TASK\"):\n+        return True\n     from gateway.session_context import get_session_env\n     platform = get_session_env(\"HERMES_SESSION_PLATFORM\", \"\")\n     if platform and platform != \"local\":\n"]}]},{"kind":"pr","number":26777,"title":"fix(kanban-dashboard): surface failed Ready-transition with blocking parent detail (#26744)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e215558ba705dac6fa4d8521761e3d7841db1af0","number":0},{"kind":"pr","value":"#28434","number":28434}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":27049,"title":"fix: allow kanban children to stay/become ready when parent is running","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24505","number":24505},{"kind":"pr","value":"#24489","number":24489}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":31765,"title":"fix(kanban): accept 'completed' as terminal status in dependency checks","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":31767,"title":"fix(kanban): normalize legacy completed task status","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31717","number":31717},{"kind":"pr","value":"#31765","number":31765}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":49909,"title":"fix(kanban): allow completing tasks from todo status","url":null,"state":"open","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":49933,"title":"fix(kanban): allow complete_task from any active status on manual completion","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"1af856816d34d7ead3472caa7ea426818f1a0175","number":0},{"kind":"commit","value":"e06df9c2eae6901f4c88f464e0ae6e753c0428b8","number":0},{"kind":"commit","value":"7bda2a3368024f7ae1c70cef16583e7c2c891674","number":0},{"kind":"commit","value":"36d5736f73","number":0},{"kind":"commit","value":"7edf7781dc","number":0},{"kind":"commit","value":"bbc56b6725","number":0},{"kind":"commit","value":"9d87097266","number":0},{"kind":"commit","value":"8f3ca6badb","number":0},{"kind":"commit","value":"fed4b4f742","number":0},{"kind":"commit","value":"66e7224230","number":0},{"kind":"commit","value":"484a50ac78","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":68210,"title":"fix(kanban): let review-required self-park not deadlock dependency children (#67963)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"36128a4a2e5ff260b670d6fd3aa66b023d1ae941","number":0},{"kind":"commit","value":"36128a4a2e5ff260b670d6fd3aa66b023d1ae941","number":0},{"kind":"commit","value":"9c797c5e8a5c","number":0},{"kind":"pr","value":"#68210","number":68210},{"kind":"pr","value":"#52544","number":52544},{"kind":"pr","value":"#72554","number":72554},{"kind":"pr","value":"#75451","number":75451},{"kind":"pr","value":"#52544","number":52544},{"kind":"pr","value":"#68210","number":68210},{"kind":"pr","value":"#72554","number":72554},{"kind":"pr","value":"#75451","number":75451},{"kind":"pr","value":"#75451","number":75451},{"kind":"pr","value":"#75451","number":75451},{"kind":"pr","value":"#52544","number":52544},{"kind":"pr","value":"#72554","number":72554},{"kind":"pr","value":"#72554","number":72554},{"kind":"pr","value":"#75451","number":75451},{"kind":"pr","value":"#68210","number":68210},{"kind":"pr","value":"#75451","number":75451},{"kind":"pr","value":"#75451","number":75451},{"kind":"pr","value":"#72554","number":72554},{"kind":"pr","value":"#68210","number":68210},{"kind":"pr","value":"#75451","number":75451},{"kind":"pr","value":"#52544","number":52544},{"kind":"pr","value":"#72554","number":72554},{"kind":"pr","value":"#1","number":1},{"kind":"pr","value":"#1","number":1},{"kind":"pr","value":"#15803","number":15803},{"kind":"pr","value":"#14532","number":14532},{"kind":"pr","value":"#6","number":6},{"kind":"pr","value":"#1","number":1},{"kind":"pr","value":"#9","number":9},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#4338","number":4338},{"kind":"pr","value":"#67963","number":67963},{"kind":"pr","value":"#67989","number":67989},{"kind":"pr","value":"#68210","number":68210}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":75451,"title":"fix(kanban): complete first-class review lifecycle","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a98aee47ce","number":0},{"kind":"pr","value":"#83412","number":83412}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":83412,"title":"fix(kanban): first-class review lifecycle — salvage of #75451 with review fixes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["AGENTS.md","acp_adapter/tools.py","agent/prompt_builder.py","agent/transports/hermes_tools_mcp_server.py","cli-config.yaml.example","cli.py","contributors/emails/[redacted:email]","gateway/kanban_watchers.py","hermes_cli/config_defaults.py","hermes_cli/goals.py","hermes_cli/kanban.py","hermes_cli/kanban_db.py","hermes_cli/kanban_diagnostics.py","hermes_cli/kanban_swarm.py","plugins/kanban/dashboard/dist/index.js","plugins/kanban/dashboard/dist/style.css","plugins/kanban/dashboard/plugin_api.py","skills/devops/sdlc-review/SKILL.md","tests/hermes_cli/test_kanban_notify.py","tests/hermes_cli/test_kanban_parent_reopen_invalidation.py","tests/hermes_cli/test_kanban_review_lifecycle.py","tests/hermes_cli/test_kanban_review_lifecycle_complete.py","tests/hermes_cli/test_kanban_review_surfaces.py","tests/hermes_cli/test_kanban_swarm.py","tests/plugins/test_kanban_dashboard_plugin.py","tests/skills/test_sdlc_review_skill.py","tools/kanban_tools.py","toolsets.py","website/docs/reference/cli-commands.md","website/docs/reference/tools-reference.md","website/docs/reference/toolsets-reference.md","website/docs/user-guide/features/kanban-tutorial.md","website/docs/user-guide/features/kanban-worker-lanes.md","website/docs/user-guide/features/kanban.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban-tutorial.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban-worker-lanes.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md"]},{"kind":"pr","number":103855,"title":"fix(kanban): render the drawer's stored PATCH error so failed Ready transitions are visible","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}