From e5fd1c7b43d634e9ef1983e3d49aa7bc618e116a Mon Sep 17 00:00:00 2001 From: kyinhub <182213728+yinkev@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:00:14 -0700 Subject: [PATCH] fix(kanban): make creator wake turns graph-safe Carry the worker's completion handoff into the synthetic creator wake turn and label it as an automatic notification with inspect-the-board / don't-recreate guidance, so a woken orchestrator doesn't re-decompose work that already exists (#70752). Salvaged from PR #71100 by @yinkev; ported onto the restructured wake region (delivery_mode gating, scope_id, sub chat_id destinations). The auto_subscribe_on_create config-default half of the original PR was dropped as already superseded on main. --- gateway/kanban_watchers.py | 20 +++++++++++++++++++ locales/af.yaml | 2 ++ locales/ar.yaml | 2 ++ locales/de.yaml | 2 ++ locales/en.yaml | 2 ++ locales/es.yaml | 2 ++ locales/fr.yaml | 2 ++ locales/ga.yaml | 2 ++ locales/hu.yaml | 2 ++ locales/it.yaml | 2 ++ locales/ja.yaml | 2 ++ locales/ko.yaml | 2 ++ locales/pt.yaml | 2 ++ locales/ru.yaml | 2 ++ locales/tr.yaml | 2 ++ locales/uk.yaml | 2 ++ locales/zh-hant.yaml | 2 ++ locales/zh.yaml | 2 ++ .../test_kanban_notifier_apiserver_wake.py | 9 ++++++++- website/docs/user-guide/features/kanban.md | 6 ++++-- .../current/user-guide/features/kanban.md | 6 ++++-- 21 files changed, 70 insertions(+), 5 deletions(-) diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index 703375c470..ba273a368a 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -467,6 +467,11 @@ class GatewayKanbanWatchersMixin: mode = sub.get("delivery_mode") or "notify" wake_agent = mode in ("notify+wake", "wake") send_passive = mode != "wake" + # Worker handoff carried into the synthetic wake turn below + # (#70752): without it the woken creator only sees + # "Task X completed" and re-decomposes work that already + # exists on the board. + wake_handoff = "" for ev in d["events"]: kind = ev.kind # Identity prefix: attribute terminal pings to the @@ -488,10 +493,12 @@ class GatewayKanbanWatchersMixin: lines = payload_summary.strip().splitlines() h = lines[0][:200] if lines else payload_summary[:200] handoff = f"\n{h}" + wake_handoff = h elif task and task.result: lines = task.result.strip().splitlines() r = lines[0][:160] if lines else task.result[:160] handoff = f"\n{r}" + wake_handoff = r msg = ( f"✔ {board_tag}{tag}Kanban {sub['task_id']} done" f" — {title}{handoff}" @@ -746,6 +753,19 @@ class GatewayKanbanWatchersMixin: assignee=_assignee, board=board_slug, ) + # Graph-safe wake turn (#70752): carry the worker's + # completion handoff into the synthetic turn and + # label it as an automatic notification so the woken + # creator inspects the board instead of + # re-decomposing work that already exists. + if wake_handoff: + _synth += "\n" + t( + "gateway.kanban.wake.handoff", + summary=wake_handoff, + ) + _synth += "\n\n" + t( + "gateway.kanban.wake.guidance" + ) if not _is_push_adapter and _wake_kinds and _session_key: # Wake self-post IS the delivery on this path — diff --git a/locales/af.yaml b/locales/af.yaml index 40dce4e162..6658c2da20 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "Geen persoonlikhede opgestel in `{path}/config.yaml` nie" diff --git a/locales/ar.yaml b/locales/ar.yaml index fcd3a6555e..9d98db08eb 100644 --- a/locales/ar.yaml +++ b/locales/ar.yaml @@ -192,6 +192,8 @@ gateway: status_default: "تغيّرت الحالة" status_joiner: "، " message: "[kanban] المهمة {task_id} {status}.\nالعنوان: {title}\nالمُكلَّف: @{assignee}\nاللوحة: {board}\n\nراجع النتيجة أو قرّر الخطوة التالية." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "لا توجد شخصيات مُهيّأة في `{path}/config.yaml`" diff --git a/locales/de.yaml b/locales/de.yaml index bd562e879c..52d14ad237 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "Keine Persönlichkeiten in `{path}/config.yaml` konfiguriert" diff --git a/locales/en.yaml b/locales/en.yaml index 72712986c7..d1069d7eac 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -184,6 +184,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "No personalities configured in `{path}/config.yaml`" diff --git a/locales/es.yaml b/locales/es.yaml index 8786078142..16c030d616 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "No hay personalidades configuradas en `{path}/config.yaml`" diff --git a/locales/fr.yaml b/locales/fr.yaml index 65d885d228..77869000e2 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "Aucune personnalité configurée dans `{path}/config.yaml`" diff --git a/locales/ga.yaml b/locales/ga.yaml index 26473ccdea..1e9231a405 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -173,6 +173,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "Níl aon phearsantachtaí cumraithe in `{path}/config.yaml`" diff --git a/locales/hu.yaml b/locales/hu.yaml index 2600210fb9..fa4705deb2 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "Nincs személyiség beállítva itt: `{path}/config.yaml`" diff --git a/locales/it.yaml b/locales/it.yaml index d7ab72628c..6549980473 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "Nessuna personalità configurata in `{path}/config.yaml`" diff --git a/locales/ja.yaml b/locales/ja.yaml index 14c78f3d82..17b66572b4 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "`{path}/config.yaml` に人格が設定されていません" diff --git a/locales/ko.yaml b/locales/ko.yaml index a2b920cafd..cdc43eb6d0 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "`{path}/config.yaml`에 구성된 성격이 없습니다" diff --git a/locales/pt.yaml b/locales/pt.yaml index be3c43d2bf..0cbf524b68 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "Nenhuma personalidade configurada em `{path}/config.yaml`" diff --git a/locales/ru.yaml b/locales/ru.yaml index 10443501a0..a0d8eb508c 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "В `{path}/config.yaml` не настроено ни одной личности" diff --git a/locales/tr.yaml b/locales/tr.yaml index 7ef3ac86b7..f39605c4b4 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "`{path}/config.yaml` içinde yapılandırılmış kişilik yok" diff --git a/locales/uk.yaml b/locales/uk.yaml index 95027ab60e..08f40a8492 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "Result: {summary}" + guidance: "This is an automatic task-status notification, not a request to decompose the task again. Inspect the current board before creating follow-up tasks; do not recreate tasks or graphs that already exist." personality: none_configured: "У `{path}/config.yaml` не налаштовано жодної особистості" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 922a1d0d21..c08ee22d68 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -169,6 +169,8 @@ gateway: status_default: "status changed" status_joiner: ", " message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step." + handoff: "結果:{summary}" + guidance: "這是自動任務狀態通知,不是再次分解任務的請求。建立後續任務前請先檢查目前看板;不要重複建立已存在的任務或任務圖。" personality: none_configured: "`{path}/config.yaml` 中未設定人格" diff --git a/locales/zh.yaml b/locales/zh.yaml index 512fbbcd72..2c8aafdae3 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -169,6 +169,8 @@ gateway: status_default: "状态变化" status_joiner: "," message: "[kanban] 任务 {task_id} {status}。\n标题: {title}\n执行者: @{assignee}\n看板: {board}\n\n请检查结果或决定下一步动作。" + handoff: "结果:{summary}" + guidance: "这是自动任务状态通知,不是再次分解任务的请求。创建后续任务前请先检查当前看板;不要重复创建已存在的任务或任务图。" personality: none_configured: "`{path}/config.yaml` 中未配置人格设定" diff --git a/tests/gateway/test_kanban_notifier_apiserver_wake.py b/tests/gateway/test_kanban_notifier_apiserver_wake.py index a257360d02..7b7c39a62f 100644 --- a/tests/gateway/test_kanban_notifier_apiserver_wake.py +++ b/tests/gateway/test_kanban_notifier_apiserver_wake.py @@ -128,7 +128,14 @@ def test_apiserver_sub_wakes_subscription_destination_via_self_post(tmp_path, mo assert len(posts) == 1 assert posts[0]["session_id"] == "origin-session" assert all(post["session_id"] != "worker-session" for post in posts) - assert tid in posts[0]["text"] + wake_text = posts[0]["text"] + assert tid in wake_text + # Graph-safe wake turn (#70752): the synthetic turn must carry the + # worker's completion handoff and the don't-recreate guidance so a + # woken orchestrator doesn't re-decompose existing work. + assert "done once" in wake_text, "creator wake must carry the worker handoff" + assert "not a request to decompose" in wake_text.lower() + assert "do not recreate" in wake_text.lower() # The wake self-post IS the delivery on this path (no separate text-ping # fallback is attempted for stateless api_server subs) — cursor advances # once the wake succeeds. diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 0bb77c3210..88bcc392d9 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -597,6 +597,8 @@ The kanban board has two ways to handle a task you drop into the Triage column: **Manual** — `kanban.auto_decompose: false`. Triage tasks stay in triage until you act. Click the **⚗ Decompose** button on a card, run `hermes kanban decompose ` (or `--all`), or use `/kanban decompose ` from a chat. This matches the pre-decomposer behavior of the board, useful when you want full control over what runs when. +**Important boundary:** Manual mode disables only the built-in Triage decomposer. It does not prevent a profile from calling `kanban_create`, and it does not disable creator-session wake-ups. With `kanban.auto_subscribe_on_create: true`, a task's terminal event resumes the originating agent with a synthetic status turn so it can inspect the handoff and decide whether genuinely new follow-up work is needed. Set `auto_subscribe_on_create: false` when task completion should remain passive. For provenance, built-in decomposer children use `created_by=auto-decomposer`; tasks created by a resumed profile carry that profile name instead. + 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). @@ -607,11 +609,11 @@ Config knobs (all under `kanban:` in `~/.hermes/config.yaml`): | Key | Default | Purpose | |---|---|---| -| `auto_decompose` | `true` | Dispatcher auto-runs the decomposer every tick. | +| `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. | -| `auto_subscribe_on_create` | `true` | When a worker calls `kanban_create` from inside a session with a persistent delivery channel (messaging gateway or TUI), the originating session is auto-subscribed to the new task's completion/block events. The dispatcher still drives the delivery — this only changes whether the caller's chat/key shows up in the notify-sub table. Set to `false` to require explicit `kanban_notify-subscribe` calls per task. | +| `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`. | And the two auxiliary LLM slots: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md index b570fd98db..c4150df1eb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md @@ -421,6 +421,8 @@ hermes dashboard # 导航栏中出现 "Kanban" 标签页,位于 "Skills **手动** —— `kanban.auto_decompose: false`。分诊任务保持在分诊中,直到你操作。点击卡片上的 **⚗ Decompose** 按钮,运行 `hermes kanban decompose `(或 `--all`),或从聊天中使用 `/kanban decompose `。这与看板的预分解器行为一致,适合需要完全控制运行时机的场景。 +**重要边界:** 手动模式只会关闭内置的 Triage 分解器。它不会阻止配置文件调用 `kanban_create`,也不会关闭创建者会话的唤醒。启用 `kanban.auto_subscribe_on_create: true` 时,任务的终止事件会通过合成状态消息恢复原始 agent,使其读取交接并判断是否确实需要新的后续工作。希望任务完成保持被动时,请设置 `auto_subscribe_on_create: false`。溯源时,内置分解器创建的子任务标记为 `created_by=auto-decomposer`;由恢复后的配置文件创建的任务则记录该配置文件名。 + 从 kanban 页面顶部的 **Orchestration: Auto/Manual** 切换按钮(翠绿色 = 自动,静音灰色 = 手动)在两种模式之间切换,或直接编辑 `config.yaml`。两种模式都与 `hermes kanban specify` 共存 —— 当你不想扇出时,它仍然可用作单任务规格重写。 分解器的路由决策依赖于配置文件描述,这是一个每配置文件的标签原语,通过 `hermes profile create --description "..."`、`hermes profile describe --text "..."`、`hermes profile describe --auto`(LLM 从配置文件安装的 skill + 模型自动生成),或仪表盘展开的 **Orchestration settings** 面板中的每配置文件编辑器来设置。没有描述的配置文件仍然出现在名册中 —— 它们可以按名称路由,只是精度较低。分解器**绝不**会将子任务落地为 `assignee=None`:当 LLM 选择未知配置文件时,子任务路由到 `kanban.default_assignee`(如果未设置,则路由到活动默认配置文件)。 @@ -429,11 +431,11 @@ hermes dashboard # 导航栏中出现 "Kanban" 标签页,位于 "Skills | 键 | 默认值 | 用途 | |---|---|---| -| `auto_decompose` | `true` | 调度器每 tick 自动运行分解器。 | +| `auto_decompose` | `true` | 调度器每 tick 为 Triage 任务运行内置分解器;它不会限制配置文件驱动的 `kanban_create` 或创建者唤醒回合。 | | `auto_decompose_per_tick` | `3` | 每个调度器 tick 的分解上限。超出部分推迟到下一个 tick。 | | `orchestrator_profile` | `""` | 拥有分解权的配置文件。空 = 回退到活动默认配置文件。 | | `default_assignee` | `""` | LLM 选择未知配置文件时子任务的落地位置。空 = 回退到活动默认配置文件。 | -| `auto_subscribe_on_create` | `true` | 当 worker 在具有持久投递通道的会话(消息网关或 TUI)内调用 `kanban_create` 时,原始会话会自动订阅新任务的完成/阻塞事件。调度器仍负责驱动投递 —— 此设置只决定调用者的聊天/密钥是否出现在通知订阅表中。设为 `false` 则要求对每个任务显式调用 `kanban_notify-subscribe`。 | +| `auto_subscribe_on_create` | `true` | 当 `kanban_create` 在持久 gateway/TUI 会话中运行时,终止事件会通过合成状态回合恢复原始 agent。设为 `false` 可让完成保持被动,或要求显式调用 `kanban_notify-subscribe`。此设置独立于 `auto_decompose`。 | 以及两个辅助 LLM 槽: