Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2173,17 +2173,90 @@ 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 (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(
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:
Expand Down
93 changes: 93 additions & 0 deletions tests/plugins/test_kanban_dashboard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,99 @@ 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


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)
# ---------------------------------------------------------------------------
Expand Down
Loading