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:
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
Reference in New Issue
Block a user