{"repo":"NousResearch/hermes-agent","public_id":"18718","display_label":"Kanban dashboard select inputs and form wiring","summary":"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.","subsystem":"skills","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":3,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19679},"resolution":{"state":"partially_resolved","by":"pr-19679,pr-24547"},"freshness":{"state":"verified","last_verified_at":"2026-05-18T14:48:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18142857142857144},"fix_edges":[{"issue_number":18718,"pr_number":19679,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":19746,"pr_number":20448,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":19998,"pr_number":20448,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":24520,"pr_number":24547,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":18718,"title":"[Feature]: Workspace kind and patch input in Kanban - Dashboard","url":null,"state":"closed"},{"kind":"issue","number":19998,"title":"Kanban Dashboard: parent/child dependency Select combobox not updating value","url":null,"state":"closed"},{"kind":"issue","number":24520,"title":"[Bug]: Kanban Dashboard task creation — workspace select reverts to \"scratch\" on choosing \"dir\" or \"worktree\"","url":null,"state":"closed"},{"kind":"issue","number":28968,"title":"bug(kanban-dashboard): bulk-reassign Select uses onChange/e.target.value but SDK Select fires onValueChange(value) — Apply does nothing","url":null,"state":"closed"},{"kind":"pr","number":19679,"title":"feat(kanban-dashboard): workspace kind + path inputs in inline create form","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/kanban/dashboard/dist/index.js"]},{"kind":"pr","number":20019,"title":"fix(kanban): wire dependency selects","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20448","number":20448}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":20448,"title":"chore(kanban): tier-2 batch salvage — doctor, started_at, parent-guard, latest_summary, selects, linked-children (closes #18344 #20022 #19473 #19828 #19743 #20251 #20019)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/doctor.py","hermes_cli/kanban.py","hermes_cli/kanban_db.py","plugins/kanban/dashboard/dist/index.js","plugins/kanban/dashboard/plugin_api.py","scripts/release.py","tests/hermes_cli/test_doctor.py","tests/hermes_cli/test_kanban_core_functionality.py","tests/hermes_cli/test_kanban_db.py","tests/plugins/test_kanban_dashboard_plugin.py"],"fix_hunks":[{"file":"hermes_cli/doctor.py","hunks":["@@ -107,15 +107,35 @@ def _honcho_is_configured_for_doctor() -> bool:\n         return False\n \n \n+def _is_kanban_worker_env_gate(item: dict) -> bool:\n+    \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n+    if item.get(\"name\") != \"kanban\":\n+        return False\n+    if os.environ.get(\"HERMES_KANBAN_TASK\"):\n+        return False\n+\n+    tools = item.get(\"tools\") or []\n+    return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n+\n+\n+def _doctor_tool_availability_detail(toolset: str) -> str:\n+    \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n+    if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n+        return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n+    return \"\"\n+\n+\n def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n     \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n-    if not _honcho_is_configured_for_doctor():\n-        return available, unavailable\n-\n     updated_available = list(available)\n     updated_unavailable = []\n     for item in unavailable:\n-        if item.get(\"name\") == \"honcho\":\n+        name = item.get(\"name\")\n+        if _is_kanban_worker_env_gate(item):\n+            if \"kanban\" not in updated_available:\n+                updated_available.append(\"kanban\")\n+            continue\n+        if name == \"honcho\" and _honcho_is_configured_for_doctor():\n             if \"honcho\" not in updated_available:\n                 updated_available.append(\"honcho\")\n             continue","@@ -1278,7 +1298,7 @@ def run_doctor(args):\n         \n         for tid in available:\n             info = TOOLSET_REQUIREMENTS.get(tid, {})\n-            check_ok(info.get(\"name\", tid))\n+            check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n         \n         for item in unavailable:\n             env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []"]},{"file":"hermes_cli/kanban.py","hunks":["@@ -1071,10 +1071,16 @@ def _cmd_show(args: argparse.Namespace) -> int:\n         parents = kb.parent_ids(conn, args.task_id)\n         children = kb.child_ids(conn, args.task_id)\n         runs = kb.list_runs(conn, args.task_id)\n+        # Workers hand off via ``task_runs.summary`` (kanban-worker skill);\n+        # ``tasks.result`` is left NULL unless the caller explicitly passed\n+        # ``result=``. Surfacing the latest summary here keeps ``show`` from\n+        # looking like a no-op when the worker actually did real work.\n+        latest_summary = kb.latest_summary(conn, args.task_id)\n \n     if getattr(args, \"json\", False):\n         payload = {\n             \"task\": _task_to_dict(task),\n+            \"latest_summary\": latest_summary,\n             \"parents\": parents,\n             \"children\": children,\n             \"comments\": [","@@ -1161,6 +1167,13 @@ def _cmd_show(args: argparse.Namespace) -> int:\n         print()\n         print(\"Result:\")\n         print(task.result)\n+    elif latest_summary:\n+        # Worker handoff lives on the latest run, not on tasks.result.\n+        # Surface it at top-level so a glance at ``hermes kanban show <id>``\n+        # tells you what the worker did even if tasks.result is empty.\n+        print()\n+        print(\"Latest summary:\")\n+        print(latest_summary)\n     if comments:\n         print()\n         print(f\"Comments ({len(comments)}):\")"]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -1978,14 +1978,23 @@ def _verify_created_cards(\n ) -> tuple[list[str], list[str]]:\n     \"\"\"Partition ``claimed_ids`` into (verified, phantom).\n \n-    A card is \"verified\" iff a row exists in ``tasks`` with the given id\n-    AND ``created_by`` matches the completing task's ``assignee`` (or\n-    the completing task itself — workers that create children of their\n-    own task also qualify).\n-\n-    ``phantom`` returns ids that either don't exist at all or exist but\n-    were not created by the completing worker. The caller decides what\n-    to do with each bucket; this helper never mutates.\n+    A card is \"verified\" iff a row exists in ``tasks`` AND at least one\n+    of the following holds:\n+\n+    * ``created_by`` matches the completing task's ``assignee`` profile\n+      (the common case: worker A spawns a card via ``kanban_create``,\n+      which stamps ``created_by=A``).\n+    * ``created_by`` matches the completing task's id (edge case where\n+      a worker passed its own task id as the ``created_by`` value).\n+    * The card is linked as a ``task_links.child`` of the completing\n+      task — i.e. the worker explicitly called ``kanban_create`` with\n+      ``parents=[<current_task>]``. This accepts cards created through\n+      the dashboard/CLI by a different principal but then attached to\n+      the completing task by the worker.\n+\n+    ``phantom`` returns ids that either don't exist at all, or exist\n+    but don't satisfy any of the three trust conditions. The caller\n+    decides what to do with each bucket; this helper never mutates.\n     \"\"\"\n     claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()]\n     if not claimed:","@@ -2014,6 +2023,10 @@ def _verify_created_cards(\n     ).fetchall()\n     found = {r[\"id\"]: r[\"created_by\"] for r in rows}\n \n+    # Pull the set of cards linked as children of the completing task.\n+    # Cheap: one query, indexed on parent_id.\n+    linked_children: set[str] = set(child_ids(conn, completing_task_id))\n+\n     verified: list[str] = []\n     phantom: list[str] = []\n     for cid in ordered:","@@ -2021,13 +2034,13 @@ def _verify_created_cards(\n         if created_by is None:\n             phantom.append(cid)\n             continue\n-        # Accept if created_by matches the completing task's assignee\n-        # profile, OR the task itself (workers whose created_by happens\n-        # to match their task id are unusual but harmless to accept).\n+        # Accept if any of the three trust conditions holds.\n         if completing_assignee and created_by == completing_assignee:\n             verified.append(cid)\n         elif created_by == completing_task_id:\n             verified.append(cid)\n+        elif cid in linked_children:\n+            verified.append(cid)\n         else:\n             phantom.append(cid)\n     return verified, phantom","@@ -2699,16 +2712,23 @@ def enforce_max_runtime(\n     host_prefix = f\"{_claimer_id().split(':', 1)[0]}:\"\n \n     rows = conn.execute(\n-        \"SELECT id, worker_pid, started_at, max_runtime_seconds, claim_lock \"\n-        \"FROM tasks \"\n-        \"WHERE status = 'running' AND max_runtime_seconds IS NOT NULL \"\n-        \"  AND started_at IS NOT NULL AND worker_pid IS NOT NULL\"\n+        \"SELECT t.id, t.worker_pid, \"\n+        \"       COALESCE(r.started_at, t.started_at) AS active_started_at, \"\n+        \"       t.max_runtime_seconds, t.claim_lock \"\n+        \"FROM tasks t \"\n+        \"LEFT JOIN task_runs r ON r.id = t.current_run_id \"\n+        \"WHERE t.status = 'running' AND t.max_runtime_seconds IS NOT NULL \"\n+        \"  AND COALESCE(r.started_at, t.started_at) IS NOT NULL \"\n+        \"  AND t.worker_pid IS NOT NULL\"\n     ).fetchall()\n     for row in rows:\n         lock = row[\"claim_lock\"] or \"\"\n         if not lock.startswith(host_prefix):\n             continue\n-        elapsed = now - int(row[\"started_at\"])\n+        # Runtime is per attempt, not lifetime-of-task. ``tasks.started_at``\n+        # intentionally records the first time a task ever started, so retries\n+        # must be measured from the active task_runs row when present.\n+        elapsed = now - int(row[\"active_started_at\"])\n         if elapsed < int(row[\"max_runtime_seconds\"]):\n             continue\n ","@@ -3993,3 +4013,61 @@ def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:\n         (task_id,),\n     ).fetchone()\n     return Run.from_row(row) if row else None\n+\n+\n+def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]:\n+    \"\"\"Return the latest non-null ``task_runs.summary`` for ``task_id``.\n+\n+    The kanban-worker skill writes its handoff to ``task_runs.summary``\n+    via ``complete_task(summary=...)``; ``tasks.result`` is left empty\n+    unless the caller passes ``result=`` explicitly. Dashboards and CLI\n+    \"show\" views need this value to surface what a worker actually did\n+    — without it, ``tasks.result`` is NULL and the task looks like a\n+    no-op even when the run completed.\n+\n+    Picks the most recent run by ``ended_at`` (falling back to ``id``\n+    for ties or unfinished rows). Returns None if no run has a summary.\n+    \"\"\"\n+    row = conn.execute(\n+        \"SELECT summary FROM task_runs \"\n+        \"WHERE task_id = ? AND summary IS NOT NULL AND summary != '' \"\n+        \"ORDER BY COALESCE(ended_at, started_at) DESC, id DESC LIMIT 1\",\n+        (task_id,),\n+    ).fetchone()\n+    return row[\"summary\"] if row else None\n+\n+\n+def latest_summaries(\n+    conn: sqlite3.Connection, task_ids: Iterable[str]\n+) -> dict[str, str]:\n+    \"\"\"Batch-fetch latest non-null summaries for a list of task ids.\n+\n+    Used by the dashboard board endpoint to attach ``latest_summary`` to\n+    every card in a single SQL query, avoiding the N+1 pattern of\n+    calling :func:`latest_summary` per task. Returns a dict mapping\n+    ``task_id`` → summary string, omitting tasks with no summary.\n+\n+    Approach: a window function picks the newest non-null-summary row\n+    per ``task_id``; works against SQLite ≥ 3.25 (default on every\n+    supported platform).\n+    \"\"\"\n+    ids = list(task_ids)\n+    if not ids:\n+        return {}\n+    placeholders = \",\".join(\"?\" for _ in ids)\n+    rows = conn.execute(\n+        f\"\"\"\n+        SELECT task_id, summary FROM (\n+            SELECT task_id, summary,\n+                   ROW_NUMBER() OVER (\n+                       PARTITION BY task_id\n+                       ORDER BY COALESCE(ended_at, started_at) DESC, id DESC\n+                   ) AS rn\n+              FROM task_runs\n+             WHERE task_id IN ({placeholders})\n+               AND summary IS NOT NULL AND summary != ''\n+        ) WHERE rn = 1\n+        \"\"\",\n+        ids,\n+    ).fetchall()\n+    return {r[\"task_id\"]: r[\"summary\"] for r in rows}"]},{"file":"plugins/kanban/dashboard/dist/index.js","hunks":["@@ -2416,11 +2416,10 @@\n         ),\n       ),\n       h(\"div\", { className: \"hermes-kanban-deps-row\" },\n-        h(Select, {\n+        h(Select, Object.assign({\n           value: newParent,\n-          onChange: function (e) { setNewParent(e.target.value); },\n           className: \"h-7 text-xs flex-1\",\n-        },\n+        }, selectChangeHandler(setNewParent)),\n           h(SelectOption, { value: \"\" }, \"— add parent —\"),\n           candidatesFor(parentExclude).map(function (t) {\n             return h(SelectOption, { key: t.id, value: t.id },","@@ -2455,11 +2454,10 @@\n         ),\n       ),\n       h(\"div\", { className: \"hermes-kanban-deps-row\" },\n-        h(Select, {\n+        h(Select, Object.assign({\n           value: newChild,\n-          onChange: function (e) { setNewChild(e.target.value); },\n           className: \"h-7 text-xs flex-1\",\n-        },\n+        }, selectChangeHandler(setNewChild)),\n           h(SelectOption, { value: \"\" }, \"— add child —\"),\n           candidatesFor(childExclude).map(function (t) {\n             return h(SelectOption, { key: t.id, value: t.id },"]},{"file":"plugins/kanban/dashboard/plugin_api.py","hunks":["@@ -124,11 +124,23 @@ def _conn(board: Optional[str] = None):\n ]\n \n \n-def _task_dict(task: kanban_db.Task) -> dict[str, Any]:\n+_CARD_SUMMARY_PREVIEW_CHARS = 200\n+\n+\n+def _task_dict(\n+    task: kanban_db.Task,\n+    *,\n+    latest_summary: Optional[str] = None,\n+) -> dict[str, Any]:\n     d = asdict(task)\n     # Add derived age metrics so the UI can colour stale cards without\n     # computing deltas client-side.\n     d[\"age\"] = kanban_db.task_age(task)\n+    # Surface the latest non-null run summary so dashboards don't show\n+    # blank cards/drawers for tasks where the worker handed off via\n+    # ``task_runs.summary`` (the kanban-worker pattern) instead of\n+    # ``tasks.result``. ``None`` when no run has produced a summary yet.\n+    d[\"latest_summary\"] = latest_summary\n     # Keep body short on list endpoints; full body comes from /tasks/:id.\n     return d\n ","@@ -381,8 +393,18 @@ def get_board(\n         if include_archived:\n             columns[\"archived\"] = []\n \n+        # Batch-fetch the latest non-null run summary per task in one\n+        # window-function query (avoids N+1 ``latest_summary`` calls\n+        # for boards with hundreds of tasks). Truncated to a card-size\n+        # preview here — the full text is available via /tasks/:id.\n+        summary_map = kanban_db.latest_summaries(conn, [t.id for t in tasks])\n+\n         for t in tasks:\n-            d = _task_dict(t)\n+            full = summary_map.get(t.id)\n+            preview = (\n+                full[:_CARD_SUMMARY_PREVIEW_CHARS] if full else None\n+            )\n+            d = _task_dict(t, latest_summary=preview)\n             d[\"link_counts\"] = link_counts.get(t.id, {\"parents\": 0, \"children\": 0})\n             d[\"comment_count\"] = comment_counts.get(t.id, 0)\n             d[\"progress\"] = progress.get(t.id)  # None when the task has no children","@@ -440,7 +462,11 @@ def get_task(task_id: str, board: Optional[str] = Query(None)):\n         task = kanban_db.get_task(conn, task_id)\n         if task is None:\n             raise HTTPException(status_code=404, detail=f\"task {task_id} not found\")\n-        task_d = _task_dict(task)\n+        # Drawer/detail view returns the FULL summary (no truncation) so\n+        # operators can read the complete worker handoff without making\n+        # a second round-trip. Cards on /board carry a 200-char preview.\n+        full_summary = kanban_db.latest_summary(conn, task_id)\n+        task_d = _task_dict(task, latest_summary=full_summary)\n         # Attach diagnostics so the drawer's Diagnostics section can\n         # render recovery actions without a second round-trip.\n         diags = _compute_task_diagnostics(conn, task_ids=[task_id])","@@ -662,6 +688,22 @@ def _set_status_direct(\n         ).fetchone()\n         if prev is None:\n             return False\n+\n+        # Guard: don't allow promoting to 'ready' unless all parents are done.\n+        # Prevents the dispatcher from spawning a child whose upstream work\n+        # hasn't completed (e.g. T4 dispatched while T3 is still blocked).\n+        if new_status == \"ready\":\n+            parent_statuses = conn.execute(\n+                \"SELECT t.status FROM tasks t \"\n+                \"JOIN task_links l ON l.parent_id = t.id \"\n+                \"WHERE l.child_id = ?\",\n+                (task_id,),\n+            ).fetchall()\n+            if parent_statuses and not all(\n+                p[\"status\"] == \"done\" for p in parent_statuses\n+            ):\n+                return False\n+\n         was_running = prev[\"status\"] == \"running\"\n \n         cur = conn.execute("]},{"file":"scripts/release.py","hunks":["@@ -129,6 +129,8 @@\n     \"[redacted:email]\": \"momowind\",\n     \"[redacted:email]\": \"misery-hl\",\n     \"[redacted:email]\": \"misery-hl\",\n+    \"[redacted:email]\": \"suncokret12\",\n+    \"[redacted:email]\": \"mioimotoai-lgtm\",\n     \"[redacted:email]\": \"heyitsaamir\",\n     \"[redacted:email]\": \"johnncenae\",\n     \"[redacted:email]\": \"ThomassJonax\","]},{"file":"tests/hermes_cli/test_doctor.py","hunks":["@@ -126,6 +126,47 @@ def test_leaves_honcho_unavailable_when_not_configured(self, monkeypatch):\n         assert available == []\n         assert unavailable == [honcho_entry]\n \n+    def test_marks_kanban_available_only_when_missing_worker_env_gate(self, monkeypatch):\n+        monkeypatch.setattr(doctor, \"_honcho_is_configured_for_doctor\", lambda: False)\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        available, unavailable = doctor._apply_doctor_tool_availability_overrides(\n+            [],\n+            [{\"name\": \"kanban\", \"env_vars\": [], \"tools\": [\"kanban_show\"]}],\n+        )\n+\n+        assert available == [\"kanban\"]\n+        assert unavailable == []\n+\n+    def test_leaves_kanban_unavailable_when_worker_env_is_set(self, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"probe\")\n+        kanban_entry = {\"name\": \"kanban\", \"env_vars\": [], \"tools\": [\"kanban_show\"]}\n+\n+        available, unavailable = doctor._apply_doctor_tool_availability_overrides(\n+            [],\n+            [kanban_entry],\n+        )\n+\n+        assert available == []\n+        assert unavailable == [kanban_entry]\n+\n+    def test_leaves_non_worker_kanban_failure_unavailable(self, monkeypatch):\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+        kanban_entry = {\"name\": \"kanban\", \"env_vars\": [], \"tools\": [\"kanban_show\", \"not_a_kanban_tool\"]}\n+\n+        available, unavailable = doctor._apply_doctor_tool_availability_overrides(\n+            [],\n+            [kanban_entry],\n+        )\n+\n+        assert available == []\n+        assert unavailable == [kanban_entry]\n+\n+    def test_kanban_doctor_detail_explains_worker_gate(self, monkeypatch):\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        assert doctor._doctor_tool_availability_detail(\"kanban\") == \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n+\n \n class TestHonchoDoctorConfigDetection:\n     def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):"]},{"file":"tests/hermes_cli/test_kanban_core_functionality.py","hunks":["@@ -682,14 +682,21 @@ def _signal_fn(pid, sig):\n                 conn, title=\"long job\", assignee=\"worker\",\n                 max_runtime_seconds=1,  # one second cap\n             )\n-            # Spawn by hand: claim + set pid + set started_at to the past.\n+            # Spawn by hand: claim + set pid + set active run start to the past.\n             kb.claim_task(conn, tid)\n             kb._set_worker_pid(conn, tid, os.getpid())   # any live pid works\n-            # Backdate started_at so elapsed > limit.\n+            # Backdate both the task-level first-start timestamp and the active\n+            # run timestamp so elapsed > limit under the per-run runtime model.\n+            old_started = int(time.time()) - 30\n             with kb.write_txn(conn):\n                 conn.execute(\n                     \"UPDATE tasks SET started_at = ? WHERE id = ?\",\n-                    (int(time.time()) - 30, tid),\n+                    (old_started, tid),\n+                )\n+                conn.execute(\n+                    \"UPDATE task_runs SET started_at = ? \"\n+                    \"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)\",\n+                    (old_started, tid),\n                 )\n \n             timed_out = kb.enforce_max_runtime(conn, signal_fn=_signal_fn)","@@ -769,10 +776,16 @@ def _signal(pid, sig):\n         )\n         kb.claim_task(conn, tid)\n         kb._set_worker_pid(conn, tid, os.getpid())\n+        old_started = int(time.time()) - 30\n         with kb.write_txn(conn):\n             conn.execute(\n                 \"UPDATE tasks SET started_at = ? WHERE id = ?\",\n-                (int(time.time()) - 30, tid),\n+                (old_started, tid),\n+            )\n+            conn.execute(\n+                \"UPDATE task_runs SET started_at = ? \"\n+                \"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)\",\n+                (old_started, tid),\n             )\n         # Use enforce_max_runtime directly with our signal stub — dispatch_once\n         # uses the default os.kill, but integration-wise calling","@@ -2978,6 +2991,46 @@ def test_complete_with_cross_worker_card_is_rejected(kanban_home):\n         conn.close()\n \n \n+def test_complete_accepts_cross_worker_card_when_linked_as_child(kanban_home):\n+    \"\"\"A card created by a different principal but explicitly linked as\n+    a child of the completing task is accepted — the worker took\n+    ownership via ``kanban_create(parents=[current_task])`` or an\n+    explicit ``link_tasks`` call, which proves the relationship even\n+    when ``created_by`` doesn't match.\n+\n+    (Relaxation salvaged from #20022 @LeonSGP43 — stricter version\n+    would incorrectly reject legitimate orchestrator flows where a\n+    specifier creates a card, then a worker picks it up and links it\n+    to its own parent task.)\n+    \"\"\"\n+    conn = kb.connect()\n+    try:\n+        parent = kb.create_task(conn, title=\"parent\", assignee=\"alice\")\n+        # Card created by a DIFFERENT principal (not alice, not parent).\n+        other = kb.create_task(\n+            conn, title=\"other\", assignee=\"x\", created_by=\"bob\",\n+            parents=[parent],  # explicitly links as child of the completing task\n+        )\n+\n+        ok = kb.complete_task(\n+            conn, parent,\n+            summary=\"completed with linked child\",\n+            created_cards=[other],\n+        )\n+        assert ok is True\n+        # The card should appear in the completed event's verified_cards list.\n+        import json as _json\n+        row = conn.execute(\n+            \"SELECT payload FROM task_events \"\n+            \"WHERE task_id=? AND kind='completed' ORDER BY id DESC LIMIT 1\",\n+            (parent,),\n+        ).fetchone()\n+        payload = _json.loads(row[\"payload\"])\n+        assert other in payload.get(\"verified_cards\", [])\n+    finally:\n+        conn.close()\n+\n+\n def test_complete_prose_scan_flags_nonexistent_ids(kanban_home):\n     \"\"\"Successful completion whose summary references a ``t_<hex>`` id\n     that doesn't resolve emits a ``suspected_hallucinated_references``","@@ -3184,10 +3237,21 @@ def _signal(pid, sig):\n         )\n         kb.claim_task(conn, tid)\n         kb._set_worker_pid(conn, tid, os.getpid())\n+        # Since PR #19473 (salvaged) changed enforce_max_runtime to read\n+        # from task_runs.started_at (per-attempt) rather than\n+        # tasks.started_at (lifetime), we need to backdate BOTH to\n+        # guarantee the timeout fires regardless of which column the\n+        # query pulls from.\n         with kb.write_txn(conn):\n+            long_ago = int(time.time()) - 30\n             conn.execute(\n                 \"UPDATE tasks SET started_at = ? WHERE id = ?\",\n-                (int(time.time()) - 30, tid),\n+                (long_ago, tid),\n+            )\n+            conn.execute(\n+                \"UPDATE task_runs SET started_at = ? \"\n+                \"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)\",\n+                (long_ago, tid),\n             )\n         before = kb.get_task(conn, tid)\n         assert before.consecutive_failures == 0"]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -182,6 +182,52 @@ def test_stale_claim_reclaimed(kanban_home):\n         assert kb.get_task(conn, t).status == \"ready\"\n \n \n+def test_max_runtime_uses_current_run_start_after_retry(kanban_home):\n+    \"\"\"A retry should get a fresh max-runtime window.\n+\n+    ``tasks.started_at`` intentionally records the first time the task ever\n+    started. Runtime enforcement must therefore use the active\n+    ``task_runs.started_at`` row; otherwise every retry of an old task is\n+    immediately timed out again.\n+    \"\"\"\n+    with kb.connect() as conn:\n+        host = kb._claimer_id().split(\":\", 1)[0]\n+        t = kb.create_task(\n+            conn, title=\"retry\", assignee=\"a\", max_runtime_seconds=10,\n+        )\n+\n+        kb.claim_task(conn, t, claimer=f\"{host}:first\")\n+        first_run_id = kb.latest_run(conn, t).id\n+        old_started = int(time.time()) - 20\n+        conn.execute(\n+            \"UPDATE tasks SET started_at = ?, worker_pid = ? WHERE id = ?\",\n+            (old_started, 999999, t),\n+        )\n+        conn.execute(\n+            \"UPDATE task_runs SET started_at = ?, worker_pid = ? WHERE id = ?\",\n+            (old_started, 999999, first_run_id),\n+        )\n+\n+        timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None)\n+        assert timed_out == [t]\n+        assert kb.get_task(conn, t).status == \"ready\"\n+\n+        kb.claim_task(conn, t, claimer=f\"{host}:retry\")\n+        retry_run = kb.latest_run(conn, t)\n+        conn.execute(\n+            \"UPDATE tasks SET worker_pid = ? WHERE id = ?\",\n+            (999999, t),\n+        )\n+        conn.execute(\n+            \"UPDATE task_runs SET worker_pid = ? WHERE id = ?\",\n+            (999999, retry_run.id),\n+        )\n+\n+        timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None)\n+        assert timed_out == []\n+        assert kb.get_task(conn, t).status == \"running\"\n+\n+\n def test_heartbeat_extends_claim(kanban_home):\n     with kb.connect() as conn:\n         t = kb.create_task(conn, title=\"x\", assignee=\"a\")","@@ -776,3 +822,80 @@ def __init__(self, cmd, **kwargs):\n             default_home / \"kanban\" / \"workspaces\"\n         )\n         assert env[\"HERMES_KANBAN_TASK\"] == \"t_dispatch_env\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# latest_summary / latest_summaries — surface task_runs.summary handoffs\n+# ---------------------------------------------------------------------------\n+\n+def test_latest_summary_returns_none_when_no_runs(kanban_home):\n+    \"\"\"A freshly-created task has no runs and therefore no summary.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"fresh\", assignee=\"alice\")\n+        assert kb.latest_summary(conn, t) is None\n+\n+\n+def test_latest_summary_returns_summary_after_complete(kanban_home):\n+    \"\"\"``complete_task(summary=...)`` is the canonical kanban-worker\n+    handoff; ``latest_summary`` must surface it so dashboards/CLI can\n+    render what the worker actually did.\"\"\"\n+    handoff = \"shipped 3 files, ran tests, opened PR #42\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"work\", assignee=\"alice\")\n+        kb.complete_task(conn, t, summary=handoff)\n+        assert kb.latest_summary(conn, t) == handoff\n+\n+\n+def test_latest_summary_picks_newest_when_multiple_runs(kanban_home):\n+    \"\"\"When a task has been re-run (block → unblock → complete), the\n+    newest run's summary wins. We unblock to take the task back to\n+    ``ready``, then complete a second time and verify the second\n+    summary surfaces.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"retry\", assignee=\"alice\")\n+        kb.complete_task(conn, t, summary=\"first attempt\")\n+        # Move back to ready by direct SQL — block_task / unblock_task\n+        # paths require an active claim, but we just want a second run\n+        # row to exist with a later ended_at.\n+        conn.execute(\n+            \"UPDATE tasks SET status='ready', completed_at=NULL WHERE id=?\",\n+            (t,),\n+        )\n+        # Sleep 1s so the second run's ended_at is provably later than\n+        # the first (complete_task uses int(time.time())).\n+        time.sleep(1.05)\n+        kb.complete_task(conn, t, summary=\"second attempt — final\")\n+        assert kb.latest_summary(conn, t) == \"second attempt — final\"\n+\n+\n+def test_latest_summary_skips_empty_string(kanban_home):\n+    \"\"\"A run with an empty-string summary should not mask an earlier\n+    populated one — empty strings carry no information.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"t\", assignee=\"alice\")\n+        kb.complete_task(conn, t, summary=\"real handoff\")\n+        # Inject a later run with empty summary directly. Workers\n+        # writing \"\" instead of None is a real shape we want to ignore.\n+        conn.execute(\n+            \"INSERT INTO task_runs (task_id, status, started_at, ended_at, \"\n+            \"outcome, summary) VALUES (?, 'done', ?, ?, 'completed', ?)\",\n+            (t, int(time.time()) + 1, int(time.time()) + 2, \"\"),\n+        )\n+        conn.commit()\n+        assert kb.latest_summary(conn, t) == \"real handoff\"\n+\n+\n+def test_latest_summaries_batch_omits_tasks_without_summary(kanban_home):\n+    \"\"\"``latest_summaries`` is the dashboard's N+1 escape hatch — it\n+    must return only entries for tasks that actually have a summary,\n+    keep the per-task latest, and accept an empty input gracefully.\"\"\"\n+    with kb.connect() as conn:\n+        t1 = kb.create_task(conn, title=\"a\", assignee=\"alice\")\n+        t2 = kb.create_task(conn, title=\"b\", assignee=\"bob\")\n+        t3 = kb.create_task(conn, title=\"c\", assignee=\"carol\")\n+        kb.complete_task(conn, t1, summary=\"alpha\")\n+        kb.complete_task(conn, t3, summary=\"charlie\")\n+        out = kb.latest_summaries(conn, [t1, t2, t3])\n+        assert out == {t1: \"alpha\", t3: \"charlie\"}\n+        # Empty input → empty dict, no SQL syntax error from \"IN ()\".\n+        assert kb.latest_summaries(conn, []) == {}"]},{"file":"tests/plugins/test_kanban_dashboard_plugin.py","hunks":["@@ -203,7 +203,10 @@ def test_patch_block_then_unblock(client):\n \n def test_patch_drag_drop_move_todo_to_ready(client):\n     \"\"\"Direct status write: the drag-drop path for statuses without a\n-    dedicated verb (e.g. manually promoting todo -> ready).\"\"\"\n+    dedicated verb (e.g. manually promoting todo -> ready).\n+\n+    Promoting a child whose parent is not done is rejected (409).\n+    Promoting a child whose parent IS done is accepted (200).\"\"\"\n     parent = client.post(\"/api/plugins/kanban/tasks\", json={\"title\": \"p\"}).json()[\"task\"]\n     child = client.post(\n         \"/api/plugins/kanban/tasks\",","@@ -211,12 +214,23 @@ def test_patch_drag_drop_move_todo_to_ready(client):\n     ).json()[\"task\"]\n     assert child[\"status\"] == \"todo\"\n \n+    # Rejected: parent not done yet.\n     r = client.patch(\n         f\"/api/plugins/kanban/tasks/{child['id']}\",\n         json={\"status\": \"ready\"},\n     )\n+    assert r.status_code == 409\n+\n+    # Complete the parent.\n+    r = client.patch(\n+        f\"/api/plugins/kanban/tasks/{parent['id']}\",\n+        json={\"status\": \"done\"},\n+    )\n     assert r.status_code == 200\n-    assert r.json()[\"task\"][\"status\"] == \"ready\"\n+\n+    # Now child auto-promoted by recompute_ready — already ready.\n+    child_after = client.get(f\"/api/plugins/kanban/tasks/{child['id']}\").json()[\"task\"]\n+    assert child_after[\"status\"] == \"ready\"\n \n \n def test_patch_reassign(client):","@@ -433,13 +447,17 @@ def test_board_progress_rollup(client):\n         \"/api/plugins/kanban/tasks\",\n         json={\"title\": \"b\", \"parents\": [parent[\"id\"]]},\n     ).json()[\"task\"]\n-    # Children start as \"todo\" because the parent isn't done yet; promote\n-    # them to \"ready\" so complete_task will accept the transition.\n+    # Children start as \"todo\" because the parent isn't done yet.  Set the\n+    # parent to done so children auto-promote to ready via recompute_ready.\n+    r = client.patch(\n+        f\"/api/plugins/kanban/tasks/{parent['id']}\",\n+        json={\"status\": \"done\"},\n+    )\n+    assert r.status_code == 200\n+    # Verify children are now ready.\n     for cid in (child_a[\"id\"], child_b[\"id\"]):\n-        r = client.patch(\n-            f\"/api/plugins/kanban/tasks/{cid}\", json={\"status\": \"ready\"},\n-        )\n-        assert r.status_code == 200\n+        t = client.get(f\"/api/plugins/kanban/tasks/{cid}\").json()[\"task\"]\n+        assert t[\"status\"] == \"ready\", f\"{cid} should be ready after parent done\"\n \n     # 0/2 done.\n     r = client.get(\"/api/plugins/kanban/board\")","@@ -604,6 +622,32 @@ def test_dashboard_done_actions_prompt_for_completion_summary():\n     assert \"body: JSON.stringify(finalPatch)\" in bundle\n \n \n+def test_dashboard_dependency_selects_use_value_change_handler():\n+    \"\"\"Regression for the dependency selects in the task drawer: the\n+    add-parent / add-child dropdowns must wire through the shared\n+    selectChangeHandler helper so their value actually lands on the\n+    underlying React state. Salvaged from #20019 @LeonSGP43.\n+    \"\"\"\n+    repo_root = Path(__file__).resolve().parents[2]\n+    bundle = (\n+        repo_root / \"plugins\" / \"kanban\" / \"dashboard\" / \"dist\" / \"index.js\"\n+    ).read_text()\n+\n+    parent_select = (\n+        'value: newParent,\\n'\n+        '          className: \"h-7 text-xs flex-1\",\\n'\n+        '        }, selectChangeHandler(setNewParent))'\n+    )\n+    child_select = (\n+        'value: newChild,\\n'\n+        '          className: \"h-7 text-xs flex-1\",\\n'\n+        '        }, selectChangeHandler(setNewChild))'\n+    )\n+\n+    assert parent_select in bundle\n+    assert child_select in bundle\n+\n+\n def test_bulk_archive(client):\n     a = client.post(\"/api/plugins/kanban/tasks\", json={\"title\": \"a\"}).json()[\"task\"]\n     b = client.post(\"/api/plugins/kanban/tasks\", json={\"title\": \"b\"}).json()[\"task\"]\n"]}]},{"kind":"pr","number":24547,"title":"fix(kanban): workspace/parent/bulk-reassign selects revert to default (selectChangeHandler)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/kanban/dashboard/dist/index.js"],"fix_hunks":[{"file":"plugins/kanban/dashboard/dist/index.js","hunks":["@@ -1744,11 +1744,10 @@\n       ),\n       h(\"div\", { className: \"hermes-kanban-bulk-reassign\",\n                  title: \"Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply.\" },\n-        h(Select, {\n+        h(Select, Object.assign({\n           value: assignee,\n-          onChange: function (e) { setAssignee(e.target.value); },\n           className: \"h-7 text-xs\",\n-        },\n+        }, selectChangeHandler(setAssignee)),\n           h(SelectOption, { value: \"\" }, \"— reassign —\"),\n           h(SelectOption, { value: \"__none__\" }, \"(unassign)\"),\n           props.assignees.map(function (a) {","@@ -2249,12 +2248,11 @@\n         className: \"h-7 text-xs\",\n       }),\n       h(\"div\", { className: \"flex gap-2\" },\n-        h(Select, {\n+        h(Select, Object.assign({\n           value: workspaceKind,\n-          onChange: function (e) { setWorkspaceKind(e.target.value); },\n           title: \"scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).\",\n           className: \"h-7 text-xs w-28\",\n-        },\n+        }, selectChangeHandler(setWorkspaceKind)),\n           h(SelectOption, { value: \"scratch\" }, \"scratch\"),\n           h(SelectOption, { value: \"worktree\" }, \"worktree\"),\n           h(SelectOption, { value: \"dir\" }, \"dir\"),","@@ -2266,12 +2264,11 @@\n           className: \"h-7 text-xs flex-1\",\n         }) : null,\n       ),\n-      h(Select, {\n+      h(Select, Object.assign({\n         value: parent,\n-        onChange: function (e) { setParent(e.target.value); },\n         className: \"h-7 text-xs\",\n         title: \"Optional parent task. A child stays blocked in its current column until the parent is marked done.\",\n-      },\n+      }, selectChangeHandler(setParent)),\n         h(SelectOption, { value: \"\" }, tx(t, \"noParent\", \"— no parent —\")),\n         (props.allTasks || []).map(function (task) {\n           return h(SelectOption, { key: task.id, value: task.id },\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}