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
+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)