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
- 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.
Next step: read #20448, then check whether your report is one of the 3 still open.
Read #20448Pick up a Lab taskThese 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
-
#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)
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.pyShow 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)
files:
hermes_cli/kanban_db.py,scripts/release.py,tests/hermes_cli/test_kanban_db.py,tests/stress/test_concurrency_parent_gate.pyShow 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
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.pyShow 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
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.00means 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