# NousResearch/hermes-agent · Kanban dashboard select inputs and form wiring

## Open now

- Nothing open on GitHub.

These items address select/combobox components in the Kanban dashboard that fail to update or apply values correctly, including workspace kind, parent/child dependency, and bulk-reassign selects, plus related inline creation form inputs.

## TL;DR

- 8 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 3 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #19679. Verify it addresses your case.
- **Recommended:** read PR #19679 (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-19679,pr-24547`. 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.18`: 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.

## Closed / duplicate issues

- #18718: \[Feature\]: Workspace kind and patch input in Kanban - Dashboard
- #19998: Kanban Dashboard: parent/child dependency Select combobox not updating value
- #24520: \[Bug\]: Kanban Dashboard task creation — workspace select reverts to "scratch" on choosing "dir" or "worktree"
- #28968: bug(kanban-dashboard): bulk-reassign Select uses onChange/e.target.value but SDK Select fires onValueChange(value) — Apply does nothing

## Prior & competing PRs

- **#19679: feat(kanban-dashboard): workspace kind + path inputs in inline create form**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/kanban/dashboard/dist/index.js`
- **#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"]
    ```
- **#24547: fix(kanban): workspace/parent/bulk-reassign selects revert to default (selectChangeHandler)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/kanban/dashboard/dist/index.js`
  - fix hunks:
  - `plugins/kanban/dashboard/dist/index.js`

    ```diff
    @@ -1744,11 +1744,10 @@
           ),
           h("div", { className: "hermes-kanban-bulk-reassign",
                      title: "Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply." },
    -        h(Select, {
    +        h(Select, Object.assign({
               value: assignee,
    -          onChange: function (e) { setAssignee(e.target.value); },
               className: "h-7 text-xs",
    -        },
    +        }, selectChangeHandler(setAssignee)),
               h(SelectOption, { value: "" }, "— reassign —"),
               h(SelectOption, { value: "__none__" }, "(unassign)"),
               props.assignees.map(function (a) {
    ```

    ```diff
    @@ -2249,12 +2248,11 @@
             className: "h-7 text-xs",
           }),
           h("div", { className: "flex gap-2" },
    -        h(Select, {
    +        h(Select, Object.assign({
               value: workspaceKind,
    -          onChange: function (e) { setWorkspaceKind(e.target.value); },
               title: "scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).",
               className: "h-7 text-xs w-28",
    -        },
    +        }, selectChangeHandler(setWorkspaceKind)),
               h(SelectOption, { value: "scratch" }, "scratch"),
               h(SelectOption, { value: "worktree" }, "worktree"),
               h(SelectOption, { value: "dir" }, "dir"),
    ```

    ```diff
    @@ -2266,12 +2264,11 @@
               className: "h-7 text-xs flex-1",
             }) : null,
           ),
    -      h(Select, {
    +      h(Select, Object.assign({
             value: parent,
    -        onChange: function (e) { setParent(e.target.value); },
             className: "h-7 text-xs",
             title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
    -      },
    +      }, selectChangeHandler(setParent)),
             h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")),
             (props.allTasks || []).map(function (task) {
               return h(SelectOption, { key: task.id, value: task.id },
    ```
- **#20019: fix(kanban): wire dependency selects**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#20448`

## Fix status

- Merged to main in PR #19679 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-18`.
- Subsystem: `skills`
