Files
hermes-agent/tui_gateway/server_requests.py
teknium1 f9d178f78e fix(tui_gateway): old app builds no longer stall the agent on clarify/approval; late approval choices count
Item 2 of #112548: a Desktop/dashboard build that predates server→client
requests has no response path, so every clarify/approval/sudo/secret/vault/
connection/bridge request sat for the full deadline (clarify: 300s). Only the
tour probed. Clients now advertise once per connection
(`client.capabilities {server_requests: true}`, sent by the shared TypeScript
channel on `gateway.ready`); `send()` / `send_async()` return the
error-response shape (None) at once when every WebSocket peer of the session
is a build that never advertised. Sessions with no client attached still wait
so the reconnect replay (`open_requests`) keeps working; the stdio TUI ships
with the backend and is not gated. The advertisement is dropped on disconnect.

Reviewer minors from #113227:
- tools/approval_gateway_wait.py: the verdict is the choice committed under
  the approval lock while leaving the queue, so an /approve that lands after
  the deadline check but before the entry is dropped is an answer, not a
  timeout (the client was already acked "ok").
- tests/tui_gateway/test_protocol.py: the error-fails-fast test that only
  restated pre-existing behaviour is replaced by the two capability
  invariants (never advertised → fails fast; advertised → frame written,
  waits, forgotten on disconnect).
- server_requests.send try/finally around event.wait already landed on main
  (4371ed34a9); nothing to change.

Docs: programmatic-integration.md (advertise once per connection; method
list), tui_gateway/AGENTS.md; contracts regenerated.
2026-09-17 09:04:38 -07:00

306 lines
14 KiB
Python

