Files
kshitijk4poor 7303b01fd6 fix(cli): a request after the gateway socket closed is reported as not sent
When the keepalive times out, the socket closes and GatewayClient's reader
ends before the dock's next poll. websockets refuses any later send on that
socket without writing it, so the frame never leaves. GatewayClient.rpc now
raises NOT_SENT once the reader has ended, instead of DISCONNECTED ("turn
outcome is unknown; do not resend"). A prompt.submit that hits this path
tells the user nothing was sent, so they know to send it again. A send that
fails while the reader is still alive stays DISCONNECTED, because the frame
may already have left.

NOT_SENT moves next to DISCONNECTED in gateway_client; gateway_connection
imports it, so gateway_queue's `str(exc) == NOT_SENT` check is unchanged.
2026-10-29 16:30:39 +05:30

498 lines
26 KiB
Python

"""One gateway connection per classic-CLI process (CALLMAP §3.1, §4).
A daemon thread owns an asyncio loop and one ``GatewayClient``. It never calls a HermesCLI
method and never blocks: every event frame goes into a thread-safe ``queue.Queue`` that the
agent thread drains (``GatewayAgent.run_conversation``), and ``session.info``-class frames
also update the plain-attribute ``SessionState`` caches the status bar reads.
Callers on any other thread use ``call()``, which blocks on the loop's future; calling it on
the loop thread itself is a deadlock and raises.
"""
from __future__ import annotations
import asyncio
import logging
import queue
import threading
import time
from hermes_cli.gateway_client import (DEFAULT_RPC_TIMEOUT, DISCONNECTED, NOT_SENT, RESUME_KEY_MISMATCH,
GatewayClientError, GatewayRPCError)
logger = logging.getLogger(__name__)
# ``session.resume`` refusals that mean "this viewer may not open that session": the caller keeps the
# session it is on instead of adopting the target (startup resume, bare -c fallback).
RESUME_REFUSALS = frozenset({"not_found", "permission_denied"})
# Resume refusals meaning the session itself is gone for this viewer (gateway/session_controls): the
# above plus profile_mismatch, raised for another profile's session (reconnect, /resume switch).
RESUME_GONE = RESUME_REFUSALS | {"profile_mismatch"}
# Keyed-resume refusals a plain resume answers as main did: the key does not apply to this session
# (``rebind_launch_key``: keyless or non-local, another key held, another fingerprint), or the viewer
# lacks the ``session:control`` a rebind needs but may still read. Any other refusal is the resume's.
_KEY_NOT_TAKEN = frozenset({"admission_conflict", "permission_denied"})
class CallStopped(GatewayClientError):
"""The caller stopped waiting (:meth:`GatewayConnection.call_until`); the request was sent."""
def _resume_plain(exc, session_id, typed):
"""A keyed ``session.resume`` the owner refused with ``exc``: returns when the caller resumes plain,
else raises. Only a refusal in ``_KEY_NOT_TAKEN`` resumes plain (a keyed startup alias on a Desktop/TUI
session); any other is raised, not hidden by a keyless attach whose first turn fails. ``typed`` (a typed
``--api-key`` the user resumes with) never resumes plain: ``admission_conflict`` is the thin client's
refusal."""
if typed and str(exc) == "admission_conflict":
raise GatewayClientError(RESUME_KEY_MISMATCH) from None
if typed or str(exc) not in _KEY_NOT_TAKEN:
raise exc
logger.debug("session.resume %s: launch key not taken (%s); resuming without it", session_id, exc)
def lost_before(frame, epoch) -> bool:
"""``frame`` is a transport error from a connection older than ``epoch``: a reconnect replaced it,
so it is no outcome of anything submitted on ``epoch``."""
lost = getattr(frame, "connection_epoch", None)
return isinstance(frame, Exception) and lost is not None and epoch is not None and lost < epoch
class SessionState:
"""What the gateway last told this viewer about one session (plain attributes, GIL-atomic
reads from the prompt_toolkit render thread; never an RPC per paint)."""
def __init__(self, session_id):
self.session_id = session_id
self.info: dict = {}
self.title: str | None = None
self.todo_state: dict | None = None # the session's {todos, revision}: resume snapshot, then todo.updated
self.revision = None
self.subscription_id = None
self.execution_generation = None
self.messages: list = []
self.usage: dict = {}
# Called (loop thread) with each ``session.usage`` payload; GatewayAgent maps it onto main's
# agent attributes, so a frame landing between turns is never dropped with the turn queue.
self.usage_listeners: list = []
# Called (loop thread) with each ``request.cancel`` payload: the agent thread may be parked in a
# modal answering that very request (another viewer settled it), so it cannot read the queue.
self.request_listeners: list = []
# A gateway-admitted notification turn the idle CLI queued a header for (gateway_tools_notify).
self.admitted_turn: dict | None = None
# The owner's pending admissions as last published (snapshot or ``session.info``), and the
# ``/queue`` inputs this viewer admitted there (``admission_id -> text``), so the turn the owner
# runs for one is painted like a typed turn (``gateway_tools_notify.queue_admitted_turn``).
self.pending: list = []
self.queued_inputs: dict = {}
# Started ``/queue`` admissions waiting for their turn (``admission_id -> receipt-like``), and the
# one whose item the input loop just took (``gateway_tools_notify.arm_adopted``).
self.adopted: dict = {}
self.armed_admission: str | None = None
# Each pending list the owner published (or a snapshot carried) bumps ``pending_seq``; a
# ``/queue`` admission whose ``message.start`` this viewer dropped unpainted (a local turn's
# drain or pump) is in ``missed``. Both make the idle tick read its receipt (``recover_queued``).
self.pending_seq = 0
# ``admission_id -> pending_seq`` when this viewer queued it: the dock shows it only until the
# owner publishes a list after that (``gateway_queue.published_queue``).
self.queued_at: dict = {}
self.recovered_seq = 0
self.missed: set = set()
# ``session.replay_gap``s seen, and how many a re-attach covered: the owner retired this viewer's
# subscription on overflow, so its frames stop until ``GatewayConnection.reattach``.
self.gaps = self.attached_gaps = 0
def adopt_snapshot(self, snapshot):
self.info = dict(snapshot.get("info") or {})
self.revision = snapshot.get("revision", self.revision)
self.subscription_id = snapshot.get("subscription_id", self.subscription_id)
self.execution_generation = snapshot.get("execution_generation", self.execution_generation)
self.messages = list(snapshot.get("messages") or [])
if "todo_state" in snapshot: # null clears an earlier plan; a missing key (older gateway) keeps it
todo_state = snapshot["todo_state"]
self.todo_state = dict(todo_state) if isinstance(todo_state, dict) else None
if isinstance(snapshot.get("pending"), list):
self.pending = list(snapshot["pending"])
self.pending_seq += 1 # a (re)attach replays no frames: the idle tick checks this viewer's /queue rows
if self.info.get("title"):
self.title = self.info["title"]
def runs_route(self, model, provider=""):
"""Whether the session runs ``model`` (and ``provider``, when both sides name one)."""
current, current_provider = self.info.get("model"), self.info.get("provider")
return bool(current) and model == current and not (
provider and current_provider and provider != current_provider)
def apply_event(self, kind, payload):
if kind in ("session.info", "session.updated") and isinstance(payload, dict):
self.info.update({k: v for k, v in payload.items() if k != "session_id"})
if isinstance(payload.get("pending"), list):
self.pending = list(payload["pending"])
self.pending_seq += 1
if payload.get("title"):
self.title = payload["title"]
elif kind == "todo.updated" and isinstance(payload, dict):
self.todo_state = {"todos": list(payload.get("todos") or []), "revision": payload.get("revision")}
elif kind == "session.usage" and isinstance(payload, dict):
# The gateway's frame carries the flat counters next to a nested TUI ``usage`` view this
# reader can't map (``session_usage.usage_frame``): the counters win when present.
nested = payload.get("usage")
flat = isinstance(payload.get("api_calls"), int) or not isinstance(nested, dict)
self.apply_usage(payload if flat else nested)
elif kind == "request.cancel" and isinstance(payload, dict):
for listener in list(self.request_listeners):
listener(payload)
elif kind == "session.replay_gap":
self.gaps += 1
def apply_usage(self, usage):
"""One session-cumulative usage dict (``session.usage`` payload or ``session.stats`` result)."""
if not isinstance(usage, dict):
return
self.usage = dict(usage)
for listener in list(self.usage_listeners):
listener(self.usage)
class GatewayConnection:
_shared = None
_shared_lock = threading.Lock()
def __init__(self, connect=None):
self._connect = connect
self.events: queue.Queue = queue.Queue()
self.states: dict[str, SessionState] = {}
self.client = None
# (client, epoch) of the open socket, swapped as one: a caller never pairs a socket with
# another life's epoch (``call_tagged``).
self._live = None
self.loop = None
self._thread = None
self._ready = threading.Event()
self._error = None
self._stop = None
self._start_lock = threading.Lock()
self.closed = False
# close() ends the viewer; a gateway hang-up (restart, idle exit, crash) lets the next call
# dial again. Each connect bumps ``epoch``, and its loss is tagged with it (``lost_before``).
self._detached = False
self.epoch = 0
# /bg and /btw answers by task id (hermes_cli/gateway_side.py); created by whichever of the
# waiter or the frame comes first, so an answer faster than the verb's reply is kept.
self._side: dict[str, queue.Queue] = {}
self._side_lock = threading.Lock()
# The wake events of :meth:`call_until` commands in flight, each on its own socket.
self._commands: set[threading.Event] = set()
# This CLI's launch-only key (``--api-key`` or a keyed startup alias), set by the session store:
# an owner restart revokes it, so every resume re-supplies it (the owner's ``rebind_launch_key``).
self.launch_api_key = None
# True when that key is the typed ``--api-key`` (not a keyed startup alias's): a startup resume the
# owner will not bind it to is refused as the thin client refuses it, never attached plain.
self.launch_api_key_typed = False
@classmethod
def shared(cls):
with cls._shared_lock:
if cls._shared is None or cls._shared.final:
cls._shared = cls()
return cls._shared
@property
def final(self) -> bool:
"""No call dials again: the viewer closed it, or its first connect failed. ``closed`` is also
true while the gateway is hung up, which the next ``call()``/``start()`` redials."""
return self._detached or (self._error is not None and not self.epoch)
def state(self, session_id) -> SessionState:
state = self.states.get(session_id)
if state is None:
state = self.states[session_id] = SessionState(session_id)
return state
# -- lifecycle ---------------------------------------------------------------------------
def start(self, timeout=90):
deadline = time.monotonic() + timeout
while True:
with self._start_lock:
if self.final:
if self._error is not None:
raise self._error
return
thread = self._thread
if thread is not None and self.closed and thread.is_alive() and thread is not threading.current_thread():
# ``_run`` reports ready before its thread returns: that life is over, so let it
# end instead of reading it as a dial in progress (and re-raising its stale error).
thread.join(max(deadline - time.monotonic(), 0))
if thread is None or not thread.is_alive():
from agent.memory_provider import spawn_context_thread
if thread is not None:
# The gateway hung up, or the last redial failed: dial again (``connect_gateway``
# starts a gateway when none is running) and resubscribe this viewer's sessions.
self._error = None
self._ready.clear()
self.closed = False
thread = self._thread = spawn_context_thread(self._run, name="hermes-gateway-loop")
thread.start()
# Every caller waits out a dial in progress, its own or another thread's.
if not self._ready.wait(max(deadline - time.monotonic(), 0)):
raise GatewayClientError("Gateway connection timed out; no local fallback")
if self._error is not None:
raise self._error
if self.client is not None or self.final:
return
# Hung up right after that dial: let its loop end, then dial again.
thread.join(max(deadline - time.monotonic(), 0))
if time.monotonic() >= deadline:
raise GatewayClientError("Gateway connection timed out; no local fallback")
def _run(self):
try:
asyncio.run(self._main())
except BaseException as exc: # health: allow BLE001 -- the loop thread reports every failure to the waiting caller
if self._error is None:
self._error = exc if isinstance(exc, GatewayClientError) else GatewayClientError(
"Gateway connection failed; no local fallback")
finally:
self.closed = True
self._ready.set()
async def _main(self):
self.loop = asyncio.get_running_loop()
self._stop = asyncio.Event()
connect = self._connect
if connect is None:
from hermes_cli.gateway_client import connect_gateway
connect = connect_gateway
try:
async with connect() as client:
if self.epoch:
await self._resubscribe(client)
self.epoch += 1
self.client = client
self._live = (client, self.epoch)
self._ready.set()
pump = asyncio.create_task(self._pump(client))
stop = asyncio.create_task(self._stop.wait())
await asyncio.wait({pump, stop}, return_when=asyncio.FIRST_COMPLETED)
pump.cancel()
stop.cancel()
except Exception as exc: # health: allow BLE001 -- a transport failure becomes the connect error or the disconnect frame
if not self._ready.is_set():
self._error = exc if isinstance(exc, GatewayClientError) else GatewayClientError(
f"Gateway connection failed; no local fallback ({type(exc).__name__})")
else:
self._put_loss(GatewayClientError(DISCONNECTED))
finally:
self.client = None
self._live = None
def _put_loss(self, error):
error.connection_epoch = self.epoch
self.events.put(error)
async def _resubscribe(self, client):
"""A new socket is a new viewer: the gateway streams a session's frames only to connections
that resumed it. Resume each session this viewer had open; one gone meanwhile is dropped. Any
other failure fails the reconnect: ready without the subscription, every prompt is refused on a
live socket that never redials, while a failed redial is retried by the next call."""
for session_id, state in list(self.states.items()):
if state.subscription_id is None:
continue
try:
state.adopt_snapshot(await self._resume(client, session_id))
except GatewayClientError as exc:
if str(exc) not in RESUME_GONE:
raise
logger.debug("session.resume %s after reconnect: %s", session_id, exc)
state.subscription_id = None
def resume(self, call, session_id, *, typed=False):
"""``session.resume`` through ``call`` (the store's attach), re-supplying :attr:`launch_api_key`;
a refusal of the keyed resume is :func:`_resume_plain`'s to decide. ``typed``: the user resumes with
a typed ``--api-key``."""
key = self.launch_api_key
if key:
try:
return call("session.resume", session_id=session_id, api_key=key)
except GatewayRPCError as exc:
_resume_plain(exc, session_id, typed)
return call("session.resume", session_id=session_id)
async def _resume(self, client, session_id):
""":meth:`resume` for :meth:`_resubscribe`, awaited on this loop so a cancelled reconnect cancels
its request. A reconnect types nothing: never ``typed``."""
key = self.launch_api_key
if key:
try:
return await client.rpc("session.resume", session_id=session_id, api_key=key)
except GatewayRPCError as exc:
_resume_plain(exc, session_id, typed=False)
return await client.rpc("session.resume", session_id=session_id)
async def _pump(self, client):
while True:
frame = await client.events.get()
if isinstance(frame, Exception):
self._put_loss(frame)
return
params = frame.get("params") if isinstance(frame, dict) else None
if isinstance(params, dict) and self._take_side(params):
continue
if isinstance(params, dict):
sid = params.get("session_id")
if sid in self.states:
try:
self.states[sid].apply_event(params.get("type"), params.get("payload"))
except Exception: # pragma: no cover - a cache update must never stop the pump
logger.debug("session state update failed", exc_info=True)
self.events.put(frame)
def _take_side(self, params):
from hermes_cli.gateway_side import SIDE_EVENTS
payload = params.get("payload")
if params.get("type") not in SIDE_EVENTS or not isinstance(payload, dict) or not payload.get("task_id"):
return False
self.side_waiter(payload["task_id"]).put(payload)
return True
def side_waiter(self, task_id) -> queue.Queue:
with self._side_lock:
return self._side.setdefault(task_id, queue.Queue())
def drop_side_waiter(self, task_id):
with self._side_lock:
self._side.pop(task_id, None)
def close(self):
self._detached = True
with self._side_lock:
for wake in self._commands: # their sockets close too
wake.set()
loop, stop = self.loop, self._stop
if loop is not None and stop is not None and not self.closed:
try:
loop.call_soon_threadsafe(stop.set)
except RuntimeError: # the loop already ended (the gateway hung up first)
pass
if self._thread is not None and self._thread is not threading.current_thread():
self._thread.join(5)
self.closed = True
# -- requests ----------------------------------------------------------------------------
def reply(self, request_id, result):
"""Answer one of the gateway's server→client requests (the ``sudo`` prompt): a JSON-RPC
response frame with the request's own id. Never raises; a lost reply times out gateway-side."""
import json
client, loop = self.client, self.loop
if client is None or loop is None:
return False
frame = json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result})
try:
asyncio.run_coroutine_threadsafe(client.websocket.send(frame), loop).result(10)
except Exception as exc: # health: allow BLE001 -- the gateway's own wait bounds a lost answer
logger.debug("server request reply failed: %s", exc)
return False
return True
def call(self, method, *, timeout=None, abandon=None, **params):
return self.call_tagged(method, timeout=timeout, abandon=abandon, **params)[0]
def reattach(self, session_id, abandon=None):
"""``session.resume`` after a replay gap; its snapshot names the subscription to detach."""
state = self.state(session_id)
gaps = state.gaps
snapshot = self.call("session.resume", session_id=session_id, abandon=abandon)
state.adopt_snapshot(snapshot)
state.attached_gaps = gaps # a gap during this resume (or a wait given up) still owes one
return snapshot
def _send(self, method, budget, params):
"""``(future, epoch)`` of one request sent on the live socket (see :meth:`call_tagged`)."""
if self._thread is not None and threading.current_thread() is self._thread:
raise RuntimeError("GatewayConnection.call() on the gateway loop thread would deadlock")
self.start()
live, loop = self._live, self.loop
if live is None or loop is None:
raise GatewayClientError(NOT_SENT)
client, epoch = live
return asyncio.run_coroutine_threadsafe(client.rpc(method, _timeout=budget, **params), loop), epoch
def call_until(self, method, wake, **params):
"""``call`` with no answer deadline, for a verb that lasts as long as the work it runs (a plugin
command's handler), on a socket of its own to this connection's gateway: the gateway answers a
socket's requests one at a time, in arrival order (``tui_gateway/ws.py``), so a handler still
running never holds this connection's reads, prompts and interrupts. One dial and one send,
never retried. Waits for the answer or the transport's error, unless *wake* (a
``threading.Event``, set by Ctrl+C or :meth:`close`) is set first: then raises
:class:`CallStopped`; the request was sent and its work goes on, but its answer is dropped. The
socket closes on any of these, and its thread is gone before this returns."""
import concurrent.futures
from agent.memory_provider import spawn_context_thread
if self._thread is not None and threading.current_thread() is self._thread:
raise RuntimeError("GatewayConnection.call_until() on the gateway loop thread would deadlock")
self.start() # this viewer's gateway, dialled (or redialled after a hang-up) as for any call
if self.final:
raise GatewayClientError(NOT_SENT)
answer, sent = concurrent.futures.Future(), threading.Event()
answer.add_done_callback(lambda _done: wake.set())
thread = spawn_context_thread(lambda: asyncio.run(self._command(method, params, wake, answer, sent)),
name="hermes-gateway-command")
with self._side_lock:
self._commands.add(wake)
thread.start()
try:
wake.wait()
finally:
wake.set() # also on KeyboardInterrupt: the socket closes
thread.join(10)
with self._side_lock:
self._commands.discard(wake)
if answer.done():
return answer.result()
if sent.is_set():
raise CallStopped(method)
raise GatewayClientError(NOT_SENT)
async def _command(self, method, params, wake, answer, sent):
"""The command socket of :meth:`call_until`: dial, send once, settle ``answer`` with the reply or
the transport's error; a set ``wake`` without an answer cancels the dial or the wait."""
connect = self._connect
if connect is None:
from hermes_cli.gateway_client import connect_gateway
connect = connect_gateway
task = asyncio.current_task()
stop = asyncio.ensure_future(asyncio.to_thread(wake.wait))
stop.add_done_callback(lambda _stop: answer.done() or task.cancel())
try:
async with connect() as client:
sent.set() # from here the request may have reached the gateway: never sent again
answer.set_result(await client.rpc(method, _timeout=float("inf"), **params))
except asyncio.CancelledError:
pass
except Exception as exc: # health: allow BLE001 -- every transport failure is the caller's error
if not isinstance(exc, GatewayClientError):
exc = GatewayClientError(DISCONNECTED if sent.is_set() else NOT_SENT)
answer.set_exception(exc)
finally:
wake.set() # ends the watcher above
await asyncio.gather(stop, return_exceptions=True)
def call_tagged(self, method, *, timeout=None, abandon=None, **params):
"""``(result, epoch)``: the epoch of the socket that carried the request, so work it admitted
is tied to that gateway life even if another caller redials before this one reads ``epoch``.
``abandon`` (polled) true stops the wait with :class:`CallStopped`; the answer is dropped."""
from concurrent.futures import wait
budget = DEFAULT_RPC_TIMEOUT if timeout is None else timeout
if abandon is not None and abandon():
raise CallStopped(method)
future, epoch = self._send(method, budget, params)
deadline = time.monotonic() + budget + 5
try:
while abandon is not None and not wait([future], 0.1).done and time.monotonic() < deadline:
if abandon():
raise CallStopped(method)
return future.result(max(deadline - time.monotonic(), 0)), epoch
except TimeoutError as exc:
future.cancel()
raise GatewayClientError(f"Gateway did not answer {method}") from exc