From 8a86c56ddb0d99616b8676fbc12d46f72aac1e53 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:10:30 -0700 Subject: [PATCH] fix(tui): session-owner poller leaves gateway-routed /loop ticks to the gateway Sibling of the heartbeat fix: a /loop set from a messaging chat carries the gateway's pinned ``route`` (platform + chat_id), and ``gateway/run_goals.py::_loop_wakeup_fire_one`` already defers route-less CLI/TUI loops to their own schedulers. The TUI/Desktop poller never returned the favor, so a Desktop viewer of the same session could fire the wakeup on its own surface and the reply never reached the chat. Mirror the rule: a routed loop is skipped before the session is claimed, so the tick stays due for the gateway scanner. Live probe (temp HERMES_HOME, qqbot-routed loop, Desktop viewer of the same session): before -> loop.ticks=1 awaiting=True (consumed on Desktop); after -> ticks=0, still due. Desktop-owned control loop still fires. --- tests/tui_gateway/test_loop_command.py | 21 +++++++++++++++++++++ tui_gateway/session_notifications.py | 12 +++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/tui_gateway/test_loop_command.py b/tests/tui_gateway/test_loop_command.py index 22af6d7f97..ea4730687a 100644 --- a/tests/tui_gateway/test_loop_command.py +++ b/tests/tui_gateway/test_loop_command.py @@ -171,6 +171,27 @@ def test_tui_tick_defers_when_running(server, session): assert LoopManager(session_key).state.ticks_fired == 0 +def test_tui_tick_leaves_gateway_routed_loop_for_gateway(server, session): + """A /loop set from a messaging chat (route pinned by the gateway) must not be consumed by a TUI/Desktop + viewer of the same session: the gateway's wakeup scanner owns delivery back to that chat (#111841).""" + sid, session_key, s = session + from hermes_cli.loops import LoopManager, save_loop + + mgr = LoopManager(session_key) + mgr.set("poll", interval_seconds=60, route={"platform": "telegram", "chat_id": "42"}) + mgr.state.next_due_at = time.time() - 1 + save_loop(session_key, mgr.state) + + with patch.object(server, "_run_prompt_submit") as submit, \ + patch.object(server, "_emit"): + server._maybe_fire_tui_loop_tick(sid, s) + + submit.assert_not_called() + assert s["running"] is False + state = LoopManager(session_key).state + assert state.ticks_fired == 0 and not state.awaiting_response and state.next_due_at <= time.time() + + def test_tui_tick_defers_to_active_goal(server, session): sid, session_key, s = session from hermes_cli.goals import GoalManager diff --git a/tui_gateway/session_notifications.py b/tui_gateway/session_notifications.py index 00e58f7ed7..50c7c4024d 100644 --- a/tui_gateway/session_notifications.py +++ b/tui_gateway/session_notifications.py @@ -248,6 +248,14 @@ def _maybe_fire_tui_heartbeat_tick(sid: str, session: dict) -> None: mgr.abandon_fire() +def _loop_route_is_gateway_chat(state) -> bool: + """A /loop set from a messaging chat carries the gateway's ``route`` (platform + chat_id); its wakeup scanner + (``gateway/run_goals.py::_loop_wakeup_fire_one``) fires those and skips route-less CLI/TUI loops. Mirror it here + so a Desktop viewer of the same session never consumes the tick and strands the reply off the chat.""" + route = getattr(state, "route", None) or {} + return bool(route.get("platform") and route.get("chat_id")) + + def _maybe_fire_tui_loop_tick(sid: str, session: dict) -> None: """Fire a due /loop wakeup for an idle TUI/Desktop/dashboard session (per-session poller, coarse cadence). Claims the session (running=True) before dispatching so a racing user prompt wins; the post-turn hook completes the tick.""" @@ -258,7 +266,9 @@ def _maybe_fire_tui_loop_tick(sid: str, session: dict) -> None: if not (sid_key := session.get("session_key") or ""): return mgr = LoopManager(session_id=sid_key) - if not mgr.is_due() or goal_blocks_loop_tick(sid_key) or not _notif_claim_turn(session): + if not mgr.is_due() or goal_blocks_loop_tick(sid_key) or _loop_route_is_gateway_chat(mgr.state): + return # not due, or the gateway's wakeup scanner owns the routed chat — stays due there + if not _notif_claim_turn(session): return # busy — stays due, next poll retries if not (wakeup := mgr.fire_tick()): _notif_release_turn(session)