"""Server→client JSON-RPC requests: the backend asks the renderer a question and waits for the
response frame carrying the same ``id``.
JSON-RPC is peer-to-peer; this is the backend's half. Every "ask the renderer" bridge (clarify,
approval, sudo, secret, vault prompts, desktop GUI reads, MCP setup consent, the tour) is one
:func:`send` (blocking) or :func:`send_async` (queue-backed approvals) and one response frame from
the client — no paired ``*.request`` notification / ``*.respond`` method, no per-kind ``*.expire``.
Ids are ``srq-<12 hex>``: strings never collide with client-minted integer ids, and the random
part keeps a compute-host child's requests distinct from the parent's when both reach one socket.
A request that times out or is cancelled (interrupt, session close, shutdown) emits ONE
``request.cancel {id, method, reason}`` notification so every renderer tears the card down the
same way. A response for an id that is no longer open is dropped — the wait already returned.
Reconnect: unanswered requests are returned as ``open_requests`` by ``session.resume`` /
``session.activate`` / ``session.events.since`` (:func:`open_requests`); the shared TypeScript
channel re-delivers them as if they had just arrived, so the notification replay ring never
has to carry "a question still waiting for an answer".
Batch clarify keeps per-question locks (``clarify.lock`` → :func:`lock_answer`): answers stay
editable until every question is locked, locked answers survive a timeout, and the last lock
resolves the request with the full answer set.
Capability: a client says once per connection that it answers server→client requests
(``client.capabilities {server_requests: true}`` → :func:`advertise`). A WebSocket client that never
did is a build older than this half of the protocol — it drops the frame silently and the agent
would wait the full deadline (clarify's 300s) for nothing — so :func:`send` / :func:`send_async`
return the same ``None`` an error response produces without writing the frame (#112548).
"""
from __future__ import annotations
import logging
import threading
import time
import uuid
from typing import Any, Callable
logger = logging.getLogger(__name__)
class ServerRequest:
__slots__ = ("id", "sid", "method", "params", "event", "result", "answered", "created_at",
"qids", "locked", "on_result")
def __init__(self, sid: str, method: str, params: dict, *, qids: list[str] | None = None,
on_result: Callable[[dict | None], None] | None = None) -> None:
self.id = f"srq-{uuid.uuid4().hex[:12]}"
self.sid = sid
self.method = method
self.params = dict(params)
self.event = threading.Event()
self.result: dict | None = None
self.answered = False
self.created_at = time.time()
# Batch clarify: question ids still to lock, and the answers locked so far.
self.qids = list(qids) if qids else None
self.locked: dict[str, str] = {}
self.on_result = on_result
def frame(self) -> dict:
return {"jsonrpc": "2.0", "id": self.id, "method": self.method,
"params": {"session_id": self.sid, **self.params}}
def snapshot(self) -> dict:
"""``open_requests`` entry: the request as sent, plus the batch answers locked so far so a
reconnecting client restores its ✓ state."""
params = {"session_id": self.sid, **self.params}
if self.locked:
params["answers"] = dict(self.locked)
return {"id": self.id, "method": self.method, "params": params}
_lock = threading.Lock()
_open: dict[str, ServerRequest] = {}
# Frame sinks, bound by ``bind_sinks`` from server.py at import time (like the method_ctx split
# modules): importing server back from here would pick a different module object under the test
# fixtures that patch ``sys.modules`` around the server import.
_write: Callable[[dict], Any] = lambda frame: None # noqa: E731
_emit: Callable[[str, str, dict], Any] = lambda event, sid, payload: None # noqa: E731
# ``answerable(sid)``: False only when every client attached to the session is a build that never
# advertised handling server→client requests (session_transports.py::_session_client_answers_requests).
_answerable: Callable[[str], bool] = lambda sid: True # noqa: E731
# Client transports that sent ``client.capabilities {server_requests: true}`` (identity set: StdioTransport
# has __slots__ and cannot be weak-referenced; ws.py forgets a peer on disconnect).
_answering_clients: set = set()
def bind_sinks(write_json: Callable[[dict], Any], emit: Callable[[str, str, dict], Any],
answerable: Callable[[str], bool]) -> None:
global _write, _emit, _answerable
_write, _emit, _answerable = write_json, emit, answerable
def advertise(transport: Any, server_requests: bool) -> None:
"""Record whether *transport*'s client answers server→client requests (``client.capabilities``)."""
with _lock:
if server_requests:
_answering_clients.add(transport)
else:
_answering_clients.discard(transport)
def forget(transport: Any) -> None:
"""Drop a disconnected transport's advertisement."""
with _lock:
_answering_clients.discard(transport)
def answers_requests(transport: Any) -> bool:
with _lock:
return transport in _answering_clients
def _unanswerable(method: str, sid: str) -> bool:
if _answerable(sid):
return False
logger.info("server request %s for %s not sent: the attached client predates server→client requests "
"(update the Hermes app)", method, sid)
return True
def _emit_cancel(req: ServerRequest, reason: str) -> None:
_emit("request.cancel", req.sid, {"id": req.id, "method": req.method, "reason": reason})
def _register(req: ServerRequest) -> None:
from tui_gateway.contracts import registry as contracts
contract = contracts.SERVER_REQUESTS.get(req.method)
if contract is None:
raise RuntimeError(f"server request {req.method!r} has no contract in tui_gateway/contracts")
_, problem = contracts.validate_params(contract, {"session_id": req.sid, **req.params})
if problem is not None:
raise ValueError(problem) # a key the renderer's typed handler would never read: our bug
with _lock:
_open[req.id] = req
_write(req.frame())
def send(method: str, sid: str, params: dict, *, timeout: float | None,
qids: list[str] | None = None) -> dict | None:
"""Send one request and block for the response ``result`` (a dict).
Returns ``None`` when the renderer never answered (timeout, cancel, or an error response — e.g.
a client without a handler for ``method``). ``timeout`` semantics: None → wait until answered or
cancelled, 0 → return immediately, > 0 → bounded wait. A batch (``qids``) that times out
returns ``{"answers": <locked so far>, "timed_out": True}`` instead of None.
"""
if _unanswerable(method, sid):
return None
req = ServerRequest(sid, method, params, qids=qids)
_register(req)
try:
req.event.wait(timeout)
except BaseException:
# The wait itself died (KeyboardInterrupt, SystemExit, injected error): withdraw the request
# or it stays in _open forever — replayed to every reconnecting client and reported by
# pending_kind() as a human still being waited on.
with _lock:
still_open = _open.pop(req.id, None) is req
if still_open:
_emit_cancel(req, "interrupted")
raise
with _lock:
# The verdict is the state committed under the lock, never wait()'s return value: a
# response frame can land after the deadline expires and before this removal, and
# settlement (resolve_response / lock_answer / cancel) already popped it (#112548).
timed_out = _open.pop(req.id, None) is req
answered, result, locked = req.answered, req.result, dict(req.locked)
if answered:
return result
if timed_out:
_emit_cancel(req, "timeout")
if req.qids is not None:
return {"answers": locked, "timed_out": True}
return None
def send_async(method: str, sid: str, params: dict, on_result: Callable[[dict | None], None]) -> Callable[[str], None]:
"""Send one request whose wait is owned elsewhere (the approval queue's own timeout). ``on_result``
runs on the dispatching thread when the response lands. Returns ``settle(reason)``: call it when
the underlying wait ends; if the request is still open it is withdrawn with ``request.cancel``."""
if _unanswerable(method, sid):
on_result(None)
return lambda reason: None
req = ServerRequest(sid, method, params, on_result=on_result)
_register(req)
def settle(reason: str) -> None:
with _lock:
still_open = _open.pop(req.id, None) is not None
if still_open:
_emit_cancel(req, reason)
return settle
def resolve_response(frame: dict) -> bool:
"""Route one client response frame to its open request. False when nothing is waiting for that id
(already timed out / cancelled, or owned by another process — see the compute-host bridge)."""
rid = frame.get("id")
if not isinstance(rid, str):
return False
with _lock:
req = _open.get(rid)
if req is None:
# Already settled (timed out, cancelled, answered from another surface) or owned by
# another process; say so — a dropped answer used to vanish without a trace.
logger.debug("server request %s: response dropped, request no longer open", rid)
return False
# Removing the request and committing its outcome are one settlement.
# ``cancel()`` also settles under this lock, so the first side to get
# here wins instead of a later cancellation overwriting a response.
_open.pop(rid, None)
if "error" in frame:
logger.debug("server request %s (%s) answered with error: %s", rid, req.method, frame.get("error"))
req.result, req.answered = None, False
else:
result = frame.get("result")
req.result = result if isinstance(result, dict) else {}
if req.qids and "answers" in req.result:
# Batch clarify: answers locked early via clarify.lock belong to the final set even when
# the closing response only carries the tail the user answered last.
answers = req.result.get("answers")
merged = dict(req.locked)
if isinstance(answers, dict):
merged.update(answers)
req.result = {**req.result, "answers": merged}
req.answered = True
if req.on_result is not None:
req.on_result(req.result)
req.event.set()
return True
def lock_answer(request_id: str, question_id: str, answer: str) -> list[str] | None:
"""Lock one batch-clarify answer (update-in-place). Returns the question ids still unanswered;
the last lock resolves the request with the full ``{"answers"}`` set. ``None`` when no open
batch has that id (expired or foreign); ``ValueError`` for an unknown question id."""
with _lock:
req = _open.get(request_id)
if req is None or req.qids is None:
return None
if question_id not in req.qids:
raise ValueError(f"unknown question_id {question_id!r}")
req.locked[question_id] = answer
remaining = [qid for qid in req.qids if qid not in req.locked]
if not remaining:
req.result, req.answered = {"answers": dict(req.locked)}, True
_open.pop(request_id, None)
if not remaining:
req.event.set()
return remaining
def cancel(sid: str | None = None, reason: str = "interrupted") -> int:
"""Withdraw open requests — only *sid*'s (session.interrupt must not touch other sessions'), or
every one when *sid* is None (shutdown). Blocked waits return None; queue-backed requests run
``on_result(None)`` so their owner can settle. Returns the number withdrawn."""
with _lock:
targets = [req for req in _open.values() if sid is None or req.sid == sid]
for req in targets:
_open.pop(req.id, None)
req.result, req.answered = None, False
for req in targets:
if req.on_result is not None:
req.on_result(None)
req.event.set()
_emit_cancel(req, reason)
return len(targets)
def open_requests(sid: str) -> list[dict]:
"""Unanswered requests for *sid*, oldest first."""
with _lock:
reqs = sorted((req for req in _open.values() if req.sid == sid), key=lambda r: r.created_at)
return [req.snapshot() for req in reqs]
def open_request_count() -> int:
"""Unanswered server→client requests across every session: the process is waiting on a
human (clarify, approval, sudo, secret, ...) and must not be treated as idle."""
with _lock:
return len(_open)
def pending_kind(sid: str) -> str:
"""Method of the oldest open request for *sid* ("" when none) — the session is waiting on a human."""
with _lock:
reqs = [req for req in _open.values() if req.sid == sid]
return min(reqs, key=lambda r: r.created_at).method if reqs else ""
def is_response_frame(obj: Any) -> bool:
"""A client response: has an ``id`` and a ``result``/``error`` member but no ``method``."""
return isinstance(obj, dict) and "method" not in obj and "id" in obj and ("result" in obj or "error" in obj)
def reset_for_tests() -> None:
with _lock:
_open.clear()
_answering_clients.clear()