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.
This commit is contained in:
teknium1
2026-09-15 12:10:30 -07:00
committed by Teknium
parent cf1e727067
commit 8a86c56ddb
2 changed files with 32 additions and 1 deletions
+21
View File
@@ -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
+11 -1
View File
@@ -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)