From 46d8cf0be32efdaeb6591d2558a9824adbe528b5 Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 18 Aug 2026 12:34:52 -0400 Subject: [PATCH] fix(gateway): /loop paths warm the SessionDB cache off-loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loops delegation (previous commit) moved /loop onto the shared bootstrap windows, but the loop-class gateway callers still constructed LoopManager on the loop thread with no warm-up — the same false-ack class as /goal, one sibling over: - The /loop command handler: a cold init past the window made save_loop discard the write while the reply claimed the loop was set. Reproduced with a 2s init: "↻ Loop set" with nothing persisted; with the warm-up the loop persists. - _post_turn_loop_completion: a cold cache at the turn boundary stalled the loop for the init duration and could drop the tick-completion write. - _loop_wakeup_watcher: the scan reads every persisted loop, so a cold cache ran the state.db init on the loop thread before the first read. All three now warm the cache off-loop first (same helper, same reason as the goal paths). save_loop also logs at WARNING when it drops a write, matching save_goal: the reply has already told the user the loop was set. Review findings (PR #88965): the goal and heartbeat command paths warmed off-loop, the loop-class paths did not. --- gateway/run.py | 11 +++++++++++ gateway/slash_commands.py | 4 ++++ hermes_cli/loops.py | 6 ++++++ 3 files changed, 21 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 04c80279c8..b5d49dbcb3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -21397,6 +21397,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not sid: return + # Warm the SessionDB cache off-loop. A cold cache at the turn + # boundary stalls the loop for the init duration and can drop + # the tick-completion write (the /goal continuation seam, one + # sibling over). + await self._warm_goals_session_db("loop completion") + mgr = LoopManager(session_id=sid) state = mgr.state if state is None or not state.awaiting_response: @@ -21435,6 +21441,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew list_active_loops, ) + # Warm the cache off-loop once per scan. The scan reads + # every persisted loop, so a cold cache runs the state.db + # init on the loop thread before the first read. + await self._warm_goals_session_db("loop wakeup") + now = time.time() for sid, state in list_active_loops(): if state.awaiting_response or now < state.next_due_at: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index d66162fb60..78c276dcfc 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3038,6 +3038,10 @@ class GatewaySlashCommandsMixin: except Exception as exc: logger.debug("loop manager unavailable: %s", exc) return None, None + # Warm the SessionDB cache off-loop. A cold cache drops the first + # /loop write while the reply claims the loop was set (same class + # as the /goal false-ack fix). + await self._warm_goals_session_db("loop manager") try: session_entry = await self.async_session_store.get_or_create_session(event.source) except Exception: diff --git a/hermes_cli/loops.py b/hermes_cli/loops.py index 87e07a6842..7368d85b2e 100644 --- a/hermes_cli/loops.py +++ b/hermes_cli/loops.py @@ -415,6 +415,12 @@ def save_loop(session_id: str, state: LoopState) -> None: return db = _get_session_db() if db is None: + logger.warning( + "LoopManager: loop for %s not persisted — session DB " + "unavailable (bootstrap window exceeded, in-memory state " + "still active)", + session_id, + ) return try: db.set_meta(_meta_key(session_id), state.to_json())