Kanban column label undefined aria-label bug fixes
NousResearch/hermes-agent · cluster 23620 · hermes cli
Data as of
Nothing open on GitHub. Fix merged in #23791. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #23791. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple reports and fix attempts address an undefined COLUMN_LABEL variable causing rendering errors and broken aria-labels on kanban column select-all checkboxes. One unrelated member covers SPA auto-reload on stale-token 401 responses.
Open issues0
Open PRs0
Fixes on record
-
#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":
-
-
#33861 fix(dashboard): auto-reload SPA on stale-token 401 in loopback mode
2 PRs closed without merging
From the close comments: merged via another change 2
Closed issues · 2
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.29: 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