Files
hermes-agent/tui_gateway/rpc_dispatch.py
Austin Pickett e33f1a0faf fix(desktop): refuse window-owned pane requests at once when no window shows the session
preview.read / preview.act / terminal.read / window.read / tour are answered
only by the Desktop window that shows the requesting session (#114981). When
no attached window showed it, every window stayed silent and the agent waited
out the full 30-45s bridge deadline, then got the generic "No preview tab is
open, or the read timed out."

A window not hosting the session now declines with JSON-RPC error 4404
instead of staying silent. The gateway counts a decline as that client's vote,
not as the answer: the request stays open for the owner window and settles
only once every attached answering client declined, with a distinct refusal
("No Hermes Desktop window is showing this chat ... Ask the user to open this
chat in the Desktop app, then retry."). A bystander window therefore still
cannot beat the owner (#113348).

The backend advertises the counting via client.capabilities
(declines_not_shown); the shared channel only sends a decline to a backend
that did, so a new app against an older backend keeps today's silence.

Fixes #119333
2026-09-28 21:04:47 -04:00

95 lines
4.2 KiB
Python

"""JSON-RPC admission and worker dispatch. Rebound onto the server namespace."""
from __future__ import annotations
from .method_ctx import bind_module
def handle_request(req: dict) -> dict | None:
from hermes_cli.backend_retirement import retirement
with retirement.work() as admitted:
if not admitted:
return _err(req.get("id"), 5035, "backend is retiring; reconnect to continue")
return _handle_admitted_request(req)
def _handle_admitted_request(req: dict) -> dict | None:
normalized = _normalize_request(req)
if isinstance(normalized, dict):
return normalized
rid, method, params = normalized
if not (fn := _methods.get(method)):
return _err(rid, -32601, f"unknown method: {method} — the client and the Hermes backend are out of sync "
"(different versions); run `hermes update` and restart both")
# Test doubles register straight into ``_methods`` without a contract; every production
# handler comes through ``register_method`` and therefore has one.
contract = _contracts.METHODS.get(method)
if contract is not None:
params, problem = _contracts.validate_params(contract, params)
if problem is not None:
return _err(rid, 4000, problem)
token = _current_rpc_method.set(method)
try:
response = fn(rid, params)
except ProfileUnavailableError as exc:
return _err(rid, 4064, str(exc))
finally:
_current_rpc_method.reset(token)
if contract is not None and isinstance(response, dict) and isinstance(response.get("result"), dict):
_contracts.check_params_accepted(contract, params)
_contracts.check_result(contract, response["result"])
return response
def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
"""Route inbound RPCs — long handlers to the pool (returns None; the worker writes its own
response via the bound transport), everything else inline (returns the response dict).
*transport* pins every write of this request — events included — to that transport;
omitted → the module stdio transport (``tui_gateway.entry`` behaviour)."""
t = transport or _stdio_transport
token = bind_transport(t)
try:
from tui_gateway import server_requests
if server_requests.is_response_frame(req):
# The renderer answering one of OUR requests (clarify, approval, …): no response frame goes back.
if not server_requests.resolve_response(req, t) and not _relay_compute_host_response(req):
logger.debug("dropping response for unknown server request id=%r", req.get("id"))
return None
normalized = _normalize_request(req)
if isinstance(normalized, dict):
return normalized
if normalized[1] not in _LONG_HANDLERS:
return handle_request(req)
from hermes_cli.backend_retirement import retirement
# Reserve BEFORE enqueueing: a queued handler has accepted work even though no worker runs yet.
if not retirement.acquire():
return _err(req.get("id"), 5035, "backend is retiring; reconnect to continue")
try:
ctx = contextvars.copy_context() # the pool worker must see the bound transport
owner = normalized[2].get("owner")
if normalized[1] in _CONNECTOR_RPC_METHODS and isinstance(owner, dict) and owner.get("type") == "session":
ctx.run(_capture_connector_rpc_owner, normalized[2])
def run():
try:
resp = _handle_admitted_request(req)
except Exception as exc:
resp = _err(req.get("id"), -32000, f"handler error: {exc}")
if resp is not None:
t.write(resp)
future = _pool.submit(lambda: ctx.run(run))
except BaseException:
retirement.release()
raise
# Also releases cancelled queued futures; the worker's own finally would never execute.
future.add_done_callback(lambda _: retirement.release())
return None
finally:
reset_transport(token)
def register(server):
bind_module(globals(), server)