Kanban dependency gating, deadlock, and lifecycle fixes

NousResearch/hermes-agent · cluster 19746 · cron

Data as of

7 open items. Fix merged in #20448. Check it covers your case.

The short answer

3open issues
4open PRs
29items grouped
Known?
Yes. 29 related items grouped here, 8 issues already closed.
Fix event
A fix was merged to main in PR #20448. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #22671, #23791, #83412. That is separate from the fix event.
Still open
3 issues and 4 PRs.

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.

Open issues3

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

Open PRs4

  • PR#31765fix(kanban): accept 'completed' as terminal status in dependency checks
  • PR#49909fix(kanban): allow completing tasks from todo status
  • PR#49933fix(kanban): allow complete_task from any active status on manual completion
  • PR#103855fix(kanban): render the drawer's stored PATCH error so failed Ready transitions are visible

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #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

    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

    Show the diff
    • hermes_cli/doctor.py
      @@ -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
      @@ -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
      @@ -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": [
      @@ -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
      @@ -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:
      @@ -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:
      @@ -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
      @@ -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
       
      @@ -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
      @@ -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 },
      @@ -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
      @@ -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
       
      @@ -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
      @@ -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])
      @@ -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
      @@ -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
      @@ -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
      @@ -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)
      @@ -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
      @@ -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``
      @@ -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
      @@ -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")
      @@ -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
      @@ -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",
      @@ -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):
      @@ -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")
      @@ -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"]
  • #22671 fix(kanban): gate claim+unblock on parent completion (salvage #22581)

    merged to main

    files: hermes_cli/kanban_db.py, scripts/release.py, tests/hermes_cli/test_kanban_db.py, tests/stress/test_concurrency_parent_gate.py

    Show the diff
    • hermes_cli/kanban_db.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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())
  • #23791 fix(kanban): batch-salvage 7 community kanban bug fixes

    merged to main

    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

    Show the diff
    • gateway/run.py
      @@ -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()
      @@ -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)
      @@ -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
      @@ -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)
      @@ -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
      @@ -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 = {
      @@ -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
      @@ -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.
      @@ -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,),
      @@ -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:
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
       
      @@ -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
      @@ -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
      @@ -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
      @@ -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

    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)

10 PRs closed without merging

From the close comments: merged via another change 4 · done elsewhere 2 · duplicate 1 · partial fix 1 · superseded by another PR 1 · other 1

  • #19743 feat(kanban): prevent child task dispatch when parent is not done · merged via another change → #20448
  • #22372 fix(kanban): route unblock through dependency gate · done elsewhere → cda20ee, #22375
  • #22405 fix(kanban): route unblock_task through parent-dependency gate · done elsewhere → cda20ee, 0c22434, #22375, #22627
  • #23298 fix(cli): warn before archiving a task with dependent children · superseded by another PR → #23325
  • #23325 fix(kanban): treat archived parent tasks as terminal for dependency resolution · merged via another change → #23791
Show 5 more
  • #26777 fix(kanban-dashboard): surface failed Ready-transition with blocking parent detail (#26744) · merged via another change → e215558, #28434
  • #27049 fix: allow kanban children to stay/become ready when parent is running · duplicate → #24505, #24489
  • #31767 fix(kanban): normalize legacy completed task status · other → #31717, #31765
  • #68210 fix(kanban): let review-required self-park not deadlock dependency children (#67963) · partial fix → 36128a4, 9c797c5, #68210, #52544, #72554, #75451, #1, #15803, #14532, #6, #9, #374151, #4338, #67963, #67989
  • #75451 fix(kanban): complete first-class review lifecycle · merged via another change → a98aee4, #83412
Closed issues · 8
  • Closed#19746feat(kanban): add parent dependency guard to direct status writes
  • Closed#22375[Bug]: unblock_task bypasses parent-dependency gate
  • Closed#23180[Bug]: archiving a parent task silently deadlocks dependent children in todo
  • Closed#24489kanban: parent-child deadlock blocks parallel War Room orchestration
  • Closed#25408feat: kanban task board drag-drop status transitions (hermes parity)
  • Closed#65195kanban unblock on a parented task unlinks parent-child edge and promotes children to ready
  • Closed#67963Dependency-gate semantics gap: blocked parents deadlock dependency children
  • Closed#67989Hermes kanban: blocked parent creates silent dependency deadlock
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
cron
Merged PRs
#20448, #22671, #23791, #83412
Closed issues
8
Attempts
10 PRs closed without merging
Fix link checked
10 Aug 2026

For your agent

The same facts, typed.