# NousResearch/hermes-agent · Kanban dependency gating, deadlock, and lifecycle fixes

## Open now

- 3 issues, 4 PRs open on GitHub.
- #26744: Kanban dashboard silently fails when Ready transition is blocked by parent dependency
- #31717: Kanban approval comments do not resume work; completed status does not satisfy dependencies
- #49908: \[Bug\]: Drag-and-drop to Done column on  tasks shows completion prompt then silently reverts
- PR #31765: fix(kanban): accept 'completed' as terminal status in dependency checks
- PR #49909: fix(kanban): allow completing tasks from todo status
- PR #49933: fix(kanban): allow complete_task from any active status on manual completion
- PR #103855: fix(kanban): render the drawer's stored PATCH error so failed Ready transitions are visible

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.

## TL;DR

- 29 member(s): 3 open issue(s), 8 closed issue(s), 4 open PR(s), 4 merged PR(s), 10 PR(s) closed without merging.
- A fix was merged to main in PR #20448. Verify it addresses your case.
- **Recommended:** read PR #20448 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-22671,pr-23791,pr-83412`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.06`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #26744: Kanban dashboard silently fails when Ready transition is blocked by parent dependency
- #31717: Kanban approval comments do not resume work; completed status does not satisfy dependencies
- #49908: \[Bug\]: Drag-and-drop to Done column on  tasks shows completion prompt then silently reverts

## Closed / duplicate issues

- #19746: feat(kanban): add parent dependency guard to direct status writes
- #22375: \[Bug\]: unblock_task bypasses parent-dependency gate
- #23180: \[Bug\]: archiving a parent task silently deadlocks dependent children in todo
- #24489: kanban: parent-child deadlock blocks parallel War Room orchestration
- #25408: feat: kanban task board drag-drop status transitions (hermes parity)
- #65195: kanban unblock on a parented task unlinks parent-child edge and promotes children to ready
- #67963: Dependency-gate semantics gap: blocked parents deadlock dependency children
- #67989: Hermes kanban: blocked parent creates silent dependency deadlock

## Prior & competing PRs

