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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user