fix(kanban-dashboard): use context-local board pin in specify/decompose endpoints

The dashboard specify and decompose endpoints run as sync FastAPI threadpool
handlers and pinned the active board by mutating the process-global
HERMES_KANBAN_BOARD env var. Two concurrent requests for different boards
race on that shared global and cross-write — the same bug class as the CLI
path (#38323), now using the scoped_current_board() contextvar introduced by
the CLI fix.
This commit is contained in:
teknium
2026-06-04 06:13:56 -07:00
committed by Teknium
parent 081694c111
commit 62f0cfd902

View File

@ -38,7 +38,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
import sqlite3 import sqlite3
import time import time
from dataclasses import asdict from dataclasses import asdict
@ -1620,10 +1619,12 @@ def specify_task_endpoint(
""" """
board = _resolve_board(board) board = _resolve_board(board)
# Pin the board for the duration of this call so the specifier module # Pin the board for the duration of this call so the specifier module
# (which calls ``kb.connect()`` with no args) hits the right DB. # (which calls ``kb.connect()`` with no args) hits the right DB. Use a
prev_env = os.environ.get("HERMES_KANBAN_BOARD") # context-local override rather than mutating the process-global
try: # HERMES_KANBAN_BOARD env var — this endpoint runs in FastAPI's
os.environ["HERMES_KANBAN_BOARD"] = board or kanban_db.DEFAULT_BOARD # threadpool, so two concurrent requests for different boards would
# otherwise race on the shared env var and cross-write (issue #38323).
with kanban_db.scoped_current_board(board or kanban_db.DEFAULT_BOARD):
# Import lazily so a missing auxiliary client at import time # Import lazily so a missing auxiliary client at import time
# doesn't break plugin load. # doesn't break plugin load.
from hermes_cli import kanban_specify # noqa: WPS433 (intentional) from hermes_cli import kanban_specify # noqa: WPS433 (intentional)
@ -1632,11 +1633,6 @@ def specify_task_endpoint(
task_id, task_id,
author=(payload.author or None), author=(payload.author or None),
) )
finally:
if prev_env is None:
os.environ.pop("HERMES_KANBAN_BOARD", None)
else:
os.environ["HERMES_KANBAN_BOARD"] = prev_env
return { return {
"ok": bool(outcome.ok), "ok": bool(outcome.ok),
@ -2233,19 +2229,16 @@ def decompose_task_endpoint(
can take minutes on reasoning models. can take minutes on reasoning models.
""" """
board = _resolve_board(board) board = _resolve_board(board)
prev_env = os.environ.get("HERMES_KANBAN_BOARD") # Context-local board pin (see specify endpoint above): this sync
try: # endpoint runs in FastAPI's threadpool, so mutating the process-global
os.environ["HERMES_KANBAN_BOARD"] = board or kanban_db.DEFAULT_BOARD # HERMES_KANBAN_BOARD env var would let concurrent requests for
# different boards race and cross-write (issue #38323).
with kanban_db.scoped_current_board(board or kanban_db.DEFAULT_BOARD):
from hermes_cli import kanban_decompose # noqa: WPS433 (intentional) from hermes_cli import kanban_decompose # noqa: WPS433 (intentional)
outcome = kanban_decompose.decompose_task( outcome = kanban_decompose.decompose_task(
task_id, task_id,
author=(payload.author or None), author=(payload.author or None),
) )
finally:
if prev_env is None:
os.environ.pop("HERMES_KANBAN_BOARD", None)
else:
os.environ["HERMES_KANBAN_BOARD"] = prev_env
return { return {
"ok": bool(outcome.ok), "ok": bool(outcome.ok),