- **#20448: chore(kanban): tier-2 batch salvage — doctor, started_at, parent-guard, latest_summary, selects, linked-children (closes #18344 #20022 #19473 #19828 #19743 #20251 #20019)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `hermes_cli/doctor.py`

    ```diff
    @@ -107,15 +107,35 @@ def _honcho_is_configured_for_doctor() -> bool:
             return False
     
     
    +def _is_kanban_worker_env_gate(item: dict) -> bool:
    +    """Return True when Kanban is unavailable only because this is not a worker process."""
    +    if item.get("name") != "kanban":
    +        return False
    +    if os.environ.get("HERMES_KANBAN_TASK"):
    +        return False
    +
    +    tools = item.get("tools") or []
    +    return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools)
    +
    +
    +def _doctor_tool_availability_detail(toolset: str) -> str:
    +    """Optional explanatory suffix for toolsets whose doctor status needs context."""
    +    if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
    +        return "(runtime-gated; loaded only for dispatcher-spawned workers)"
    +    return ""
    +
    +
     def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
         """Adjust runtime-gated tool availability for doctor diagnostics."""
    -    if not _honcho_is_configured_for_doctor():
    -        return available, unavailable
    -
         updated_available = list(available)
         updated_unavailable = []
         for item in unavailable:
    -        if item.get("name") == "honcho":
    +        name = item.get("name")
    +        if _is_kanban_worker_env_gate(item):
    +            if "kanban" not in updated_available:
    +                updated_available.append("kanban")
    +            continue
    +        if name == "honcho" and _honcho_is_configured_for_doctor():
                 if "honcho" not in updated_available:
                     updated_available.append("honcho")
                 continue
    ```

    ```diff
    @@ -1278,7 +1298,7 @@ def run_doctor(args):
             
             for tid in available:
                 info = TOOLSET_REQUIREMENTS.get(tid, {})
    -            check_ok(info.get("name", tid))
    +            check_ok(info.get("name", tid), _doctor_tool_availability_detail(tid))
             
             for item in unavailable:
                 env_vars = item.get("missing_vars") or item.get("env_vars") or []
    ```
  - `hermes_cli/kanban.py`

    ```diff
    @@ -1071,10 +1071,16 @@ def _cmd_show(args: argparse.Namespace) -> int:
             parents = kb.parent_ids(conn, args.task_id)
             children = kb.child_ids(conn, args.task_id)
             runs = kb.list_runs(conn, args.task_id)
    +        # Workers hand off via ``task_runs.summary`` (kanban-worker skill);
    +        # ``tasks.result`` is left NULL unless the caller explicitly passed
    +        # ``result=``. Surfacing the latest summary here keeps ``show`` from
    +        # looking like a no-op when the worker actually did real work.
    +        latest_summary = kb.latest_summary(conn, args.task_id)
     
         if getattr(args, "json", False):
             payload = {
                 "task": _task_to_dict(task),
    +            "latest_summary": latest_summary,
                 "parents": parents,
                 "children": children,
                 "comments": [
    ```

    ```diff
    @@ -1161,6 +1167,13 @@ def _cmd_show(args: argparse.Namespace) -> int:
             print()
             print("Result:")
             print(task.result)
    +    elif latest_summary:
    +        # Worker handoff lives on the latest run, not on tasks.result.
    +        # Surface it at top-level so a glance at ``hermes kanban show <id>``
    +        # tells you what the worker did even if tasks.result is empty.
    +        print()
    +        print("Latest summary:")
    +        print(latest_summary)
         if comments:
             print()
             print(f"Comments ({len(comments)}):")
    ```
  - `hermes_cli/kanban_db.py`

    ```diff
    @@ -1978,14 +1978,23 @@ def _verify_created_cards(
     ) -> tuple[list[str], list[str]]:
         """Partition ``claimed_ids`` into (verified, phantom).
     
    -    A card is "verified" iff a row exists in ``tasks`` with the given id
    -    AND ``created_by`` matches the completing task's ``assignee`` (or
    -    the completing task itself — workers that create children of their
    -    own task also qualify).
    -
    -    ``phantom`` returns ids that either don't exist at all or exist but
    -    were not created by the completing worker. The caller decides what
    -    to do with each bucket; this helper never mutates.
    +    A card is "verified" iff a row exists in ``tasks`` AND at least one
    +    of the following holds:
    +
    +    * ``created_by`` matches the completing task's ``assignee`` profile
    +      (the common case: worker A spawns a card via ``kanban_create``,
    +      which stamps ``created_by=A``).
    +    * ``created_by`` matches the completing task's id (edge case where
    +      a worker passed its own task id as the ``created_by`` value).
    +    * The card is linked as a ``task_links.child`` of the completing
    +      task — i.e. the worker explicitly called ``kanban_create`` with
    +      ``parents=[<current_task>]``. This accepts cards created through
    +      the dashboard/CLI by a different principal but then attached to
    +      the completing task by the worker.
    +
    +    ``phantom`` returns ids that either don't exist at all, or exist
    +    but don't satisfy any of the three trust conditions. The caller
    +    decides what to do with each bucket; this helper never mutates.
         """
         claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()]
         if not claimed:
    ```

    ```diff
    @@ -2014,6 +2023,10 @@ def _verify_created_cards(
         ).fetchall()
         found = {r["id"]: r["created_by"] for r in rows}
     
    +    # Pull the set of cards linked as children of the completing task.
    +    # Cheap: one query, indexed on parent_id.
    +    linked_children: set[str] = set(child_ids(conn, completing_task_id))
    +
         verified: list[str] = []
         phantom: list[str] = []
         for cid in ordered:
    ```

    ```diff
    @@ -2021,13 +2034,13 @@ def _verify_created_cards(
             if created_by is None:
                 phantom.append(cid)
                 continue
    -        # Accept if created_by matches the completing task's assignee
    -        # profile, OR the task itself (workers whose created_by happens
    -        # to match their task id are unusual but harmless to accept).
    +        # Accept if any of the three trust conditions holds.
             if completing_assignee and created_by == completing_assignee:
                 verified.append(cid)
             elif created_by == completing_task_id:
                 verified.append(cid)
    +        elif cid in linked_children:
    +            verified.append(cid)
             else:
                 phantom.append(cid)
         return verified, phantom
    ```

    ```diff
    @@ -2699,16 +2712,23 @@ def enforce_max_runtime(
         host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
     
         rows = conn.execute(
    -        "SELECT id, worker_pid, started_at, max_runtime_seconds, claim_lock "
    -        "FROM tasks "
    -        "WHERE status = 'running' AND max_runtime_seconds IS NOT NULL "
    -        "  AND started_at IS NOT NULL AND worker_pid IS NOT NULL"
    +        "SELECT t.id, t.worker_pid, "
    +        "       COALESCE(r.started_at, t.started_at) AS active_started_at, "
    +        "       t.max_runtime_seconds, t.claim_lock "
    +        "FROM tasks t "
    +        "LEFT JOIN task_runs r ON r.id = t.current_run_id "
    +        "WHERE t.status = 'running' AND t.max_runtime_seconds IS NOT NULL "
    +        "  AND COALESCE(r.started_at, t.started_at) IS NOT NULL "
    +        "  AND t.worker_pid IS NOT NULL"
         ).fetchall()
         for row in rows:
             lock = row["claim_lock"] or ""
             if not lock.startswith(host_prefix):
                 continue
    -        elapsed = now - int(row["started_at"])
    +        # Runtime is per attempt, not lifetime-of-task. ``tasks.started_at``
    +        # intentionally records the first time a task ever started, so retries
    +        # must be measured from the active task_runs row when present.
    +        elapsed = now - int(row["active_started_at"])
             if elapsed < int(row["max_runtime_seconds"]):
                 continue
     
    ```

    ```diff
    @@ -3993,3 +4013,61 @@ def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
             (task_id,),
         ).fetchone()
         return Run.from_row(row) if row else None
    +
    +
    +def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]:
    +    """Return the latest non-null ``task_runs.summary`` for ``task_id``.
    +
    +    The kanban-worker skill writes its handoff to ``task_runs.summary``
    +    via ``complete_task(summary=...)``; ``tasks.result`` is left empty
    +    unless the caller passes ``result=`` explicitly. Dashboards and CLI
    +    "show" views need this value to surface what a worker actually did
    +    — without it, ``tasks.result`` is NULL and the task looks like a
    +    no-op even when the run completed.
    +
    +    Picks the most recent run by ``ended_at`` (falling back to ``id``
    +    for ties or unfinished rows). Returns None if no run has a summary.
    +    """
    +    row = conn.execute(
    +        "SELECT summary FROM task_runs "
    +        "WHERE task_id = ? AND summary IS NOT NULL AND summary != '' "
    +        "ORDER BY COALESCE(ended_at, started_at) DESC, id DESC LIMIT 1",
    +        (task_id,),
    +    ).fetchone()
    +    return row["summary"] if row else None
    +
    +
    +def latest_summaries(
    +    conn: sqlite3.Connection, task_ids: Iterable[str]
    +) -> dict[str, str]:
    +    """Batch-fetch latest non-null summaries for a list of task ids.
    +
    +    Used by the dashboard board endpoint to attach ``latest_summary`` to
    +    every card in a single SQL query, avoiding the N+1 pattern of
    +    calling :func:`latest_summary` per task. Returns a dict mapping
    +    ``task_id`` → summary string, omitting tasks with no summary.
    +
    +    Approach: a window function picks the newest non-null-summary row
    +    per ``task_id``; works against SQLite ≥ 3.25 (default on every
    +    supported platform).
    +    """
    +    ids = list(task_ids)
    +    if not ids:
    +        return {}
    +    placeholders = ",".join("?" for _ in ids)
    +    rows = conn.execute(
    +        f"""
    +        SELECT task_id, summary FROM (
    +            SELECT task_id, summary,
    +                   ROW_NUMBER() OVER (
    +                       PARTITION BY task_id
    +                       ORDER BY COALESCE(ended_at, started_at) DESC, id DESC
    +                   ) AS rn
    +              FROM task_runs
    +             WHERE task_id IN ({placeholders})
    +               AND summary IS NOT NULL AND summary != ''
    +        ) WHERE rn = 1
    +        """,
    +        ids,
    +    ).fetchall()
    +    return {r["task_id"]: r["summary"] for r in rows}
    ```
  - `plugins/kanban/dashboard/dist/index.js`

    ```diff
    @@ -2416,11 +2416,10 @@
             ),
           ),
           h("div", { className: "hermes-kanban-deps-row" },
    -        h(Select, {
    +        h(Select, Object.assign({
               value: newParent,
    -          onChange: function (e) { setNewParent(e.target.value); },
               className: "h-7 text-xs flex-1",
    -        },
    +        }, selectChangeHandler(setNewParent)),
               h(SelectOption, { value: "" }, "— add parent —"),
               candidatesFor(parentExclude).map(function (t) {
                 return h(SelectOption, { key: t.id, value: t.id },
    ```

    ```diff
    @@ -2455,11 +2454,10 @@
             ),
           ),
           h("div", { className: "hermes-kanban-deps-row" },
    -        h(Select, {
    +        h(Select, Object.assign({
               value: newChild,
    -          onChange: function (e) { setNewChild(e.target.value); },
               className: "h-7 text-xs flex-1",
    -        },
    +        }, selectChangeHandler(setNewChild)),
               h(SelectOption, { value: "" }, "— add child —"),
               candidatesFor(childExclude).map(function (t) {
                 return h(SelectOption, { key: t.id, value: t.id },
    ```
  - `plugins/kanban/dashboard/plugin_api.py`

    ```diff
    @@ -124,11 +124,23 @@ def _conn(board: Optional[str] = None):
     ]
     
     
    -def _task_dict(task: kanban_db.Task) -> dict[str, Any]:
    +_CARD_SUMMARY_PREVIEW_CHARS = 200
    +
    +
    +def _task_dict(
    +    task: kanban_db.Task,
    +    *,
    +    latest_summary: Optional[str] = None,
    +) -> dict[str, Any]:
         d = asdict(task)
         # Add derived age metrics so the UI can colour stale cards without
         # computing deltas client-side.
         d["age"] = kanban_db.task_age(task)
    +    # Surface the latest non-null run summary so dashboards don't show
    +    # blank cards/drawers for tasks where the worker handed off via
    +    # ``task_runs.summary`` (the kanban-worker pattern) instead of
    +    # ``tasks.result``. ``None`` when no run has produced a summary yet.
    +    d["latest_summary"] = latest_summary
         # Keep body short on list endpoints; full body comes from /tasks/:id.
         return d
     
    ```

    ```diff
    @@ -381,8 +393,18 @@ def get_board(
             if include_archived:
                 columns["archived"] = []
     
    +        # Batch-fetch the latest non-null run summary per task in one
    +        # window-function query (avoids N+1 ``latest_summary`` calls
    +        # for boards with hundreds of tasks). Truncated to a card-size
    +        # preview here — the full text is available via /tasks/:id.
    +        summary_map = kanban_db.latest_summaries(conn, [t.id for t in tasks])
    +
             for t in tasks:
    -            d = _task_dict(t)
    +            full = summary_map.get(t.id)
    +            preview = (
    +                full[:_CARD_SUMMARY_PREVIEW_CHARS] if full else None
    +            )
    +            d = _task_dict(t, latest_summary=preview)
                 d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0})
                 d["comment_count"] = comment_counts.get(t.id, 0)
                 d["progress"] = progress.get(t.id)  # None when the task has no children
    ```

    ```diff
    @@ -440,7 +462,11 @@ def get_task(task_id: str, board: Optional[str] = Query(None)):
             task = kanban_db.get_task(conn, task_id)
             if task is None:
                 raise HTTPException(status_code=404, detail=f"task {task_id} not found")
    -        task_d = _task_dict(task)
    +        # Drawer/detail view returns the FULL summary (no truncation) so
    +        # operators can read the complete worker handoff without making
    +        # a second round-trip. Cards on /board carry a 200-char preview.
    +        full_summary = kanban_db.latest_summary(conn, task_id)
    +        task_d = _task_dict(task, latest_summary=full_summary)
             # Attach diagnostics so the drawer's Diagnostics section can
             # render recovery actions without a second round-trip.
             diags = _compute_task_diagnostics(conn, task_ids=[task_id])
    ```

    ```diff
    @@ -662,6 +688,22 @@ def _set_status_direct(
             ).fetchone()
             if prev is None:
                 return False
    +
    +        # Guard: don't allow promoting to 'ready' unless all parents are done.
    +        # Prevents the dispatcher from spawning a child whose upstream work
    +        # hasn't completed (e.g. T4 dispatched while T3 is still blocked).
    +        if new_status == "ready":
    +            parent_statuses = conn.execute(
    +                "SELECT t.status FROM tasks t "
    +                "JOIN task_links l ON l.parent_id = t.id "
    +                "WHERE l.child_id = ?",
    +                (task_id,),
    +            ).fetchall()
    +            if parent_statuses and not all(
    +                p["status"] == "done" for p in parent_statuses
    +            ):
    +                return False
    +
             was_running = prev["status"] == "running"
     
             cur = conn.execute(
    ```
  - `scripts/release.py`

    ```diff
    @@ -129,6 +129,8 @@
         "[redacted:email]": "momowind",
         "[redacted:email]": "misery-hl",
         "[redacted:email]": "misery-hl",
    +    "[redacted:email]": "suncokret12",
    +    "[redacted:email]": "mioimotoai-lgtm",
         "[redacted:email]": "heyitsaamir",
         "[redacted:email]": "johnncenae",
         "[redacted:email]": "ThomassJonax",
    ```
  - `tests/hermes_cli/test_doctor.py`

    ```diff
    @@ -126,6 +126,47 @@ def test_leaves_honcho_unavailable_when_not_configured(self, monkeypatch):
             assert available == []
             assert unavailable == [honcho_entry]
     
    +    def test_marks_kanban_available_only_when_missing_worker_env_gate(self, monkeypatch):
    +        monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: False)
    +        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +
    +        available, unavailable = doctor._apply_doctor_tool_availability_overrides(
    +            [],
    +            [{"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}],
    +        )
    +
    +        assert available == ["kanban"]
    +        assert unavailable == []
    +
    +    def test_leaves_kanban_unavailable_when_worker_env_is_set(self, monkeypatch):
    +        monkeypatch.setenv("HERMES_KANBAN_TASK", "probe")
    +        kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}
    +
    +        available, unavailable = doctor._apply_doctor_tool_availability_overrides(
    +            [],
    +            [kanban_entry],
    +        )
    +
    +        assert available == []
    +        assert unavailable == [kanban_entry]
    +
    +    def test_leaves_non_worker_kanban_failure_unavailable(self, monkeypatch):
    +        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +        kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show", "not_a_kanban_tool"]}
    +
    +        available, unavailable = doctor._apply_doctor_tool_availability_overrides(
    +            [],
    +            [kanban_entry],
    +        )
    +
    +        assert available == []
    +        assert unavailable == [kanban_entry]
    +
    +    def test_kanban_doctor_detail_explains_worker_gate(self, monkeypatch):
    +        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +
    +        assert doctor._doctor_tool_availability_detail("kanban") == "(runtime-gated; loaded only for dispatcher-spawned workers)"
    +
     
     class TestHonchoDoctorConfigDetection:
         def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):
    ```
  - `tests/hermes_cli/test_kanban_core_functionality.py`

    ```diff
    @@ -682,14 +682,21 @@ def _signal_fn(pid, sig):
                     conn, title="long job", assignee="worker",
                     max_runtime_seconds=1,  # one second cap
                 )
    -            # Spawn by hand: claim + set pid + set started_at to the past.
    +            # Spawn by hand: claim + set pid + set active run start to the past.
                 kb.claim_task(conn, tid)
                 kb._set_worker_pid(conn, tid, os.getpid())   # any live pid works
    -            # Backdate started_at so elapsed > limit.
    +            # Backdate both the task-level first-start timestamp and the active
    +            # run timestamp so elapsed > limit under the per-run runtime model.
    +            old_started = int(time.time()) - 30
                 with kb.write_txn(conn):
                     conn.execute(
                         "UPDATE tasks SET started_at = ? WHERE id = ?",
    -                    (int(time.time()) - 30, tid),
    +                    (old_started, tid),
    +                )
    +                conn.execute(
    +                    "UPDATE task_runs SET started_at = ? "
    +                    "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
    +                    (old_started, tid),
                     )
     
                 timed_out = kb.enforce_max_runtime(conn, signal_fn=_signal_fn)
    ```

    ```diff
    @@ -769,10 +776,16 @@ def _signal(pid, sig):
             )
             kb.claim_task(conn, tid)
             kb._set_worker_pid(conn, tid, os.getpid())
    +        old_started = int(time.time()) - 30
             with kb.write_txn(conn):
                 conn.execute(
                     "UPDATE tasks SET started_at = ? WHERE id = ?",
    -                (int(time.time()) - 30, tid),
    +                (old_started, tid),
    +            )
    +            conn.execute(
    +                "UPDATE task_runs SET started_at = ? "
    +                "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
    +                (old_started, tid),
                 )
             # Use enforce_max_runtime directly with our signal stub — dispatch_once
             # uses the default os.kill, but integration-wise calling
    ```

    ```diff
    @@ -2978,6 +2991,46 @@ def test_complete_with_cross_worker_card_is_rejected(kanban_home):
             conn.close()
     
     
    +def test_complete_accepts_cross_worker_card_when_linked_as_child(kanban_home):
    +    """A card created by a different principal but explicitly linked as
    +    a child of the completing task is accepted — the worker took
    +    ownership via ``kanban_create(parents=[current_task])`` or an
    +    explicit ``link_tasks`` call, which proves the relationship even
    +    when ``created_by`` doesn't match.
    +
    +    (Relaxation salvaged from #20022 @LeonSGP43 — stricter version
    +    would incorrectly reject legitimate orchestrator flows where a
    +    specifier creates a card, then a worker picks it up and links it
    +    to its own parent task.)
    +    """
    +    conn = kb.connect()
    +    try:
    +        parent = kb.create_task(conn, title="parent", assignee="alice")
    +        # Card created by a DIFFERENT principal (not alice, not parent).
    +        other = kb.create_task(
    +            conn, title="other", assignee="x", created_by="bob",
    +            parents=[parent],  # explicitly links as child of the completing task
    +        )
    +
    +        ok = kb.complete_task(
    +            conn, parent,
    +            summary="completed with linked child",
    +            created_cards=[other],
    +        )
    +        assert ok is True
    +        # The card should appear in the completed event's verified_cards list.
    +        import json as _json
    +        row = conn.execute(
    +            "SELECT payload FROM task_events "
    +            "WHERE task_id=? AND kind='completed' ORDER BY id DESC LIMIT 1",
    +            (parent,),
    +        ).fetchone()
    +        payload = _json.loads(row["payload"])
    +        assert other in payload.get("verified_cards", [])
    +    finally:
    +        conn.close()
    +
    +
     def test_complete_prose_scan_flags_nonexistent_ids(kanban_home):
         """Successful completion whose summary references a ``t_<hex>`` id
         that doesn't resolve emits a ``suspected_hallucinated_references``
    ```

    ```diff
    @@ -3184,10 +3237,21 @@ def _signal(pid, sig):
             )
             kb.claim_task(conn, tid)
             kb._set_worker_pid(conn, tid, os.getpid())
    +        # Since PR #19473 (salvaged) changed enforce_max_runtime to read
    +        # from task_runs.started_at (per-attempt) rather than
    +        # tasks.started_at (lifetime), we need to backdate BOTH to
    +        # guarantee the timeout fires regardless of which column the
    +        # query pulls from.
             with kb.write_txn(conn):
    +            long_ago = int(time.time()) - 30
                 conn.execute(
                     "UPDATE tasks SET started_at = ? WHERE id = ?",
    -                (int(time.time()) - 30, tid),
    +                (long_ago, tid),
    +            )
    +            conn.execute(
    +                "UPDATE task_runs SET started_at = ? "
    +                "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
    +                (long_ago, tid),
                 )
             before = kb.get_task(conn, tid)
             assert before.consecutive_failures == 0
    ```
  - `tests/hermes_cli/test_kanban_db.py`

    ```diff
    @@ -182,6 +182,52 @@ def test_stale_claim_reclaimed(kanban_home):
             assert kb.get_task(conn, t).status == "ready"
     
     
    +def test_max_runtime_uses_current_run_start_after_retry(kanban_home):
    +    """A retry should get a fresh max-runtime window.
    +
    +    ``tasks.started_at`` intentionally records the first time the task ever
    +    started. Runtime enforcement must therefore use the active
    +    ``task_runs.started_at`` row; otherwise every retry of an old task is
    +    immediately timed out again.
    +    """
    +    with kb.connect() as conn:
    +        host = kb._claimer_id().split(":", 1)[0]
    +        t = kb.create_task(
    +            conn, title="retry", assignee="a", max_runtime_seconds=10,
    +        )
    +
    +        kb.claim_task(conn, t, claimer=f"{host}:first")
    +        first_run_id = kb.latest_run(conn, t).id
    +        old_started = int(time.time()) - 20
    +        conn.execute(
    +            "UPDATE tasks SET started_at = ?, worker_pid = ? WHERE id = ?",
    +            (old_started, 999999, t),
    +        )
    +        conn.execute(
    +            "UPDATE task_runs SET started_at = ?, worker_pid = ? WHERE id = ?",
    +            (old_started, 999999, first_run_id),
    +        )
    +
    +        timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None)
    +        assert timed_out == [t]
    +        assert kb.get_task(conn, t).status == "ready"
    +
    +        kb.claim_task(conn, t, claimer=f"{host}:retry")
    +        retry_run = kb.latest_run(conn, t)
    +        conn.execute(
    +            "UPDATE tasks SET worker_pid = ? WHERE id = ?",
    +            (999999, t),
    +        )
    +        conn.execute(
    +            "UPDATE task_runs SET worker_pid = ? WHERE id = ?",
    +            (999999, retry_run.id),
    +        )
    +
    +        timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None)
    +        assert timed_out == []
    +        assert kb.get_task(conn, t).status == "running"
    +
    +
     def test_heartbeat_extends_claim(kanban_home):
         with kb.connect() as conn:
             t = kb.create_task(conn, title="x", assignee="a")
    ```

    ```diff
    @@ -776,3 +822,80 @@ def __init__(self, cmd, **kwargs):
                 default_home / "kanban" / "workspaces"
             )
             assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env"
    +
    +
    +# ---------------------------------------------------------------------------
    +# latest_summary / latest_summaries — surface task_runs.summary handoffs
    +# ---------------------------------------------------------------------------
    +
    +def test_latest_summary_returns_none_when_no_runs(kanban_home):
    +    """A freshly-created task has no runs and therefore no summary."""
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="fresh", assignee="alice")
    +        assert kb.latest_summary(conn, t) is None
    +
    +
    +def test_latest_summary_returns_summary_after_complete(kanban_home):
    +    """``complete_task(summary=...)`` is the canonical kanban-worker
    +    handoff; ``latest_summary`` must surface it so dashboards/CLI can
    +    render what the worker actually did."""
    +    handoff = "shipped 3 files, ran tests, opened PR #42"
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="work", assignee="alice")
    +        kb.complete_task(conn, t, summary=handoff)
    +        assert kb.latest_summary(conn, t) == handoff
    +
    +
    +def test_latest_summary_picks_newest_when_multiple_runs(kanban_home):
    +    """When a task has been re-run (block → unblock → complete), the
    +    newest run's summary wins. We unblock to take the task back to
    +    ``ready``, then complete a second time and verify the second
    +    summary surfaces."""
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="retry", assignee="alice")
    +        kb.complete_task(conn, t, summary="first attempt")
    +        # Move back to ready by direct SQL — block_task / unblock_task
    +        # paths require an active claim, but we just want a second run
    +        # row to exist with a later ended_at.
    +        conn.execute(
    +            "UPDATE tasks SET status='ready', completed_at=NULL WHERE id=?",
    +            (t,),
    +        )
    +        # Sleep 1s so the second run's ended_at is provably later than
    +        # the first (complete_task uses int(time.time())).
    +        time.sleep(1.05)
    +        kb.complete_task(conn, t, summary="second attempt — final")
    +        assert kb.latest_summary(conn, t) == "second attempt — final"
    +
    +
    +def test_latest_summary_skips_empty_string(kanban_home):
    +    """A run with an empty-string summary should not mask an earlier
    +    populated one — empty strings carry no information."""
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="t", assignee="alice")
    +        kb.complete_task(conn, t, summary="real handoff")
    +        # Inject a later run with empty summary directly. Workers
    +        # writing "" instead of None is a real shape we want to ignore.
    +        conn.execute(
    +            "INSERT INTO task_runs (task_id, status, started_at, ended_at, "
    +            "outcome, summary) VALUES (?, 'done', ?, ?, 'completed', ?)",
    +            (t, int(time.time()) + 1, int(time.time()) + 2, ""),
    +        )
    +        conn.commit()
    +        assert kb.latest_summary(conn, t) == "real handoff"
    +
    +
    +def test_latest_summaries_batch_omits_tasks_without_summary(kanban_home):
    +    """``latest_summaries`` is the dashboard's N+1 escape hatch — it
    +    must return only entries for tasks that actually have a summary,
    +    keep the per-task latest, and accept an empty input gracefully."""
    +    with kb.connect() as conn:
    +        t1 = kb.create_task(conn, title="a", assignee="alice")
    +        t2 = kb.create_task(conn, title="b", assignee="bob")
    +        t3 = kb.create_task(conn, title="c", assignee="carol")
    +        kb.complete_task(conn, t1, summary="alpha")
    +        kb.complete_task(conn, t3, summary="charlie")
    +        out = kb.latest_summaries(conn, [t1, t2, t3])
    +        assert out == {t1: "alpha", t3: "charlie"}
    +        # Empty input → empty dict, no SQL syntax error from "IN ()".
    +        assert kb.latest_summaries(conn, []) == {}
    ```
  - `tests/plugins/test_kanban_dashboard_plugin.py`

    ```diff
    @@ -203,7 +203,10 @@ def test_patch_block_then_unblock(client):
     
     def test_patch_drag_drop_move_todo_to_ready(client):
         """Direct status write: the drag-drop path for statuses without a
    -    dedicated verb (e.g. manually promoting todo -> ready)."""
    +    dedicated verb (e.g. manually promoting todo -> ready).
    +
    +    Promoting a child whose parent is not done is rejected (409).
    +    Promoting a child whose parent IS done is accepted (200)."""
         parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"]
         child = client.post(
             "/api/plugins/kanban/tasks",
    ```

    ```diff
    @@ -211,12 +214,23 @@ def test_patch_drag_drop_move_todo_to_ready(client):
         ).json()["task"]
         assert child["status"] == "todo"
     
    +    # Rejected: parent not done yet.
         r = client.patch(
             f"/api/plugins/kanban/tasks/{child['id']}",
             json={"status": "ready"},
         )
    +    assert r.status_code == 409
    +
    +    # Complete the parent.
    +    r = client.patch(
    +        f"/api/plugins/kanban/tasks/{parent['id']}",
    +        json={"status": "done"},
    +    )
         assert r.status_code == 200
    -    assert r.json()["task"]["status"] == "ready"
    +
    +    # Now child auto-promoted by recompute_ready — already ready.
    +    child_after = client.get(f"/api/plugins/kanban/tasks/{child['id']}").json()["task"]
    +    assert child_after["status"] == "ready"
     
     
     def test_patch_reassign(client):
    ```

    ```diff
    @@ -433,13 +447,17 @@ def test_board_progress_rollup(client):
             "/api/plugins/kanban/tasks",
             json={"title": "b", "parents": [parent["id"]]},
         ).json()["task"]
    -    # Children start as "todo" because the parent isn't done yet; promote
    -    # them to "ready" so complete_task will accept the transition.
    +    # Children start as "todo" because the parent isn't done yet.  Set the
    +    # parent to done so children auto-promote to ready via recompute_ready.
    +    r = client.patch(
    +        f"/api/plugins/kanban/tasks/{parent['id']}",
    +        json={"status": "done"},
    +    )
    +    assert r.status_code == 200
    +    # Verify children are now ready.
         for cid in (child_a["id"], child_b["id"]):
    -        r = client.patch(
    -            f"/api/plugins/kanban/tasks/{cid}", json={"status": "ready"},
    -        )
    -        assert r.status_code == 200
    +        t = client.get(f"/api/plugins/kanban/tasks/{cid}").json()["task"]
    +        assert t["status"] == "ready", f"{cid} should be ready after parent done"
     
         # 0/2 done.
         r = client.get("/api/plugins/kanban/board")
    ```

    ```diff
    @@ -604,6 +622,32 @@ def test_dashboard_done_actions_prompt_for_completion_summary():
         assert "body: JSON.stringify(finalPatch)" in bundle
     
     
    +def test_dashboard_dependency_selects_use_value_change_handler():
    +    """Regression for the dependency selects in the task drawer: the
    +    add-parent / add-child dropdowns must wire through the shared
    +    selectChangeHandler helper so their value actually lands on the
    +    underlying React state. Salvaged from #20019 @LeonSGP43.
    +    """
    +    repo_root = Path(__file__).resolve().parents[2]
    +    bundle = (
    +        repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
    +    ).read_text()
    +
    +    parent_select = (
    +        'value: newParent,\n'
    +        '          className: "h-7 text-xs flex-1",\n'
    +        '        }, selectChangeHandler(setNewParent))'
    +    )
    +    child_select = (
    +        'value: newChild,\n'
    +        '          className: "h-7 text-xs flex-1",\n'
    +        '        }, selectChangeHandler(setNewChild))'
    +    )
    +
    +    assert parent_select in bundle
    +    assert child_select in bundle
    +
    +
     def test_bulk_archive(client):
         a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
         b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
    ```
