diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index abe13f8139..f29777d310 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -781,7 +781,16 @@ class GatewayKanbanWatchersMixin: # claim exactly like a failed send() above, so the # next tick retries. task_terminal = task and task.status == "archived" - _WAKE_KINDS = ("completed", "gave_up", "crashed", "timed_out", "blocked") + # Kinds that hand a decision back to the origin, so the + # origin has to take a turn. ``review_requested`` (the + # implementation is done and waits for a reviewer) and + # ``block_loop_detected`` (routed to triage) belong here + # for the same reason ``blocked`` does. ``status`` / + # ``archived`` / ``unblocked`` stay out: bookkeeping. + _WAKE_KINDS = ( + "completed", "gave_up", "crashed", "timed_out", + "blocked", "review_requested", "block_loop_detected", + ) _wake_kinds = ( {ev.kind for ev in d["events"] if ev.kind in _WAKE_KINDS} if wake_agent @@ -818,6 +827,8 @@ class GatewayKanbanWatchersMixin: if "crashed" in _wake_kinds: _parts.append(t("gateway.kanban.wake.crashed")) if "timed_out" in _wake_kinds: _parts.append(t("gateway.kanban.wake.timed_out")) if "blocked" in _wake_kinds: _parts.append(t("gateway.kanban.wake.blocked")) + if "review_requested" in _wake_kinds: _parts.append(t("gateway.kanban.wake.review_requested")) + if "block_loop_detected" in _wake_kinds: _parts.append(t("gateway.kanban.wake.block_loop_detected")) _status = t("gateway.kanban.wake.status_joiner").join(_parts) or t("gateway.kanban.wake.status_default") _synth = t( "gateway.kanban.wake.message", diff --git a/locales/af.yaml b/locales/af.yaml index 4a5aa89bd6..b2c8a37945 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/ar.yaml b/locales/ar.yaml index 933b10f361..b2e4fc7e8f 100644 --- a/locales/ar.yaml +++ b/locales/ar.yaml @@ -189,6 +189,8 @@ gateway: crashed: "تعطّل (خرج العامل)؛ سيعيد المُوزّع المحاولة" timed_out: "انتهت المهلة؛ سيعيد المُوزّع المحاولة" blocked: "محجوبة؛ تحتاج إلى انتباه" + review_requested: "أُحيلت للمراجعة؛ اكتمل التنفيذ" + block_loop_detected: "أُحيلت إلى الفرز بعد تكرار الحجب؛ تحتاج إلى قرار" status_default: "تغيّرت الحالة" status_joiner: "، " message: "[kanban] المهمة {task_id} {status}.\nالعنوان: {title}\nالمُكلَّف: @{assignee}\nاللوحة: {board}\n\nراجع النتيجة أو قرّر الخطوة التالية." diff --git a/locales/de.yaml b/locales/de.yaml index 8a18ff65cf..56995f85c4 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/en.yaml b/locales/en.yaml index 34b0904cf0..7a7ced00fe 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -181,6 +181,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/es.yaml b/locales/es.yaml index a51c05ef82..61d228b2e7 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/fr.yaml b/locales/fr.yaml index df65467285..3dd054b4d5 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/ga.yaml b/locales/ga.yaml index ff061d4471..fa2e6791b5 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -170,6 +170,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/hu.yaml b/locales/hu.yaml index d85807ae21..3d2cd0dabd 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/it.yaml b/locales/it.yaml index 8a7c55b861..bda8272750 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/ja.yaml b/locales/ja.yaml index 7f1bed5918..11108138ac 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/ko.yaml b/locales/ko.yaml index b240547aaf..3641c23ead 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/pt.yaml b/locales/pt.yaml index 7a8a131c5d..e189d2a86d 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/ru.yaml b/locales/ru.yaml index a4220d3d28..06a87cf716 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/tr.yaml b/locales/tr.yaml index c0ffa802a3..cef70e372a 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/uk.yaml b/locales/uk.yaml index 43ccae3911..dbf345f4e5 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 1b26f787fd..fcada7c4c8 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -166,6 +166,8 @@ gateway: crashed: "crashed (worker exited); dispatcher will retry" timed_out: "timed out; dispatcher will retry" blocked: "blocked; needs attention" + review_requested: "handed off for review; the implementation is done" + block_loop_detected: "routed to triage after repeated blocks; needs a decision" 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." diff --git a/locales/zh.yaml b/locales/zh.yaml index d7bed9149d..bc5110f83e 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -166,6 +166,8 @@ gateway: crashed: "崩溃(worker 异常退出),dispatcher 将重试" timed_out: "超时,dispatcher 将重试" blocked: "被阻塞,需要处理" + review_requested: "已交付评审;实现已完成" + block_loop_detected: "反复阻塞后被转入待分诊,需要做出决定" status_default: "状态变化" status_joiner: "," message: "[kanban] 任务 {task_id} {status}。\n标题: {title}\n执行者: @{assignee}\n看板: {board}\n\n请检查结果或决定下一步动作。" diff --git a/tests/gateway/test_kanban_notifier.py b/tests/gateway/test_kanban_notifier.py index a698c13cc7..27f4122358 100644 --- a/tests/gateway/test_kanban_notifier.py +++ b/tests/gateway/test_kanban_notifier.py @@ -622,3 +622,116 @@ def test_notifier_delivers_block_loop_detected_triage_ping(tmp_path, monkeypatch finally: conn.close() assert remaining == [] + + +# --------------------------------------------------------------------------- +# Handoffs that hand a decision back to the origin must wake it, not only ping +# it: `review_requested` (implementation done, waiting for a reviewer) and +# `block_loop_detected` (routed to triage) are terminal kinds just like +# `blocked`. +# --------------------------------------------------------------------------- + + +def _wake_text(adapter): + """Text of the single synthetic wake turn injected into the adapter.""" + assert len(adapter.handled) == 1, ( + f"expected exactly one wake turn, got {len(adapter.handled)}" + ) + return getattr(adapter.handled[0], "text", "") or "" + + +def _review_handoff_task(*, delivery_mode="notify+wake"): + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="implement the thing", + assignee="worker", + session_id="agent:main:telegram:dm:chat-1", + ) + kb.add_notify_sub( + conn, + task_id=tid, + platform="telegram", + chat_id="chat-1", + chat_type="dm", + delivery_mode=delivery_mode, + ) + kb.claim_task(conn, tid) + run_id = kb.get_task(conn, tid).current_run_id + assert kb.request_review( + conn, tid, summary="implementation done", expected_run_id=run_id, + ) is True + return tid + finally: + conn.close() + + +def test_review_requested_wakes_the_origin_session(tmp_path, monkeypatch): + """A review handoff wakes the origin on top of the passive ping.""" + monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "review-wake.db")) + kb.init_db() + tid = _review_handoff_task() + + adapter = RecordingAdapter() + runner = _make_runner(adapter) + asyncio.run(_run_one_notifier_tick(monkeypatch, runner)) + + assert len(adapter.sent) == 1, "the passive review ping is unchanged" + assert "ready for review" in adapter.sent[0]["text"] + assert tid in _wake_text(adapter) + + +def test_block_loop_detected_wakes_the_origin_session(tmp_path, monkeypatch): + """A triage escalation wakes the origin so a decision gets made.""" + monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "triage-wake.db")) + kb.init_db() + + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="loops forever", + assignee="worker", + session_id="agent:main:telegram:dm:chat-1", + ) + kb.add_notify_sub( + conn, + task_id=tid, + platform="telegram", + chat_id="chat-1", + chat_type="dm", + delivery_mode="notify+wake", + ) + kb._append_event( + conn, tid, "block_loop_detected", + {"reason": "needs credentials", "kind": "needs_input", + "recurrences": 2, "limit": kb.BLOCK_RECURRENCE_LIMIT}, + ) + finally: + conn.close() + + adapter = RecordingAdapter() + runner = _make_runner(adapter) + asyncio.run(_run_one_notifier_tick(monkeypatch, runner)) + + assert len(adapter.sent) == 1 + assert tid in _wake_text(adapter) + + +def test_review_requested_does_not_wake_a_notify_only_subscription( + tmp_path, monkeypatch, +): + """delivery_mode still decides whether a wake-worthy kind wakes at all.""" + monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "review-notify.db")) + kb.init_db() + _review_handoff_task(delivery_mode="notify") + + adapter = RecordingAdapter() + runner = _make_runner(adapter) + asyncio.run(_run_one_notifier_tick(monkeypatch, runner)) + + assert len(adapter.sent) == 1 + assert adapter.handled == [], ( + "notify-only subscriptions must not be woken by a review handoff" + ) diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index ebb1c26d65..470360f921 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -1035,6 +1035,8 @@ A subscription removes itself automatically once the task reaches `done` or `arc A "wake" forges a synthetic inbound message to the destination gateway agent so it takes a normal turn (reads the comment + result, reasons, replies) instead of getting a one-line passive notification. It only fires when the notifier runs inside a live gateway process; otherwise a `notify+wake` subscription still delivers its passive message, while a `wake`-only subscription does nothing in that process. +**Which events wake.** The ones that hand a decision back to the origin: `completed`, `blocked`, `gave_up`, `crashed`, `timed_out`, `review_requested` (a worker finished the implementation and handed off via `kanban_request_review`) and `block_loop_detected` (the task was routed to `triage` after repeated blocks). `status`, `archived` and `unblocked` are delivered but never wake — they are bookkeeping transitions, not decisions. + `--chat-type` (`dm` | `group` | `channel` | `thread`) records the originating chat's type so a woken turn resolves the operator's **real** session: `build_session_key` keys groups, channels, and threads differently from DMs, so an inaccurate `chat_type` would route the wake into a separate, context-less session. The `/kanban` auto-subscribe and slash-command paths capture this automatically — you only set it by hand when subscribing a chat from a script or cron. Omit it to leave an existing subscription unchanged (new subscriptions default to `dm`). ### Multi-profile setups: delivery is profile-owned