Durable async_delegation rows carry a delivery ledger that every other drain consumer honors (cli_process_notifications, tui session_notifications, gateway run_notifications). Without the claim/complete handshake the row stays delivery_state='pending' and restore_undelivered_completions re-queues it on the next process start, so the next chat -Q on the resumed session injects the same delegation result twice.
94 lines
4.1 KiB
Python
94 lines
4.1 KiB
Python
"""Quiet ``hermes chat -Q`` helpers: bind this session's key and resume nested notifies.
|
|
|
|
Bot Mode delivers a local DM as ``hermes -p <bot> 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
|