- **#22372: fix(kanban): route unblock through dependency gate**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `cda20eec0`, `#22375`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#22405: fix(kanban): route unblock_task through parent-dependency gate**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `cda20eec0`, `0c22434f0`, `#22375`, `#22627`
- **#22671: fix(kanban): gate claim+unblock on parent completion (salvage #22581)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/kanban_db.py`, `scripts/release.py`, `tests/hermes_cli/test_kanban_db.py`, `tests/stress/test_concurrency_parent_gate.py`
  - fix hunks:
  - `hermes_cli/kanban_db.py`

    ```diff
    @@ -1804,6 +1804,31 @@ def claim_task(
         lock = claimer or _claimer_id()
         expires = now + int(ttl_seconds)
         with write_txn(conn):
    +        # Structural invariant: never transition ready -> running while any
    +        # parent is not yet 'done'. This is the single enforcement point
    +        # regardless of which writer (create_task, link_tasks, unblock_task,
    +        # release_stale_claims, manual SQL) set status='ready'. If a racy
    +        # writer promoted a task with undone parents, demote it back to
    +        # 'todo' here — recompute_ready will re-promote when the parents
    +        # actually finish. See RCA at
    +        # kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md.
    +        undone = conn.execute(
    +            "SELECT 1 FROM task_links l "
    +            "JOIN tasks p ON p.id = l.parent_id "
    +            "WHERE l.child_id = ? AND p.status != 'done' LIMIT 1",
    +            (task_id,),
    +        ).fetchone()
    +        if undone:
    +            conn.execute(
    +                "UPDATE tasks SET status = 'todo' "
    +                "WHERE id = ? AND status = 'ready'",
    +                (task_id,),
    +            )
    +            _append_event(
    +                conn, task_id, "claim_rejected",
    +                {"reason": "parents_not_done"},
    +            )
    +            return None
             # Defensive: if a prior run somehow leaked (invariant violation from
             # an unknown code path), close it as 'reclaimed' so we don't strand
             # it when the CAS resets the pointer below. No-op when the invariant
    ```

    ```diff
    @@ -2503,14 +2528,30 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:
                     """,
                     (now, int(stale["current_run_id"])),
                 )
    +        # Re-gate on parent completion before flipping 'blocked' back to
    +        # 'ready'. Unconditionally setting status='ready' here bypasses the
    +        # parent-completion invariant (the dispatcher trusts that column);
    +        # if parents are still in progress the task must wait in 'todo'
    +        # until recompute_ready picks it up. RCA: Bug 2 at
    +        # kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md.
    +        undone_parents = conn.execute(
    +            "SELECT 1 FROM task_links l "
    +            "JOIN tasks p ON p.id = l.parent_id "
    +            "WHERE l.child_id = ? AND p.status != 'done' LIMIT 1",
    +            (task_id,),
    +        ).fetchone()
    +        new_status = "todo" if undone_parents else "ready"
             cur = conn.execute(
    -            "UPDATE tasks SET status = 'ready', current_run_id = NULL "
    +            "UPDATE tasks SET status = ?, current_run_id = NULL "
                 "WHERE id = ? AND status = 'blocked'",
    -            (task_id,),
    +            (new_status, task_id),
             )
             if cur.rowcount != 1:
                 return False
    -        _append_event(conn, task_id, "unblocked", None)
    +        _append_event(
    +            conn, task_id, "unblocked",
    +            {"status": new_status} if new_status != "ready" else None,
    +        )
             return True
     
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -65,6 +65,7 @@
         "[redacted:email]": "subtract0",
         "[redacted:email]": "qWaitCrypto",
         "[redacted:email]": "wesleysimplicio",
    +    "[redacted:email]": "SiliconID",
         "[redacted:email]": "uzunkuyruk",
         "[redacted:email]": "Zhekinmaksim",
         "[redacted:email]": "KvnGz",
    ```
  - `tests/hermes_cli/test_kanban_db.py`

    ```diff
    @@ -298,6 +298,122 @@ def test_block_then_unblock(kanban_home):
             assert kb.get_task(conn, t).status == "ready"
     
     
    +# ---------------------------------------------------------------------------
    +# Parent-completion invariant at the claim gate (RCA t_a6acd07d)
    +# ---------------------------------------------------------------------------
    +
    +def test_claim_rejects_when_parents_not_done(kanban_home):
    +    """claim_task must refuse ready->running if any parent isn't 'done'.
    +
    +    Simulates the create-then-link race: a task gets status='ready' via a
    +    racy writer while it still has undone parents. The claim gate must
    +    detect the violation, demote the child back to 'todo', append a
    +    'claim_rejected' event, and return None. Covers Fix 1 of the RCA.
    +    """
    +    with kb.connect() as conn:
    +        parent = kb.create_task(conn, title="parent", assignee="a")
    +        child = kb.create_task(
    +            conn, title="child", assignee="a", parents=[parent],
    +        )
    +        # Child correctly starts 'todo' because parent is not 'done'.
    +        assert kb.get_task(conn, child).status == "todo"
    +        # Simulate the race: a racy writer force-promotes the child to
    +        # 'ready' while parent is still pending.
    +        conn.execute(
    +            "UPDATE tasks SET status='ready' WHERE id=?", (child,),
    +        )
    +        conn.commit()
    +        assert kb.get_task(conn, child).status == "ready"
    +
    +        result = kb.claim_task(conn, child, claimer="host:1")
    +
    +    assert result is None
    +    with kb.connect() as conn:
    +        assert kb.get_task(conn, child).status == "todo"
    +        events = conn.execute(
    +            "SELECT kind, payload FROM task_events "
    +            "WHERE task_id = ? ORDER BY id",
    +            (child,),
    +        ).fetchall()
    +    kinds = [e["kind"] for e in events]
    +    assert "claim_rejected" in kinds
    +    # No 'claimed' event was emitted for the blocked attempt.
    +    assert "claimed" not in kinds
    +
    +
    +def test_claim_succeeds_once_parents_done(kanban_home):
    +    """After parents complete, recompute_ready -> claim_task must succeed."""
    +    with kb.connect() as conn:
    +        parent = kb.create_task(conn, title="parent", assignee="a")
    +        child = kb.create_task(
    +            conn, title="child", assignee="a", parents=[parent],
    +        )
    +        kb.claim_task(conn, parent)
    +        assert kb.complete_task(conn, parent, result="ok")
    +        kb.recompute_ready(conn)
    +        assert kb.get_task(conn, child).status == "ready"
    +        claimed = kb.claim_task(conn, child, claimer="host:1")
    +    assert claimed is not None
    +    assert claimed.status == "running"
    +
    +
    +def test_create_with_parents_stays_todo_until_parents_done(kanban_home):
    +    """kanban_create(parents=[...]) must land in 'todo' and only promote on parent done."""
    +    with kb.connect() as conn:
    +        parent = kb.create_task(conn, title="parent", assignee="a")
    +        child = kb.create_task(
    +            conn, title="child", assignee="a", parents=[parent],
    +        )
    +        assert kb.get_task(conn, child).status == "todo"
    +        # Dispatcher tick between create and some later event must NOT
    +        # produce a winner for this child.
    +        promoted = kb.recompute_ready(conn)
    +        assert promoted == 0
    +        assert kb.get_task(conn, child).status == "todo"
    +        # Complete parent; complete_task internally runs recompute_ready,
    +        # which promotes the child to 'ready'.
    +        kb.claim_task(conn, parent)
    +        kb.complete_task(conn, parent, result="ok")
    +        assert kb.get_task(conn, child).status == "ready"
    +
    +
    +def test_unblock_with_pending_parents_goes_to_todo(kanban_home):
    +    """unblock_task must re-gate on parent completion (Fix 3).
    +
    +    A task blocked while parents are still in progress must return to
    +    'todo' (not 'ready') on unblock. Otherwise the dispatcher will claim
    +    it immediately, repeating Bug 2 from the RCA.
    +    """
    +    with kb.connect() as conn:
    +        parent = kb.create_task(conn, title="parent", assignee="a")
    +        child = kb.create_task(
    +            conn, title="child", assignee="a", parents=[parent],
    +        )
    +        # Force child into 'blocked' regardless of parent progress
    +        # (simulates a worker that self-blocked, or an operator block).
    +        conn.execute(
    +            "UPDATE tasks SET status='blocked' WHERE id=?", (child,),
    +        )
    +        conn.commit()
    +        assert kb.unblock_task(conn, child)
    +        assert kb.get_task(conn, child).status == "todo"
    +        # After parent completes + recompute, the child is ready.
    +        kb.claim_task(conn, parent)
    +        kb.complete_task(conn, parent, result="ok")
    +        kb.recompute_ready(conn)
    +        assert kb.get_task(conn, child).status == "ready"
    +
    +
    +def test_unblock_without_parents_goes_to_ready(kanban_home):
    +    """Parent-free unblock still produces 'ready' (behavior preserved)."""
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="lone", assignee="a")
    +        kb.claim_task(conn, t)
    +        assert kb.block_task(conn, t, reason="need input")
    +        assert kb.unblock_task(conn, t)
    +        assert kb.get_task(conn, t).status == "ready"
    +
    +
     def test_assign_refuses_while_running(kanban_home):
         with kb.connect() as conn:
             t = kb.create_task(conn, title="x", assignee="a")
    ```
  - `tests/stress/test_concurrency_parent_gate.py`

    ```diff
    @@ -0,0 +1,183 @@
    +"""Stress test for parent-completion invariant at the claim gate.
    +
    +Simulates the create-then-link race described in RCA t_a6acd07d:
    +
    +  Thread A: repeatedly inserts a child row with status='ready' (racy
    +            writer) and a split-second-later inserts the parent link,
    +            emulating the pre-fix _kanban_create path.
    +  Thread B: repeatedly runs claim_task against every ready task.
    +
    +Pass criteria: no task is ever 'claimed' while any of its parents is
    +not 'done'. The claim_task gate added in hermes_cli/kanban_db.py must
    +demote such tasks back to 'todo' and emit a 'claim_rejected' event
    +instead of spawning.
    +
    +Run as a script (`python tests/stress/test_concurrency_parent_gate.py`)
    +or via `pytest --run-stress`. The default pytest collection in
    +tests/stress/conftest.py ignores *.py globs, so this is a script.
    +"""
    +from __future__ import annotations
    +
    +import os
    +import random
    +import sys
    +import tempfile
    +import threading
    +import time
    +from pathlib import Path
    +
    +WT = str(Path(__file__).resolve().parents[2])
    +sys.path.insert(0, WT)
    +
    +NUM_CREATE_ROUNDS = 200
    +WORKERS_RUN_DURATION_S = 8
    +
    +
    +def run() -> int:
    +    home = tempfile.mkdtemp(prefix="hermes_parent_gate_stress_")
    +    os.environ["HERMES_HOME"] = home
    +    os.environ["HOME"] = home
    +
    +    from hermes_cli import kanban_db as kb
    +
    +    kb.init_db()
    +
    +    # Seed N parents in 'ready' state. They stay ready for the whole run
    +    # (never 'done'), so every child linked to one of them must remain
    +    # unclaimable.
    +    parent_ids: list[str] = []
    +    conn = kb.connect()
    +    try:
    +        for i in range(10):
    +            parent_ids.append(
    +                kb.create_task(conn, title=f"parent-{i}", assignee="a")
    +            )
    +    finally:
    +        conn.close()
    +
    +    created_children: list[str] = []
    +    created_lock = threading.Lock()
    +    stop = threading.Event()
    +    violations: list[str] = []
    +
    +    def racy_creator() -> None:
    +        """Inserts child rows with status='ready' and links them after.
    +
    +        This is the pre-fix _kanban_create behavior — the very race
    +        the gate in claim_task must catch.
    +        """
    +        conn = kb.connect()
    +        try:
    +            for _ in range(NUM_CREATE_ROUNDS):
    +                if stop.is_set():
    +                    return
    +                parents = random.sample(parent_ids, k=2)
    +                # Step 1: insert child WITHOUT parents (ends up ready).
    +                child = kb.create_task(
    +                    conn, title="child", assignee="a", parents=[],
    +                )
    +                # Tiny delay so worker threads get a chance to see the
    +                # ready row before the links are inserted.
    +                time.sleep(random.uniform(0.0001, 0.002))
    +                # Step 2: add the parent links after the fact.
    +                for p in parents:
    +                    try:
    +                        kb.link_tasks(conn, parent_id=p, child_id=child)
    +                    except Exception:
    +                        pass
    +                with created_lock:
    +                    created_children.append(child)
    +        finally:
    +            conn.close()
    +
    +    def worker_loop() -> None:
    +        conn = kb.connect()
    +        try:
    +            end = time.monotonic() + WORKERS_RUN_DURATION_S
    +            while time.monotonic() < end and not stop.is_set():
    +                row = conn.execute(
    +                    "SELECT id FROM tasks WHERE status='ready' "
    +                    "AND claim_lock IS NULL ORDER BY RANDOM() LIMIT 1"
    +                ).fetchone()
    +                if row is None:
    +                    time.sleep(0.002)
    +                    continue
    +                tid = row["id"]
    +                try:
    +                    claimed = kb.claim_task(conn, tid, claimer="w")
    +                except Exception:
    +                    continue
    +                if claimed is None:
    +                    continue
    +                # Invariant: a successful claim on `tid` must mean all
    +                # parents are 'done'. Check in the same connection txn
    +                # so we see the post-claim state.
    +                undone = conn.execute(
    +                    "SELECT l.parent_id, p.status FROM task_links l "
    +                    "JOIN tasks p ON p.id = l.parent_id "
    +                    "WHERE l.child_id = ? AND p.status != 'done'",
    +                    (tid,),
    +                ).fetchall()
    +                if undone:
    +                    violations.append(
    +                        f"claimed {tid} while parents not done: "
    +                        + ",".join(f"{r['parent_id']}={r['status']}" for r in undone)
    +                    )
    +                # Release so the run doesn't leak and the next round sees ready.
    +                kb.complete_task(conn, tid, result="stress-ok")
    +        finally:
    +            conn.close()
    +
    +    creator = threading.Thread(target=racy_creator, daemon=True)
    +    workers = [threading.Thread(target=worker_loop, daemon=True)
    +               for _ in range(4)]
    +    creator.start()
    +    for w in workers:
    +        w.start()
    +    creator.join()
    +    # Give the workers a chance to fully drain ready rows before we stop.
    +    time.sleep(0.5)
    +    stop.set()
    +    for w in workers:
    +        w.join(timeout=WORKERS_RUN_DURATION_S + 2)
    +
    +    # Post-run audit: the DB event log must show no 'claimed' event on any
    +    # task whose parents were not 'done' at the time of the claim.
    +    conn = kb.connect()
    +    try:
    +        bad = conn.execute(
    +            """
    +            WITH claims AS (
    +              SELECT task_id, created_at AS t
    +              FROM task_events WHERE kind='claimed'
    +            )
    +            SELECT c.task_id, l.parent_id, p.status, p.completed_at
    +            FROM claims c
    +            JOIN task_links l ON l.child_id = c.task_id
    +            JOIN tasks p ON p.id = l.parent_id
    +            WHERE p.completed_at IS NULL OR p.completed_at > c.t
    +            """
    +        ).fetchall()
    +        rejections = conn.execute(
    +            "SELECT COUNT(*) FROM task_events WHERE kind='claim_rejected'"
    +        ).fetchone()[0]
    +    finally:
    +        conn.close()
    +
    +    print(f"children created:  {len(created_children)}")
    +    print(f"violations:        {len(violations)}")
    +    print(f"event-log bad:     {len(bad)}")
    +    print(f"claim_rejected:    {rejections}")
    +
    +    if violations or bad:
    +        for v in violations[:10]:
    +            print("  VIOLATION:", v)
    +        for row in list(bad)[:10]:
    +            print("  EVENT-LOG BAD:", dict(row))
    +        return 1
    +    print("PARENT-GATE INVARIANT HELD UNDER RACE")
    +    return 0
    +
    +
    +if __name__ == "__main__":
    +    sys.exit(run())
    ```
