fix(kanban): decomposed cards fall back to the root task's assignee, never the dispatcher's own profile

The decomposer resolves both `kanban.default_assignee` (unrouted children)
and `kanban.orchestrator_profile` (the root after fan-out) as
"explicit config, else the active profile". The active profile is whatever
HERMES_HOME hosts the dispatcher — in the report an incognito `private`
profile with no credentials — so every unrouted child AND the root card
itself were silently re-owned by a profile that can never do the work.

The resolver now tries the root task's own assignee before the active
profile: explicit config → root assignee (if it names an existing profile)
→ active profile. Slim redo of #114303's decompose hunk at the resolver so
it covers the orchestrator fallback too; tests and docs from #114303.

Fixes #114294
Salvages #114303

Co-authored-by: Christopher <210261288+Christopher-Schulze@users.noreply.github.com>
This commit is contained in:
liuhao1024
2026-09-18 00:45:04 -07:00
committed by Teknium
parent 27a30d8515
commit 1ea94b3745
3 changed files with 109 additions and 19 deletions

View File

@@ -126,19 +126,27 @@ def _profile_author() -> str:
return _specify_author("decomposer") return _specify_author("decomposer")
def _resolve_profile_from_cfg(cfg: dict, key: str) -> str: def _resolve_profile_from_cfg(cfg: dict, key: str, *, fallback: Optional[str] = None) -> str:
"""``kanban.<key>`` if it names an existing profile, else the active """``kanban.<key>`` if it names an existing profile, else ``fallback``
default profile — so a task is never stranded for lack of an owner. (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`` ``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 {} kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
explicit = (kanban_cfg.get(key) or "").strip() explicit = (kanban_cfg.get(key) or "").strip()
if explicit: for candidate in (explicit, (fallback or "").strip()):
try: if candidate:
if profiles_mod.profile_exists(explicit): try:
return explicit if profiles_mod.profile_exists(candidate):
except Exception: return candidate
pass except Exception:
pass
try: try:
return profiles_mod.get_active_profile_name() or "default" return profiles_mod.get_active_profile_name() or "default"
except Exception: except Exception:
@@ -193,7 +201,7 @@ class _Routing:
valid_names: set[str] 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 from hermes_cli.config import load_config_readonly
try: try:
cfg = load_config_readonly() cfg = load_config_readonly()
@@ -202,8 +210,8 @@ def _load_routing() -> _Routing:
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
roster, valid_names = _build_roster() roster, valid_names = _build_roster()
return _Routing( return _Routing(
orchestrator=_resolve_profile_from_cfg(cfg, "orchestrator_profile"), orchestrator=_resolve_profile_from_cfg(cfg, "orchestrator_profile", fallback=root_assignee),
default_assignee=_resolve_profile_from_cfg(cfg, "default_assignee"), default_assignee=_resolve_profile_from_cfg(cfg, "default_assignee", fallback=root_assignee),
auto_promote=bool(kanban_cfg.get("auto_promote_children", True)), auto_promote=bool(kanban_cfg.get("auto_promote_children", True)),
roster=roster, roster=roster,
valid_names=valid_names, valid_names=valid_names,
@@ -305,7 +313,7 @@ def decompose_task(
if task is None: if task is None:
return DecomposeOutcome(task_id, False, reason) return DecomposeOutcome(task_id, False, reason)
routing = _load_routing() routing = _load_routing(root_assignee=task.assignee)
raw, reason = _call_aux( raw, reason = _call_aux(
"decompose", task_id, aux_task="kanban_decomposer", system=_SYSTEM_PROMPT, "decompose", task_id, aux_task="kanban_decomposer", system=_SYSTEM_PROMPT,
user=_USER_TEMPLATE.format( user=_USER_TEMPLATE.format(

View File

@@ -114,6 +114,88 @@ def test_decompose_with_fanout_creates_children(kanban_home):
assert c1.assignee == "engineer" 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): def test_decompose_fanout_false_invalid_llm_assignee_uses_default(kanban_home):
with kbc.connect() as conn: with kbc.connect() as conn:
tid = kb.create_task(conn, title="route me safely", triage=True) tid = kb.create_task(conn, title="route me safely", triage=True)

View File

@@ -688,7 +688,7 @@ hermes dashboard # "Kanban" tab appears in the nav, after "Skills"
### What the plugin gives you ### 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). - 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. - 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. - **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. - **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: 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 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 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. 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 <name> --text "..."`, `hermes profile describe <name> --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 <name> --text "..."`, `hermes profile describe <name> --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. `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` | `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. | | `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. | | `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 active default. | | `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`. | | `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. | | `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. | | `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. |