refactor(gateway): run_inbound/run_goals wave-2 — fold manager/post-turn heads, shared cmd helpers, drop redundant locals
This commit is contained in:
+112
-179
@@ -27,11 +27,8 @@ class GatewayGoalsMixin:
|
||||
|
||||
# ── /goal — persistent cross-turn goals (Ralph-style loop) ──────────
|
||||
def _goal_max_turns_from_config(self) -> int:
|
||||
"""Resolve the configured /goal turn budget.
|
||||
|
||||
GatewayRunner.config is a GatewayConfig dataclass, not the full user config, so the
|
||||
top-level ``goals`` block is only reachable via hermes_cli.config.load_config().
|
||||
"""
|
||||
"""Configured /goal turn budget. GatewayRunner.config is a GatewayConfig dataclass, so the
|
||||
top-level ``goals`` block is only reachable via hermes_cli.config.load_config()."""
|
||||
try:
|
||||
goals_cfg = (
|
||||
(self.config or {}).get("goals", {})
|
||||
@@ -47,12 +44,9 @@ class GatewayGoalsMixin:
|
||||
return 20
|
||||
|
||||
async def _warm_goals_session_db(self, label: str) -> None:
|
||||
"""Warm the goals SessionDB cache off-loop (best-effort).
|
||||
|
||||
A cold cache runs the state.db init on the loop thread and freezes the loop. The executor
|
||||
hop keeps the profile home override alive under multiplex. On failure the caller falls
|
||||
back to the bootstrap windows, so a dropped warm-up is a bounded stall, never a crash.
|
||||
"""
|
||||
"""Warm the goals SessionDB cache off-loop (best-effort): a cold cache runs the state.db
|
||||
init on the loop thread and freezes the loop. The executor hop keeps the profile home
|
||||
override alive under multiplex; a failed warm-up is a bounded stall, never a crash."""
|
||||
try:
|
||||
from hermes_cli.goals import _get_session_db as _warm_goals_db
|
||||
|
||||
@@ -61,12 +55,9 @@ class GatewayGoalsMixin:
|
||||
logger.warning("%s: session DB warm-up failed: %s", label, exc)
|
||||
|
||||
async def _session_entry_for_manager(self, event: "MessageEvent", label: str):
|
||||
"""Session entry for a /goal or /heartbeat manager, or None when lookup fails.
|
||||
|
||||
Warms the SessionDB cache first (a cold cache drops the first write while the reply
|
||||
claims it was set). Internal events never touch activity, so they don't advance the
|
||||
idle/daily reset clock.
|
||||
"""
|
||||
"""Session entry for a /goal or /heartbeat manager, or None when lookup fails. Warms the
|
||||
SessionDB cache first (a cold cache drops the first write while the reply claims it was
|
||||
set). Internal events never touch activity (idle/daily reset clock)."""
|
||||
await self._warm_goals_session_db(label)
|
||||
try:
|
||||
session_entry = await self.async_session_store.get_or_create_session(
|
||||
@@ -75,34 +66,35 @@ class GatewayGoalsMixin:
|
||||
except Exception as exc:
|
||||
logger.debug("%s: session lookup failed: %s", label, exc)
|
||||
return None
|
||||
if not (getattr(session_entry, "session_id", None) or ""):
|
||||
return None
|
||||
return session_entry
|
||||
return session_entry if getattr(session_entry, "session_id", None) else None
|
||||
|
||||
async def _manager_for_event(self, event: "MessageEvent", kind: str, load):
|
||||
"""``(manager, session_entry)`` for *kind* ("goal"/"heartbeat"), or ``(None, None)``.
|
||||
``load()`` imports the manager class and returns a ``session_id -> manager`` factory."""
|
||||
try:
|
||||
factory = load()
|
||||
except Exception as exc:
|
||||
logger.debug("%s manager unavailable: %s", kind, exc)
|
||||
return None, None
|
||||
session_entry = await self._session_entry_for_manager(event, f"{kind} manager")
|
||||
if session_entry is None:
|
||||
return None, None
|
||||
return factory(session_entry.session_id), session_entry
|
||||
|
||||
async def _get_goal_manager_for_event(self, event: "MessageEvent"):
|
||||
"""Return ``(GoalManager, session_entry)`` for this event, or ``(None, None)``."""
|
||||
try:
|
||||
def _load():
|
||||
from hermes_cli.goals import GoalManager
|
||||
except Exception as exc:
|
||||
logger.debug("goal manager unavailable: %s", exc)
|
||||
return None, None
|
||||
session_entry = await self._session_entry_for_manager(event, "goal manager")
|
||||
if session_entry is None:
|
||||
return None, None
|
||||
max_turns = self._goal_max_turns_from_config()
|
||||
return GoalManager(session_id=session_entry.session_id, default_max_turns=max_turns), session_entry
|
||||
max_turns = self._goal_max_turns_from_config()
|
||||
return lambda sid: GoalManager(session_id=sid, default_max_turns=max_turns)
|
||||
return await self._manager_for_event(event, "goal", _load)
|
||||
|
||||
async def _get_heartbeat_manager_for_event(self, event: "MessageEvent"):
|
||||
"""Return ``(HeartbeatManager, session_entry)`` for this event, or ``(None, None)``."""
|
||||
try:
|
||||
def _load():
|
||||
from hermes_cli.heartbeat import HeartbeatManager
|
||||
except Exception as exc:
|
||||
logger.debug("heartbeat manager unavailable: %s", exc)
|
||||
return None, None
|
||||
session_entry = await self._session_entry_for_manager(event, "heartbeat manager")
|
||||
if session_entry is None:
|
||||
return None, None
|
||||
return HeartbeatManager(session_id=session_entry.session_id), session_entry
|
||||
return lambda sid: HeartbeatManager(session_id=sid)
|
||||
return await self._manager_for_event(event, "heartbeat", _load)
|
||||
|
||||
@staticmethod
|
||||
def _synthetic_prompt_event(source: Any, text: str, *, internal: bool = False) -> MessageEvent:
|
||||
@@ -110,35 +102,26 @@ class GatewayGoalsMixin:
|
||||
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source, internal=internal)
|
||||
|
||||
def _register_heartbeat_watch(self, quick_key: str, source: Any, session_id: str) -> None:
|
||||
"""Track a session with an active heartbeat and start the poller.
|
||||
|
||||
The registry maps ``quick_key`` → ``(source, session_id)`` so the poller can rebuild a
|
||||
MessageEvent and enqueue via the adapter FIFO. In-memory by design: heartbeat STATE
|
||||
survives restarts in SessionDB, but firing resumes only when the user touches /heartbeat
|
||||
again (durable schedules belong to cron).
|
||||
"""
|
||||
"""Track a session with an active heartbeat (``quick_key`` → ``(source, session_id)``) and
|
||||
start the poller. In-memory by design: heartbeat STATE survives restarts in SessionDB, but
|
||||
firing resumes only when the user touches /heartbeat again."""
|
||||
watch = getattr(self, "_heartbeat_watch", None)
|
||||
if watch is None:
|
||||
watch = {}
|
||||
self._heartbeat_watch = watch
|
||||
watch = self._heartbeat_watch = {}
|
||||
watch[quick_key] = (source, session_id)
|
||||
self._start_heartbeat_poller()
|
||||
|
||||
def _unregister_heartbeat_watch(self, quick_key: str) -> None:
|
||||
watch = getattr(self, "_heartbeat_watch", None)
|
||||
if watch:
|
||||
watch.pop(quick_key, None)
|
||||
(getattr(self, "_heartbeat_watch", None) or {}).pop(quick_key, None)
|
||||
|
||||
async def _heartbeat_poll_once(self, watch: dict) -> None:
|
||||
"""One heartbeat poll pass: enqueue every due prompt of a non-busy watched session."""
|
||||
# Warm the cache off-loop once per poll: this only covers the degraded path where the
|
||||
# /heartbeat command's own warm-up failed.
|
||||
# Off-loop warm-up covers the degraded path where /heartbeat's own warm-up failed.
|
||||
await self._warm_goals_session_db("heartbeat poll")
|
||||
for quick_key, (source, session_id) in list(watch.items()):
|
||||
try:
|
||||
# Busy sessions coalesce their tick to the next idle poll.
|
||||
if quick_key in self._running_agents:
|
||||
continue
|
||||
continue # busy sessions coalesce their tick to the next idle poll
|
||||
from hermes_cli.heartbeat import HeartbeatManager
|
||||
|
||||
mgr = HeartbeatManager(session_id=session_id)
|
||||
@@ -146,12 +129,9 @@ class GatewayGoalsMixin:
|
||||
watch.pop(quick_key, None)
|
||||
continue
|
||||
prompt = mgr.due_prompt()
|
||||
if not prompt:
|
||||
continue
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter is None:
|
||||
continue
|
||||
self._enqueue_fifo(quick_key, self._synthetic_prompt_event(source, prompt), adapter)
|
||||
adapter = self._adapter_for_source(source) if prompt else None
|
||||
if adapter is not None:
|
||||
self._enqueue_fifo(quick_key, self._synthetic_prompt_event(source, prompt), adapter)
|
||||
except Exception as exc:
|
||||
logger.debug("heartbeat poll for %s failed: %s", quick_key, exc)
|
||||
|
||||
@@ -171,10 +151,9 @@ class GatewayGoalsMixin:
|
||||
await self._heartbeat_poll_once(watch)
|
||||
|
||||
try:
|
||||
task = asyncio.create_task(_poll_loop())
|
||||
self._heartbeat_poll_task = task
|
||||
# PERMANENT once started (infinite loop) — same as a _spawn_supervised watcher. Tag it
|
||||
# so _scale_to_zero_has_live_background_work() doesn't treat the gateway as busy forever.
|
||||
task = self._heartbeat_poll_task = asyncio.create_task(_poll_loop())
|
||||
# PERMANENT once started (infinite loop) — tag it like a _spawn_supervised watcher so
|
||||
# _scale_to_zero_has_live_background_work() doesn't treat the gateway as busy forever.
|
||||
task._hermes_supervised_watcher = True # type: ignore[attr-defined]
|
||||
_bg = getattr(self, "_background_tasks", None)
|
||||
if _bg is not None:
|
||||
@@ -183,16 +162,20 @@ class GatewayGoalsMixin:
|
||||
except Exception:
|
||||
logger.debug("Failed to start heartbeat poller", exc_info=True)
|
||||
|
||||
async def _send_goal_status_notice(self, source: Any, message: str) -> None:
|
||||
"""Send a /goal judge status line back to the originating chat/thread."""
|
||||
def _goal_notice_adapter(self, source: Any):
|
||||
adapter = self._adapter_for_source(source)
|
||||
if not adapter:
|
||||
logger.debug("goal continuation: no adapter for %s", getattr(source, "platform", None))
|
||||
return adapter
|
||||
|
||||
async def _send_goal_status_notice(self, source: Any, message: str) -> None:
|
||||
"""Send a /goal judge status line back to the originating chat/thread."""
|
||||
adapter = self._goal_notice_adapter(source)
|
||||
if not adapter:
|
||||
return
|
||||
try:
|
||||
metadata = None
|
||||
with suppress(Exception):
|
||||
metadata = self._thread_metadata_for_source(source)
|
||||
except Exception:
|
||||
metadata = None
|
||||
result = await adapter.send(source.chat_id, message, metadata=metadata)
|
||||
if result is not None and not getattr(result, "success", True):
|
||||
logger.warning(
|
||||
@@ -202,13 +185,11 @@ class GatewayGoalsMixin:
|
||||
async def _defer_goal_status_notice_after_delivery(self, source: Any, message: str) -> None:
|
||||
"""Send a /goal status line after the main response is delivered.
|
||||
|
||||
The adapter sends the agent response after this caller returns, so for reading order the
|
||||
status must follow that send: use the adapter's one-shot post-delivery callback when
|
||||
available, else fall back to direct awaited delivery rather than dropping the notice.
|
||||
The adapter sends the agent response after this caller returns, so for reading order use
|
||||
its one-shot post-delivery callback when available, else deliver directly (never drop).
|
||||
"""
|
||||
adapter = self._adapter_for_source(source)
|
||||
adapter = self._goal_notice_adapter(source)
|
||||
if not adapter:
|
||||
logger.debug("goal continuation: no adapter for %s", getattr(source, "platform", None))
|
||||
return
|
||||
|
||||
async def _deliver() -> None:
|
||||
@@ -217,17 +198,13 @@ class GatewayGoalsMixin:
|
||||
except Exception as exc:
|
||||
logger.warning("goal continuation: status send failed: %s", exc, exc_info=True)
|
||||
|
||||
try:
|
||||
session_key = None
|
||||
with suppress(Exception):
|
||||
session_key = self._session_key_for_source(source)
|
||||
except Exception:
|
||||
session_key = None
|
||||
|
||||
if session_key and hasattr(adapter, "register_post_delivery_callback"):
|
||||
try:
|
||||
generation = None
|
||||
active = getattr(adapter, "_active_sessions", {}).get(session_key)
|
||||
if active is not None:
|
||||
generation = getattr(active, "_hermes_run_generation", None)
|
||||
generation = getattr(active, "_hermes_run_generation", None) if active is not None else None
|
||||
adapter.register_post_delivery_callback(session_key, _deliver, generation=generation)
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -235,62 +212,56 @@ class GatewayGoalsMixin:
|
||||
|
||||
await _deliver()
|
||||
|
||||
async def _post_turn_manager(self, session_entry: Any, label: str, module: str, load):
|
||||
"""Shared head of the post-turn hooks: ``load()`` imports the manager module and returns a
|
||||
``session_id -> manager`` factory; None when unavailable / no session id. Warms the
|
||||
SessionDB cache first — a cold cache at the turn boundary drops the read/write."""
|
||||
try:
|
||||
factory = load()
|
||||
except Exception as exc:
|
||||
logger.debug("%s: %s module unavailable: %s", label, module, exc)
|
||||
return None
|
||||
sid = getattr(session_entry, "session_id", None) or ""
|
||||
if not sid:
|
||||
return None
|
||||
await self._warm_goals_session_db(label)
|
||||
return factory(sid)
|
||||
|
||||
async def _post_turn_goal_continuation(
|
||||
self, *, session_entry: Any, source: Any, final_response: str,
|
||||
) -> None:
|
||||
"""Run the goal judge after a gateway turn and, if still active, enqueue a continuation.
|
||||
|
||||
Called at turn boundary AFTER delivery. Uses the adapter's pending-message/FIFO machinery
|
||||
so a simultaneous real user message is handled by the same queue and takes priority.
|
||||
"""
|
||||
try:
|
||||
"""Run the goal judge after a gateway turn (AFTER delivery) and, if still active, enqueue a
|
||||
continuation through the adapter FIFO so a simultaneous real user message takes priority."""
|
||||
def _load():
|
||||
from hermes_cli.goals import GoalManager
|
||||
except Exception as exc:
|
||||
logger.debug("goal continuation: goals module unavailable: %s", exc)
|
||||
max_turns = self._goal_max_turns_from_config()
|
||||
return lambda sid: GoalManager(session_id=sid, default_max_turns=max_turns)
|
||||
|
||||
mgr = await self._post_turn_manager(session_entry, "goal continuation", "goals", _load)
|
||||
if mgr is None or not mgr.is_active():
|
||||
return
|
||||
|
||||
sid = getattr(session_entry, "session_id", None) or ""
|
||||
if not sid:
|
||||
return
|
||||
|
||||
max_turns = self._goal_max_turns_from_config()
|
||||
# Cold cache at the turn boundary: a slow state.db init on the loop thread can drop the
|
||||
# goal read and silently end the goal loop.
|
||||
await self._warm_goals_session_db("goal continuation")
|
||||
|
||||
mgr = GoalManager(session_id=sid, default_max_turns=max_turns)
|
||||
if not mgr.is_active():
|
||||
return
|
||||
|
||||
try:
|
||||
_bg_procs = None
|
||||
with suppress(Exception):
|
||||
from hermes_cli.goals import gather_background_processes as _gather_bg
|
||||
_bg_procs = _gather_bg()
|
||||
except Exception:
|
||||
_bg_procs = None
|
||||
|
||||
# evaluate_after_turn → judge_goal() is a synchronous aux-LLM HTTP call; on the loop thread
|
||||
# it blocks Discord heartbeats 10-40 s. _run_in_executor_with_context (not bare
|
||||
# run_in_executor) carries the profile secret scope / aux runtime contextvars, without which
|
||||
# aux credential resolution fails under multiplexing.
|
||||
# judge_goal() is a synchronous aux-LLM HTTP call (10-40 s; would block Discord heartbeats).
|
||||
# _run_in_executor_with_context carries the profile secret scope / aux runtime contextvars
|
||||
# without which aux credential resolution fails under multiplexing.
|
||||
decision = await self._run_in_executor_with_context(
|
||||
lambda: mgr.evaluate_after_turn(
|
||||
final_response or "", user_initiated=True, background_processes=_bg_procs,
|
||||
),
|
||||
)
|
||||
msg = decision.get("message") or ""
|
||||
|
||||
# Status line is deferred until the adapter has delivered the visible final response,
|
||||
# otherwise "✓ Goal achieved" would show before the answer itself.
|
||||
# Deferred until the visible final response is delivered, else "✓ Goal achieved" precedes it.
|
||||
if msg and source is not None:
|
||||
await self._defer_goal_status_notice_after_delivery(source, msg)
|
||||
|
||||
if not decision.get("should_continue"):
|
||||
return
|
||||
|
||||
prompt = decision.get("continuation_prompt") or ""
|
||||
if not prompt or source is None:
|
||||
if not decision.get("should_continue") or not prompt or source is None:
|
||||
return
|
||||
|
||||
# Enqueue via the adapter's FIFO so a user message already in flight preempts naturally.
|
||||
try:
|
||||
adapter = self._adapter_for_source(source)
|
||||
@@ -305,7 +276,6 @@ class GatewayGoalsMixin:
|
||||
) -> None:
|
||||
"""Run goal and loop bookkeeping after an agent turn returns."""
|
||||
final_text = self._final_text_for_post_turn_hooks(agent_result, event)
|
||||
|
||||
try:
|
||||
session_entry = await self.async_session_store.get_or_create_session(
|
||||
source, touch_activity=not is_internal,
|
||||
@@ -313,30 +283,21 @@ class GatewayGoalsMixin:
|
||||
except Exception as exc:
|
||||
logger.debug("post-turn session resolution failed: %s", exc)
|
||||
return
|
||||
|
||||
# Empty interrupted/errored responses must not drive /goal, but an in-flight /loop tick
|
||||
# still needs to be released and rescheduled.
|
||||
hooks = [("loop completion", self._post_turn_loop_completion)]
|
||||
if final_text.strip():
|
||||
hooks.insert(0, ("goal continuation", self._post_turn_goal_continuation))
|
||||
for label, hook in hooks:
|
||||
try:
|
||||
await self._post_turn_goal_continuation(
|
||||
session_entry=session_entry, source=source, final_response=final_text,
|
||||
)
|
||||
await hook(session_entry=session_entry, source=source, final_response=final_text)
|
||||
except Exception as exc:
|
||||
logger.debug("goal continuation hook failed: %s", exc)
|
||||
try:
|
||||
await self._post_turn_loop_completion(
|
||||
session_entry=session_entry, source=source, final_response=final_text,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("loop completion hook failed: %s", exc)
|
||||
logger.debug("%s hook failed: %s", label, exc)
|
||||
|
||||
@staticmethod
|
||||
def _final_text_for_post_turn_hooks(agent_result, event=None) -> str:
|
||||
"""Text for /goal and /loop after a gateway turn.
|
||||
|
||||
Streamed turns return None from _handle_message_with_agent (already_sent); the delivered
|
||||
reply is stashed on the event so those hooks still see it.
|
||||
"""
|
||||
"""Text for /goal and /loop after a gateway turn. Streamed turns return None from
|
||||
_handle_message_with_agent (already_sent); the delivered reply is stashed on the event."""
|
||||
text = ""
|
||||
if isinstance(agent_result, dict):
|
||||
text = str(agent_result.get("final_response") or "")
|
||||
@@ -345,37 +306,22 @@ class GatewayGoalsMixin:
|
||||
if text.strip():
|
||||
return text
|
||||
streamed = getattr(event, "_streamed_final_response", None)
|
||||
if isinstance(streamed, str) and streamed.strip():
|
||||
return streamed
|
||||
return text
|
||||
return streamed if isinstance(streamed, str) and streamed.strip() else text
|
||||
|
||||
async def _post_turn_loop_completion(
|
||||
self, *, session_entry: Any, source: Any, final_response: str,
|
||||
) -> None:
|
||||
"""Complete a /loop wakeup tick after a gateway turn.
|
||||
|
||||
No-op unless the session has a loop whose tick is in flight (``awaiting_response`` — set
|
||||
when the wakeup was injected). Applies the LOOP_COMPLETE marker / --until judge / caps
|
||||
and schedules the next tick; the idle wakeup watcher fires it when due.
|
||||
"""
|
||||
try:
|
||||
"""Complete a /loop wakeup tick after a gateway turn. No-op unless a tick is in flight
|
||||
(``awaiting_response``, set when the wakeup was injected); applies the LOOP_COMPLETE marker
|
||||
/ --until judge / caps and schedules the next tick for the idle wakeup watcher."""
|
||||
def _load():
|
||||
from hermes_cli.loops import LoopManager
|
||||
except Exception as exc:
|
||||
logger.debug("loop completion: loops module unavailable: %s", exc)
|
||||
return
|
||||
return lambda sid: LoopManager(session_id=sid)
|
||||
|
||||
sid = getattr(session_entry, "session_id", None) or ""
|
||||
if not sid:
|
||||
return
|
||||
|
||||
# Cold cache at the turn boundary can drop the tick-completion write (see /goal above).
|
||||
await self._warm_goals_session_db("loop completion")
|
||||
|
||||
mgr = LoopManager(session_id=sid)
|
||||
state = mgr.state
|
||||
mgr = await self._post_turn_manager(session_entry, "loop completion", "loops", _load)
|
||||
state = mgr.state if mgr is not None else None
|
||||
if state is None or not state.awaiting_response:
|
||||
return
|
||||
|
||||
# The --until judge is a sync aux-LLM call — keep it off the event loop.
|
||||
decision = await asyncio.get_running_loop().run_in_executor(
|
||||
None, mgr.complete_tick, final_response or ""
|
||||
@@ -404,31 +350,24 @@ class GatewayGoalsMixin:
|
||||
)
|
||||
return
|
||||
|
||||
# Build the source + session key to check business.
|
||||
source = self._build_process_event_source({
|
||||
"session_key": "",
|
||||
"platform": platform_name,
|
||||
"chat_id": chat_id,
|
||||
"chat_type": route.get("chat_type", ""),
|
||||
"thread_id": route.get("thread_id", ""),
|
||||
"user_id": route.get("user_id", ""),
|
||||
"user_name": route.get("user_name", ""),
|
||||
**{k: route.get(k, "") for k in ("chat_type", "thread_id", "user_id", "user_name")},
|
||||
})
|
||||
if source is None:
|
||||
return
|
||||
try:
|
||||
session_key = None
|
||||
with suppress(Exception):
|
||||
session_key = self._session_key_for_source(source)
|
||||
except Exception:
|
||||
session_key = None
|
||||
if session_key and session_key in self._running_agents:
|
||||
return # busy — stays due, next scan retries
|
||||
if goal_blocks_loop_tick(sid):
|
||||
return
|
||||
|
||||
mgr = LoopManager(session_id=sid)
|
||||
if not mgr.is_due(now):
|
||||
return
|
||||
wakeup = mgr.fire_tick()
|
||||
wakeup = mgr.fire_tick() if mgr.is_due(now) else None
|
||||
if not wakeup:
|
||||
return
|
||||
try:
|
||||
@@ -448,25 +387,19 @@ class GatewayGoalsMixin:
|
||||
mgr.abandon_tick()
|
||||
|
||||
async def _loop_wakeup_watcher(self, interval: float = 15.0) -> None:
|
||||
"""Fire due /loop wakeups for idle gateway sessions.
|
||||
|
||||
The gateway has no per-session scheduler thread, so a coarse ticker scans persisted loops
|
||||
(SessionDB ``loop:*`` rows) and injects the wakeup prompt into each due session's chat
|
||||
via the same synthetic-message path used by watch notifications. Deferrals: session
|
||||
currently running a turn → skip (the FIFO would race the live turn); active non-parked
|
||||
/goal → skip (goal owns the idle boundary); no routing metadata → skip with a one-time
|
||||
warning (CLI/TUI loops carry no route).
|
||||
"""
|
||||
"""Fire due /loop wakeups for idle gateway sessions: a coarse ticker scans persisted loops
|
||||
(SessionDB ``loop:*`` rows) and injects each due prompt via the synthetic-message path.
|
||||
Deferrals: session running a turn (FIFO would race the live turn); active non-parked /goal
|
||||
(goal owns the idle boundary); no routing metadata (one-time warning)."""
|
||||
await asyncio.sleep(5) # let platforms finish connecting
|
||||
warned_no_route: set = set()
|
||||
while self._running:
|
||||
try:
|
||||
from hermes_cli.loops import list_active_loops
|
||||
|
||||
# Warm the cache off-loop once per scan: the scan reads every persisted loop, so a
|
||||
# cold cache would run the state.db init on the loop thread before the first read.
|
||||
# Warm once per scan: the scan reads every persisted loop and a cold cache would
|
||||
# run 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():
|
||||
await self._loop_wakeup_fire_one(sid, state, now, warned_no_route)
|
||||
|
||||
+244
-387
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user