- **#23298: fix(cli): warn before archiving a task with dependent children**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#23325`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#23791: fix(kanban): batch-salvage 7 community kanban bug fixes**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `gateway/run.py`

    ```diff
    @@ -8329,6 +8329,7 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:
             """
             import asyncio
             import re
    +        import shlex
             from hermes_cli.kanban import run_slash
     
             text = (event.text or "").strip()
    ```

    ```diff
    @@ -8338,7 +8339,26 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:
             if text.startswith("kanban"):
                 text = text[len("kanban"):].lstrip()
     
    -        is_create = text.split(None, 1)[:1] == ["create"]
    +        tokens = [redacted:secret-assignment] if text else []
    +        requested_board = None
    +        action = None
    +        i = 0
    +        while i < len(tokens):
    +            tok = tokens[i]
    +            if tok == "--board":
    +                if i + 1 >= len(tokens):
    +                    break
    +                requested_board = tokens[i + 1]
    +                i += 2
    +                continue
    +            if tok.startswith("--board="):
    +                requested_board = tok.split("=", 1)[1]
    +                i += 1
    +                continue
    +            action = tok
    +            break
    +
    +        is_create = action == "create"
     
             try:
                 output = await asyncio.to_thread(run_slash, text)
    ```

    ```diff
    @@ -8365,7 +8385,7 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:
                         if platform_str and chat_id:
                             def _sub():
                                 from hermes_cli import kanban_db as _kb
    -                            conn = _kb.connect()
    +                            conn = _kb.connect(board=requested_board)
                                 try:
                                     _kb.add_notify_sub(
                                         conn, task_id=task_id,
    ```
  - `hermes_cli/kanban.py`

    ```diff
    @@ -652,6 +652,16 @@ def kanban_command(args: argparse.Namespace) -> int:
         # keeps the patch small and inherits the exact same resolution the
         # dispatcher uses for workers — consistency is a feature here.
         board_override = getattr(args, "board", None)
    +    prev_board_env = os.environ.get("HERMES_KANBAN_BOARD")
    +    restore_board_env = False
    +
    +    def _restore_board_env() -> None:
    +        if not restore_board_env:
    +            return
    +        if prev_board_env is None:
    +            os.environ.pop("HERMES_KANBAN_BOARD", None)
    +        else:
    +            os.environ["HERMES_KANBAN_BOARD"] = prev_board_env
         if board_override:
             try:
                 normed = kb._normalize_board_slug(board_override)
    ```

    ```diff
    @@ -671,12 +681,16 @@ def kanban_command(args: argparse.Namespace) -> int:
                 )
                 return 1
             os.environ["HERMES_KANBAN_BOARD"] = normed
    +        restore_board_env = True
     
         # Boards management doesn't touch the DB at all — dispatch early so
         # fresh installs that haven't initialized any DB can still use
         # `hermes kanban boards create …`.
         if action == "boards":
    -        return _dispatch_boards(args)
    +        try:
    +            return _dispatch_boards(args)
    +        finally:
    +            _restore_board_env()
     
         # Auto-initialize the DB before dispatching any subcommand. init_db
         # is idempotent, so running it every invocation is cheap (one
    ```

    ```diff
    @@ -689,6 +703,7 @@ def kanban_command(args: argparse.Namespace) -> int:
             kb.init_db()
         except Exception as exc:
             print(f"kanban: could not initialize database: {exc}", file=sys.stderr)
    +        _restore_board_env()
             return 1
     
         handlers = {
    ```

    ```diff
    @@ -730,12 +745,16 @@ def kanban_command(args: argparse.Namespace) -> int:
         handler = handlers.get(action)
         if not handler:
             print(f"kanban: unknown action {action!r}", file=sys.stderr)
    +        _restore_board_env()
             return 2
         try:
             return int(handler(args) or 0)
         except (ValueError, RuntimeError) as exc:
             print(f"kanban: {exc}", file=sys.stderr)
    +        _restore_board_env()
             return 1
    +    finally:
    +        _restore_board_env()
     
     
     # ---------------------------------------------------------------------------
    ```
  - `hermes_cli/kanban_db.py`

    ```diff
    @@ -1826,7 +1826,7 @@ def _synthesize_ended_run(
     # ---------------------------------------------------------------------------
     
     def recompute_ready(conn: sqlite3.Connection) -> int:
    -    """Promote ``todo`` tasks to ``ready`` when all parents are ``done``.
    +    """Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
     
         Returns the number of tasks promoted.  Safe to call inside or outside
         an existing transaction; it opens its own IMMEDIATE txn.
    ```

    ```diff
    @@ -1844,7 +1844,7 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
                     "WHERE l.child_id = ?",
                     (task_id,),
                 ).fetchall()
    -            if all(p["status"] == "done" for p in parents):
    +            if all(p["status"] in ("done", "archived") for p in parents):
                     conn.execute(
                         "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'",
                         (task_id,),
    ```

    ```diff
    @@ -1885,7 +1885,7 @@ def claim_task(
             undone = conn.execute(
                 "SELECT 1 FROM task_links l "
                 "JOIN tasks p ON p.id = l.parent_id "
    -            "WHERE l.child_id = ? AND p.status != 'done' LIMIT 1",
    +            "WHERE l.child_id = ? AND p.status NOT IN ('done', 'archived') LIMIT 1",
                 (task_id,),
             ).fetchone()
             if undone:
    ```

    ```diff
    @@ -3930,6 +3930,25 @@ def _default_spawn(
     
         prompt = f"work kanban task {task.id}"
         env = dict(os.environ)
    +
    +    # Inject HERMES_HOME so the worker reads the profile-scoped config.yaml
    +    # (fallback_providers, toolsets, agent settings, etc.) instead of the root
    +    # config.  Without this, `env = dict(os.environ)` copies only the parent's
    +    # env, and when the child process starts `hermes -p <name>` the
    +    # _apply_profile_override() runs *before* hermes_constants is imported.
    +    # If HERMES_HOME is absent from the child's env, get_hermes_home() falls
    +    # back to Path.home() / ".hermes" (the DEFAULT profile root), ignoring the
    +    # profile-specific config entirely.  Fixes profile-scoped fallback_providers
    +    # being invisible to kanban workers.
    +    from hermes_cli.profiles import resolve_profile_env
    +    try:
    +        env["HERMES_HOME"] = resolve_profile_env(profile_arg)
    +    except FileNotFoundError:
    +        # Profile dir doesn't exist — defer resolution to the CLI's
    +        # _apply_profile_override() via HERMES_PROFILE (set below).
    +        # This only happens in test fixtures where the isolated
    +        # HERMES_HOME never had profiles created.
    +        pass
         if task.tenant:
             env["HERMES_TENANT"] = task.tenant
         env["HERMES_KANBAN_TASK"] = task.id
    ```
  - `plugins/kanban/dashboard/dist/index.js`

    ```diff
    @@ -1898,7 +1898,7 @@
               type: "checkbox",
               className: "hermes-kanban-col-check",
               title: "Select all tasks in this column",
    -          "aria-label": `Select all tasks in ${COLUMN_LABEL[props.column.name] || props.column.name}`,
    +          "aria-label": `Select all tasks in ${colLabel || props.column.name}`,
               checked: props.column.tasks.length > 0 && props.column.tasks.every(function (t) { return props.selectedIds.has(t.id); }),
               onChange: function (e) {
                 e.stopPropagation();
    ```
  - `run_agent.py`

    ```diff
    @@ -14987,7 +14987,41 @@ def _stop_spinner():
                         "— requesting summary..."
                     )
                 final_response = self._handle_max_iterations(messages, api_call_count)
    -        
    +
    +            # If running as a kanban worker, block the task so the dispatcher
    +            # knows the worker could not complete (rather than treating it as a
    +            # protocol violation).  The agent loop strips tools before calling
    +            # _handle_max_iterations, so the model cannot call kanban_block
    +            # itself — we must do it on its behalf.
    +            _kanban_task = os.environ.get("HERMES_KANBAN_TASK")
    +            if _kanban_task:
    +                try:
    +                    handle_function_call(
    +                        "kanban_block",
    +                        {
    +                            "task_id": _kanban_task,
    +                            "reason": (
    +                                f"Iteration budget exhausted "
    +                                f"({api_call_count}/{self.max_iterations}) — "
    +                                "task could not complete within the allowed "
    +                                "iterations"
    +                            ),
    +                        },
    +                        task_id=effective_task_id,
    +                    )
    +                    logger.info(
    +                        "kanban_block called for task %s after iteration "
    +                        "exhaustion (%d/%d)",
    +                        _kanban_task, api_call_count, self.max_iterations,
    +                    )
    +                except Exception:
    +                    logger.warning(
    +                        "Failed to call kanban_block after iteration "
    +                        "exhaustion for task %s",
    +                        _kanban_task,
    +                        exc_info=True,
    +                    )
    +
             # Determine if conversation completed successfully
             completed = final_response is not None and api_call_count < self.max_iterations
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -971,6 +971,12 @@
         "[redacted:email]": "leehack",  # PR #22053 salvage (telegram DM topic reply fallback)
         # pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan
         "[redacted:email]": "A-kamal",  # PR #18678 (xAI image resolution fix)
    +    # Kanban bug-fix batch salvage (May 2026)
    +    "[redacted:email]": "Frowtek",  # salvage of #23206 (gateway --board auto-subscribe)
    +    "[redacted:email]": "Sylw3ster",  # salvage of #23252 (HERMES_KANBAN_BOARD restore)
    +    "[redacted:email]": "dmnkhorvath",  # salvage of #23358 (kanban worker send_message)
    +    "[redacted:email]": "smwbev",  # salvage of #23659 (aria-label colLabel)
    +    "[redacted:email]": "TurgutKural",  # salvage of #23356 (HERMES_HOME inject)
     }
     
     
    ```
  - `tests/hermes_cli/test_kanban_cli.py`

    ```diff
    @@ -392,3 +392,13 @@ def test_run_slash_missing_required_arg_friendly_error(kanban_home):
         out = kc.run_slash("show")
         assert "/kanban show" in out
         assert "task_id" in out
    +
    +
    +def test_run_slash_board_override_restores_prior_env(kanban_home, monkeypatch):
    +    kb.create_board("alpha")
    +    kb.create_board("beta")
    +    monkeypatch.setenv("HERMES_KANBAN_BOARD", "beta")
    +
    +    kc.run_slash("--board alpha list")
    +
    +    assert os.environ.get("HERMES_KANBAN_BOARD") == "beta"
    ```
  - `tests/hermes_cli/test_kanban_notify.py`

    ```diff
    @@ -2,6 +2,7 @@
     import pytest
     
     from pathlib import Path
    +from types import SimpleNamespace
     from hermes_cli import kanban_db as kb
     from unittest.mock import AsyncMock, MagicMock, patch
     
    ```

    ```diff
    @@ -429,3 +430,52 @@ async def _fast_sleep(_):
         finally:
             conn.close()
         assert subs == []
    +
    +
    [redacted:email]
    +async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home):
    +    """`/kanban --board <slug> create ...` must subscribe on that board.
    +
    +    The gateway handler currently auto-subscribes after `/kanban create`,
    +    but the create detection must still work when the shared `--board`
    +    flag appears before the subcommand, and the subscription must land in
    +    that board's DB rather than the ambient/default board.
    +    """
    +    from gateway.run import GatewayRunner
    +    from gateway.config import Platform
    +
    +    kb.create_board("projx")
    +
    +    runner = object.__new__(GatewayRunner)
    +    source = SimpleNamespace(
    +        platform=Platform.TELEGRAM,
    +        chat_id="chat1",
    +        thread_id="th1",
    +        user_id="u1",
    +    )
    +    event = SimpleNamespace(
    +        text='/kanban --board projx create "hello" --assignee alice',
    +        source=source,
    +    )
    +
    +    out = await GatewayRunner._handle_kanban_command(runner, event)
    +
    +    assert "subscribed" in out.lower()
    +
    +    conn = kb.connect(board="projx")
    +    try:
    +        subs = kb.list_notify_subs(conn)
    +        tasks = kb.list_tasks(conn)
    +    finally:
    +        conn.close()
    +
    +    assert [t.title for t in tasks] == ["hello"]
    +    assert len(subs) == 1
    +    assert subs[0]["chat_id"] == "chat1"
    +    assert subs[0]["thread_id"] == "th1"
    +
    +    conn = kb.connect(board="default")
    +    try:
    +        assert kb.list_notify_subs(conn) == []
    +    finally:
    +        conn.close()
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -3344,6 +3344,88 @@ def test_truncated_tool_args_detected_when_finish_reason_not_length(self, agent)
             assert "truncated due to output length limit" in result["error"]
             mock_handle_function_call.assert_not_called()
     
    +    def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch):
    +        """Regression: kanban worker must call kanban_block when iteration
    +        budget is exhausted, otherwise the dispatcher sees a protocol
    +        violation and gives up after 1 failure (issue #23216)."""
    +        self._setup_agent(agent)
    +        agent.max_iterations = 2
    +
    +        monkeypatch.setenv("HERMES_KANBAN_TASK", "t_test_task_123")
    +
    +        # Return a tool call for every iteration to exhaust the budget.
    +        tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
    +        tool_resp = _mock_response(
    +            content="", finish_reason="tool_calls", tool_calls=[tc],
    +        )
    +        # Final summary response from _handle_max_iterations.
    +        summary_resp = _mock_response(
    +            content="Could not finish — budget exhausted.", finish_reason="stop",
    +        )
    +        agent.client.chat.completions.create.side_effect = [
    +            tool_resp, tool_resp, summary_resp,
    +        ]
    +
    +        with (
    +            patch("run_agent.handle_function_call", return_value="ok") as mock_hfc,
    +            patch.object(agent, "_persist_session"),
    +            patch.object(agent, "_save_trajectory"),
    +            patch.object(agent, "_cleanup_task_resources"),
    +        ):
    +            result = agent.run_conversation("do the kanban work")
    +
    +        # The agent should have reported the task as not completed.
    +        assert result["completed"] is False
    +
    +        # Among all handle_function_call invocations, one must be
    +        # kanban_block with the correct task_id and a reason mentioning
    +        # iteration exhaustion.
    +        kanban_block_calls = [
    +            c for c in mock_hfc.call_args_list
    +            if c[0][0] == "kanban_block"
    +        ]
    +        assert len(kanban_block_calls) == 1, (
    +            f"Expected exactly 1 kanban_block call, got {len(kanban_block_calls)}. "
    +            f"All calls: {mock_hfc.call_args_list}"
    +        )
    +        call = kanban_block_calls[0]
    +        assert call[0][1]["task_id"] == "t_test_task_123"
    +        assert "Iteration budget exhausted" in call[0][1]["reason"]
    +
    +    def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch):
    +        """kanban_block must NOT be called when HERMES_KANBAN_TASK is unset."""
    +        self._setup_agent(agent)
    +        agent.max_iterations = 2
    +
    +        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +
    +        tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
    +        tool_resp = _mock_response(
    +            content="", finish_reason="tool_calls", tool_calls=[tc],
    +        )
    +        summary_resp = _mock_response(
    +            content="Summary.", finish_reason="stop",
    +        )
    +        agent.client.chat.completions.create.side_effect = [
    +            tool_resp, tool_resp, summary_resp,
    +        ]
    +
    +        with (
    +            patch("run_agent.handle_function_call", return_value="ok") as mock_hfc,
    +            patch.object(agent, "_persist_session"),
    +            patch.object(agent, "_save_trajectory"),
    +            patch.object(agent, "_cleanup_task_resources"),
    +        ):
    +            agent.run_conversation("do stuff")
    +
    +        kanban_block_calls = [
    +            c for c in mock_hfc.call_args_list
    +            if c[0][0] == "kanban_block"
    +        ]
    +        assert len(kanban_block_calls) == 0, (
    +            "kanban_block should not be called outside kanban mode"
    +        )
    +
     
     class TestRetryExhaustion:
         """Regression: retry_count > max_retries was dead code (off-by-one).
    ```
  - `tests/tools/test_send_message_tool.py`

    ```diff
    @@ -2229,3 +2229,106 @@ async def fake_send(pconfig, chat_id, message, **kwargs):
             assert result["success"] is True
             assert result["message_id"] == "abc-123"
             assert result["extra_field"] == "preserved"
    +
    +
    +# ---------------------------------------------------------------------------
    +# _check_send_message — availability gating
    +# ---------------------------------------------------------------------------
    +
    +class TestCheckSendMessage:
    +    """The tool's check_fn governs whether the model sees ``send_message`` as
    +    callable for a given session. The four passing conditions are:
    +
    +    1. ``HERMES_KANBAN_TASK`` is set (worker spawned by the kanban dispatcher
    +       — parent gateway is by definition running, but the worker's
    +       ``HERMES_HOME`` may be a profile dir without a ``gateway.pid``).
    +    2. ``HERMES_SESSION_PLATFORM`` resolves to a non-empty, non-``local`` value
    +       (the session is wired to a messaging platform like Telegram).
    +    3. ``is_gateway_running()`` returns True (CLI / orchestrator profile with
    +       a live gateway colocated under the same ``HERMES_HOME``).
    +    4. None of the above → False, tool is hidden.
    +    """
    +
    +    def test_kanban_task_env_grants_access(self, monkeypatch):
    +        """Workers spawned by the dispatcher (HERMES_KANBAN_TASK set) must be
    +        allowed regardless of session_platform / gateway-pid state."""
    +        from tools.send_message_tool import _check_send_message
    +
    +        monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc12345")
    +        monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
    +
    +        with patch("gateway.session_context.get_session_env", return_value=""), \
    +             patch("gateway.status.is_gateway_running", return_value=False):
    +            assert _check_send_message() is True
    +
    +    def test_kanban_task_env_short_circuits_before_gateway_check(self, monkeypatch):
    +        """Honoring HERMES_KANBAN_TASK must not depend on importing or calling
    +        gateway.status — the worker may run with a HERMES_HOME that has no
    +        gateway.pid, and we don't want that import path to be load-bearing."""
    +        from tools.send_message_tool import _check_send_message
    +
    +        monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc12345")
    +
    +        with patch("gateway.session_context.get_session_env",
    +                   side_effect=AssertionError("session_context not consulted "
    +                                              "when HERMES_KANBAN_TASK is set")), \
    +             patch("gateway.status.is_gateway_running",
    +                   side_effect=AssertionError("gateway.status not consulted "
    +                                              "when HERMES_KANBAN_TASK is set")):
    +            assert _check_send_message() is True
    +
    +    def test_messaging_platform_session_grants_access(self, monkeypatch):
    +        """Telegram/Discord/etc. sessions pass via the platform branch even
    +        without HERMES_KANBAN_TASK."""
    +        from tools.send_message_tool import _check_send_message
    +
    +        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +
    +        with patch("gateway.session_context.get_session_env", return_value="telegram"), \
    +             patch("gateway.status.is_gateway_running", return_value=False):
    +            assert _check_send_message() is True
    +
    +    def test_local_platform_falls_through_to_gateway_check(self, monkeypatch):
    +        """``HERMES_SESSION_PLATFORM=local`` means CLI-style — must defer to
    +        is_gateway_running() rather than auto-grant."""
    +        from tools.send_message_tool import _check_send_message
    +
    +        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +
    +        with patch("gateway.session_context.get_session_env", return_value="local"), \
    +             patch("gateway.status.is_gateway_running", return_value=True) as gw_mock:
    +            assert _check_send_message() is True
    +            gw_mock.assert_called_once()
    +
    +    def test_running_gateway_grants_access(self, monkeypatch):
    +        """Plain CLI session (no kanban task, empty platform) with a live
    +        gateway: tool is callable."""
    +        from tools.send_message_tool import _check_send_message
    +
    +        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +
    +        with patch("gateway.session_context.get_session_env", return_value=""), \
    +             patch("gateway.status.is_gateway_running", return_value=True):
    +            assert _check_send_message() is True
    +
    +    def test_no_signals_means_unavailable(self, monkeypatch):
    +        """No kanban task, no platform, no gateway: tool is hidden."""
    +        from tools.send_message_tool import _check_send_message
    +
    +        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +
    +        with patch("gateway.session_context.get_session_env", return_value=""), \
    +             patch("gateway.status.is_gateway_running", return_value=False):
    +            assert _check_send_message() is False
    +
    +    def test_gateway_status_import_error_is_swallowed(self, monkeypatch):
    +        """If gateway.status can't be imported (unusual deployment / partial
    +        install), the check returns False rather than raising."""
    +        from tools.send_message_tool import _check_send_message
    +
    +        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +
    +        with patch("gateway.session_context.get_session_env", return_value=""), \
    +             patch("gateway.status.is_gateway_running",
    +                   side_effect=ImportError("simulated")):
    +            assert _check_send_message() is False
    ```
  - `tools/send_message_tool.py`

    ```diff
    @@ -1757,7 +1757,20 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No
     
     
     def _check_send_message():
    -    """Gate send_message on gateway running (always available on messaging platforms)."""
    +    """Gate send_message on gateway running (always available on messaging platforms).
    +
    +    Also passes for kanban workers — the dispatcher sets ``HERMES_KANBAN_TASK``
    +    on every spawned worker, but those workers run with the assignee profile's
    +    ``HERMES_HOME`` which has no ``gateway.pid``, so the gateway-running check
    +    would fail even though the parent gateway is alive. Honoring the env var
    +    lets workers call ``send_message`` to deliver rich content directly to the
    +    originating chat (paired with ``kanban_complete`` for the short notifier
    +    summary), which is the canonical pattern for any worker that needs to
    +    reply with more than the ~200-char first-line truncation the kanban
    +    notifier applies.
    +    """
    +    if os.environ.get("HERMES_KANBAN_TASK"):
    +        return True
         from gateway.session_context import get_session_env
         platform = get_session_env("HERMES_SESSION_PLATFORM", "")
         if platform and platform != "local":
    ```
