{"repo":"NousResearch/hermes-agent","public_id":"38323","display_label":"Kanban board env mutation causing concurrency bugs","summary":"A bug where kanban board logic mutates process-global state (os.environ) causes race conditions and KeyErrors under concurrent calls. Multiple fixes attempt to isolate the board override per call and avoid unsafe environment variable popping.","subsystem":"gateway","counts":{"members":7,"open_issues":1,"open_issues_distinct":1,"open_prs":2,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":39074,"merged_at":"2026-06-04T14:39:54+00:00"},"resolution":{"state":"partially_resolved","by":"pr-39074"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T14:39:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":38323,"pr_number":39074,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-04T14:39:54+00:00"}],"members":[{"kind":"issue","number":38323,"title":"[Bug]: kanban --board uses process-global state and can cross-write tasks under concurrent calls","url":null,"state":"closed"},{"kind":"issue","number":65225,"title":"KeyError: 'HERMES_KANBAN_BOARD' — kanban dispatcher mutates os.environ, racing dotenv's env.update(os.environ)","url":null,"state":"open"},{"kind":"pr","number":38325,"title":"fix(kanban): isolate board override per concurrent call","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"081694c11","number":0},{"kind":"pr","value":"#39074","number":39074}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":39074,"title":"fix(kanban): isolate board override per concurrent call (CLI + dashboard)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/kanban.py","hermes_cli/kanban_db.py","plugins/kanban/dashboard/plugin_api.py","scripts/release.py","tests/hermes_cli/test_kanban_cli.py"],"fix_hunks":[{"file":"hermes_cli/kanban.py","hunks":["@@ -15,6 +15,7 @@\n from __future__ import annotations\n \n import argparse\n+import contextlib\n import json\n import os\n import shlex","@@ -884,16 +885,7 @@ def kanban_command(args: argparse.Namespace) -> int:\n     # keeps the patch small and inherits the exact same resolution the\n     # dispatcher uses for workers — consistency is a feature here.\n     board_override = getattr(args, \"board\", None)\n-    prev_board_env = os.environ.get(\"HERMES_KANBAN_BOARD\")\n-    restore_board_env = False\n-\n-    def _restore_board_env() -> None:\n-        if not restore_board_env:\n-            return\n-        if prev_board_env is None:\n-            os.environ.pop(\"HERMES_KANBAN_BOARD\", None)\n-        else:\n-            os.environ[\"HERMES_KANBAN_BOARD\"] = prev_board_env\n+    board_scope = contextlib.nullcontext()\n     if board_override:\n         try:\n             normed = kb._normalize_board_slug(board_override)","@@ -912,8 +904,7 @@ def _restore_board_env() -> None:\n                 file=sys.stderr,\n             )\n             return 1\n-        os.environ[\"HERMES_KANBAN_BOARD\"] = normed\n-        restore_board_env = True\n+        board_scope = kb.scoped_current_board(normed)\n \n     # Auto-initialize the DB before dispatching any subcommand. init_db\n     # is idempotent, so running it every invocation is cheap (one","@@ -922,66 +913,62 @@ def _restore_board_env() -> None:\n     # HERMES_HOME. Previously only `init` and `daemon` triggered\n     # schema creation; `create` / `list` / every other command would\n     # error out on a fresh install.\n-    try:\n-        kb.init_db()\n-    except Exception as exc:\n-        print(f\"kanban: could not initialize database: {exc}\", file=sys.stderr)\n-        _restore_board_env()\n-        return 1\n+    with board_scope:\n+        try:\n+            kb.init_db()\n+        except Exception as exc:\n+            print(f\"kanban: could not initialize database: {exc}\", file=sys.stderr)\n+            return 1\n \n-    handlers = {\n-        \"init\":     _cmd_init,\n-        \"create\":   _cmd_create,\n-        \"swarm\":    _cmd_swarm,\n-        \"list\":     _cmd_list,\n-        \"ls\":       _cmd_list,\n-        \"show\":     _cmd_show,\n-        \"assign\":   _cmd_assign,\n-        \"reclaim\":  _cmd_reclaim,\n-        \"reassign\": _cmd_reassign,\n-        \"diagnostics\": _cmd_diagnostics,\n-        \"diag\":     _cmd_diagnostics,\n-        \"link\":     _cmd_link,\n-        \"unlink\":   _cmd_unlink,\n-        \"claim\":    _cmd_claim,\n-        \"comment\":  _cmd_comment,\n-        \"complete\": _cmd_complete,\n-        \"edit\":     _cmd_edit,\n-        \"block\":    _cmd_block,\n-        \"schedule\": _cmd_schedule,\n-        \"unblock\":  _cmd_unblock,\n-        \"promote\":  _cmd_promote,\n-        \"archive\":  _cmd_archive,\n-        \"tail\":     _cmd_tail,\n-        \"dispatch\": _cmd_dispatch,\n-        \"daemon\":   _cmd_daemon,\n-        \"watch\":    _cmd_watch,\n-        \"stats\":    _cmd_stats,\n-        \"log\":      _cmd_log,\n-        \"runs\":     _cmd_runs,\n-        \"heartbeat\": _cmd_heartbeat,\n-        \"assignees\": _cmd_assignees,\n-        \"notify-subscribe\":   _cmd_notify_subscribe,\n-        \"notify-list\":        _cmd_notify_list,\n-        \"notify-unsubscribe\": _cmd_notify_unsubscribe,\n-        \"context\":  _cmd_context,\n-        \"specify\":  _cmd_specify,\n-        \"decompose\":  _cmd_decompose,\n-        \"gc\":       _cmd_gc,\n-    }\n-    handler = handlers.get(action)\n-    if not handler:\n-        print(f\"kanban: unknown action {action!r}\", file=sys.stderr)\n-        _restore_board_env()\n-        return 2\n-    try:\n-        return int(handler(args) or 0)\n-    except (ValueError, RuntimeError) as exc:\n-        print(f\"kanban: {exc}\", file=sys.stderr)\n-        _restore_board_env()\n-        return 1\n-    finally:\n-        _restore_board_env()\n+        handlers = {\n+            \"init\":     _cmd_init,\n+            \"create\":   _cmd_create,\n+            \"swarm\":    _cmd_swarm,\n+            \"list\":     _cmd_list,\n+            \"ls\":       _cmd_list,\n+            \"show\":     _cmd_show,\n+            \"assign\":   _cmd_assign,\n+            \"reclaim\":  _cmd_reclaim,\n+            \"reassign\": _cmd_reassign,\n+            \"diagnostics\": _cmd_diagnostics,\n+            \"diag\":     _cmd_diagnostics,\n+            \"link\":     _cmd_link,\n+            \"unlink\":   _cmd_unlink,\n+            \"claim\":    _cmd_claim,\n+            \"comment\":  _cmd_comment,\n+            \"complete\": _cmd_complete,\n+            \"edit\":     _cmd_edit,\n+            \"block\":    _cmd_block,\n+            \"schedule\": _cmd_schedule,\n+            \"unblock\":  _cmd_unblock,\n+            \"promote\":  _cmd_promote,\n+            \"archive\":  _cmd_archive,\n+            \"tail\":     _cmd_tail,\n+            \"dispatch\": _cmd_dispatch,\n+            \"daemon\":   _cmd_daemon,\n+            \"watch\":    _cmd_watch,\n+            \"stats\":    _cmd_stats,\n+            \"log\":      _cmd_log,\n+            \"runs\":     _cmd_runs,\n+            \"heartbeat\": _cmd_heartbeat,\n+            \"assignees\": _cmd_assignees,\n+            \"notify-subscribe\":   _cmd_notify_subscribe,\n+            \"notify-list\":        _cmd_notify_list,\n+            \"notify-unsubscribe\": _cmd_notify_unsubscribe,\n+            \"context\":  _cmd_context,\n+            \"specify\":  _cmd_specify,\n+            \"decompose\":  _cmd_decompose,\n+            \"gc\":       _cmd_gc,\n+        }\n+        handler = handlers.get(action)\n+        if not handler:\n+            print(f\"kanban: unknown action {action!r}\", file=sys.stderr)\n+            return 2\n+        try:\n+            return int(handler(args) or 0)\n+        except (ValueError, RuntimeError) as exc:\n+            print(f\"kanban: {exc}\", file=sys.stderr)\n+            return 1\n \n \n # ---------------------------------------------------------------------------"]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -83,6 +83,7 @@\n import threading\n import logging\n import time\n+from contextvars import ContextVar, Token\n from dataclasses import dataclass, field\n from pathlib import Path\n from typing import Any, Iterable, Optional","@@ -222,6 +223,20 @@ def _resolve_rate_limit_cooldown_seconds() -> int:\n # ---------------------------------------------------------------------------\n \n DEFAULT_BOARD = \"default\"\n+_CURRENT_BOARD_OVERRIDE: ContextVar[str | None] = ContextVar(\n+    \"hermes_kanban_current_board_override\",\n+    default=None,\n+)\n+\n+\n[redacted:email]\n+def scoped_current_board(slug: str):\n+    \"\"\"Temporarily pin the active board for the current context only.\"\"\"\n+    token: [redacted:secret-assignment] | None] = _CURRENT_BOARD_OVERRIDE.set(slug)\n+    try:\n+        yield\n+    finally:\n+        _CURRENT_BOARD_OVERRIDE.reset(token)\n \n # Slug validator: lowercase alphanumerics, digits, hyphens; 1–64 chars.\n # Strict enough to stop traversal (`..`) and embedded path separators, loose","@@ -305,6 +320,15 @@ def get_current_board() -> str:\n     with a best-effort warning — the dispatcher must never crash because a\n     user hand-edited a file or removed a board directory.\n     \"\"\"\n+    scoped = (_CURRENT_BOARD_OVERRIDE.get() or \"\").strip()\n+    if scoped:\n+        try:\n+            normed = _normalize_board_slug(scoped)\n+            if normed and board_exists(normed):\n+                return normed\n+        except ValueError:\n+            pass\n+\n     env = os.environ.get(\"HERMES_KANBAN_BOARD\", \"\").strip()\n     if env:\n         try:"]},{"file":"plugins/kanban/dashboard/plugin_api.py","hunks":["@@ -38,7 +38,6 @@\n import asyncio\n import json\n import logging\n-import os\n import sqlite3\n import time\n from dataclasses import asdict","@@ -1620,10 +1619,12 @@ def specify_task_endpoint(\n     \"\"\"\n     board = _resolve_board(board)\n     # Pin the board for the duration of this call so the specifier module\n-    # (which calls ``kb.connect()`` with no args) hits the right DB.\n-    prev_env = os.environ.get(\"HERMES_KANBAN_BOARD\")\n-    try:\n-        os.environ[\"HERMES_KANBAN_BOARD\"] = board or kanban_db.DEFAULT_BOARD\n+    # (which calls ``kb.connect()`` with no args) hits the right DB. Use a\n+    # context-local override rather than mutating the process-global\n+    # HERMES_KANBAN_BOARD env var — this endpoint runs in FastAPI's\n+    # threadpool, so two concurrent requests for different boards would\n+    # otherwise race on the shared env var and cross-write (issue #38323).\n+    with kanban_db.scoped_current_board(board or kanban_db.DEFAULT_BOARD):\n         # Import lazily so a missing auxiliary client at import time\n         # doesn't break plugin load.\n         from hermes_cli import kanban_specify  # noqa: WPS433 (intentional)","@@ -1632,11 +1633,6 @@ def specify_task_endpoint(\n             task_id,\n             author=(payload.author or None),\n         )\n-    finally:\n-        if prev_env is None:\n-            os.environ.pop(\"HERMES_KANBAN_BOARD\", None)\n-        else:\n-            os.environ[\"HERMES_KANBAN_BOARD\"] = prev_env\n \n     return {\n         \"ok\": bool(outcome.ok),","@@ -2233,19 +2229,16 @@ def decompose_task_endpoint(\n     can take minutes on reasoning models.\n     \"\"\"\n     board = _resolve_board(board)\n-    prev_env = os.environ.get(\"HERMES_KANBAN_BOARD\")\n-    try:\n-        os.environ[\"HERMES_KANBAN_BOARD\"] = board or kanban_db.DEFAULT_BOARD\n+    # Context-local board pin (see specify endpoint above): this sync\n+    # endpoint runs in FastAPI's threadpool, so mutating the process-global\n+    # HERMES_KANBAN_BOARD env var would let concurrent requests for\n+    # different boards race and cross-write (issue #38323).\n+    with kanban_db.scoped_current_board(board or kanban_db.DEFAULT_BOARD):\n         from hermes_cli import kanban_decompose  # noqa: WPS433 (intentional)\n         outcome = kanban_decompose.decompose_task(\n             task_id,\n             author=(payload.author or None),\n         )\n-    finally:\n-        if prev_env is None:\n-            os.environ.pop(\"HERMES_KANBAN_BOARD\", None)\n-        else:\n-            os.environ[\"HERMES_KANBAN_BOARD\"] = prev_env\n \n     return {\n         \"ok\": bool(outcome.ok),"]},{"file":"scripts/release.py","hunks":["@@ -47,6 +47,7 @@\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"stremtec\",\n     \"[redacted:email]\": \"solaitken\",\n+    \"[redacted:email]\": \"worlldz\",\n     \"[redacted:email]\": \"vladkvlchk\",\n     \"116314616+ThyFriendlyFox@users.noreply.github.com\": \"ThyFriendlyFox\",\n     \"[redacted:email]\": \"truenorth-lj\","]},{"file":"tests/hermes_cli/test_kanban_cli.py","hunks":["@@ -5,6 +5,7 @@\n import argparse\n import json\n import os\n+import threading\n from pathlib import Path\n \n import pytest","@@ -263,6 +264,52 @@ def test_run_slash_link_unlink(kanban_home):\n     assert \"Unlinked\" in kc.run_slash(f\"unlink {ta} {tb}\")\n \n \n+def test_board_override_is_isolated_per_concurrent_call(kanban_home, monkeypatch):\n+    kb.create_board(\"alpha\")\n+    kb.create_board(\"beta\")\n+\n+    parser = argparse.ArgumentParser(prog=\"hermes\", add_help=False)\n+    sub = parser.add_subparsers(dest=\"command\")\n+    kc.build_parser(sub)\n+\n+    barrier = threading.Barrier(2)\n+    original_init_db = kb.init_db\n+\n+    def slow_init_db(*args, **kwargs):\n+        try:\n+            barrier.wait(timeout=5)\n+        except threading.BrokenBarrierError:\n+            pass\n+        return original_init_db(*args, **kwargs)\n+\n+    monkeypatch.setattr(kb, \"init_db\", slow_init_db)\n+\n+    failures: list[str] = []\n+\n+    def worker(board: str, title: str) -> None:\n+        args = parser.parse_args([\"kanban\", \"--board\", board, \"create\", title])\n+        rc = kc.kanban_command(args)\n+        if rc != 0:\n+            failures.append(f\"{board}:{rc}\")\n+\n+    t1 = threading.Thread(target=worker, args=(\"alpha\", \"alpha-task\"))\n+    t2 = threading.Thread(target=worker, args=(\"beta\", \"beta-task\"))\n+    t1.start()\n+    t2.start()\n+    t1.join()\n+    t2.join()\n+\n+    assert failures == []\n+\n+    with kb.connect_closing(board=\"alpha\") as conn:\n+        alpha_titles = [row.title for row in kb.list_tasks(conn, limit=100)]\n+    with kb.connect_closing(board=\"beta\") as conn:\n+        beta_titles = [row.title for row in kb.list_tasks(conn, limit=100)]\n+\n+    assert alpha_titles == [\"alpha-task\"]\n+    assert beta_titles == [\"beta-task\"]\n+\n+\n # ---------------------------------------------------------------------------\n # Integration with the COMMAND_REGISTRY\n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":65246,"title":"fix(gateway): avoid kanban board env race","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"c4c7c1a1774b","number":0},{"kind":"pr","value":"#65246","number":65246},{"kind":"pr","value":"#65332","number":65332},{"kind":"pr","value":"#54295","number":54295},{"kind":"pr","value":"#67057","number":67057},{"kind":"pr","value":"#68068","number":68068},{"kind":"pr","value":"#65225","number":65225},{"kind":"pr","value":"#65252","number":65252},{"kind":"pr","value":"#374151","number":374151}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":65332,"title":"fix(gateway): stop popping HERMES_KANBAN_BOARD env to eliminate dotenv KeyError race (#65225)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65252","number":65252}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":120478,"title":"fix(kanban): pin the auto-decompose board for the tick's context only","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#65252","number":65252},{"kind":"pr","value":"#65225","number":65225}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}