diff --git a/hermes_cli/kanban_decompose.py b/hermes_cli/kanban_decompose.py index 875da2e277..1d8284ce3b 100644 --- a/hermes_cli/kanban_decompose.py +++ b/hermes_cli/kanban_decompose.py @@ -126,19 +126,27 @@ def _profile_author() -> str: return _specify_author("decomposer") -def _resolve_profile_from_cfg(cfg: dict, key: str) -> str: - """``kanban.`` if it names an existing profile, else the active - default profile — so a task is never stranded for lack of an owner. +def _resolve_profile_from_cfg(cfg: dict, key: str, *, fallback: Optional[str] = None) -> str: + """``kanban.`` if it names an existing profile, else ``fallback`` + (the root task's own assignee) if that does, else the active default + profile — so a task is never stranded for lack of an owner. ``orchestrator_profile`` owns the root after fan-out; ``default_assignee`` - catches children the decomposer can't route.""" + catches children the decomposer can't route. + + The root's assignee sits before the active profile because the decomposer + runs inside whatever profile hosts the dispatcher — an operator's + credential-less incognito profile, say — and that profile must never + silently become the owner of work the card was assigned away from (#114294). + """ kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} explicit = (kanban_cfg.get(key) or "").strip() - if explicit: - try: - if profiles_mod.profile_exists(explicit): - return explicit - except Exception: - pass + for candidate in (explicit, (fallback or "").strip()): + if candidate: + try: + if profiles_mod.profile_exists(candidate): + return candidate + except Exception: + pass try: return profiles_mod.get_active_profile_name() or "default" except Exception: @@ -193,7 +201,7 @@ class _Routing: valid_names: set[str] -def _load_routing() -> _Routing: +def _load_routing(*, root_assignee: Optional[str] = None) -> _Routing: from hermes_cli.config import load_config_readonly try: cfg = load_config_readonly() @@ -202,8 +210,8 @@ def _load_routing() -> _Routing: kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} roster, valid_names = _build_roster() return _Routing( - orchestrator=_resolve_profile_from_cfg(cfg, "orchestrator_profile"), - default_assignee=_resolve_profile_from_cfg(cfg, "default_assignee"), + orchestrator=_resolve_profile_from_cfg(cfg, "orchestrator_profile", fallback=root_assignee), + default_assignee=_resolve_profile_from_cfg(cfg, "default_assignee", fallback=root_assignee), auto_promote=bool(kanban_cfg.get("auto_promote_children", True)), roster=roster, valid_names=valid_names, @@ -305,7 +313,7 @@ def decompose_task( if task is None: return DecomposeOutcome(task_id, False, reason) - routing = _load_routing() + routing = _load_routing(root_assignee=task.assignee) raw, reason = _call_aux( "decompose", task_id, aux_task="kanban_decomposer", system=_SYSTEM_PROMPT, user=_USER_TEMPLATE.format( diff --git a/tests/hermes_cli/test_kanban_decompose.py b/tests/hermes_cli/test_kanban_decompose.py index c6fdc7dcb0..11205a00d8 100644 --- a/tests/hermes_cli/test_kanban_decompose.py +++ b/tests/hermes_cli/test_kanban_decompose.py @@ -114,6 +114,88 @@ def test_decompose_with_fanout_creates_children(kanban_home): assert c1.assignee == "engineer" +def test_decompose_fanout_children_inherit_root_assignee_when_unrouted(kanban_home): + """Unrouted children fall back to the ROOT task's assignee, not + the decomposer's active profile (#114294). The active profile here is ``private`` + (an incognito profile with no credentials), so the old fallback spawned + workers that deadlocked on capability blockers.""" + with kbc.connect() as conn: + tid = kb.create_task(conn, title="ship it", assignee="zdr", triage=True) + + llm_payload = jsonlib.dumps({ + "fanout": True, + "rationale": "test split", + "tasks": [ + {"title": "research", "body": "look it up", "assignee": "made_up", "parents": []}, + {"title": "build", "body": "code it", "assignee": None, "parents": [0]}, + ], + }) + + # get_active_profile_name() is mocked to names[0] = "private" — the + # global default chain would resolve there without kanban.default_assignee. + patches = _patch_list_profiles(["private", "zdr"]) + for p in patches: + p.start() + try: + with _patch_aux_client(llm_payload), _patch_extra_body(), patch( + "hermes_cli.config.load_config_readonly", + return_value={}, + ): + outcome = decomp.decompose_task(tid, author="me") + finally: + for p in patches: + p.stop() + + assert outcome.ok, outcome.reason + with kbc.connect() as conn: + root = kb.get_task(conn, tid) + c0 = kb.get_task(conn, outcome.child_ids[0]) + c1 = kb.get_task(conn, outcome.child_ids[1]) + assert c0.assignee == "zdr" + assert c1.assignee == "zdr" + # Same class for the root: no ``orchestrator_profile`` must not hand the + # orchestration card to the dispatcher's own (here: incognito) profile. + assert root.assignee == "zdr" + + +def test_decompose_explicit_default_assignee_wins_over_root_assignee(kanban_home): + """An explicitly configured ``kanban.default_assignee`` stays + authoritative for unroutable children; the root task's assignee only + fills in when no explicit default is set (explicit config → card + assignee → active profile).""" + with kbc.connect() as conn: + tid = kb.create_task(conn, title="ship it", assignee="engineer", triage=True) + + llm_payload = jsonlib.dumps({ + "fanout": True, + "rationale": "test split", + "tasks": [ + {"title": "research", "body": "look it up", "assignee": "made_up", "parents": []}, + {"title": "build", "body": "code it", "assignee": None, "parents": [0]}, + ], + }) + + patches = _patch_list_profiles(["engineer", "docs", "private"]) + for p in patches: + p.start() + try: + with _patch_aux_client(llm_payload), _patch_extra_body(), patch( + "hermes_cli.config.load_config_readonly", + return_value={"kanban": {"default_assignee": "docs"}}, + ): + outcome = decomp.decompose_task(tid, author="me") + finally: + for p in patches: + p.stop() + + assert outcome.ok, outcome.reason + with kbc.connect() as conn: + c0 = kb.get_task(conn, outcome.child_ids[0]) + c1 = kb.get_task(conn, outcome.child_ids[1]) + assert c0.assignee == "docs" + assert c1.assignee == "docs" + + def test_decompose_fanout_false_invalid_llm_assignee_uses_default(kanban_home): with kbc.connect() as conn: tid = kb.create_task(conn, title="route me safely", triage=True) diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index f50e5a8970..f2972c3dce 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -688,7 +688,7 @@ hermes dashboard # "Kanban" tab appears in the nav, after "Skills" ### What the plugin gives you - A **Kanban** tab showing one column per status: `triage`, `todo`, `ready`, `running`, `blocked`, `done` (plus `archived` when the toggle is on). - - `triage` is the parking column for rough ideas. By default (`kanban.auto_decompose: true`), the dispatcher auto-runs the **decomposer** on tasks that land here. The built-in decomposer uses the `auxiliary.kanban_decomposer` model path, reads your profile roster (with descriptions), and fans the task out into a small graph of child tasks routed to the best-fit specialists. The original task stays alive as the parent of every child so its assignee (`kanban.orchestrator_profile`, or the active default profile when unset) wakes back up to judge completion when everything finishes. Flip the **Orchestration: Auto/Manual** pill at the top of the page (emerald = Auto, muted gray = Manual), or by editing `config.yaml` directly. Both modes coexist with `hermes kanban specify` - that's still available as a single-task spec rewrite when you don't want fan-out. + - `triage` is the parking column for rough ideas. By default (`kanban.auto_decompose: true`), the dispatcher auto-runs the **decomposer** on tasks that land here. The built-in decomposer uses the `auxiliary.kanban_decomposer` model path, reads your profile roster (with descriptions), and fans the task out into a small graph of child tasks routed to the best-fit specialists. The original task stays alive as the parent of every child so its assignee (`kanban.orchestrator_profile`, else the assignee the task already had, else the active default profile) wakes back up to judge completion when everything finishes. Flip the **Orchestration: Auto/Manual** pill at the top of the page (emerald = Auto, muted gray = Manual), or by editing `config.yaml` directly. Both modes coexist with `hermes kanban specify` - that's still available as a single-task spec rewrite when you don't want fan-out. - Cards show the task id, title, priority badge, tenant tag, assigned profile, comment/link counts, a **progress pill** (`N/M` children done when the task has dependents), and "created N ago". A per-card checkbox enables multi-select. - **Per-profile lanes inside Running** — toolbar checkbox toggles sub-grouping of the Running column by assignee. - **Live updates via WebSocket** — the plugin tails the append-only `task_events` table on a short poll interval; the board reflects changes the instant any profile (CLI, gateway, or another dashboard tab) acts. Reloads are debounced so a burst of events triggers a single refetch. @@ -710,7 +710,7 @@ Visually the target is the familiar Linear / Fusion layout: dark theme, column h The kanban board has two ways to handle a task you drop into the Triage column: -**Auto (default)** — `kanban.auto_decompose: true`. The gateway-embedded dispatcher runs the **decomposer** on each tick, capped by `kanban.auto_decompose_per_tick` (default 3 tasks per tick) so a bulk-load of triage tasks doesn't burst-spend the auxiliary LLM. The decomposer uses the built-in decomposition prompt plus the `auxiliary.kanban_decomposer` model path, reads your installed profiles + their descriptions, and asks the LLM to produce a JSON task graph: which tasks to spawn, who they go to, and which depend on which. The original triage task becomes the parent of every leaf in the graph, so it stays alive until the whole graph completes - and then promotes back to `ready` so its assignee (`kanban.orchestrator_profile`, or the active default profile when unset) can judge completion and add more tasks if the work isn't done. This is the "drop a one-liner, walk away" flow. +**Auto (default)** — `kanban.auto_decompose: true`. The gateway-embedded dispatcher runs the **decomposer** on each tick, capped by `kanban.auto_decompose_per_tick` (default 3 tasks per tick) so a bulk-load of triage tasks doesn't burst-spend the auxiliary LLM. The decomposer uses the built-in decomposition prompt plus the `auxiliary.kanban_decomposer` model path, reads your installed profiles + their descriptions, and asks the LLM to produce a JSON task graph: which tasks to spawn, who they go to, and which depend on which. The original triage task becomes the parent of every leaf in the graph, so it stays alive until the whole graph completes - and then promotes back to `ready` so its assignee (`kanban.orchestrator_profile`, else the assignee the task already had, else the active default profile) can judge completion and add more tasks if the work isn't done. This is the "drop a one-liner, walk away" flow. A completed built-in fan-out is recorded atomically with its child graph. Moving that root back to Triage does not create another graph; ordinary prerequisite @@ -728,7 +728,7 @@ active tenant passed by tools) wins. Boards remain the hard isolation boundary. Flip between the two modes from the **Orchestration: Auto/Manual** pill at the top of the kanban page (emerald = Auto, muted gray = Manual), or by editing `config.yaml` directly. Both modes coexist with `hermes kanban specify` — that's still available as a single-task spec rewrite when you don't want fan-out. -The decomposer's routing decisions depend on profile descriptions, which is a per-profile labeling primitive you set with `hermes profile create --description "..."`, `hermes profile describe --text "..."`, `hermes profile describe --auto` (LLM-generates from the profile's installed skills + model), or the dashboard's per-profile editor in the expanded **Orchestration settings** panel. Profiles without a description still appear in the roster — they're routable by name, just less precisely. The decomposer NEVER lands a child task with `assignee=None`: when the LLM picks an unknown profile, the child gets routed to `kanban.default_assignee` (or the active default profile if that's unset). +The decomposer's routing decisions depend on profile descriptions, which is a per-profile labeling primitive you set with `hermes profile create --description "..."`, `hermes profile describe --text "..."`, `hermes profile describe --auto` (LLM-generates from the profile's installed skills + model), or the dashboard's per-profile editor in the expanded **Orchestration settings** panel. Profiles without a description still appear in the roster — they're routable by name, just less precisely. The decomposer NEVER lands a child task with `assignee=None`: when the LLM picks an unknown profile, the child gets routed to `kanban.default_assignee`, else the root task's assignee (if it names an existing profile), else the active default profile. `kanban.orchestrator_profile` does not load that profile's prompt, skills, or custom logic into the decomposition call. It controls who owns the root/orchestration task after fan-out. To change the decomposer's model/provider, configure `auxiliary.kanban_decomposer`. To use a profile's custom task-splitting logic instead of the built-in decomposer, switch to Manual mode and have that profile create or decompose tasks explicitly. @@ -738,8 +738,8 @@ Config knobs (all under `kanban:` in `~/.hermes/config.yaml`): |---|---|---| | `auto_decompose` | `true` | Dispatcher auto-runs the built-in decomposer for Triage tasks every tick. It does not gate profile-driven `kanban_create` calls or creator wake turns. | | `auto_decompose_per_tick` | `3` | Cap on decompositions per dispatcher tick. Excess defers to the next tick. | -| `orchestrator_profile` | `""` | Profile assigned to the root/orchestration task after decomposition. Empty = fall back to active default profile. | -| `default_assignee` | `""` | Where a child task lands when the LLM picks an unknown profile. Empty = fall back to active default. | +| `orchestrator_profile` | `""` | Profile assigned to the root/orchestration task after decomposition. Empty = the root task keeps its own assignee, else the active default profile. | +| `default_assignee` | `""` | Where a child task lands when the LLM picks an unknown profile. Empty = fall back to the root task's assignee, else the active default. | | `auto_subscribe_on_create` | `true` | When `kanban_create` runs inside a persistent gateway/TUI session, terminal events resume that originating agent with a synthetic status turn. Set to `false` for passive completion or to require explicit `kanban_notify-subscribe` calls. Independent of `auto_decompose`. | | `notify_in_gateway` | `true` | Poll and deliver Kanban subscriptions from this gateway. Set to `false` on profiles that own no notification subscriptions to stop the idle five-second notifier poll. Independent of `dispatch_in_gateway`; non-dispatch gateways may still own profile-specific delivery adapters. | | `done_sub_retention_days` | `30` | Notify subscriptions survive `done` (reopen-safe) and are removed on `archived`. The notifier GC purges subscriptions whose task has been `done` or `blocked` with no new events for this many days, bounding sub-table growth on boards that never archive. `0` disables the sweep. |