{"repo":"NousResearch/hermes-agent","public_id":"21582","display_label":"Kanban in-progress task concurrency limits and config","summary":"These issues and pull requests concern adding configuration options to cap the number of concurrent in-progress tasks in the kanban system, including per-profile limits and default assignee fallback behavior.","subsystem":"cron","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28420},"resolution":{"state":"partially_resolved","by":"pr-34244"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T02:02:55+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":21582,"pr_number":34244,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":22829,"pr_number":28420,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":27145,"pr_number":34244,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":21582,"title":"[Feature]: limit tasks per profile in kanban","url":null,"state":"closed"},{"kind":"issue","number":22829,"title":"[Feature]: Kanban system tasks limit for  in progress","url":null,"state":"closed"},{"kind":"pr","number":22981,"title":"feat(kanban): add max_in_progress config to cap concurrent running tasks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28420","number":28420}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28420,"title":"feat(kanban): add max_in_progress config to cap concurrent running tasks (#22981)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/kanban_db.py","tests/hermes_cli/test_kanban_db.py"]},{"kind":"pr","number":34244,"title":"feat(kanban): default_assignee fallback + per-profile concurrency cap (closes #27145, closes #21582)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/config.py","hermes_cli/kanban.py","hermes_cli/kanban_db.py","tests/hermes_cli/test_kanban_default_assignee.py","tests/hermes_cli/test_kanban_per_profile_cap.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -5418,6 +5418,49 @@ async def _kanban_dispatcher_watcher(self) -> None:\n             )\n             stale_timeout_seconds = 0\n \n+        # Read kanban.default_assignee — fallback profile for tasks\n+        # created without an explicit assignee (e.g. via the dashboard).\n+        # When set, the dispatcher applies it to unassigned ready tasks\n+        # instead of skipping them indefinitely (#27145). Empty string\n+        # (the schema default) means \"no fallback, keep skipping\" —\n+        # backward-compatible with existing installs.\n+        default_assignee = (kanban_cfg.get(\"default_assignee\") or \"\").strip() or None\n+        if default_assignee:\n+            logger.info(\n+                \"kanban dispatcher: default_assignee=%r (unassigned ready tasks \"\n+                \"will route to this profile)\",\n+                default_assignee,\n+            )\n+\n+        # Read kanban.max_in_progress_per_profile — per-profile concurrency\n+        # cap (#21582). When set, no single profile gets more than N\n+        # workers running at once, even if the global max_in_progress\n+        # would allow it. Prevents one profile's local model / API quota\n+        # / browser pool from being overwhelmed by a fan-out.\n+        raw_per_profile = kanban_cfg.get(\"max_in_progress_per_profile\", None)\n+        max_in_progress_per_profile = None\n+        if raw_per_profile is not None:\n+            try:\n+                max_in_progress_per_profile = int(raw_per_profile)\n+            except (TypeError, ValueError):\n+                logger.warning(\n+                    \"kanban dispatcher: invalid kanban.max_in_progress_per_profile=%r; ignoring\",\n+                    raw_per_profile,\n+                )\n+                max_in_progress_per_profile = None\n+            else:\n+                if max_in_progress_per_profile < 1:\n+                    logger.warning(\n+                        \"kanban dispatcher: kanban.max_in_progress_per_profile=%r is below 1; ignoring\",\n+                        raw_per_profile,\n+                    )\n+                    max_in_progress_per_profile = None\n+                else:\n+                    logger.info(\n+                        \"kanban dispatcher: max_in_progress_per_profile=%d\",\n+                        max_in_progress_per_profile,\n+                    )\n+\n         # Initial delay so the gateway finishes wiring adapters before the\n         # dispatcher spawns workers (those workers may hit gateway notify\n         # subscriptions etc.). Matches the notifier watcher's delay.","@@ -5509,6 +5552,8 @@ def _tick_once_for_board(slug: str) -> \"Optional[object]\":\n                     max_in_progress=max_in_progress,\n                     failure_limit=failure_limit,\n                     stale_timeout_seconds=stale_timeout_seconds,\n+                    default_assignee=default_assignee,\n+                    max_in_progress_per_profile=max_in_progress_per_profile,\n                 )\n             except sqlite3.DatabaseError as exc:\n                 if _is_corrupt_board_db_error(exc):"]},{"file":"hermes_cli/config.py","hunks":["@@ -1726,6 +1726,15 @@ def _ensure_hermes_home_managed(home: Path):\n         # assignee to any installed profile. When unset, falls back to the\n         # default profile. A task never ends up with assignee=None.\n         \"default_assignee\": \"\",\n+        # Per-profile concurrency cap (#21582). When set to a positive int,\n+        # no single profile can have more than N workers running at once,\n+        # even if the global max_in_progress / max_spawn caps would allow\n+        # it. Tasks blocked this way defer to the next dispatcher tick.\n+        # Unset (None) means \"no per-profile cap\" — backward-compatible\n+        # with existing installs. Useful for fan-out workflows that would\n+        # otherwise saturate one profile's local model / API quota /\n+        # browser pool while leaving other profiles idle.\n+        \"max_in_progress_per_profile\": None,\n         # When true, the kanban dispatcher auto-runs the decomposer on\n         # tasks that land in Triage (every dispatcher tick). When false,\n         # decomposition is manual via `hermes kanban decompose <id>` or"]},{"file":"hermes_cli/kanban.py","hunks":["@@ -2087,12 +2087,35 @@ def _cmd_tail(args: argparse.Namespace) -> int:\n \n \n def _cmd_dispatch(args: argparse.Namespace) -> int:\n+    # Honour kanban.default_assignee as the fallback for unassigned ready\n+    # tasks (#27145) and kanban.max_in_progress_per_profile as the\n+    # per-profile concurrency cap (#21582). Same semantics as the\n+    # gateway dispatch path.\n+    try:\n+        from hermes_cli.config import load_config\n+        _cfg = load_config()\n+        _kanban_cfg = _cfg.get(\"kanban\", {}) if isinstance(_cfg, dict) else {}\n+        default_assignee = (_kanban_cfg.get(\"default_assignee\") or \"\").strip() or None\n+        _raw_per_profile = _kanban_cfg.get(\"max_in_progress_per_profile\", None)\n+        try:\n+            max_in_progress_per_profile = (\n+                int(_raw_per_profile) if _raw_per_profile is not None else None\n+            )\n+            if max_in_progress_per_profile is not None and max_in_progress_per_profile < 1:\n+                max_in_progress_per_profile = None\n+        except (TypeError, ValueError):\n+            max_in_progress_per_profile = None\n+    except Exception:\n+        default_assignee = None\n+        max_in_progress_per_profile = None\n     with kb.connect_closing() as conn:\n         res = kb.dispatch_once(\n             conn,\n             dry_run=args.dry_run,\n             max_spawn=args.max,\n             failure_limit=getattr(args, \"failure_limit\", kb.DEFAULT_SPAWN_FAILURE_LIMIT),\n+            default_assignee=default_assignee,\n+            max_in_progress_per_profile=max_in_progress_per_profile,\n         )\n     if getattr(args, \"json\", False):\n         print(json.dumps({","@@ -2108,6 +2131,11 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:\n             ],\n             \"skipped_unassigned\": res.skipped_unassigned,\n             \"skipped_nonspawnable\": res.skipped_nonspawnable,\n+            \"skipped_per_profile_capped\": [\n+                {\"task_id\": tid, \"assignee\": who, \"current\": current}\n+                for (tid, who, current) in res.skipped_per_profile_capped\n+            ],\n+            \"auto_assigned_default\": res.auto_assigned_default,\n         }, indent=2))\n         return 0\n     print(f\"Reclaimed:    {res.reclaimed}\")","@@ -2128,8 +2156,18 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:\n     for tid, who, ws in res.spawned:\n         tag = \" (dry)\" if args.dry_run else \"\"\n         print(f\"  - {tid}  ->  {who}  @ {ws or '-'}{tag}\")\n+    if res.auto_assigned_default:\n+        print(\n+            f\"Auto-assigned to kanban.default_assignee={default_assignee!r}: \"\n+            f\"{', '.join(res.auto_assigned_default)}\"\n+        )\n     if res.skipped_unassigned:\n         print(f\"Skipped (unassigned): {', '.join(res.skipped_unassigned)}\")\n+    if res.skipped_per_profile_capped:\n+        for tid, who, current in res.skipped_per_profile_capped:\n+            print(\n+                f\"Deferred ({who} at per-profile cap, {current} running): {tid}\"\n+            )\n     if res.skipped_nonspawnable:\n         print(\n             f\"Skipped (non-spawnable assignee — terminal lane, OK): \""]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -4289,6 +4289,12 @@ class DispatchResult:\n     skipped_unassigned: list[str] = field(default_factory=list)\n     \"\"\"Ready task ids skipped because they have no assignee at all.\n     Operator-actionable — usually a misfiled task waiting for routing.\"\"\"\n+    auto_assigned_default: list[str] = field(default_factory=list)\n+    \"\"\"Task ids that were unassigned in the DB and had\n+    ``kanban.default_assignee`` applied this tick before spawning (#27145).\n+    Surfaces the auto-assignment to telemetry / CLI / dashboard so the\n+    operator can see when the dispatcher is acting on the fallback rule\n+    rather than on explicit per-task assignments.\"\"\"\n     skipped_nonspawnable: list[str] = field(default_factory=list)\n     \"\"\"Ready task ids skipped because their assignee names a control-plane\n     lane (a Claude Code terminal like ``orion-cc``) rather than a Hermes","@@ -4296,6 +4302,14 @@ class DispatchResult:\n     operator-actionable failure. Tracked separately so health telemetry\n     can distinguish \"real stuck\" (nothing spawned but spawnable work\n     available) from \"correctly idle\" (nothing spawnable in the queue).\"\"\"\n+    skipped_per_profile_capped: list[tuple[str, str, int]] = field(default_factory=list)\n+    \"\"\"Tasks deferred this tick because their assignee is already at\n+    ``kanban.max_in_progress_per_profile`` (#21582). Each entry is\n+    ``(task_id, assignee, current_running_count)``. NOT an\n+    operator-actionable failure — the task will be picked up on a\n+    subsequent tick when the assignee has capacity. Separate bucket so\n+    telemetry / dashboards can show \"this profile is busy\" vs\n+    \"task is genuinely stuck\".\"\"\"\n     crashed: list[str] = field(default_factory=list)\n     \"\"\"Task ids reclaimed because their worker PID disappeared.\"\"\"\n     auto_blocked: list[str] = field(default_factory=list)","@@ -5342,6 +5356,8 @@ def dispatch_once(\n     failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,\n     stale_timeout_seconds: int = 0,\n     board: Optional[str] = None,\n+    default_assignee: Optional[str] = None,\n+    max_in_progress_per_profile: Optional[int] = None,\n ) -> DispatchResult:\n     \"\"\"Run one dispatcher tick.\n ","@@ -5427,12 +5443,89 @@ def dispatch_once(\n         if max_spawn is None or max_spawn > remaining:\n             max_spawn = remaining\n     spawned = 0\n+    # Per-profile concurrency cap (#21582): when set, track how many\n+    # workers each assignee already has in flight, and refuse to spawn\n+    # when this would push that assignee past the cap. Prevents\n+    # fan-out workloads from melting a single profile's local model /\n+    # API quota / browser pool while leaving other profiles idle.\n+    # Tasks blocked this way go to skipped_per_profile_capped (not\n+    # skipped_unassigned — the operator-actionable signal is different:\n+    # \"this profile is busy, try again later\" not \"this needs routing\").\n+    _per_profile_cap = max_in_progress_per_profile if (\n+        isinstance(max_in_progress_per_profile, int)\n+        and max_in_progress_per_profile > 0\n+    ) else None\n+    _per_profile_running: dict[str, int] = {}\n+    if _per_profile_cap is not None:\n+        for prow in conn.execute(\n+            \"SELECT assignee, COUNT(*) AS n FROM tasks \"\n+            \"WHERE status = 'running' AND assignee IS NOT NULL \"\n+            \"GROUP BY assignee\"\n+        ):\n+            _per_profile_running[prow[\"assignee\"]] = int(prow[\"n\"])\n+    # Normalize default_assignee once: empty/whitespace string → None so the\n+    # rest of the loop can use ``if default_assignee:`` as a single check.\n+    # We also resolve profile_exists once here for the same reason.\n+    _default_assignee = (default_assignee or \"\").strip() or None\n+    _default_assignee_resolved = False\n+    if _default_assignee:\n+        try:\n+            from hermes_cli.profiles import profile_exists as _pe\n+            _default_assignee_resolved = bool(_pe(_default_assignee))\n+        except Exception:\n+            # Profiles module not importable (test stubs, exotic envs).\n+            # Trust the operator's config and try the assignment; the\n+            # downstream profile_exists check on the assigned row will\n+            # bucket it as nonspawnable if the profile genuinely isn't\n+            # there, with the existing diagnostic.\n+            _default_assignee_resolved = True\n     for row in ready_rows:\n         if max_spawn is not None and running_count + spawned >= max_spawn:\n             break\n-        if not row[\"assignee\"]:\n-            result.skipped_unassigned.append(row[\"id\"])\n-            continue\n+        row_assignee = row[\"assignee\"]\n+        if not row_assignee:\n+            # Honour kanban.default_assignee: when the dispatcher hits an\n+            # unassigned ready task and an operator-configured fallback\n+            # exists, persist the assignment and proceed. This removes the\n+            # dashboard footgun where a task created without an assignee\n+            # parks in 'ready' forever even though the operator's intent\n+            # (\"default\") was perfectly clear (#27145). Mutating the row\n+            # (not just the in-memory view) keeps diagnostics and the\n+            # board state consistent: the task is now legitimately owned\n+            # by ``kanban.default_assignee``, not \"unassigned but secretly\n+            # routed\".\n+            if _default_assignee and _default_assignee_resolved:\n+                # Dry-run: show what WOULD happen (auto-assign + spawn) without\n+                # mutating the DB. Real run: mutate the row + emit the\n+                # 'assigned' event so the board state matches what just happened.\n+                if not dry_run:\n+                    try:\n+                        with write_txn(conn):\n+                            conn.execute(\n+                                \"UPDATE tasks SET assignee = ? WHERE id = ? \"\n+                                \"AND (assignee IS NULL OR assignee = '')\",\n+                                (_default_assignee, row[\"id\"]),\n+                            )\n+                            _append_event(\n+                                conn, row[\"id\"], \"assigned\",\n+                                {\n+                                    \"assignee\": _default_assignee,\n+                                    \"source\": \"kanban.default_assignee\",\n+                                },\n+                            )\n+                    except Exception:\n+                        _log.debug(\n+                            \"kanban dispatch: failed to apply default_assignee=%r \"\n+                            \"to task %s\",\n+                            _default_assignee, row[\"id\"], exc_info=True,\n+                        )\n+                        result.skipped_unassigned.append(row[\"id\"])\n+                        continue\n+                row_assignee = _default_assignee\n+                result.auto_assigned_default.append(row[\"id\"])\n+            else:\n+                result.skipped_unassigned.append(row[\"id\"])\n+                continue\n         # Skip ready tasks whose assignee is not a real Hermes profile.\n         # `_default_spawn` invokes ``hermes -p <assignee>`` which fails\n         # with \"Profile 'X' does not exist\" when the assignee names a","@@ -5447,7 +5540,7 @@ def dispatch_once(\n             from hermes_cli.profiles import profile_exists  # local import: avoids cycle\n         except Exception:\n             profile_exists = None  # type: ignore[assignment]\n-        if profile_exists is not None and not profile_exists(row[\"assignee\"]):\n+        if profile_exists is not None and not profile_exists(row_assignee):\n             # Bucket separately from skipped_unassigned: the operator\n             # cannot fix this by assigning a profile (the assignee IS the\n             # intended owner — a terminal lane). Health telemetry uses","@@ -5456,6 +5549,19 @@ def dispatch_once(\n             # of human-pulled work.\n             result.skipped_nonspawnable.append(row[\"id\"])\n             continue\n+        # Per-profile concurrency cap (#21582): even if there's global\n+        # headroom, refuse to spawn for an assignee that's already at\n+        # its in-flight cap. Prevents one profile's local model / API\n+        # quota / browser pool from being overwhelmed by a fan-out\n+        # while the global max_in_progress / max_spawn caps still allow\n+        # work on OTHER profiles.\n+        if _per_profile_cap is not None:\n+            current = _per_profile_running.get(row_assignee, 0)\n+            if current >= _per_profile_cap:\n+                result.skipped_per_profile_capped.append(\n+                    (row[\"id\"], row_assignee, current)\n+                )\n+                continue\n         # Respawn guard: refuse to re-spawn when useful work is already\n         # in-flight/recent, or when the last failure is a deterministic\n         # blocker (quota / auth). The guard defers the spawn this tick so","@@ -5478,7 +5584,15 @@ def dispatch_once(\n                     )\n             continue\n         if dry_run:\n-            result.spawned.append((row[\"id\"], row[\"assignee\"], \"\"))\n+            result.spawned.append((row[\"id\"], row_assignee, \"\"))\n+            # Increment per-profile counter even in dry_run so the cap\n+            # check sees the would-be spawn on subsequent iterations.\n+            # Without this, dry_run reports every task as spawnable and\n+            # under-reports the capped subset (#21582).\n+            if _per_profile_cap is not None and row_assignee:\n+                _per_profile_running[row_assignee] = (\n+                    _per_profile_running.get(row_assignee, 0) + 1\n+                )\n             continue\n         claimed = claim_task(conn, row[\"id\"], ttl_seconds=ttl_seconds)\n         if claimed is None:","@@ -5521,6 +5635,13 @@ def dispatch_once(\n             # complete_task).\n             result.spawned.append((claimed.id, claimed.assignee or \"\", str(workspace)))\n             spawned += 1\n+            # Track the new in-flight count for this profile so later\n+            # iterations in this same tick respect the per-profile cap\n+            # (#21582). Subsequent ticks re-query from the DB.\n+            if _per_profile_cap is not None and claimed.assignee:\n+                _per_profile_running[claimed.assignee] = (\n+                    _per_profile_running.get(claimed.assignee, 0) + 1\n+                )\n         except Exception as exc:\n             auto = _record_spawn_failure(\n                 conn, claimed.id, str(exc),"]},{"file":"tests/hermes_cli/test_kanban_default_assignee.py","hunks":["@@ -0,0 +1,154 @@\n+\"\"\"Regression tests for #27145 — kanban.default_assignee for unassigned ready tasks.\n+\n+When the dispatcher hits an unassigned ready task and ``kanban.default_assignee``\n+is set, the dispatcher applies the assignment and spawns. Without the config,\n+the task is skipped (existing behavior preserved).\n+\"\"\"\n+from __future__ import annotations\n+\n+import json\n+import os\n+import sys\n+import tempfile\n+\n+import pytest\n+\n+\n[redacted:email]()\n+def isolated_kanban_home(monkeypatch):\n+    \"\"\"Spin up a fresh HERMES_HOME with a clean kanban DB.\"\"\"\n+    test_home = tempfile.mkdtemp(prefix=\"kanban_default_assignee_test_\")\n+    monkeypatch.setenv(\"HERMES_HOME\", test_home)\n+    # Force-reimport so the fresh HERMES_HOME is picked up.\n+    for mod in list(sys.modules.keys()):\n+        if mod.startswith(\"hermes_cli\") or mod.startswith(\"hermes_state\") or mod == \"hermes_constants\":\n+            del sys.modules[mod]\n+    from hermes_cli import kanban_db\n+    yield kanban_db, test_home\n+    # Cleanup is best-effort; tempfile dir survives but pytest isolation\n+    # gives each test its own monkeypatched HERMES_HOME so no cross-test\n+    # contamination.\n+\n+\n+def _fake_spawn(*args, **kwargs):\n+    \"\"\"Stand-in for the real worker spawn — returns a fake PID.\"\"\"\n+    return 12345\n+\n+\n+def test_unassigned_task_skipped_without_default_assignee(isolated_kanban_home):\n+    \"\"\"Baseline: with no default_assignee, an unassigned ready task is\n+    skipped via the existing `skipped_unassigned` bucket and the DB row\n+    is untouched.\"\"\"\n+    kb, _home = isolated_kanban_home\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        task_id = kb.create_task(conn, title=\"t1\", assignee=None)\n+    with kb.connect_closing() as conn:\n+        res = kb.dispatch_once(conn, spawn_fn=_fake_spawn, dry_run=False)\n+    assert res.skipped_unassigned == [task_id]\n+    assert not res.auto_assigned_default\n+    assert not res.spawned\n+    with kb.connect_closing() as conn:\n+        row = conn.execute(\"SELECT assignee FROM tasks WHERE id = ?\", (task_id,)).fetchone()\n+    assert row[\"assignee\"] is None\n+\n+\n+def test_unassigned_task_auto_assigned_with_default_assignee(isolated_kanban_home):\n+    \"\"\"Core #27145 contract: with default_assignee set, an unassigned ready\n+    task gets the assignment applied and dispatched on the same tick. The\n+    DB row is mutated (assignee column + an 'assigned' event).\"\"\"\n+    kb, _home = isolated_kanban_home\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        task_id = kb.create_task(conn, title=\"t1\", assignee=None)\n+    with kb.connect_closing() as conn:\n+        res = kb.dispatch_once(\n+            conn, spawn_fn=_fake_spawn, dry_run=False,\n+            default_assignee=\"default\",\n+        )\n+    assert res.auto_assigned_default == [task_id]\n+    assert not res.skipped_unassigned\n+    assert len(res.spawned) == 1\n+    assert res.spawned[0][0] == task_id\n+    assert res.spawned[0][1] == \"default\"\n+\n+    with kb.connect_closing() as conn:\n+        row = conn.execute(\"SELECT assignee FROM tasks WHERE id = ?\", (task_id,)).fetchone()\n+    assert row[\"assignee\"] == \"default\"\n+\n+    # 'assigned' event emitted for the audit trail\n+    with kb.connect_closing() as conn:\n+        evs = list(conn.execute(\n+            \"SELECT kind, payload FROM task_events WHERE task_id = ? AND kind = 'assigned'\",\n+            (task_id,),\n+        ))\n+    assert len(evs) == 1\n+    payload = json.loads(evs[0][1])\n+    assert payload[\"assignee\"] == \"default\"\n+    assert payload[\"source\"] == \"kanban.default_assignee\"\n+\n+\n+def test_dry_run_with_default_assignee_reports_without_mutating(isolated_kanban_home):\n+    \"\"\"Dry-run mode: reports what WOULD happen (task in auto_assigned_default,\n+    spawn entry) but does NOT mutate the DB. Operators using\n+    `hermes kanban dispatch --dry-run` see the routing decision before\n+    committing.\"\"\"\n+    kb, _home = isolated_kanban_home\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        task_id = kb.create_task(conn, title=\"t1\", assignee=None)\n+    with kb.connect_closing() as conn:\n+        res = kb.dispatch_once(\n+            conn, spawn_fn=_fake_spawn, dry_run=True,\n+            default_assignee=\"default\",\n+        )\n+    assert res.auto_assigned_default == [task_id]\n+    assert len(res.spawned) == 1\n+    with kb.connect_closing() as conn:\n+        row = conn.execute(\"SELECT assignee FROM tasks WHERE id = ?\", (task_id,)).fetchone()\n+    # DB unchanged — dry_run did not commit the assignment.\n+    assert row[\"assignee\"] is None\n+\n+\n+def test_whitespace_default_assignee_treated_as_none(isolated_kanban_home):\n+    \"\"\"Empty / whitespace-only default_assignee values must be treated as\n+    'no fallback set' so a misconfigured kanban.default_assignee=' '\n+    doesn't surprise operators by silently routing unassigned tasks.\"\"\"\n+    kb, _home = isolated_kanban_home\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        task_id = kb.create_task(conn, title=\"t1\", assignee=None)\n+    with kb.connect_closing() as conn:\n+        res = kb.dispatch_once(\n+            conn, spawn_fn=_fake_spawn, dry_run=False,\n+            default_assignee=\"   \",\n+        )\n+    assert task_id in res.skipped_unassigned\n+    assert not res.auto_assigned_default\n+\n+\n+def test_explicitly_assigned_task_untouched_by_default_assignee(isolated_kanban_home):\n+    \"\"\"A task with an explicit assignee must NOT be touched by the\n+    default_assignee logic — that fallback only applies to genuinely\n+    unassigned rows.\"\"\"\n+    kb, _home = isolated_kanban_home\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        task_id = kb.create_task(conn, title=\"t1\", assignee=\"default\")\n+    with kb.connect_closing() as conn:\n+        res = kb.dispatch_once(\n+            conn, spawn_fn=_fake_spawn, dry_run=False,\n+            default_assignee=\"someother\",\n+        )\n+    assert task_id not in res.auto_assigned_default\n+    assert any(s[0] == task_id and s[1] == \"default\" for s in res.spawned)\n+\n+\n+def test_dispatch_result_has_auto_assigned_default_field():\n+    \"\"\"Schema-level invariant: DispatchResult exposes the\n+    auto_assigned_default field so CLI / dashboard / gateway can surface\n+    the new routing decisions.\"\"\"\n+    from hermes_cli.kanban_db import DispatchResult\n+    r = DispatchResult()\n+    assert hasattr(r, \"auto_assigned_default\")\n+    assert r.auto_assigned_default == []"]},{"file":"tests/hermes_cli/test_kanban_per_profile_cap.py","hunks":["@@ -0,0 +1,167 @@\n+\"\"\"Regression tests for #21582 — per-profile concurrency cap in dispatcher.\n+\n+When ``kanban.max_in_progress_per_profile`` is set, no single profile\n+gets more than N workers running at once even if the global\n+``max_in_progress`` cap would allow it. Prevents one profile's local\n+model / API quota / browser pool from being overwhelmed by a fan-out.\n+\"\"\"\n+from __future__ import annotations\n+\n+import os\n+import sys\n+import tempfile\n+\n+import pytest\n+\n+\n[redacted:email]()\n+def isolated_kanban_home_with_profiles(monkeypatch):\n+    \"\"\"Spin up a fresh HERMES_HOME with kanban DB + alpha/beta profiles.\"\"\"\n+    test_home = tempfile.mkdtemp(prefix=\"kanban_per_profile_cap_test_\")\n+    for prof in (\"alpha\", \"beta\", \"default\"):\n+        os.makedirs(os.path.join(test_home, \"profiles\", prof), exist_ok=True)\n+    monkeypatch.setenv(\"HERMES_HOME\", test_home)\n+    for mod in list(sys.modules.keys()):\n+        if mod.startswith(\"hermes_cli\") or mod.startswith(\"hermes_state\") or mod == \"hermes_constants\":\n+            del sys.modules[mod]\n+    from hermes_cli import kanban_db\n+    yield kanban_db\n+\n+\n+def _fake_spawn(*args, **kwargs):\n+    return 12345\n+\n+\n+def test_no_cap_all_tasks_dispatched(isolated_kanban_home_with_profiles):\n+    \"\"\"Baseline: with no per-profile cap, all ready tasks dispatch.\"\"\"\n+    kb = isolated_kanban_home_with_profiles\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        for i in range(5):\n+            kb.create_task(conn, title=f\"a{i}\", assignee=\"alpha\")\n+        for i in range(3):\n+            kb.create_task(conn, title=f\"b{i}\", assignee=\"beta\")\n+    with kb.connect_closing() as conn:\n+        res = kb.dispatch_once(conn, spawn_fn=_fake_spawn, dry_run=True)\n+    assert len(res.spawned) == 8\n+    assert not res.skipped_per_profile_capped\n+\n+\n+def test_cap_2_balances_two_profiles(isolated_kanban_home_with_profiles):\n+    \"\"\"With cap=2: 2 alpha + 2 beta dispatched; remaining 3 alpha + 1 beta\n+    deferred to skipped_per_profile_capped.\"\"\"\n+    kb = isolated_kanban_home_with_profiles\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        for i in range(5):\n+            kb.create_task(conn, title=f\"a{i}\", assignee=\"alpha\")\n+        for i in range(3):\n+            kb.create_task(conn, title=f\"b{i}\", assignee=\"beta\")\n+    with kb.connect_closing() as conn:\n+        res = kb.dispatch_once(\n+            conn, spawn_fn=_fake_spawn, dry_run=True,\n+            max_in_progress_per_profile=2,\n+        )\n+    spawn_assignees = [s[1] for s in res.spawned]\n+    capped_assignees = [c[1] for c in res.skipped_per_profile_capped]\n+    assert spawn_assignees.count(\"alpha\") == 2\n+    assert spawn_assignees.count(\"beta\") == 2\n+    assert capped_assignees.count(\"alpha\") == 3\n+    assert capped_assignees.count(\"beta\") == 1\n+\n+\n+def test_pre_existing_running_counts_against_cap(isolated_kanban_home_with_profiles):\n+    \"\"\"A task already in 'running' status when dispatch_once starts counts\n+    toward the per-profile cap. With 1 alpha pre-running and cap=1, NO new\n+    alpha tasks should spawn; beta is independent so 1 beta spawns.\"\"\"\n+    kb = isolated_kanban_home_with_profiles\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        running_alpha = kb.create_task(conn, title=\"running alpha\", assignee=\"alpha\")\n+        with kb.write_txn(conn):\n+            conn.execute(\n+                \"UPDATE tasks SET status = 'running', claim_lock = 'test:1' WHERE id = ?\",\n+                (running_alpha,),\n+            )\n+        for i in range(2):\n+            kb.create_task(conn, title=f\"a{i}\", assignee=\"alpha\")\n+        for i in range(2):\n+            kb.create_task(conn, title=f\"b{i}\", assignee=\"beta\")\n+    with kb.connect_closing() as conn:\n+        res = kb.dispatch_once(\n+            conn, spawn_fn=_fake_spawn, dry_run=True,\n+            max_in_progress_per_profile=1,\n+        )\n+    spawn_assignees = [s[1] for s in res.spawned]\n+    capped_assignees = [c[1] for c in res.skipped_per_profile_capped]\n+    assert spawn_assignees.count(\"alpha\") == 0\n+    assert spawn_assignees.count(\"beta\") == 1\n+    assert capped_assignees.count(\"alpha\") == 2\n+    assert capped_assignees.count(\"beta\") == 1\n+\n+\n[redacted:email](\"cap\", [0, -1, \"abc\", None])\n+def test_invalid_cap_treated_as_no_cap(isolated_kanban_home_with_profiles, cap):\n+    \"\"\"Cap values that don't represent a positive int should be treated as\n+    'no cap' — silently falling through rather than crashing the dispatcher.\"\"\"\n+    kb = isolated_kanban_home_with_profiles\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        for i in range(3):\n+            kb.create_task(conn, title=f\"a{i}\", assignee=\"alpha\")\n+    with kb.connect_closing() as conn:\n+        res = kb.dispatch_once(\n+            conn, spawn_fn=_fake_spawn, dry_run=True,\n+            max_in_progress_per_profile=cap,\n+        )\n+    assert not res.skipped_per_profile_capped\n+    assert len(res.spawned) == 3\n+\n+\n+def test_capped_tasks_dispatched_on_subsequent_tick(isolated_kanban_home_with_profiles):\n+    \"\"\"A task deferred this tick because its profile was at cap should be\n+    eligible for dispatch on the next tick (after running tasks complete).\n+    This verifies the cap is per-tick state, not a permanent block.\"\"\"\n+    kb = isolated_kanban_home_with_profiles\n+    with kb.connect_closing() as conn:\n+        kb.create_board(slug=\"default\", name=\"Test\")\n+        ids = [kb.create_task(conn, title=f\"a{i}\", assignee=\"alpha\") for i in range(3)]\n+\n+    # First tick: cap=1, only 1 alpha dispatched\n+    with kb.connect_closing() as conn:\n+        res1 = kb.dispatch_once(\n+            conn, spawn_fn=_fake_spawn, dry_run=False,\n+            max_in_progress_per_profile=1,\n+        )\n+    assert len(res1.spawned) == 1\n+    assert len(res1.skipped_per_profile_capped) == 2\n+\n+    # Simulate the running task completing — set it back to done so the\n+    # 'running' count drops\n+    spawned_id = res1.spawned[0][0]\n+    with kb.connect_closing() as conn:\n+        with kb.write_txn(conn):\n+            conn.execute(\n+                \"UPDATE tasks SET status = 'done', claim_lock = NULL WHERE id = ?\",\n+                (spawned_id,),\n+            )\n+\n+    # Second tick: 1 more alpha should now dispatch\n+    with kb.connect_closing() as conn:\n+        res2 = kb.dispatch_once(\n+            conn, spawn_fn=_fake_spawn, dry_run=False,\n+            max_in_progress_per_profile=1,\n+        )\n+    assert len(res2.spawned) == 1\n+    assert len(res2.skipped_per_profile_capped) == 1\n+    assert res2.spawned[0][0] != spawned_id  # different task this time\n+\n+\n+def test_dispatch_result_has_skipped_per_profile_capped_field():\n+    \"\"\"Schema-level invariant: DispatchResult exposes the\n+    skipped_per_profile_capped field as a list of\n+    (task_id, assignee, current_running) tuples.\"\"\"\n+    from hermes_cli.kanban_db import DispatchResult\n+    r = DispatchResult()\n+    assert hasattr(r, \"skipped_per_profile_capped\")\n+    assert r.skipped_per_profile_capped == []\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}