- **#83412: fix(kanban): first-class review lifecycle — salvage of #75451 with review fixes**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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` (+17 more)
- **#19743: feat(kanban): prevent child task dispatch when parent is not done**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#20448`
- **#23325: fix(kanban): treat archived parent tasks as terminal for dependency resolution**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#23791`
- **#26777: fix(kanban-dashboard): surface failed Ready-transition with blocking parent detail (#26744)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `e215558ba705dac6fa4d8521761e3d7841db1af0`, `#28434`
- **#27049: fix: allow kanban children to stay/become ready when parent is running**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#24505`, `#24489`
- **#31765: fix(kanban): accept 'completed' as terminal status in dependency checks**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `hermes_cli/kanban_db.py`
- **#31767: fix(kanban): normalize legacy completed task status**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#31717`, `#31765`
- **#49909: fix(kanban): allow completing tasks from todo status**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - files: `hermes_cli/kanban_db.py`, `plugins/kanban/dashboard/dist/index.js`, `scripts/release.py`, `tests/plugins/test_kanban_dashboard_plugin.py`
- **#49933: fix(kanban): allow complete_task from any active status on manual completion**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `1af856816d34d7ead3472caa7ea426818f1a0175`, `e06df9c2eae6901f4c88f464e0ae6e753c0428b8`, `7bda2a3368024f7ae1c70cef16583e7c2c891674`, `36d5736f73`, `7edf7781dc`, `bbc56b6725`, `9d87097266`, `8f3ca6badb`, `fed4b4f742`, `66e7224230`, `484a50ac78`
  - files: `hermes_cli/kanban_db.py`
- **#68210: fix(kanban): let review-required self-park not deadlock dependency children (#67963)**. closed without merge
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `36128a4a2e5ff260b670d6fd3aa66b023d1ae941`, `36128a4a2e5ff260b670d6fd3aa66b023d1ae941`, `9c797c5e8a5c`, `#68210`, `#52544`, `#72554`, `#75451`, `#52544`, `#68210`, `#72554`, `#75451`, `#75451`, `#75451`, `#52544`, `#72554`, `#72554`, `#75451`, `#68210`, `#75451`, `#75451`, `#72554`, `#68210`, `#75451`, `#52544`, `#72554`, `#1`, `#1`, `#15803`, `#14532`, `#6`, `#1`, `#9`, `#374151`, `#4338`, `#67963`, `#67989`, `#68210`
- **#75451: fix(kanban): complete first-class review lifecycle**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `a98aee47ce`, `#83412`
- **#103855: fix(kanban): render the drawer's stored PATCH error so failed Ready transitions are visible**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `plugins/kanban/dashboard/dist/index.js`, `tests/plugins/test_kanban_dashboard_plugin.py`

## Fix status

- Merged to main in PR #20448 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-08-10`.
- Subsystem: `cron`
