From 3d3d1c072b52efc7429dacb1eaec4d7851f25b02 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Sat, 8 Aug 2026 07:32:07 +0800 Subject: [PATCH 1/2] fix(plugins): honor max_in_progress on kanban dashboard dispatch nudge --- plugins/kanban/dashboard/plugin_api.py | 72 ++++++++++++++++++- tests/plugins/test_kanban_dashboard_plugin.py | 69 ++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index fdc49da34f13..d7df1751b190 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -2173,17 +2173,85 @@ def get_task_log( # Dispatch nudge (optional quick-path so the UI doesn't wait 60 s) # --------------------------------------------------------------------------- +def _coerce_positive_int(value: Any) -> Optional[int]: + """Return int(value) when it is an integer >= 1, else None.""" + if value is None: + return None + try: + ival = int(value) + except (TypeError, ValueError): + return None + return ival if ival >= 1 else None + + +def _dispatch_concurrency_from_config() -> dict[str, Any]: + """Load kanban concurrency knobs for the dashboard nudge path. + + Mirrors ``hermes_cli.kanban._cmd_dispatch`` and the gateway-embedded + dispatcher so ``POST /dispatch`` cannot bypass + ``kanban.max_in_progress`` / ``max_in_progress_per_profile`` (the old + nudge defaulted ``max=8`` and passed only ``max_spawn``, so a board + capped at 3 could still jump to 8 running after one UI nudge). + """ + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} + if not isinstance(kanban_cfg, dict): + kanban_cfg = {} + except Exception: + return { + "max_in_progress": None, + "max_in_progress_per_profile": None, + "max_spawn": None, + "default_assignee": None, + } + return { + "max_in_progress": _coerce_positive_int(kanban_cfg.get("max_in_progress")), + "max_in_progress_per_profile": _coerce_positive_int( + kanban_cfg.get("max_in_progress_per_profile") + ), + "max_spawn": _coerce_positive_int(kanban_cfg.get("max_spawn")), + "default_assignee": (kanban_cfg.get("default_assignee") or "").strip() or None, + } + + @router.post("/dispatch") def dispatch( dry_run: bool = Query(False), - max_n: int = Query(8, alias="max"), + max_n: Optional[int] = Query(None, alias="max"), board: Optional[str] = Query(None), ): + """Nudge one dispatcher tick (skip the 60 s wait). + + Honours the same concurrency config as the gateway / CLI: + + - ``kanban.max_in_progress`` — board-wide running cap + - ``kanban.max_in_progress_per_profile`` — per-assignee cap + - ``kanban.max_spawn`` — per-tick spawn budget (overridden by ``?max=``) + - ``kanban.default_assignee`` — fallback for unassigned ready tasks + + ``?max=N`` is an explicit per-tick spawn budget (same as + ``hermes kanban dispatch --max N``); it does **not** bypass + ``max_in_progress``. + """ board = _resolve_board(board) + caps = _dispatch_concurrency_from_config() + # Explicit ?max= wins over config max_spawn; omit → config / unlimited. + max_spawn = ( + _coerce_positive_int(max_n) if max_n is not None else caps["max_spawn"] + ) conn = _conn(board=board) try: result = kanban_db.dispatch_once( - conn, dry_run=dry_run, max_spawn=max_n, board=board, + conn, + dry_run=dry_run, + max_spawn=max_spawn, + max_in_progress=caps["max_in_progress"], + max_in_progress_per_profile=caps["max_in_progress_per_profile"], + default_assignee=caps["default_assignee"], + board=board, ) # DispatchResult is a dataclass. try: diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 5fdb750a385d..c92d6255530a 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -319,6 +319,75 @@ def test_dispatch_dry_run(client): assert isinstance(body, dict) +def test_dispatch_passes_max_in_progress_from_config(client, monkeypatch): + """Dashboard nudge must honour kanban.max_in_progress (and friends). + + Regression: POST /dispatch used to default max_spawn=8 and omit the + board-wide / per-profile caps, so a UI nudge could push running count + past kanban.max_in_progress even though the gateway tick respected it. + """ + import sys + + fake_config = { + "kanban": { + "max_in_progress": 3, + "max_in_progress_per_profile": 2, + "max_spawn": 5, + "default_assignee": "researcher", + } + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_config) + + captured: dict = {} + + def fake_dispatch_once(conn, **kwargs): + captured.update(kwargs) + return kb.DispatchResult() + + monkeypatch.setattr(kb, "dispatch_once", fake_dispatch_once) + # The plugin module binds `kanban_db` at import time — patch that alias too. + plugin_mod = sys.modules.get("hermes_dashboard_plugin_kanban_test") + assert plugin_mod is not None + monkeypatch.setattr(plugin_mod.kanban_db, "dispatch_once", fake_dispatch_once) + + r = client.post("/api/plugins/kanban/dispatch?dry_run=true") + assert r.status_code == 200 + assert captured.get("max_in_progress") == 3 + assert captured.get("max_in_progress_per_profile") == 2 + assert captured.get("max_spawn") == 5 # from config when ?max= omitted + assert captured.get("default_assignee") == "researcher" + assert captured.get("dry_run") is True + + +def test_dispatch_max_query_overrides_config_max_spawn(client, monkeypatch): + """?max=N overrides kanban.max_spawn but must still pass max_in_progress.""" + import sys + + fake_config = { + "kanban": { + "max_in_progress": 3, + "max_spawn": 10, + } + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_config) + + captured: dict = {} + + def fake_dispatch_once(conn, **kwargs): + captured.update(kwargs) + return kb.DispatchResult() + + monkeypatch.setattr(kb, "dispatch_once", fake_dispatch_once) + plugin_mod = sys.modules.get("hermes_dashboard_plugin_kanban_test") + assert plugin_mod is not None + monkeypatch.setattr(plugin_mod.kanban_db, "dispatch_once", fake_dispatch_once) + + r = client.post("/api/plugins/kanban/dispatch?dry_run=true&max=2") + assert r.status_code == 200 + assert captured.get("max_spawn") == 2 + assert captured.get("max_in_progress") == 3 + + # --------------------------------------------------------------------------- # Triage column (new v1 status) # --------------------------------------------------------------------------- From 5e8c945ebd74fe0c07b94cae154fbf166bbf332c Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Sat, 8 Aug 2026 07:36:16 +0800 Subject: [PATCH 2/2] fix(plugins): pass dashboard dispatch max=0 through like CLI --- plugins/kanban/dashboard/plugin_api.py | 13 ++++++---- tests/plugins/test_kanban_dashboard_plugin.py | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index d7df1751b190..3784d4290197 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -2238,10 +2238,15 @@ def dispatch( """ board = _resolve_board(board) caps = _dispatch_concurrency_from_config() - # Explicit ?max= wins over config max_spawn; omit → config / unlimited. - max_spawn = ( - _coerce_positive_int(max_n) if max_n is not None else caps["max_spawn"] - ) + # Explicit ?max= wins over config max_spawn (same as CLI --max). Pass the + # integer through — including 0, which means "spawn nothing this tick" — + # rather than coercing non-positive values to None (unlimited). + if max_n is not None and max_n < 0: + raise HTTPException( + status_code=http_status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="max must be >= 0", + ) + max_spawn = max_n if max_n is not None else caps["max_spawn"] conn = _conn(board=board) try: result = kanban_db.dispatch_once( diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index c92d6255530a..dcc1a38f5f47 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -388,6 +388,30 @@ def fake_dispatch_once(conn, **kwargs): assert captured.get("max_in_progress") == 3 +def test_dispatch_max_zero_means_no_spawns(client, monkeypatch): + """?max=0 must reach dispatch_once as 0 (block spawns), not None/unlimited.""" + import sys + + fake_config = {"kanban": {"max_in_progress": 3, "max_spawn": 10}} + monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_config) + + captured: dict = {} + + def fake_dispatch_once(conn, **kwargs): + captured.update(kwargs) + return kb.DispatchResult() + + monkeypatch.setattr(kb, "dispatch_once", fake_dispatch_once) + plugin_mod = sys.modules.get("hermes_dashboard_plugin_kanban_test") + assert plugin_mod is not None + monkeypatch.setattr(plugin_mod.kanban_db, "dispatch_once", fake_dispatch_once) + + r = client.post("/api/plugins/kanban/dispatch?dry_run=true&max=0") + assert r.status_code == 200 + assert captured.get("max_spawn") == 0 + assert captured.get("max_in_progress") == 3 + + # --------------------------------------------------------------------------- # Triage column (new v1 status) # ---------------------------------------------------------------------------