fix(gateway): /loop paths warm the SessionDB cache off-loop

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.
This commit is contained in:
ethernet
2026-08-18 12:34:52 -04:00
committed by Teknium
parent 246477a809
commit 46d8cf0be3
3 changed files with 21 additions and 0 deletions
+11
View File
@@ -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:
+4
View File
@@ -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:
+6
View File
@@ -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())