"""Quiet ``hermes chat -Q`` helpers: bind this session's key and resume nested notifies. Bot Mode delivers a local DM as ``hermes -p chat -Q --query-file``. Interactive chat binds ``set_current_session_key(self.session_id)`` around the turn; the quiet path did not, so a nested ``message_agent`` notify inherited the dispatcher's ``HERMES_SESSION_KEY`` and never woke the recipient. Quiet also printed and exited after one turn, so a nested teammate reply that finished during the one-shot linger was never injected as a follow-up. """ from __future__ import annotations import contextlib import time from typing import Any, Callable # Nested A→B→C is one extra turn; this caps a runaway message_agent chain. _MAX_QUIET_NOTIFY_ROUNDS = 8 @contextlib.contextmanager def bind_quiet_session_key(session_id: str): """Bind the approval/session key to *this* quiet session for the enclosing ``with`` block.""" from tools.approval_context import reset_current_session_key, set_current_session_key token = set_current_session_key(session_id or "default") try: yield finally: reset_current_session_key(token) def quiet_notify_linger_seconds() -> float: """Total linger budget for one quiet run: the shared ``terminal.oneshot_completion_wait_seconds``. One budget covers the drain loop here AND the later ``_wait_for_oneshot_background_completions`` pass, so a stuck ``notify_on_complete`` child cannot stack round-after-round waits on top of the finalize re-wait (pre-fix worst case: 8 rounds x 600s + 600s). """ from tools.process_registry import ProcessRegistry return ProcessRegistry._oneshot_completion_wait_seconds() def continue_quiet_notify_completions( session_id: str, run_turn: Callable[[str], Any], *, owns_event=None, max_rounds: int = _MAX_QUIET_NOTIFY_ROUNDS, linger_budget: float | None = None, ) -> Any: """Linger for ``notify_on_complete`` work, then run owned completion texts as follow-up turns. Returns the last ``run_turn`` result, or ``None`` when nothing owned completed. The whole loop shares ONE linger budget (default: ``terminal.oneshot_completion_wait_seconds``) — a process that times out is waited on no further this run: after the current round's drained texts run, the loop stops (the finalize linger still covers it once, bounded, via the budget handshake below). """ from tools.process_registry import process_registry from tools.async_delegation import claim_event_delivery, complete_event_delivery last: Any = None key = session_id or "" if linger_budget is None: linger_budget = quiet_notify_linger_seconds() deadline = time.monotonic() + max(float(linger_budget), 0.0) for _ in range(max_rounds): wait = process_registry.wait_for_pending_completions(None, timeout=max(deadline - time.monotonic(), 0.0)) drained = [] for event, text in process_registry.drain_notifications(session_key=key, owns_event=owns_event): # Durable async_delegation events carry a delivery ledger: without the # claim/complete handshake the row stays delivery_state='pending' and # restore_undelivered_completions re-queues it on the next process start, # injecting the same result twice. Same contract as every other drain consumer. claim = claim_event_delivery(event, "cli-quiet") if claim is None: continue complete_event_delivery(event, claim) drained.append((event, text)) # Every drained event type carries formatted text (completions, watch matches, # async_delegation results): drain_notifications POPS owned events off the queue, # so filtering by type here would consume-and-silently-drop owned # async_delegation results. Keep everything that rendered. texts = [text for _event, text in drained if text] if texts: last = run_turn("\n\n".join(texts)) if wait.get("timed_out"): break if not texts: return last return last