Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""Shared spurious stdin-EOF recovery for the TUI gateway entry point and slash worker.
|
||||
|
||||
When a child process inherits fd 0 (stdin) and sets ``O_NONBLOCK``, the flag
|
||||
lands on the **shared open file description** — not just the child's descriptor.
|
||||
The gateway's next ``read()`` returns ``EAGAIN``, which CPython's buffered
|
||||
``TextIOWrapper`` converts to ``b''`` (apparent EOF), killing the gateway.
|
||||
|
||||
This module provides:
|
||||
- :func:`diagnose_stdin_state` — forensic diagnostic (``O_NONBLOCK`` / ``SO_RCVTIMEO``)
|
||||
- :func:`handle_spurious_eof` — check whether an empty ``readline()`` is a genuine
|
||||
peer-close or a spurious EOF, and recover if spurious.
|
||||
|
||||
The recovery is **POSIX-only** (``fcntl``). On Windows, ``O_NONBLOCK`` on a
|
||||
shared file description is not a concern, so the guard simply reports a
|
||||
genuine EOF and lets the caller exit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
try:
|
||||
import fcntl as _fcntl
|
||||
_HAS_FCNTL = True
|
||||
except ImportError:
|
||||
_fcntl = None # type: ignore[assignment]
|
||||
_HAS_FCNTL = False
|
||||
|
||||
try:
|
||||
import socket as _socket
|
||||
_HAS_SOCKET = True
|
||||
except ImportError:
|
||||
_socket = None # type: ignore[assignment]
|
||||
_HAS_SOCKET = False
|
||||
|
||||
import struct
|
||||
|
||||
|
||||
# Rate-limit: at most this many spurious-EOF recoveries per 60-second window.
|
||||
# A child aggressively flipping ``O_NONBLOCK`` on the shared fd would otherwise
|
||||
# create a tight busy-loop burning CPU. Exceeding the cap exits the process —
|
||||
# the parent (TUI / gateway) respawns it with fresh state, which is safer than
|
||||
# fighting forever.
|
||||
MAX_RECOVERIES_PER_MINUTE = 10
|
||||
|
||||
|
||||
def diagnose_stdin_state() -> str:
|
||||
"""Return a diagnostic string about stdin's current state.
|
||||
|
||||
Used for crash-log forensics when stdin iteration falls through.
|
||||
Distinguishes genuine peer-close (flag clear) from spurious EOF
|
||||
caused by a child setting ``O_NONBLOCK`` on the shared file description.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if _HAS_FCNTL and _fcntl is not None:
|
||||
try:
|
||||
flags = _fcntl.fcntl(0, _fcntl.F_GETFL)
|
||||
parts.append(f"O_NONBLOCK={'1' if flags & os.O_NONBLOCK else '0'}")
|
||||
except Exception as e:
|
||||
parts.append(f"F_GETFL error: {e}")
|
||||
else:
|
||||
parts.append("O_NONBLOCK=n/a (no fcntl)")
|
||||
# ``SO_RCVTIMEO`` is a socket option (not a file-status flag), equally
|
||||
# shared on the open file description. A child setting it via
|
||||
# ``setsockopt`` launders into the same spurious-EOF path with
|
||||
# ``O_NONBLOCK`` clear, so we report it alongside the flag.
|
||||
if _HAS_SOCKET and _socket is not None:
|
||||
try:
|
||||
s = _socket.fromfd(0, _socket.AF_UNIX, _socket.SOCK_STREAM)
|
||||
try:
|
||||
tv = s.getsockopt(_socket.SOL_SOCKET, _socket.SO_RCVTIMEO)
|
||||
parts.append(f"SO_RCVTIMEO={tv!r}")
|
||||
finally:
|
||||
# ``fromfd`` duped the fd; ``close`` releases the dup without
|
||||
# touching the original fd 0.
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
return ", ".join(parts) if parts else "unknown"
|
||||
|
||||
|
||||
def handle_spurious_eof(
|
||||
recovery_times: list[float],
|
||||
log_fn: object,
|
||||
) -> bool:
|
||||
"""Check whether an empty ``readline()`` is spurious; recover if so.
|
||||
|
||||
Returns ``True`` if the caller should ``continue`` the read loop
|
||||
(spurious EOF was recovered), ``False`` if it should ``break`` (genuine
|
||||
peer-close or rate limit exceeded).
|
||||
|
||||
``log_fn`` is called with a diagnostic string — ``_log_exit`` in
|
||||
``entry.py``, ``print(file=sys.stderr)`` in ``slash_worker.py``.
|
||||
"""
|
||||
# Without ``fcntl`` (Windows) we can't check the flag, and the
|
||||
# ``O_NONBLOCK`` shared-description issue is POSIX-specific anyway —
|
||||
# treat it as a genuine EOF.
|
||||
if not (_HAS_FCNTL and _fcntl is not None):
|
||||
log_fn("stdin EOF (peer closed)") # type: ignore[operator]
|
||||
return False
|
||||
|
||||
try:
|
||||
flags = _fcntl.fcntl(0, _fcntl.F_GETFL)
|
||||
is_nonblock = bool(flags & os.O_NONBLOCK)
|
||||
except Exception:
|
||||
is_nonblock = False
|
||||
|
||||
if not is_nonblock:
|
||||
# Genuine peer-close — no subprocess flag tampering detected.
|
||||
log_fn("stdin EOF (peer closed)") # type: ignore[operator]
|
||||
return False
|
||||
|
||||
# Spurious EOF: a child set ``O_NONBLOCK`` (and/or ``SO_RCVTIMEO``) on
|
||||
# the shared file description, laundered into ``b''`` / ``EAGAIN`` by
|
||||
# CPython's buffered layer. Restore blocking mode and resume.
|
||||
now = time.time()
|
||||
recovery_times.append(now)
|
||||
recovery_times[:] = [t for t in recovery_times if t > now - 60]
|
||||
if len(recovery_times) > MAX_RECOVERIES_PER_MINUTE:
|
||||
log_fn( # type: ignore[operator]
|
||||
f"stdin spurious-EOF recovery rate exceeded "
|
||||
f"({len(recovery_times)}/min, cap {MAX_RECOVERIES_PER_MINUTE})"
|
||||
)
|
||||
return False
|
||||
|
||||
diag = diagnose_stdin_state()
|
||||
log_fn(f"stdin spurious EOF (subprocess O_NONBLOCK flip), recovering: {diag}") # type: ignore[operator]
|
||||
|
||||
# Clear ``O_NONBLOCK`` on the shared file description.
|
||||
os.set_blocking(0, True)
|
||||
|
||||
# Also clear ``SO_RCVTIMEO`` if it was set by a child on the shared
|
||||
# description. A non-zero timeout would cause the next ``readline()``
|
||||
# to time out and return ``''`` again, looping until the rate limiter
|
||||
# kicks in. Clearing it restores fully blocking semantics.
|
||||
if _HAS_SOCKET and _socket is not None:
|
||||
try:
|
||||
s = _socket.fromfd(0, _socket.AF_UNIX, _socket.SOCK_STREAM)
|
||||
try:
|
||||
# Zero timeval: tv_sec=0, tv_usec=0 (struct timeval on most platforms)
|
||||
s.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVTIMEO, struct.pack("ll", 0, 0))
|
||||
finally:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ``_io.TextIOWrapper.readline`` returns an empty string on ``EAGAIN``
|
||||
# but does NOT stick EOF; after restoring blocking, the next call will
|
||||
# block until data arrives or the peer truly closes.
|
||||
return True
|
||||
@@ -0,0 +1,950 @@
|
||||
"""Persistent dashboard compute-host process.
|
||||
|
||||
Phase 0 used this module as a deterministic line-JSON spike. Phase 1 keeps the
|
||||
same transport and turns it into the long-lived child that owns live AIAgent
|
||||
objects when ``dashboard.turn_isolation`` is enabled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Collection
|
||||
|
||||
from agent.interrupt_compat import request_hard_interrupt
|
||||
|
||||
|
||||
def now_ns() -> int:
|
||||
return time.perf_counter_ns()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpikeAgent:
|
||||
"""A deterministic AIAgent-shaped object for pipe/interrupt measurements."""
|
||||
|
||||
session_id: str
|
||||
history: list[dict[str, str]] = field(default_factory=list)
|
||||
_interrupt: threading.Event = field(default_factory=threading.Event)
|
||||
|
||||
def clear_interrupt(self) -> None:
|
||||
self._interrupt.clear()
|
||||
|
||||
def interrupt(self, *, hard_cancel: bool = False) -> None:
|
||||
self._interrupt.set()
|
||||
|
||||
def run_conversation(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
conversation_history: list[dict[str, str]] | None = None,
|
||||
stream_callback: Callable[[str], None] | None = None,
|
||||
delta_count: int = 24,
|
||||
delay_s: float = 0.001,
|
||||
) -> dict[str, Any]:
|
||||
base_history = list(conversation_history if conversation_history is not None else self.history)
|
||||
chunks: list[str] = []
|
||||
interrupted = False
|
||||
for index in range(max(0, int(delta_count))):
|
||||
if self._interrupt.is_set():
|
||||
interrupted = True
|
||||
break
|
||||
chunk = f"{self.session_id}:{prompt}:{index:04d} "
|
||||
chunks.append(chunk)
|
||||
if stream_callback is not None:
|
||||
stream_callback(chunk)
|
||||
if delay_s > 0:
|
||||
time.sleep(delay_s)
|
||||
if self._interrupt.is_set():
|
||||
interrupted = True
|
||||
final = "".join(chunks)
|
||||
if interrupted:
|
||||
final += "[interrupted]"
|
||||
messages = [
|
||||
*base_history,
|
||||
{"role": "user", "content": prompt},
|
||||
{"role": "assistant", "content": final},
|
||||
]
|
||||
self.history = messages
|
||||
return {"final_response": final, "messages": messages, "interrupted": interrupted}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostSession:
|
||||
sid: str
|
||||
agent: SpikeAgent
|
||||
history_version: int = 0
|
||||
running: bool = False
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
|
||||
class _HostTransport:
|
||||
def __init__(self, emit: Callable[[dict[str, Any]], None]) -> None:
|
||||
self._emit = emit
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
sid = ""
|
||||
try:
|
||||
if obj.get("method") == "event":
|
||||
sid = str(((obj.get("params") or {}).get("session_id")) or "")
|
||||
except Exception:
|
||||
sid = ""
|
||||
self._emit({"type": "rpc", "sid": sid, "message": obj})
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _build_sha() -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=str(_repo_root()),
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
).strip()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# Slice of ``ComputeHost.shutdown``'s budget held back for the post-drain
|
||||
# finalize. ``HostSupervisor._terminate_pid`` SIGKILLs the host
|
||||
# ``_SHUTDOWN_TIMEOUT_SECS`` (10s — the same value as ``shutdown``'s default
|
||||
# ``wait``) after SIGTERM, so a drain allowed to consume the whole budget would
|
||||
# leave the flush racing that kill and persist nothing at all.
|
||||
_FLUSH_RESERVE_SECS = 1.0
|
||||
|
||||
|
||||
class ComputeHost:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
stdout: Any = None,
|
||||
max_workers: int | None = None,
|
||||
heartbeat_secs: int | float | None = None,
|
||||
) -> None:
|
||||
self._stdout = stdout or sys.stdout
|
||||
self._write_lock = threading.Lock()
|
||||
self._sessions: dict[str, HostSession] = {}
|
||||
self._executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=max_workers or _default_workers(),
|
||||
thread_name_prefix="compute-host-turn",
|
||||
)
|
||||
self._closed = threading.Event()
|
||||
self._parent_pid = os.getppid()
|
||||
self._boot_id = uuid.uuid4().hex
|
||||
self._progress_counter = 0
|
||||
self._progress_lock = threading.Lock()
|
||||
# Future -> the ``sid`` whose turn it is running. ``shutdown`` needs to
|
||||
# know *whose* turn is still live, not merely that something is, so that
|
||||
# it can leave those sessions unfinalized; a bare set cannot answer that.
|
||||
self._turn_futures: dict[concurrent.futures.Future, str] = {}
|
||||
self._turn_futures_lock = threading.Lock()
|
||||
self._transport = _HostTransport(self.emit)
|
||||
self._heartbeat_secs = (
|
||||
float(heartbeat_secs)
|
||||
if heartbeat_secs is not None
|
||||
else float(os.environ.get("HERMES_COMPUTE_HOST_HEARTBEAT_SECS") or "15")
|
||||
)
|
||||
if self._heartbeat_secs > 0:
|
||||
threading.Thread(target=self._heartbeat_loop, name="compute-host-heartbeat", daemon=True).start()
|
||||
threading.Thread(target=self._parent_guard_loop, name="compute-host-ppid-guard", daemon=True).start()
|
||||
|
||||
def emit(self, frame: dict[str, Any]) -> None:
|
||||
frame.setdefault("host_ns", now_ns())
|
||||
data = json.dumps(frame, separators=(",", ":"), ensure_ascii=False)
|
||||
with self._write_lock:
|
||||
print(data, file=self._stdout, flush=True)
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed.set()
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def shutdown(self, *, reason: str = "shutdown", wait: float = 10.0) -> None:
|
||||
"""Drain in-flight turns, then finalize every session.
|
||||
|
||||
Order matters. ``_finalize_session`` is a one-shot latch: it sets
|
||||
``session["_finalized"]`` and every later call returns immediately, so
|
||||
the flush gets exactly one chance to snapshot the session. Running it
|
||||
before the drain meant that chance was spent while turns were still
|
||||
producing output — the tail was unpersistable, ``on_session_end`` fired
|
||||
with ``interrupted=True`` against a session that was still running, and
|
||||
the active-session lease was released out from under a live turn. The
|
||||
drain loop exists precisely so that work survives; finalizing first
|
||||
defeated it.
|
||||
|
||||
``_FLUSH_RESERVE_SECS`` of the budget — but never more than half of it,
|
||||
so a short explicit ``wait`` still gets a real drain — is withheld from
|
||||
the drain, so the flush still runs when in-flight turns outlast the
|
||||
window. ``wait`` itself is unchanged, so this adds no shutdown latency
|
||||
and no new exposure to the supervisor's kill escalation.
|
||||
|
||||
Sessions whose turn is *still running* when the drain deadline expires
|
||||
are excluded from that flush. Finalizing one would spend its single
|
||||
latch mid-turn — ``shutdown(wait=False, cancel_futures=True)`` below
|
||||
does not join the turn — leaving the session permanently
|
||||
un-finalizable and its active-session lease released out from under
|
||||
live work: exactly the race the drain exists to close, just moved later.
|
||||
Leaving them unfinalized keeps them recoverable instead. Sessions with
|
||||
no live turn finalize here as they always have.
|
||||
|
||||
NOTE: ``server._shutdown_sessions`` is registered via ``atexit``
|
||||
(``server.py``) and runs on ``SystemExit`` after ``shutdown()``
|
||||
returns. It calls ``_finalize_session`` on any session still in
|
||||
``server._sessions`` — including ones skipped here whose turn is
|
||||
still running, since ``_executor.shutdown(wait=False)`` only cancels
|
||||
pending futures, not running ones. The orphan path (``os._exit(0)``)
|
||||
bypasses atexit, so the skip is fully effective there. For the
|
||||
SIGTERM and stdin_closed paths the atexit handler may re-finalize
|
||||
skipped sessions; this is a pre-existing issue (the old finalize-
|
||||
first order had the same atexit interaction) and does not make the
|
||||
drain-before-finalize reordering worse. A follow-up could gate
|
||||
``_shutdown_sessions`` on ``not session.get("_finalized") and not
|
||||
session.get("running")`` to close the gap.
|
||||
"""
|
||||
self._closed.set()
|
||||
budget = max(0.0, wait)
|
||||
deadline = time.monotonic() + budget - min(_FLUSH_RESERVE_SECS, budget / 2.0)
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
with self._turn_futures_lock:
|
||||
pending = [f for f in self._turn_futures if not f.done()]
|
||||
if not pending:
|
||||
break
|
||||
# Bounded by ``remaining``: a flat 0.05s sleep would overshoot the
|
||||
# deadline and eat into the reserve it is there to protect, which
|
||||
# for a small ``wait`` can be the whole of it.
|
||||
time.sleep(min(0.05, remaining))
|
||||
with self._turn_futures_lock:
|
||||
live_sids = {sid for future, sid in self._turn_futures.items() if sid and not future.done()}
|
||||
self.flush_all_sessions(reason=reason, skip_sids=live_sids)
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def flush_all_sessions(
|
||||
self,
|
||||
*,
|
||||
reason: str = "shutdown",
|
||||
skip_sids: Collection[str] | None = None,
|
||||
) -> None:
|
||||
"""Finalize every server session except the ones named in ``skip_sids``.
|
||||
|
||||
``skip_sids`` carries the sessions whose turn is still live, which must
|
||||
not spend their one-shot ``_finalize_session`` while running.
|
||||
"""
|
||||
try:
|
||||
from tui_gateway import server
|
||||
except Exception:
|
||||
return
|
||||
skip = set(skip_sids or ())
|
||||
for sid, session in list(getattr(server, "_sessions", {}).items()):
|
||||
if sid in skip:
|
||||
continue
|
||||
try:
|
||||
server._finalize_session(session, end_reason=f"compute_host_{reason}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def handle_frame(self, frame: dict[str, Any]) -> None:
|
||||
kind = str(frame.get("type") or "")
|
||||
if kind == "session.seed":
|
||||
self._handle_seed(frame)
|
||||
elif kind == "turn.start":
|
||||
self._handle_turn_start(frame)
|
||||
elif kind == "interrupt":
|
||||
self._handle_interrupt(frame)
|
||||
elif kind == "respond":
|
||||
self._handle_respond(frame)
|
||||
elif kind == "reload_mcp":
|
||||
self._handle_reload_mcp(frame)
|
||||
elif kind == "control":
|
||||
self._handle_control(frame)
|
||||
elif kind == "shutdown":
|
||||
self.emit({"type": "shutdown.ack", "request_id": frame.get("request_id")})
|
||||
# Explicit supervisor/test shutdown is a clean child-process close;
|
||||
# SIGTERM and orphan paths are the durability flush paths.
|
||||
self._closed.set()
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
else:
|
||||
self.emit(
|
||||
{
|
||||
"type": "error",
|
||||
"request_id": frame.get("request_id"),
|
||||
"message": f"unknown frame type: {kind}",
|
||||
}
|
||||
)
|
||||
|
||||
# ── Phase-0 deterministic spike frames ─────────────────────────────
|
||||
|
||||
def _handle_seed(self, frame: dict[str, Any]) -> None:
|
||||
sid = str(frame.get("sid") or "")
|
||||
if not sid:
|
||||
self.emit({"type": "error", "request_id": frame.get("request_id"), "message": "sid required"})
|
||||
return
|
||||
history = frame.get("history")
|
||||
if not isinstance(history, list):
|
||||
history = []
|
||||
self._sessions[sid] = HostSession(sid=sid, agent=SpikeAgent(sid, list(history)))
|
||||
self.emit({"type": "session.seeded", "sid": sid, "request_id": frame.get("request_id")})
|
||||
|
||||
def _track_turn_future(self, future: concurrent.futures.Future, sid: str) -> None:
|
||||
"""Register an in-flight turn against the session running it.
|
||||
|
||||
The callback has to remove the entry under the lock — a bare
|
||||
``dict.pop`` bound method is not the drop-in ``set.discard`` was — or
|
||||
the mapping grows for the life of the host.
|
||||
"""
|
||||
with self._turn_futures_lock:
|
||||
self._turn_futures[future] = sid
|
||||
future.add_done_callback(self._untrack_turn_future)
|
||||
|
||||
def _untrack_turn_future(self, future: concurrent.futures.Future) -> None:
|
||||
with self._turn_futures_lock:
|
||||
self._turn_futures.pop(future, None)
|
||||
|
||||
def _handle_turn_start(self, frame: dict[str, Any]) -> None:
|
||||
sid = str(frame.get("sid") or "")
|
||||
if sid in self._sessions:
|
||||
self._handle_spike_turn_start(frame)
|
||||
return
|
||||
future = self._executor.submit(self._run_real_turn, dict(frame))
|
||||
self._track_turn_future(future, sid)
|
||||
|
||||
def _handle_spike_turn_start(self, frame: dict[str, Any]) -> None:
|
||||
sid = str(frame.get("sid") or "")
|
||||
session = self._sessions.get(sid)
|
||||
if session is None:
|
||||
self.emit({"type": "turn.error", "sid": sid, "request_id": frame.get("request_id"), "message": "unknown session"})
|
||||
return
|
||||
with session.lock:
|
||||
if session.running:
|
||||
self.emit({"type": "turn.error", "sid": sid, "request_id": frame.get("request_id"), "message": "session busy"})
|
||||
return
|
||||
session.running = True
|
||||
future = self._executor.submit(self._run_spike_turn, session, dict(frame))
|
||||
self._track_turn_future(future, sid)
|
||||
|
||||
def _handle_interrupt(self, frame: dict[str, Any]) -> None:
|
||||
sid = str(frame.get("sid") or "")
|
||||
spike = self._sessions.get(sid)
|
||||
if spike is not None:
|
||||
request_hard_interrupt(spike.agent)
|
||||
self.emit(
|
||||
{
|
||||
"type": "interrupt.ack",
|
||||
"sid": sid,
|
||||
"request_id": frame.get("request_id"),
|
||||
"applied": True,
|
||||
"applied_ns": now_ns(),
|
||||
}
|
||||
)
|
||||
return
|
||||
try:
|
||||
from tui_gateway import server
|
||||
|
||||
session = server._sessions.get(sid)
|
||||
if session is None:
|
||||
self.emit({"type": "interrupt.ack", "sid": sid, "request_id": frame.get("request_id"), "applied": False})
|
||||
return
|
||||
# In the child, `_session_uses_compute_host()` is false, so the
|
||||
# shared helper interrupts the local agent and releases this
|
||||
# process's pending clarify Event. The parent has only a metadata
|
||||
# mirror and cannot release the prompt that is blocking the turn.
|
||||
server._interrupt_session_turn(sid, session)
|
||||
self.emit({"type": "interrupt.ack", "sid": sid, "request_id": frame.get("request_id"), "applied": True, "applied_ns": now_ns()})
|
||||
except Exception as exc:
|
||||
self.emit({"type": "interrupt.ack", "sid": sid, "request_id": frame.get("request_id"), "applied": False, "message": str(exc)})
|
||||
|
||||
def _handle_respond(self, frame: dict[str, Any]) -> None:
|
||||
"""Resolve an interactive request in the host-owned pending registry."""
|
||||
sid = str(frame.get("sid") or "")
|
||||
request_id = frame.get("request_id")
|
||||
try:
|
||||
from tui_gateway import server
|
||||
|
||||
if sid not in server._sessions:
|
||||
self.emit(
|
||||
{
|
||||
"type": "respond.error",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"message": "session not found",
|
||||
}
|
||||
)
|
||||
return
|
||||
params = frame.get("params")
|
||||
if not isinstance(params, dict):
|
||||
self.emit(
|
||||
{
|
||||
"type": "respond.error",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"message": "response params must be an object",
|
||||
}
|
||||
)
|
||||
return
|
||||
response = server._methods["clarify.respond"](request_id, params)
|
||||
self.emit(
|
||||
{
|
||||
"type": "respond.ack",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"response": response,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
self.emit(
|
||||
{
|
||||
"type": "respond.error",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"message": str(exc),
|
||||
}
|
||||
)
|
||||
|
||||
def _run_spike_turn(self, session: HostSession, frame: dict[str, Any]) -> None:
|
||||
request_id = frame.get("request_id") or uuid.uuid4().hex
|
||||
prompt = str(frame.get("prompt") or frame.get("text") or "")
|
||||
try:
|
||||
delta_count = int(frame.get("delta_count", 24))
|
||||
except (TypeError, ValueError):
|
||||
delta_count = 24
|
||||
try:
|
||||
delay_s = float(frame.get("delay_s", 0.001))
|
||||
except (TypeError, ValueError):
|
||||
delay_s = 0.001
|
||||
with session.lock:
|
||||
history = list(session.agent.history)
|
||||
session.agent.clear_interrupt()
|
||||
self.emit({"type": "turn.started", "sid": session.sid, "request_id": request_id, "started_ns": now_ns()})
|
||||
|
||||
def stream(delta: str) -> None:
|
||||
self._bump_progress()
|
||||
self.emit(
|
||||
{
|
||||
"type": "delta",
|
||||
"sid": session.sid,
|
||||
"request_id": request_id,
|
||||
"text": delta,
|
||||
"emitted_ns": now_ns(),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
result = session.agent.run_conversation(
|
||||
prompt,
|
||||
conversation_history=history,
|
||||
stream_callback=stream,
|
||||
delta_count=delta_count,
|
||||
delay_s=delay_s,
|
||||
)
|
||||
with session.lock:
|
||||
session.history_version += 1
|
||||
session.running = False
|
||||
history_version = session.history_version
|
||||
self._bump_progress()
|
||||
self.emit(
|
||||
{
|
||||
"type": "turn.end",
|
||||
"sid": session.sid,
|
||||
"request_id": request_id,
|
||||
"history_version": history_version,
|
||||
"message_count": len(result.get("messages") or []),
|
||||
"interrupted": bool(result.get("interrupted")),
|
||||
"ended_ns": now_ns(),
|
||||
}
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive host boundary
|
||||
with session.lock:
|
||||
session.running = False
|
||||
self.emit({"type": "turn.error", "sid": session.sid, "request_id": request_id, "message": str(exc)})
|
||||
|
||||
# ── Real dashboard turn path ───────────────────────────────────────
|
||||
|
||||
def _run_real_turn(self, frame: dict[str, Any]) -> None:
|
||||
sid = str(frame.get("sid") or "")
|
||||
request_id = str(frame.get("request_id") or uuid.uuid4().hex)
|
||||
if not sid:
|
||||
self.emit({"type": "turn.error", "sid": sid, "request_id": request_id, "message": "sid required"})
|
||||
return
|
||||
try:
|
||||
from tui_gateway import server
|
||||
|
||||
session = self._ensure_server_session(server, frame)
|
||||
with session["history_lock"]:
|
||||
queued_prompt_generation = frame.get("queued_prompt_generation")
|
||||
if (
|
||||
queued_prompt_generation is not None
|
||||
and int(session.get("_queued_prompt_generation", 0))
|
||||
!= int(queued_prompt_generation)
|
||||
):
|
||||
self.emit(
|
||||
{
|
||||
"type": "turn.end",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"interrupted": True,
|
||||
"ended_ns": now_ns(),
|
||||
}
|
||||
)
|
||||
return
|
||||
if session.get("running"):
|
||||
self.emit({"type": "turn.error", "sid": sid, "request_id": request_id, "message": "session busy"})
|
||||
return
|
||||
session["running"] = True
|
||||
session["_turn_cancel_requested"] = False
|
||||
session["last_active"] = time.time()
|
||||
server._start_inflight_turn(session, frame.get("text") if "text" in frame else frame.get("prompt"))
|
||||
self.emit({"type": "turn.started", "sid": sid, "request_id": request_id, "started_ns": now_ns()})
|
||||
try:
|
||||
server._ensure_session_db_row(session)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import hermes_undo
|
||||
|
||||
hermes_undo.on_user_message_appended(session["session_key"])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
server._persist_branch_seed(session)
|
||||
except Exception:
|
||||
pass
|
||||
text = frame.get("text") if "text" in frame else frame.get("prompt", "")
|
||||
server._run_prompt_submit(
|
||||
request_id,
|
||||
sid,
|
||||
session,
|
||||
text,
|
||||
display_kind=frame.get("display_kind") or None,
|
||||
)
|
||||
run_thread = session.get("_run_thread")
|
||||
if run_thread is not None and hasattr(run_thread, "join"):
|
||||
run_thread.join()
|
||||
with session["history_lock"]:
|
||||
history_version = int(session.get("history_version", 0))
|
||||
message_count = len(session.get("history") or [])
|
||||
interrupted = bool(session.get("_turn_cancel_requested"))
|
||||
session_key = str(session.get("session_key") or "")
|
||||
session_info = server._session_info(session.get("agent"), session)
|
||||
self._bump_progress()
|
||||
self.emit(
|
||||
{
|
||||
"type": "turn.end",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"history_version": history_version,
|
||||
"session_key": session_key,
|
||||
"message_count": message_count,
|
||||
"interrupted": interrupted,
|
||||
"ended_ns": now_ns(),
|
||||
"session_info": session_info,
|
||||
"session_info_emitted": True,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
from tui_gateway import server
|
||||
|
||||
session = server._sessions.get(sid)
|
||||
if session is not None:
|
||||
with session.get("history_lock", threading.Lock()):
|
||||
session["running"] = False
|
||||
server._clear_inflight_turn(session)
|
||||
except Exception:
|
||||
pass
|
||||
self.emit({"type": "turn.error", "sid": sid, "request_id": request_id, "reason": "exception", "message": str(exc)})
|
||||
|
||||
def _ensure_server_session(self, server: Any, frame: dict[str, Any]) -> dict:
|
||||
sid = str(frame.get("sid") or "")
|
||||
key = str(frame.get("session_key") or sid)
|
||||
session = server._sessions.get(sid)
|
||||
if session is not None:
|
||||
session["transport"] = self._transport
|
||||
if frame.get("cols") is not None:
|
||||
session["cols"] = int(frame.get("cols") or 80)
|
||||
if frame.get("cwd"):
|
||||
session["cwd"] = str(frame.get("cwd"))
|
||||
if frame.get("profile_home"):
|
||||
session["profile_home"] = str(frame.get("profile_home"))
|
||||
if isinstance(frame.get("attached_images"), list):
|
||||
session["attached_images"] = list(frame.get("attached_images") or [])
|
||||
return session
|
||||
|
||||
history = frame.get("history") if isinstance(frame.get("history"), list) else []
|
||||
profile_home = str(frame.get("profile_home") or "")
|
||||
session_db = None
|
||||
owns_db = False
|
||||
home_token = None
|
||||
secret_token = None
|
||||
try:
|
||||
if profile_home:
|
||||
from hermes_constants import set_hermes_home_override
|
||||
from agent.secret_scope import build_profile_secret_scope, set_secret_scope
|
||||
from hermes_state import SessionDB
|
||||
|
||||
home_token = set_hermes_home_override(profile_home)
|
||||
secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
|
||||
# DEDICATED handle — ours only until _make_agent succeeds. Every
|
||||
# path after that keeps the agent registered in
|
||||
# server._sessions[sid] (via _init_session, or the fallback dict
|
||||
# in the except below), so the agent is the right owner; a
|
||||
# _make_agent that RAISES is the one path where nothing takes it.
|
||||
from hermes_state import get_shared_session_db
|
||||
session_db = get_shared_session_db(Path(profile_home) / "state.db")
|
||||
owns_db = True
|
||||
agent = server._make_agent(
|
||||
sid,
|
||||
key,
|
||||
session_id=key,
|
||||
model_override=frame.get("model_override"),
|
||||
reasoning_config_override=frame.get("reasoning_config_override"),
|
||||
service_tier_override=frame.get("service_tier_override"),
|
||||
platform_override=frame.get("source"),
|
||||
context_cwd_is_launch_artifact=bool(
|
||||
frame.get("context_cwd_is_launch_artifact", False)
|
||||
),
|
||||
session_db=session_db,
|
||||
)
|
||||
if server._transfer_db_to_agent(agent, session_db):
|
||||
owns_db = False
|
||||
finally:
|
||||
if owns_db and session_db is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
from hermes_state import release_or_close
|
||||
release_or_close(session_db)
|
||||
if home_token is not None:
|
||||
try:
|
||||
from hermes_constants import reset_hermes_home_override
|
||||
from agent.secret_scope import reset_secret_scope
|
||||
|
||||
reset_hermes_home_override(home_token)
|
||||
reset_secret_scope(secret_token)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tui_gateway.transport import bind_transport, reset_transport
|
||||
|
||||
token = bind_transport(self._transport)
|
||||
try:
|
||||
server._init_session(
|
||||
sid,
|
||||
key,
|
||||
agent,
|
||||
list(history),
|
||||
cols=int(frame.get("cols") or 80),
|
||||
cwd=str(frame.get("cwd") or "") or None,
|
||||
session_db=session_db,
|
||||
source=frame.get("source"),
|
||||
)
|
||||
finally:
|
||||
reset_transport(token)
|
||||
except Exception:
|
||||
# If _init_session's side machinery (slash worker, approval notify) is
|
||||
# unavailable, keep a minimal host-owned session rather than failing
|
||||
# the turn after the expensive agent build succeeded.
|
||||
server._sessions[sid] = {
|
||||
"agent": agent,
|
||||
"session_key": key,
|
||||
"history": list(history),
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": int(frame.get("history_version") or 0),
|
||||
"inflight_turn": None,
|
||||
"created_at": time.time(),
|
||||
"last_active": time.time(),
|
||||
"running": False,
|
||||
"attached_images": [],
|
||||
"image_counter": 0,
|
||||
"cwd": str(frame.get("cwd") or os.getcwd()),
|
||||
"cols": int(frame.get("cols") or 80),
|
||||
"slash_worker": None,
|
||||
"show_reasoning": server._load_show_reasoning(),
|
||||
"tool_progress_mode": server._load_tool_progress_mode(),
|
||||
"edit_snapshots": {},
|
||||
"tool_started_at": {},
|
||||
"model_override": frame.get("model_override"),
|
||||
"source": server._sanitize_client_source(frame.get("source")),
|
||||
"transport": self._transport,
|
||||
}
|
||||
session = server._sessions[sid]
|
||||
session["transport"] = self._transport
|
||||
session["profile_home"] = profile_home or session.get("profile_home")
|
||||
if isinstance(frame.get("attached_images"), list):
|
||||
session["attached_images"] = list(frame.get("attached_images") or [])
|
||||
if frame.get("model_override") is not None:
|
||||
session["model_override"] = frame.get("model_override")
|
||||
return session
|
||||
|
||||
def _handle_reload_mcp(self, frame: dict[str, Any]) -> None:
|
||||
sid = str(frame.get("sid") or "")
|
||||
request_id = frame.get("request_id")
|
||||
try:
|
||||
from tui_gateway import server
|
||||
|
||||
resp = server.handle_request({"id": request_id, "method": "reload.mcp", "params": {"session_id": sid, "confirm": True}})
|
||||
self.emit({"type": "reload_mcp.ack", "sid": sid, "request_id": request_id, "response": resp})
|
||||
except Exception as exc:
|
||||
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": str(exc)})
|
||||
|
||||
def _handle_control(self, frame: dict[str, Any]) -> None:
|
||||
sid = str(frame.get("sid") or "")
|
||||
request_id = frame.get("request_id")
|
||||
route_name = str(frame.get("route_name") or "")
|
||||
try:
|
||||
from tui_gateway import server
|
||||
from tui_gateway.host_supervisor import MUTATOR_ROUTE_TABLE
|
||||
|
||||
route = MUTATOR_ROUTE_TABLE.get(route_name)
|
||||
if route is None:
|
||||
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": f"unclassified route: {route_name}"})
|
||||
return
|
||||
session = server._sessions.get(sid)
|
||||
if session is None:
|
||||
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": "session not found"})
|
||||
return
|
||||
if route == "idle-gated" and session.get("running"):
|
||||
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": "session busy"})
|
||||
return
|
||||
if route_name == "reload.mcp":
|
||||
self._handle_reload_mcp({**frame, "type": "reload_mcp"})
|
||||
return
|
||||
if route_name == "session.save":
|
||||
response = server._methods["session.save"](
|
||||
request_id,
|
||||
{"session_id": sid},
|
||||
)
|
||||
if "error" in response:
|
||||
self.emit(
|
||||
{
|
||||
"type": "control.error",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"message": str(response["error"].get("message") or "session save failed"),
|
||||
}
|
||||
)
|
||||
return
|
||||
self.emit(
|
||||
{
|
||||
"type": "control.ack",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"route_name": route_name,
|
||||
"result": response.get("result") or {},
|
||||
}
|
||||
)
|
||||
return
|
||||
if route_name == "session.compress":
|
||||
command = str(frame.get("command") or "")
|
||||
focus_topic = command.removeprefix("/compress").strip()
|
||||
response = server._methods["session.compress"](
|
||||
request_id,
|
||||
{
|
||||
"session_id": sid,
|
||||
**({"focus_topic": focus_topic} if focus_topic else {}),
|
||||
},
|
||||
)
|
||||
if "error" in response:
|
||||
self.emit(
|
||||
{
|
||||
"type": "control.error",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"message": str(response["error"].get("message") or "session compression failed"),
|
||||
}
|
||||
)
|
||||
return
|
||||
with session["history_lock"]:
|
||||
session_key = str(session.get("session_key") or "")
|
||||
history_version = int(session.get("history_version", 0))
|
||||
message_count = len(session.get("history") or [])
|
||||
self.emit(
|
||||
{
|
||||
"type": "control.ack",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"route_name": route_name,
|
||||
"result": response.get("result") or {},
|
||||
"session_key": session_key,
|
||||
"history_version": history_version,
|
||||
"message_count": message_count,
|
||||
"session_info": server._session_info(session.get("agent"), session),
|
||||
}
|
||||
)
|
||||
return
|
||||
command = str(frame.get("command") or "")
|
||||
output = ""
|
||||
if command:
|
||||
output = server._mirror_slash_side_effects(sid, session, command)
|
||||
with session["history_lock"]:
|
||||
messages = server._history_to_messages(list(session.get("history") or []))
|
||||
history_version = int(session.get("history_version", 0))
|
||||
message_count = len(session.get("history") or [])
|
||||
session_key = str(session.get("session_key") or "")
|
||||
self.emit(
|
||||
{
|
||||
"type": "control.ack",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"route_name": route_name,
|
||||
"output": output,
|
||||
"session_key": session_key,
|
||||
"history_version": history_version,
|
||||
"message_count": message_count,
|
||||
"messages": messages,
|
||||
"session_info": server._session_info(session.get("agent"), session),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
if route_name in {"session.compress", "slash.compress"}:
|
||||
# The compress mirror defers the context-engine boundary
|
||||
# notification until the host commits. If anything raises
|
||||
# between queueing and finalize (e.g. building the ack's
|
||||
# session_info), discard the pending notification so it can't
|
||||
# leak onto the agent and fire against a rejected boundary on
|
||||
# a later compress. finalize is exactly-once, so this is a
|
||||
# no-op when the mirror already emitted or discarded it.
|
||||
try:
|
||||
from tui_gateway import server as _server
|
||||
from agent.conversation_compression import (
|
||||
finalize_context_engine_compression_notification,
|
||||
)
|
||||
|
||||
_agent = (_server._sessions.get(sid) or {}).get("agent")
|
||||
if _agent is not None:
|
||||
finalize_context_engine_compression_notification(
|
||||
_agent,
|
||||
committed=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": str(exc)})
|
||||
|
||||
def _bump_progress(self) -> None:
|
||||
with self._progress_lock:
|
||||
self._progress_counter += 1
|
||||
|
||||
def _heartbeat_loop(self) -> None:
|
||||
while not self._closed.wait(self._heartbeat_secs):
|
||||
with self._turn_futures_lock:
|
||||
active_turns = sum(1 for f in self._turn_futures if not f.done())
|
||||
with self._progress_lock:
|
||||
counter = self._progress_counter
|
||||
self.emit(
|
||||
{
|
||||
"type": "hb",
|
||||
"active_turns": active_turns,
|
||||
"progress_counter": counter,
|
||||
"rss_mb": _rss_mb(os.getpid()),
|
||||
}
|
||||
)
|
||||
|
||||
def _parent_guard_loop(self) -> None:
|
||||
while not self._closed.wait(1.0):
|
||||
ppid = os.getppid()
|
||||
if ppid in {0, 1} or (self._parent_pid and ppid != self._parent_pid):
|
||||
self.emit({"type": "orphan", "old_ppid": self._parent_pid, "ppid": ppid})
|
||||
self.shutdown(reason="orphan")
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def _rss_mb(pid: int) -> float:
|
||||
try:
|
||||
out = subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)], text=True, encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2).strip()
|
||||
return int(out.splitlines()[-1].strip()) / 1024.0 if out else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _default_workers() -> int:
|
||||
try:
|
||||
return max(2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "8"))
|
||||
except (TypeError, ValueError):
|
||||
return 8
|
||||
|
||||
|
||||
def run_host(stdin: Any = None, stdout: Any = None) -> None:
|
||||
os.environ["HERMES_COMPUTE_HOST_CHILD"] = "1"
|
||||
stdin = stdin or sys.stdin
|
||||
host = ComputeHost(stdout=stdout or sys.stdout)
|
||||
shutting_down = threading.Event()
|
||||
|
||||
def _signal_handler(_signum, _frame) -> None:
|
||||
if shutting_down.is_set():
|
||||
return
|
||||
shutting_down.set()
|
||||
host.shutdown(reason="sigterm")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
host.emit(
|
||||
{
|
||||
"type": "hello",
|
||||
"host_pid": os.getpid(),
|
||||
"boot_id": host._boot_id,
|
||||
"build_sha": _build_sha(),
|
||||
"cwd": os.getcwd(),
|
||||
"hermes_home": os.environ.get("HERMES_HOME", ""),
|
||||
}
|
||||
)
|
||||
|
||||
def _reader() -> None:
|
||||
for raw in stdin:
|
||||
if host._closed.is_set():
|
||||
break
|
||||
try:
|
||||
frame = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
host.emit({"type": "error", "message": f"invalid json: {exc}"})
|
||||
continue
|
||||
if not isinstance(frame, dict):
|
||||
host.emit({"type": "error", "message": "frame must be an object"})
|
||||
continue
|
||||
host.handle_frame(frame)
|
||||
if frame.get("type") == "shutdown":
|
||||
os._exit(0)
|
||||
if host._closed.is_set():
|
||||
break
|
||||
|
||||
reader = threading.Thread(target=_reader, name="compute-host-control-reader", daemon=True)
|
||||
reader.start()
|
||||
try:
|
||||
while not host._closed.wait(0.2):
|
||||
if not reader.is_alive():
|
||||
break
|
||||
finally:
|
||||
host.shutdown(reason="stdin_closed", wait=2.0)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Dashboard compute-host process")
|
||||
parser.parse_args(argv)
|
||||
run_host()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,516 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Stop a ``utils/`` (or ``proxy/``, ``ui/``) package in the launch directory
|
||||
# from shadowing Hermes's own top-level modules. ``hermes_bootstrap`` lives at
|
||||
# the repo root next to this package, so importing it is safe before the guard
|
||||
# runs (its name won't collide with a user package), and it owns the canonical
|
||||
# path-hardening logic shared with the other entry points.
|
||||
import hermes_bootstrap
|
||||
|
||||
hermes_bootstrap.harden_import_path()
|
||||
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from tui_gateway._stdin_recovery import handle_spurious_eof
|
||||
|
||||
from tui_gateway import server
|
||||
from tui_gateway.event_replay import replay_epoch
|
||||
from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json
|
||||
from tui_gateway.transport import TeeTransport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Handle for the background MCP tool-discovery thread (see
|
||||
# ensure_mcp_discovery_started). The first agent build briefly joins this so
|
||||
# already-spawning fast servers land before the agent snapshots its tool list
|
||||
# (see wait_for_mcp_discovery). Stays None when discovery is delegated to the
|
||||
# shared owner in hermes_cli.mcp_startup — the wait/in-flight/join helpers
|
||||
# below consult both owners.
|
||||
_mcp_discovery_thread = None
|
||||
|
||||
# True once ensure_mcp_discovery_started decided this process has MCP servers
|
||||
# configured and spawned discovery through the shared owner. Lets
|
||||
# wait_for_mcp_discovery re-invoke the (idempotent) spawn on later agent
|
||||
# builds so the retry-after-zero-connected allowance in
|
||||
# hermes_cli.mcp_startup.start_background_mcp_discovery can actually fire —
|
||||
# without this, the single spawn is the only call and a first run that
|
||||
# connected nothing latches the process MCP-less. Kept as a flag (rather than
|
||||
# re-probing config) so non-MCP sessions never pay the tools.mcp_tool import
|
||||
# on the per-agent-build wait path.
|
||||
_mcp_discovery_enabled = False
|
||||
|
||||
|
||||
def _install_sidecar_publisher() -> None:
|
||||
"""Mirror every dispatcher emit to the dashboard sidebar via WS.
|
||||
|
||||
Activated by `HERMES_TUI_SIDECAR_URL`, set by the dashboard's
|
||||
``/api/pty`` endpoint when a chat tab passes a ``channel`` query param.
|
||||
Best-effort: connect failure or runtime drop falls back to stdio-only.
|
||||
"""
|
||||
url = os.environ.get("HERMES_TUI_SIDECAR_URL")
|
||||
|
||||
if not url:
|
||||
return
|
||||
|
||||
from tui_gateway.event_publisher import WsPublisherTransport
|
||||
|
||||
server._stdio_transport = TeeTransport(
|
||||
server._stdio_transport, WsPublisherTransport(url)
|
||||
)
|
||||
|
||||
|
||||
# How long to wait for orderly shutdown (atexit + finalisers) before
|
||||
# falling back to ``os._exit(0)`` so a wedged worker mid-flush can't
|
||||
# strand the process. 1s covers the gateway's own shutdown work
|
||||
# (thread-pool drain + session finalize) on every machine we've
|
||||
# tested; override via ``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S`` if a
|
||||
# slower environment needs more headroom (e.g. encrypted disks
|
||||
# flushing checkpoints) and accept that a longer grace also means a
|
||||
# longer wait when shutdown actually deadlocks.
|
||||
_DEFAULT_SHUTDOWN_GRACE_S = 1.0
|
||||
|
||||
|
||||
def _shutdown_grace_seconds() -> float:
|
||||
raw = (os.environ.get("HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S") or "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_SHUTDOWN_GRACE_S
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_SHUTDOWN_GRACE_S
|
||||
return value if value > 0 else _DEFAULT_SHUTDOWN_GRACE_S
|
||||
|
||||
|
||||
def _log_signal(signum: int, frame) -> None:
|
||||
"""Capture WHICH thread and WHERE a termination signal hit us.
|
||||
|
||||
SIG_DFL for SIGPIPE kills the process silently the instant any
|
||||
background thread (TTS playback, beep, voice status emitter, etc.)
|
||||
writes to a stdout the TUI has stopped reading. Without this
|
||||
handler the gateway-exited banner in the TUI has no trace — the
|
||||
crash log never sees a Python exception because the kernel reaps
|
||||
the process before the interpreter runs anything.
|
||||
|
||||
Termination semantics: ``sys.exit(0)`` here used to race the worker
|
||||
pool — a thread holding ``_stdout_lock`` mid-flush would block the
|
||||
interpreter shutdown indefinitely. We now log the stack, give the
|
||||
process the configured shutdown grace
|
||||
(``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S``, default
|
||||
``_DEFAULT_SHUTDOWN_GRACE_S``) to drain naturally on a background
|
||||
thread, and fall back to ``os._exit(0)`` so a wedged write/flush
|
||||
can never strand the process.
|
||||
"""
|
||||
# SIGPIPE and SIGHUP don't exist on Windows — build the lookup
|
||||
# dict from attributes that actually exist on the current platform.
|
||||
_signal_names: dict[int, str] = {}
|
||||
for _attr in ("SIGPIPE", "SIGTERM", "SIGHUP", "SIGINT", "SIGBREAK"):
|
||||
_sig = getattr(signal, _attr, None)
|
||||
if _sig is not None:
|
||||
_signal_names[int(_sig)] = _attr
|
||||
name = _signal_names.get(signum, f"signal {signum}")
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
|
||||
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"\n=== {name} received · {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n"
|
||||
)
|
||||
if frame is not None:
|
||||
f.write("main-thread stack at signal delivery:\n")
|
||||
traceback.print_stack(frame, file=f)
|
||||
# All live threads — signal may have been triggered by a
|
||||
# background thread (write to broken stdout from TTS, etc.).
|
||||
import threading as _threading
|
||||
for tid, th in _threading._active.items():
|
||||
f.write(f"\n--- thread {th.name} (id={tid}) ---\n")
|
||||
f.write("".join(traceback.format_stack(sys._current_frames().get(tid))))
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[gateway-signal] {name}", file=sys.stderr, flush=True)
|
||||
|
||||
import threading as _threading
|
||||
|
||||
def _hard_exit() -> None:
|
||||
# If a worker thread is still mid-flush on a half-closed pipe,
|
||||
# ``sys.exit(0)`` would wait forever for it to drop the GIL on
|
||||
# interpreter shutdown. ``os._exit`` skips atexit handlers but
|
||||
# breaks the deadlock. The crash log + stderr line above are
|
||||
# the forensic trail.
|
||||
os._exit(0)
|
||||
|
||||
timer = _threading.Timer(_shutdown_grace_seconds(), _hard_exit)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
# ── Flush sessions before exit ───────────────────────────────────
|
||||
# The atexit handler (_shutdown_sessions) is registered in
|
||||
# tui_gateway/server.py, but a worker thread holding the GIL or
|
||||
# _stdout_lock can block atexit from completing within the grace
|
||||
# window. Explicitly finalize sessions here so that unpersisted
|
||||
# messages reach state.db before the hard-exit timer fires.
|
||||
try:
|
||||
from tui_gateway.server import _shutdown_sessions
|
||||
|
||||
_shutdown_sessions()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
# Re-raise so the main-thread interpreter unwinds and runs
|
||||
# atexit + finalisers inside the grace window. Python signal
|
||||
# handlers always run on the main thread, but a worker thread
|
||||
# holding ``_stdout_lock`` mid-flush can keep that unwind
|
||||
# waiting indefinitely; the daemon timer above is the safety
|
||||
# net for that exact case.
|
||||
raise
|
||||
|
||||
|
||||
# SIGPIPE: ignore, don't exit. The old SIG_DFL killed the process
|
||||
# silently whenever a *background* thread (TTS playback chain, voice
|
||||
# debug stderr emitter, beep thread) wrote to a pipe the TUI had gone
|
||||
# quiet on — even though the main thread was perfectly fine waiting on
|
||||
# stdin. Ignoring the signal lets Python raise BrokenPipeError on the
|
||||
# offending write (write_json already handles that with a clean
|
||||
# sys.exit(0) + _log_exit), which keeps the gateway alive as long as
|
||||
# the main command pipe is still readable. Terminal signals still
|
||||
# route through _log_signal so kills and hangups are diagnosable.
|
||||
#
|
||||
# SIGPIPE and SIGHUP don't exist on Windows; guard each installation
|
||||
# with hasattr so ``python -m tui_gateway.entry`` (spawned by
|
||||
# ``hermes --tui``) imports cleanly there. SIGBREAK (Windows' Ctrl+Break)
|
||||
# is installed when available as a weaker equivalent of SIGHUP.
|
||||
#
|
||||
# signal.signal() is only legal in the MAIN thread. On the Desktop/WebSocket
|
||||
# agent-build path, server._build() runs in a daemon thread and does
|
||||
# ``from tui_gateway.entry import ensure_mcp_discovery_started`` as the first
|
||||
# import of entry (entry.main() is never run there), which used to raise
|
||||
# "ValueError: signal only works in main thread of the main interpreter" and
|
||||
# abort MCP discovery startup. Install each handler only when we're in the
|
||||
# main thread: handlers are process-global, so a main-thread import anywhere
|
||||
# in the process still installs them for everyone, and an off-thread import
|
||||
# (Desktop build path) simply no-ops instead of crashing the import. This
|
||||
# preserves the original SIG_IGN/SIG_DFL behavior on the classic TUI/serve
|
||||
# path while fixing the off-thread import crash.
|
||||
|
||||
|
||||
def _install_signal(signame, handler):
|
||||
"""Install a signal handler if legal in this thread.
|
||||
|
||||
signal.signal() raises ValueError outside the main thread; skip silently
|
||||
there so a worker-thread import of this module (Desktop build path) does
|
||||
not abort. On any main-thread import the handler is installed as before.
|
||||
"""
|
||||
if threading.current_thread() is not threading.main_thread():
|
||||
return
|
||||
sig = getattr(signal, signame, None)
|
||||
if sig is None:
|
||||
return # Windows: SIGPIPE/SIGHUP absent
|
||||
try:
|
||||
signal.signal(sig, handler)
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
# Not in the main thread despite the check, or handler rejected.
|
||||
# Skip rather than crash the import (see above).
|
||||
pass
|
||||
|
||||
|
||||
_install_signal("SIGPIPE", signal.SIG_IGN)
|
||||
_install_signal("SIGTERM", _log_signal)
|
||||
if hasattr(signal, "SIGHUP"):
|
||||
_install_signal("SIGHUP", _log_signal)
|
||||
elif hasattr(signal, "SIGBREAK"):
|
||||
# Windows-only: Ctrl+Break in a console window delivers SIGBREAK.
|
||||
# Route it through the same handler so kills are diagnosable.
|
||||
_install_signal("SIGBREAK", _log_signal)
|
||||
_install_signal("SIGINT", signal.SIG_IGN)
|
||||
|
||||
|
||||
def _log_exit(reason: str) -> None:
|
||||
"""Record why the gateway subprocess is shutting down.
|
||||
|
||||
Three exit paths (startup write fail, parse-error-response write fail,
|
||||
dispatch-response write fail, stdin EOF) all collapse into a silent
|
||||
sys.exit(0) here. Without this trail the TUI shows "gateway exited"
|
||||
with no actionable clue about WHICH broken pipe or WHICH message
|
||||
triggered it — the main reason voice-mode turns look like phantom
|
||||
crashes when the real story is "TUI read pipe closed on this event".
|
||||
"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
|
||||
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"\n=== gateway exit · {time.strftime('%Y-%m-%d %H:%M:%S')} "
|
||||
f"· reason={reason} ===\n"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[gateway-exit] {reason}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def wait_for_mcp_discovery(timeout: "float | None" = None) -> None:
|
||||
"""Block until background MCP discovery finishes, up to the resolved bound.
|
||||
|
||||
MCP discovery runs in a daemon thread spawned at startup (see main()) so a
|
||||
slow/dead server can't freeze ``gateway.ready``. But the agent snapshots
|
||||
its tool list ONCE at build time and never re-reads it, so a reachable-but-
|
||||
slow server that finishes connecting *after* the first prompt would be
|
||||
invisible for the whole session. Joining with a bounded timeout before the
|
||||
first agent build lets already-spawning servers land without re-introducing
|
||||
the startup hang: ``thread.join(timeout)`` returns the instant discovery
|
||||
completes (so fast/no-MCP startups pay ~0s), and a dead server is simply not
|
||||
waited on beyond the bound. No-op when no discovery thread was started.
|
||||
|
||||
The bound comes from ``mcp_discovery_timeout`` in config (shared with the
|
||||
CLI path via ``hermes_cli.mcp_startup``); ``timeout`` overrides it.
|
||||
"""
|
||||
thread = _mcp_discovery_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
try:
|
||||
from hermes_cli.mcp_startup import _resolve_discovery_timeout
|
||||
|
||||
bound = _resolve_discovery_timeout(timeout)
|
||||
except Exception:
|
||||
bound = timeout if timeout is not None else 0.75
|
||||
thread.join(timeout=bound)
|
||||
return
|
||||
# Discovery is spawned via the shared owner (ensure_mcp_discovery_started
|
||||
# → hermes_cli.mcp_startup); wait on it so the first agent build still
|
||||
# catches fast servers. Re-invoke the idempotent spawn first: if the
|
||||
# previous run finished with zero connected servers,
|
||||
# start_background_mcp_discovery's retry-after-zero-connected allowance
|
||||
# kicks off a fresh discovery run here instead of leaving the process
|
||||
# latched MCP-less for the session. In multi-profile processes this
|
||||
# retry runs under the CALLER's profile context (agent build binds the
|
||||
# session profile's HERMES_HOME first), so a launch profile with no
|
||||
# mcp_servers no longer starves selected profiles of discovery (#67605).
|
||||
# Gated on _mcp_discovery_enabled so non-MCP sessions never pay the
|
||||
# tools.mcp_tool import on the per-agent-build wait path.
|
||||
if not _mcp_discovery_enabled:
|
||||
return
|
||||
try:
|
||||
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
||||
|
||||
start_background_mcp_discovery(
|
||||
logger=logger, thread_name="tui-mcp-discovery"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"TUI MCP discovery retry-spawn failed", exc_info=True
|
||||
)
|
||||
try:
|
||||
from hermes_cli.mcp_startup import (
|
||||
wait_for_mcp_discovery as _startup_wait,
|
||||
)
|
||||
|
||||
_startup_wait(timeout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def mcp_discovery_in_flight() -> bool:
|
||||
"""Return True if ANY background MCP discovery thread is still running.
|
||||
|
||||
Used by the agent-build path to decide whether to schedule a late tool
|
||||
snapshot refresh: if discovery didn't land within the bounded
|
||||
``wait_for_mcp_discovery`` join, the agent was built without those tools
|
||||
and the banner/tool count will be stale until they arrive.
|
||||
|
||||
There are two independent discovery-thread owners by surface: the stdio
|
||||
``hermes --tui`` path spawns ITS thread here (``_mcp_discovery_thread``),
|
||||
while the desktop app + dashboard WebSocket sidecar (``tui_gateway/ws.py``)
|
||||
and ``hermes dashboard`` spawn theirs via
|
||||
``hermes_cli.mcp_startup.start_background_mcp_discovery``. The late-refresh
|
||||
scheduler imports this function regardless of surface, so it MUST consult
|
||||
both — checking only the entry thread left the desktop/dashboard surfaces
|
||||
with no late refresh, so a slow MCP server's tools never surfaced for the
|
||||
whole session (#51587).
|
||||
"""
|
||||
thread = _mcp_discovery_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.mcp_startup import (
|
||||
mcp_discovery_in_flight as _startup_in_flight,
|
||||
)
|
||||
|
||||
return _startup_in_flight()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def join_mcp_discovery(timeout: float | None = None) -> bool:
|
||||
"""Block until background MCP discovery finishes, up to ``timeout`` seconds.
|
||||
|
||||
Returns True if discovery has completed (both thread owners absent or no
|
||||
longer alive), False if either is still running after the timeout. Unlike
|
||||
``wait_for_mcp_discovery`` this accepts an unbounded/long wait and reports
|
||||
the outcome, for the off-critical-path late-refresh waiter.
|
||||
|
||||
Joins both discovery-thread owners (see ``mcp_discovery_in_flight``): the
|
||||
entry thread first, then the ``hermes_cli.mcp_startup`` thread used by the
|
||||
desktop/dashboard surfaces. ``timeout`` bounds EACH join, mirroring the
|
||||
pre-#51587 single-owner behavior for the entry thread.
|
||||
"""
|
||||
entry_done = True
|
||||
thread = _mcp_discovery_thread
|
||||
if thread is not None:
|
||||
thread.join(timeout=timeout)
|
||||
entry_done = not thread.is_alive()
|
||||
try:
|
||||
from hermes_cli.mcp_startup import join_mcp_discovery as _startup_join
|
||||
|
||||
startup_done = _startup_join(timeout=timeout)
|
||||
except Exception:
|
||||
startup_done = True
|
||||
return entry_done and startup_done
|
||||
|
||||
|
||||
# Spurious stdin-EOF recovery tracker (shared open-file-description O_NONBLOCK flip).
|
||||
_recovery_times: list[float] = []
|
||||
|
||||
|
||||
|
||||
def _has_configured_mcp_servers() -> bool:
|
||||
"""Delegate to the shared native and portable MCP startup gate."""
|
||||
from hermes_cli.mcp_startup import _has_configured_mcp_servers as configured
|
||||
|
||||
return configured()
|
||||
|
||||
|
||||
def ensure_mcp_discovery_started() -> None:
|
||||
"""Start background MCP discovery for the current profile context, once.
|
||||
|
||||
``main()`` calls this for the stdio/TUI path. WebSocket/Desktop
|
||||
entrypoints can accept sessions without running ``main()``, so the
|
||||
agent-build path (``server._start_agent_build``) also calls it AFTER
|
||||
binding the session profile's HERMES_HOME override — the shared owner in
|
||||
``hermes_cli.mcp_startup`` captures the caller's context-local override
|
||||
and propagates it into the discovery thread, so discovery reads the
|
||||
SELECTED profile's ``mcp_servers``, not the launch profile's (#67605).
|
||||
|
||||
Delegating to the shared owner (instead of a hand-rolled thread) keeps
|
||||
the process-wide start lock, the retry-after-zero-connected allowance,
|
||||
and interactive-OAuth suppression.
|
||||
|
||||
Known limitation: MCP tool registration is process-global, so in a
|
||||
multi-profile process the FIRST profile that builds an agent wins the
|
||||
discovery slot. Full per-profile MCP registries are tracked in #67605.
|
||||
"""
|
||||
global _mcp_discovery_enabled
|
||||
|
||||
if not _has_configured_mcp_servers():
|
||||
return
|
||||
_mcp_discovery_enabled = True
|
||||
try:
|
||||
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
||||
|
||||
start_background_mcp_discovery(
|
||||
logger=logger, thread_name="tui-mcp-discovery"
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Background MCP tool discovery failed to start", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
_install_sidecar_publisher()
|
||||
|
||||
# Cross-backend liveness (#94895): register a heartbeat row so the
|
||||
# startup orphan sweep can distinguish "row owned by a live but idle
|
||||
# backend" from "row truly orphaned". Must run BEFORE the sweep so
|
||||
# the sweep sees our row in the same transaction.
|
||||
try:
|
||||
server._start_backend_heartbeat_refresher()
|
||||
except Exception:
|
||||
logger.warning("backend heartbeat refresher start failed", exc_info=True)
|
||||
|
||||
# One-time sweep of session rows orphaned by a previous gateway process
|
||||
# (#65194) — the in-process WS-orphan reap timer dies with the process.
|
||||
# Desktop/dashboard reach the agent through handle_ws instead; the
|
||||
# scheduler is once-per-process + config-gated so the second site is a
|
||||
# no-op when this already ran.
|
||||
try:
|
||||
server._schedule_startup_orphan_sweep()
|
||||
except Exception:
|
||||
logger.warning("startup orphan sweep scheduling failed", exc_info=True)
|
||||
|
||||
# MCP tool discovery — backgrounded so a slow or unreachable MCP server
|
||||
# can't freeze TUI startup (a dead stdio/http server burns 1+2+4s of
|
||||
# connect retries → ~7s of dead air before the composer appears). The
|
||||
# agent isn't built until the first prompt, at which point _make_agent
|
||||
# briefly joins the discovery thread (wait_for_mcp_discovery, bounded) so
|
||||
# already-spawning fast servers land in the tool snapshot. The config
|
||||
# gate inside ensure_mcp_discovery_started keeps the ~200ms MCP SDK
|
||||
# import cost entirely off the path for users with no mcp_servers.
|
||||
ensure_mcp_discovery_started()
|
||||
|
||||
if not write_json({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "event",
|
||||
"params": {
|
||||
"type": "gateway.ready",
|
||||
# change_events: see tui_gateway/ws.py — clients demote legacy polls.
|
||||
# replay_epoch: restart detection for the WS replay contract (the
|
||||
# stdio TUI ignores it).
|
||||
"payload": {
|
||||
"skin": resolve_skin(),
|
||||
"change_events": True,
|
||||
"replay_epoch": replay_epoch(),
|
||||
},
|
||||
},
|
||||
}):
|
||||
_log_exit("startup write failed (broken stdout pipe before first event)")
|
||||
sys.exit(0)
|
||||
|
||||
# Live-apply skins Hermes activates mid-conversation.
|
||||
server._ensure_skin_watcher()
|
||||
|
||||
# Warm the /model picker's provider-models cache off-thread during this
|
||||
# idle window (gateway.ready sent, user about to type). Mirrors the classic
|
||||
# CLI run() loop — the stdio TUI otherwise never prewarms, so the first
|
||||
# /model open blocks on serial /v1/models fetches. Fire-and-forget,
|
||||
# guarded once-per-process, fully exception-isolated.
|
||||
try:
|
||||
from hermes_cli.model_switch import prewarm_picker_cache_async
|
||||
prewarm_picker_cache_async()
|
||||
except Exception:
|
||||
logger.debug("picker cache prewarm (tui) failed to start", exc_info=True)
|
||||
|
||||
while True:
|
||||
raw = sys.stdin.readline()
|
||||
if not raw:
|
||||
# Stdin fell through — check if spurious (O_NONBLOCK flip by a
|
||||
# child on the shared open file description) or genuine EOF.
|
||||
if not handle_spurious_eof(_recovery_times, _log_exit):
|
||||
break
|
||||
continue
|
||||
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
if not write_json({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}):
|
||||
_log_exit("parse-error-response write failed (broken stdout pipe)")
|
||||
sys.exit(0)
|
||||
continue
|
||||
|
||||
method = req.get("method") if isinstance(req, dict) else None
|
||||
resp = dispatch(req)
|
||||
if resp is not None:
|
||||
if not write_json(resp):
|
||||
_log_exit(f"response write failed for method={method!r} (broken stdout pipe)")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Best-effort WebSocket publisher transport for the PTY-side gateway.
|
||||
|
||||
The dashboard's `/api/pty` spawns `hermes --tui` as a child process, which
|
||||
spawns its own ``tui_gateway.entry``. Tool/reasoning/status events fire on
|
||||
*that* gateway's transport — three processes removed from the dashboard
|
||||
server itself. To surface them in the dashboard sidebar (`/api/events`),
|
||||
the PTY-side gateway opens a back-WS to the dashboard at startup and
|
||||
mirrors every emit through this transport.
|
||||
|
||||
Wire protocol: newline-framed JSON dicts (the same shape the dispatcher
|
||||
already passes to ``write``). No JSON-RPC envelope here — the dashboard's
|
||||
``/api/pub`` endpoint just rebroadcasts the bytes verbatim to subscribers.
|
||||
|
||||
Failure mode: silent. The agent loop must never block waiting for the
|
||||
sidecar to drain. A dead WS short-circuits all subsequent writes.
|
||||
Actual ``send`` calls run on a daemon thread so the TeeTransport's
|
||||
``write`` returns after enqueueing (best-effort; drop when the queue is full).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from websockets.sync.client import connect as ws_connect
|
||||
except ImportError: # pragma: no cover - websockets is a required install path
|
||||
ws_connect = None # type: ignore[assignment]
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_DRAIN_STOP = object()
|
||||
|
||||
_QUEUE_MAX = 256
|
||||
|
||||
|
||||
class WsPublisherTransport:
|
||||
__slots__ = ("_url", "_lock", "_ws", "_dead", "_q", "_worker")
|
||||
|
||||
def __init__(self, url: str, *, connect_timeout: float = 2.0) -> None:
|
||||
self._url = url
|
||||
self._lock = threading.Lock()
|
||||
self._ws: Optional[object] = None
|
||||
self._dead = False
|
||||
self._q: queue.Queue[object] = queue.Queue(maxsize=_QUEUE_MAX)
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
|
||||
if ws_connect is None:
|
||||
self._dead = True
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
self._ws = ws_connect(url, open_timeout=connect_timeout, max_size=None)
|
||||
except Exception as exc:
|
||||
_log.debug("event publisher connect failed: %s", exc)
|
||||
self._dead = True
|
||||
self._ws = None
|
||||
|
||||
return
|
||||
|
||||
self._worker = threading.Thread(
|
||||
target=self._drain,
|
||||
name="hermes-ws-pub",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker.start()
|
||||
|
||||
def _drain(self) -> None:
|
||||
while True:
|
||||
item = self._q.get()
|
||||
if item is _DRAIN_STOP:
|
||||
return
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
if self._ws is None:
|
||||
continue
|
||||
try:
|
||||
with self._lock:
|
||||
if self._ws is not None:
|
||||
self._ws.send(item) # type: ignore[union-attr]
|
||||
except Exception as exc:
|
||||
_log.debug("event publisher write failed: %s", exc)
|
||||
self._dead = True
|
||||
self._ws = None
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
if self._dead or self._ws is None or self._worker is None:
|
||||
return False
|
||||
|
||||
line = json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
self._q.put_nowait(line)
|
||||
|
||||
return True
|
||||
except queue.Full:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
self._dead = True
|
||||
w = self._worker
|
||||
if w is not None and w.is_alive():
|
||||
try:
|
||||
self._q.put_nowait(_DRAIN_STOP)
|
||||
except queue.Full:
|
||||
# Best-effort: if the queue is wedged, the daemon thread
|
||||
# will be torn down with the process.
|
||||
pass
|
||||
w.join(timeout=3.0)
|
||||
self._worker = None
|
||||
|
||||
if self._ws is None:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
if self._ws is not None:
|
||||
self._ws.close() # type: ignore[union-attr]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._ws = None
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Per-session event sequencing + bounded replay for WS reconnects.
|
||||
|
||||
Every gateway event frame that flows through :func:`server.write_json` (and
|
||||
therefore ``_emit``) is stamped with a per-session monotonic ``seq`` and
|
||||
appended to a small ring buffer keyed by session id. A reconnecting client
|
||||
calls the ``session.events.since`` RPC with its last observed seq; the server
|
||||
replays everything newer from the buffer, then live events resume seamlessly.
|
||||
|
||||
Design constraints honored:
|
||||
- stdio TUI path unaffected: frames gain a ``seq`` field only on event frames;
|
||||
Ink ignores unknown params keys.
|
||||
- Thread safety: a single module lock guards counters + buffers; write_json
|
||||
already serializes per-transport writes, so stamping under the lock cannot
|
||||
reorder frames relative to each other.
|
||||
- Memory bound: _REPLAY_BUFFER_MAX events / _REPLAY_SESSIONS_MAX sessions,
|
||||
oldest session evicted FIFO.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from collections import OrderedDict, deque
|
||||
|
||||
# Process identity for the replay contract. Seq counters live in-process, so
|
||||
# a gateway restart silently resets them to 1 while clients still hold high
|
||||
# watermarks — events_since(sid, 97) then returns [] with truncated=False and
|
||||
# the client believes it missed nothing (and its stale watermark makes every
|
||||
# future replay empty too). The epoch lets clients detect the restart and
|
||||
# reset their watermarks.
|
||||
_REPLAY_EPOCH = uuid.uuid4().hex
|
||||
|
||||
# Replay ring per session. A long turn emits ~hundreds of token events; this
|
||||
# covers several minutes of streaming plus all control events.
|
||||
_REPLAY_BUFFER_MAX = 512
|
||||
# Distinct sessions remembered. Desktop users rarely exceed a dozen live chats.
|
||||
_REPLAY_SESSIONS_MAX = 64
|
||||
|
||||
_replay_lock = threading.Lock()
|
||||
# sid -> deque of (seq, event_object) where event_object is the frame's
|
||||
# ``params`` dict (bare event: type/session_id/seq/payload) — the exact shape
|
||||
# the client's dispatch path consumes.
|
||||
_replay_buffers: "OrderedDict[str, deque]" = OrderedDict()
|
||||
_replay_next_seq: dict[str, int] = {}
|
||||
|
||||
|
||||
def replay_epoch() -> str:
|
||||
"""Opaque token identifying this server process's seq numbering."""
|
||||
return _REPLAY_EPOCH
|
||||
|
||||
|
||||
def _stamp_event(obj: dict) -> None:
|
||||
"""Stamp one outgoing event frame (mutates obj in place) and record it."""
|
||||
if obj.get("method") != "event":
|
||||
return
|
||||
params = obj.get("params")
|
||||
if not isinstance(params, dict):
|
||||
return
|
||||
sid = params.get("session_id") or ""
|
||||
if not sid:
|
||||
# Session-less global events (skin.changed etc.) are re-fetchable via
|
||||
# their own RPCs; no replay contract for them.
|
||||
return
|
||||
with _replay_lock:
|
||||
seq = _replay_next_seq.get(sid, 0) + 1
|
||||
_replay_next_seq[sid] = seq
|
||||
params["seq"] = seq
|
||||
buf = _replay_buffers.get(sid)
|
||||
if buf is None:
|
||||
buf = deque(maxlen=_REPLAY_BUFFER_MAX)
|
||||
_replay_buffers[sid] = buf
|
||||
while len(_replay_buffers) > _REPLAY_SESSIONS_MAX:
|
||||
_oldest_sid, _oldest_buf = _replay_buffers.popitem(last=False)
|
||||
_replay_next_seq.pop(_oldest_sid, None)
|
||||
buf.append((seq, params))
|
||||
|
||||
|
||||
def events_since(sid: str, last_seen: int) -> list[dict]:
|
||||
"""Return recorded EVENT OBJECTS with seq > last_seen for *sid*, in order.
|
||||
|
||||
Shape contract: each element is the frame's ``params`` dict — a bare event
|
||||
object with top-level ``type`` / ``session_id`` / ``seq`` — because that is
|
||||
exactly what the client's dispatch path consumes. Returning the full
|
||||
JSON-RPC envelope here would make every replayed event fail the client's
|
||||
``event.type`` gate and be silently dropped.
|
||||
"""
|
||||
with _replay_lock:
|
||||
buf = _replay_buffers.get(sid or "")
|
||||
if not buf:
|
||||
return []
|
||||
return [event for seq, event in buf if seq > last_seen]
|
||||
|
||||
|
||||
def is_truncated(sid: str, last_seen: int) -> bool:
|
||||
"""True when events between *last_seen* and the ring's oldest retained
|
||||
seq were evicted — the client must refetch history instead of trusting
|
||||
the replay to be gap-free."""
|
||||
with _replay_lock:
|
||||
buf = _replay_buffers.get(sid or "")
|
||||
if not buf:
|
||||
return False
|
||||
return last_seen + 1 < buf[0][0]
|
||||
|
||||
|
||||
def latest_seq(sid: str) -> int:
|
||||
"""Current highest stamped seq for *sid* (0 when unknown)."""
|
||||
with _replay_lock:
|
||||
return _replay_next_seq.get(sid or "", 0)
|
||||
|
||||
|
||||
def reset_replay_state() -> None:
|
||||
"""Test hook."""
|
||||
with _replay_lock:
|
||||
_replay_buffers.clear()
|
||||
_replay_next_seq.clear()
|
||||
|
||||
|
||||
def replay_stats() -> dict:
|
||||
"""Telemetry: buffer occupancy for the ops/debug surface."""
|
||||
with _replay_lock:
|
||||
return {
|
||||
"sessions": len(_replay_buffers),
|
||||
"events": sum(len(b) for b in _replay_buffers.values()),
|
||||
"max_per_session": _REPLAY_BUFFER_MAX,
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Git working-tree probing for the gateway: run git, resolve repo roots, fold
|
||||
linked worktrees under their common root.
|
||||
|
||||
Probing runs where the gateway runs, so it resolves repos for both local and
|
||||
remote backends (unlike the desktop's electron probe, which only sees the local
|
||||
fs). Resolved roots are cached with a thread-safe, single-flight cache: the
|
||||
gateway's long handlers run on worker threads, so concurrent identical probes
|
||||
(e.g. two overlapping project-tree builds) share one `git` invocation instead of
|
||||
racing an unguarded dict.
|
||||
|
||||
Positive results are cached for the process lifetime; negative results (a cwd
|
||||
that isn't a git repo, or a deleted/nonexistent dir) are cached only for a short
|
||||
TTL (`_NEG_TTL`). Caching negatives matters a lot for the desktop Projects tree:
|
||||
``project_tree.build_tree`` resolves a cwd once *per session* (not per distinct
|
||||
cwd), so a power user with hundreds of sessions in non-git/deleted dirs would
|
||||
otherwise re-spawn ``git`` hundreds of times on *every* sidebar open — the cause
|
||||
of the multi-second "Projects" load. The TTL keeps a not-yet-repo cwd
|
||||
re-probable (we `git init` a new project's folder on its first worktree, and a
|
||||
frozen "" would mislabel its main lane by the dir basename) — it just stops the
|
||||
same "not a repo" answer from being re-derived dozens of times within one build
|
||||
and across rapid re-opens. `invalidate()` drops everything after a known
|
||||
mutation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from hermes_cli._subprocess_compat import bounded_git_probe
|
||||
|
||||
_GIT_TIMEOUT = 1.5
|
||||
_WARM_WORKERS = 8
|
||||
|
||||
# How long a "not a git repo" answer stays cached before it's re-probed. Short
|
||||
# enough that a freshly `git init`-ed / newly-created folder shows correctly
|
||||
# within a few seconds; long enough to collapse the hundreds of redundant probes
|
||||
# a single project-tree build (and rapid re-opens) would otherwise fire.
|
||||
_NEG_TTL = 30.0
|
||||
|
||||
|
||||
def run_git(cwd: str, *args: str) -> str:
|
||||
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
|
||||
|
||||
Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded
|
||||
on Windows — a plain ``subprocess.run(timeout=...)`` here deadlocked Desktop
|
||||
session readiness when a killed git left a suspended descendant holding the
|
||||
pipe handles (issue #68609).
|
||||
"""
|
||||
if not cwd or not os.path.isdir(cwd):
|
||||
# `git -C` on a directory that no longer exists can only fail, and it
|
||||
# fails at the price of a fork. Deleted worktrees dominate the cwds a
|
||||
# long-lived session history hands us, so the stat pays for itself many
|
||||
# times over on every project-tree build.
|
||||
return ""
|
||||
return bounded_git_probe(["git", "-C", cwd, *args], timeout=_GIT_TIMEOUT)
|
||||
|
||||
|
||||
def branch(cwd: str) -> str:
|
||||
return run_git(cwd, "branch", "--show-current") or run_git(cwd, "rev-parse", "--short", "HEAD")
|
||||
|
||||
|
||||
class _RootCache:
|
||||
"""Thread-safe, single-flight cache of git-root probes. Positive results are
|
||||
cached for the process lifetime; negative ("not a repo") results are cached
|
||||
only for ``_NEG_TTL`` seconds so a not-yet-repo cwd stays re-probable.
|
||||
Followers wait on the leader's probe instead of duplicating it."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._roots: dict[str, str] = {}
|
||||
self._neg: dict[str, float] = {} # key -> monotonic expiry
|
||||
self._inflight: dict[str, threading.Event] = {}
|
||||
|
||||
def invalidate(self) -> None:
|
||||
with self._lock:
|
||||
self._roots.clear()
|
||||
self._neg.clear()
|
||||
self._inflight.clear()
|
||||
|
||||
def resolve(self, key: str, probe) -> str:
|
||||
while True:
|
||||
with self._lock:
|
||||
hit = self._roots.get(key)
|
||||
if hit:
|
||||
return hit
|
||||
expiry = self._neg.get(key)
|
||||
if expiry is not None:
|
||||
if expiry > time.monotonic():
|
||||
# Recently probed as "not a repo" — trust it briefly
|
||||
# instead of re-spawning git for the same dead/non-repo
|
||||
# cwd on every session in the tree build.
|
||||
return ""
|
||||
# TTL elapsed: drop it and re-probe (it may be a repo now).
|
||||
del self._neg[key]
|
||||
gate = self._inflight.get(key)
|
||||
if gate is None:
|
||||
gate = threading.Event()
|
||||
self._inflight[key] = gate
|
||||
leader = True
|
||||
else:
|
||||
leader = False
|
||||
|
||||
if not leader:
|
||||
# Another thread is probing this key — wait, then re-read.
|
||||
gate.wait(timeout=_GIT_TIMEOUT + 0.5)
|
||||
continue
|
||||
|
||||
value = ""
|
||||
try:
|
||||
value = probe()
|
||||
finally:
|
||||
with self._lock:
|
||||
if value:
|
||||
self._roots[key] = value
|
||||
else:
|
||||
self._neg[key] = time.monotonic() + _NEG_TTL
|
||||
self._inflight.pop(key, None)
|
||||
gate.set()
|
||||
return value
|
||||
|
||||
|
||||
_cache = _RootCache()
|
||||
|
||||
|
||||
def invalidate() -> None:
|
||||
"""Drop cached roots after a known mutation (e.g. a worktree was added)."""
|
||||
_cache.invalidate()
|
||||
|
||||
|
||||
def repo_root(cwd: str) -> str:
|
||||
"""Top-level git repo root for ``cwd`` (``""`` when not a repo)."""
|
||||
if not cwd:
|
||||
return ""
|
||||
return _cache.resolve(cwd, lambda: run_git(cwd, "rev-parse", "--show-toplevel"))
|
||||
|
||||
|
||||
def common_repo_root(cwd: str) -> str:
|
||||
"""The MAIN (common) repo root for ``cwd``, folding linked worktrees.
|
||||
|
||||
``--show-toplevel`` returns a linked worktree's OWN root, so grouping by it
|
||||
splits every worktree into a separate "repo". The common ``.git`` dir
|
||||
(``--git-common-dir``) is shared by a repo and all its worktrees, so its
|
||||
parent is the one true repo root; fall back to the toplevel root otherwise.
|
||||
|
||||
The returned path is normalized to git's forward-slash spelling so it can be
|
||||
compared against :func:`repo_root` (which returns raw ``--show-toplevel``
|
||||
output). ``os.path.realpath`` rewrites separators to the platform's native
|
||||
``\\`` on Windows, so without this the SAME directory came back spelled two
|
||||
ways and the repo's own checkout compared unequal to its common root — the
|
||||
main checkout was then misread as a linked worktree and the desktop sidebar
|
||||
rendered it twice (a dir-labeled lane plus a branch-labeled ``main`` lane).
|
||||
"""
|
||||
if not cwd:
|
||||
return ""
|
||||
|
||||
# No work tree, nothing to fold. Reading the (warmed, negative-cached)
|
||||
# toplevel first spares every non-repo cwd a second `git` spawn — one the
|
||||
# parallel warm can never absorb, since `resolve()` only reaches here for
|
||||
# cwds that ARE repos.
|
||||
if not repo_root(cwd):
|
||||
return ""
|
||||
|
||||
def _probe() -> str:
|
||||
gitdir = run_git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||
if gitdir:
|
||||
gitdir = os.path.realpath(gitdir)
|
||||
if os.path.basename(gitdir) == ".git":
|
||||
return os.path.dirname(gitdir).replace(os.sep, "/")
|
||||
return repo_root(cwd)
|
||||
|
||||
return _cache.resolve(f"common:{cwd}", _probe)
|
||||
|
||||
|
||||
def resolve(cwd: str) -> dict | None:
|
||||
"""Inject-able resolver for ``project_tree.build_tree``.
|
||||
|
||||
Returns ``{"repo_root": <common root>, "worktree_root": <this checkout>}``
|
||||
or ``None`` when ``cwd`` is not in a git repo. ``build_tree`` treats
|
||||
``worktree_root == repo_root`` as the main checkout.
|
||||
"""
|
||||
worktree_root = repo_root(cwd)
|
||||
if not worktree_root:
|
||||
return None
|
||||
return {"repo_root": common_repo_root(cwd) or worktree_root, "worktree_root": worktree_root}
|
||||
|
||||
|
||||
def warm_roots(cwds: Iterable[str], max_workers: int = _WARM_WORKERS) -> None:
|
||||
"""Pre-resolve many cwds' roots in parallel (bounded) so a cold first paint
|
||||
doesn't serialize one git subprocess per session cwd. Single-flight dedupes
|
||||
overlap; results land in the shared cache for the sequential consumers."""
|
||||
pending = sorted({(cwd or "").strip() for cwd in cwds} - {""})
|
||||
if not pending:
|
||||
return
|
||||
if len(pending) == 1:
|
||||
resolve(pending[0])
|
||||
return
|
||||
with ThreadPoolExecutor(max_workers=min(max_workers, len(pending))) as pool:
|
||||
list(pool.map(resolve, pending))
|
||||
@@ -0,0 +1,652 @@
|
||||
"""Supervisor for the dashboard compute-host child process.
|
||||
|
||||
The dashboard process owns sockets and JSON-RPC dispatch. When
|
||||
``dashboard.turn_isolation`` is enabled, agent turns move behind one persistent
|
||||
``python -m tui_gateway.compute_host`` child so compute-heavy agent threads do
|
||||
not contend with the serving process' event loop for the same GIL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.environments.local import hermes_subprocess_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_Thread = threading.Thread
|
||||
|
||||
MUTATOR_ROUTE_TABLE: dict[str, str] = {
|
||||
"prompt.submit": "turn-path",
|
||||
"session.interrupt": "turn-path",
|
||||
"reload.mcp": "run-concurrent",
|
||||
"session.save": "run-concurrent",
|
||||
"session.compress": "idle-gated",
|
||||
"prompt.submit.truncate": "idle-gated",
|
||||
"slash.model": "idle-gated",
|
||||
"slash.personality": "idle-gated",
|
||||
"slash.prompt": "idle-gated",
|
||||
"slash.compress": "idle-gated",
|
||||
"session.reset": "idle-gated",
|
||||
"session.history.reload": "idle-gated",
|
||||
"slash.retry": "idle-gated",
|
||||
}
|
||||
|
||||
_REGISTRY_NAME = "dashboard-compute-host.json"
|
||||
_RESPAWN_WINDOW_SECS = 300.0
|
||||
_SHUTDOWN_TIMEOUT_SECS = 10.0
|
||||
# Late control-ack handlers (#97948): a compress that outlives its RPC waiter
|
||||
# can legitimately run for the full compression ceiling plus a stall-fallback
|
||||
# retry, so keep registrations around well past that — but bounded.
|
||||
_LATE_CONTROL_TTL_SECS = 1800.0
|
||||
_LATE_CONTROL_MAX = 64
|
||||
|
||||
|
||||
def append_log_record(path: str | Path, record: str) -> None:
|
||||
"""Append one log record using O_APPEND and exactly one os.write call."""
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = record if record.endswith("\n") else f"{record}\n"
|
||||
data = text.encode("utf-8", errors="replace")
|
||||
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _build_sha() -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=str(_repo_root()),
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
).strip()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _default_registry_path() -> Path:
|
||||
return get_hermes_home() / "state" / _REGISTRY_NAME
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _pid_command(pid: int) -> str:
|
||||
if pid <= 0:
|
||||
return ""
|
||||
# Linux fast path.
|
||||
proc_cmdline = Path("/proc") / str(pid) / "cmdline"
|
||||
try:
|
||||
data = proc_cmdline.read_bytes()
|
||||
if data:
|
||||
return data.replace(b"\x00", b" ").decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["ps", "-p", str(pid), "-o", "command="],
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def is_compute_host_identity(pid: int) -> bool:
|
||||
cmd = _pid_command(pid)
|
||||
return "tui_gateway.compute_host" in cmd
|
||||
|
||||
|
||||
class HostSupervisor:
|
||||
"""Own one persistent compute-host child and relay its frames."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry_path: str | Path | None = None,
|
||||
argv: list[str] | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
rpc_sink: Callable[[dict], None] | None = None,
|
||||
respawn_max: int = 3,
|
||||
heartbeat_secs: int = 15,
|
||||
expected_build_sha: str | None = None,
|
||||
expected_hermes_home: str | None = None,
|
||||
autostart: bool = True,
|
||||
) -> None:
|
||||
self.registry_path = Path(registry_path) if registry_path is not None else _default_registry_path()
|
||||
self.argv = argv or [sys.executable, "-m", "tui_gateway.compute_host"]
|
||||
self.cwd = Path(cwd) if cwd is not None else _repo_root()
|
||||
self.env = env
|
||||
self.rpc_sink = rpc_sink or (lambda _obj: None)
|
||||
self.respawn_max = max(0, int(respawn_max))
|
||||
self.heartbeat_secs = max(1, int(heartbeat_secs))
|
||||
self.expected_build_sha = expected_build_sha if expected_build_sha is not None else _build_sha()
|
||||
self.expected_hermes_home = expected_hermes_home if expected_hermes_home is not None else str(get_hermes_home())
|
||||
|
||||
self._lock = threading.RLock()
|
||||
self._proc: subprocess.Popen[str] | None = None
|
||||
self._stdout_thread: threading.Thread | None = None
|
||||
self._stderr_thread: threading.Thread | None = None
|
||||
self._wait_thread: threading.Thread | None = None
|
||||
self._hello_event = threading.Event()
|
||||
self._hello: dict[str, Any] = {}
|
||||
self._closing = False
|
||||
self._stopped_respawning = False
|
||||
self._restart_times: list[float] = []
|
||||
self._pending_turns: dict[str, tuple[str, Callable[[dict], None] | None]] = {}
|
||||
self._pending_controls: dict[str, queue.Queue[dict]] = {}
|
||||
# request_id -> (registered_at, handler) for control waiters that timed
|
||||
# out but whose host work is still running (#97948). The host emits
|
||||
# its control.ack whenever it finishes; without this the ack matched
|
||||
# no queue and was silently dropped.
|
||||
self._late_control_handlers: dict[str, tuple[float, Callable[[dict], None]]] = {}
|
||||
self._stderr_tail: list[str] = []
|
||||
self._last_progress_counter = 0
|
||||
|
||||
if autostart:
|
||||
self.start()
|
||||
|
||||
@property
|
||||
def pid(self) -> int:
|
||||
proc = self._proc
|
||||
return int(proc.pid or 0) if proc is not None else 0
|
||||
|
||||
@property
|
||||
def hello(self) -> dict[str, Any]:
|
||||
return dict(self._hello)
|
||||
|
||||
def is_running(self) -> bool:
|
||||
proc = self._proc
|
||||
return proc is not None and proc.poll() is None and not self._stopped_respawning
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self.is_running():
|
||||
return
|
||||
self._closing = False
|
||||
self.reconcile_startup_orphan()
|
||||
self._spawn_locked(reason="startup")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
with self._lock:
|
||||
self._closing = True
|
||||
proc = self._proc
|
||||
if proc is None:
|
||||
return
|
||||
try:
|
||||
if proc.poll() is None and proc.stdin is not None:
|
||||
self._send_frame({"type": "shutdown", "request_id": f"shutdown-{uuid.uuid4().hex}"})
|
||||
proc.wait(timeout=_SHUTDOWN_TIMEOUT_SECS)
|
||||
except Exception:
|
||||
self._terminate_process(proc)
|
||||
finally:
|
||||
self._remove_registry()
|
||||
|
||||
def reconcile_startup_orphan(self) -> str:
|
||||
"""Terminate a stale registered host, guarding against PID reuse."""
|
||||
try:
|
||||
data = json.loads(self.registry_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return "none"
|
||||
except Exception:
|
||||
self._remove_registry()
|
||||
return "invalid-registry"
|
||||
|
||||
try:
|
||||
pid = int(data.get("host_pid") or 0)
|
||||
except Exception:
|
||||
pid = 0
|
||||
if pid <= 0 or not _pid_alive(pid):
|
||||
self._remove_registry()
|
||||
return "not-running"
|
||||
if not self._pid_matches_compute_host(pid):
|
||||
# PID was reused by another process. Never signal it.
|
||||
self._remove_registry()
|
||||
return "pid-reuse-ignored"
|
||||
|
||||
self._terminate_pid(pid, timeout=_SHUTDOWN_TIMEOUT_SECS)
|
||||
self._remove_registry()
|
||||
return "terminated"
|
||||
|
||||
def submit_turn(
|
||||
self,
|
||||
frame: dict[str, Any],
|
||||
*,
|
||||
on_complete: Callable[[dict], None] | None = None,
|
||||
) -> str:
|
||||
self.start()
|
||||
request_id = str(frame.get("request_id") or uuid.uuid4().hex)
|
||||
sid = str(frame.get("sid") or "")
|
||||
payload = dict(frame)
|
||||
payload["type"] = "turn.start"
|
||||
payload["request_id"] = request_id
|
||||
with self._lock:
|
||||
self._pending_turns[request_id] = (sid, on_complete)
|
||||
try:
|
||||
self._send_frame(payload)
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
self._pending_turns.pop(request_id, None)
|
||||
err = {
|
||||
"type": "turn.error",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"reason": "send_failed",
|
||||
"message": str(exc),
|
||||
}
|
||||
if on_complete is not None:
|
||||
on_complete(err)
|
||||
raise
|
||||
return request_id
|
||||
|
||||
def interrupt(self, sid: str, *, request_id: str | None = None) -> None:
|
||||
self.start()
|
||||
self._send_frame({"type": "interrupt", "sid": sid, "request_id": request_id or uuid.uuid4().hex})
|
||||
|
||||
def respond(self, sid: str, params: dict[str, Any], *, timeout: float = 15.0) -> dict:
|
||||
"""Deliver an interactive prompt response to the host that owns it."""
|
||||
self.start()
|
||||
request_id = uuid.uuid4().hex
|
||||
q: queue.Queue[dict] = queue.Queue(maxsize=1)
|
||||
with self._lock:
|
||||
self._pending_controls[request_id] = q
|
||||
try:
|
||||
self._send_frame(
|
||||
{
|
||||
"type": "respond",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"params": dict(params),
|
||||
}
|
||||
)
|
||||
return q.get(timeout=timeout)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._pending_controls.pop(request_id, None)
|
||||
|
||||
def reload_mcp(self, sid: str, *, request_id: str | None = None) -> dict:
|
||||
return self.control(
|
||||
sid,
|
||||
route_name="reload.mcp",
|
||||
payload={"type": "reload_mcp", "sid": sid, "request_id": request_id or uuid.uuid4().hex},
|
||||
wait=True,
|
||||
)
|
||||
|
||||
def control(
|
||||
self,
|
||||
sid: str,
|
||||
*,
|
||||
route_name: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
wait: bool = True,
|
||||
timeout: float = 30.0,
|
||||
on_late_ack: Callable[[dict], None] | None = None,
|
||||
) -> dict:
|
||||
"""Send a control frame; with ``wait`` block up to ``timeout`` for its ack.
|
||||
|
||||
``on_late_ack`` (only meaningful with ``wait``) keeps the request
|
||||
adoptable after the waiter gives up: when the host's ``control.ack`` /
|
||||
``control.error`` / ``error`` for this ``request_id`` eventually
|
||||
arrives, the handler fires once instead of the frame being dropped.
|
||||
Registrations are bounded by ``_LATE_CONTROL_TTL_SECS`` /
|
||||
``_LATE_CONTROL_MAX``.
|
||||
"""
|
||||
if route_name not in MUTATOR_ROUTE_TABLE:
|
||||
raise ValueError(f"unclassified host mutator route: {route_name}")
|
||||
self.start()
|
||||
request_id = str((payload or {}).get("request_id") or uuid.uuid4().hex)
|
||||
frame = dict(payload or {})
|
||||
frame.setdefault("type", "control")
|
||||
frame["sid"] = sid
|
||||
frame["route_name"] = route_name
|
||||
frame["request_id"] = request_id
|
||||
q: queue.Queue[dict] | None = None
|
||||
if wait:
|
||||
q = queue.Queue(maxsize=1)
|
||||
with self._lock:
|
||||
self._pending_controls[request_id] = q
|
||||
self._send_frame(frame)
|
||||
if not wait or q is None:
|
||||
return {"status": "sent", "request_id": request_id}
|
||||
try:
|
||||
return q.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
if on_late_ack is not None:
|
||||
self._register_late_control_handler(request_id, on_late_ack)
|
||||
raise
|
||||
finally:
|
||||
with self._lock:
|
||||
self._pending_controls.pop(request_id, None)
|
||||
|
||||
def _register_late_control_handler(self, request_id: str, handler: Callable[[dict], None]) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
expired = [
|
||||
rid
|
||||
for rid, (registered_at, _cb) in self._late_control_handlers.items()
|
||||
if now - registered_at > _LATE_CONTROL_TTL_SECS
|
||||
]
|
||||
for rid in expired:
|
||||
self._late_control_handlers.pop(rid, None)
|
||||
while len(self._late_control_handlers) >= _LATE_CONTROL_MAX:
|
||||
oldest = min(self._late_control_handlers, key=lambda rid: self._late_control_handlers[rid][0])
|
||||
self._late_control_handlers.pop(oldest, None)
|
||||
self._late_control_handlers[request_id] = (now, handler)
|
||||
|
||||
def _deliver_control_frame(self, request_id: str, frame: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
q = self._pending_controls.get(request_id)
|
||||
late = None if q is not None else self._late_control_handlers.pop(request_id, None)
|
||||
if q is not None:
|
||||
try:
|
||||
q.put_nowait(frame)
|
||||
except queue.Full:
|
||||
pass
|
||||
return
|
||||
if late is None:
|
||||
return
|
||||
_registered_at, handler = late
|
||||
try:
|
||||
handler(frame)
|
||||
except Exception:
|
||||
logger.exception("compute host late control ack handler failed (request_id=%s)", request_id)
|
||||
|
||||
def _spawn_locked(self, *, reason: str) -> None:
|
||||
if self._stopped_respawning:
|
||||
raise RuntimeError("compute host respawn disabled after crash loop")
|
||||
self._hello_event.clear()
|
||||
self._hello = {}
|
||||
env = hermes_subprocess_env(inherit_credentials=True)
|
||||
env.update(os.environ)
|
||||
if self.env:
|
||||
env.update(self.env)
|
||||
env["HERMES_COMPUTE_HOST_HEARTBEAT_SECS"] = str(self.heartbeat_secs)
|
||||
env.setdefault("PYTHONPATH", str(_repo_root()))
|
||||
if str(_repo_root()) not in env["PYTHONPATH"].split(os.pathsep):
|
||||
env["PYTHONPATH"] = str(_repo_root()) + os.pathsep + env["PYTHONPATH"]
|
||||
proc = subprocess.Popen(
|
||||
self.argv,
|
||||
cwd=str(self.cwd),
|
||||
env=env,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
# Lossy UTF-8 decode — the compute host emits UTF-8; a
|
||||
# locale-mismatched byte must not raise inside the drain
|
||||
# threads and kill the supervisor (#52649).
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
self._proc = proc
|
||||
self._stdout_thread = _Thread(target=self._drain_stdout, args=(proc,), name="compute-host-stdout", daemon=True)
|
||||
self._stderr_thread = _Thread(target=self._drain_stderr, args=(proc,), name="compute-host-stderr", daemon=True)
|
||||
self._wait_thread = _Thread(target=self._wait_for_exit, args=(proc,), name="compute-host-wait", daemon=True)
|
||||
self._stdout_thread.start()
|
||||
self._stderr_thread.start()
|
||||
self._wait_thread.start()
|
||||
if not self._hello_event.wait(timeout=10.0):
|
||||
self._terminate_process(proc)
|
||||
raise RuntimeError(f"compute host did not send hello; stderr={self._stderr_tail[-5:]}")
|
||||
self._validate_hello()
|
||||
self._persist_registry()
|
||||
logger.info("compute host started pid=%s reason=%s", proc.pid, reason)
|
||||
|
||||
def _validate_hello(self) -> None:
|
||||
hello = self._hello
|
||||
if not hello:
|
||||
raise RuntimeError("compute host missing hello")
|
||||
got_home = str(hello.get("hermes_home") or "")
|
||||
if got_home and got_home != self.expected_hermes_home:
|
||||
raise RuntimeError(f"compute host HERMES_HOME mismatch: {got_home} != {self.expected_hermes_home}")
|
||||
got_sha = str(hello.get("build_sha") or "")
|
||||
if self.expected_build_sha != "unknown" and got_sha not in {"", "unknown", self.expected_build_sha}:
|
||||
raise RuntimeError(f"compute host build mismatch: {got_sha} != {self.expected_build_sha}")
|
||||
|
||||
def _persist_registry(self) -> None:
|
||||
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.registry_path.with_suffix(self.registry_path.suffix + ".tmp")
|
||||
payload = {
|
||||
"host_pid": self.pid,
|
||||
"boot_id": self._hello.get("boot_id") or "",
|
||||
"build_sha": self._hello.get("build_sha") or "",
|
||||
"started_at": time.time(),
|
||||
"argv": self.argv,
|
||||
}
|
||||
tmp.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(self.registry_path)
|
||||
|
||||
def _remove_registry(self) -> None:
|
||||
try:
|
||||
self.registry_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("failed to remove compute host registry", exc_info=True)
|
||||
|
||||
def _send_frame(self, frame: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
proc = self._proc
|
||||
if proc is None or proc.poll() is not None or proc.stdin is None:
|
||||
raise RuntimeError("compute host is not running")
|
||||
proc.stdin.write(json.dumps(frame, separators=(",", ":"), ensure_ascii=False) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
def _drain_stdout(self, proc: subprocess.Popen[str]) -> None:
|
||||
assert proc.stdout is not None
|
||||
for raw in proc.stdout:
|
||||
try:
|
||||
frame = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("compute host emitted invalid json: %r", raw[:200])
|
||||
continue
|
||||
if isinstance(frame, dict):
|
||||
self._handle_host_frame(frame)
|
||||
|
||||
def _drain_stderr(self, proc: subprocess.Popen[str]) -> None:
|
||||
assert proc.stderr is not None
|
||||
for raw in proc.stderr:
|
||||
text = raw.rstrip("\n")
|
||||
if text:
|
||||
self._stderr_tail = (self._stderr_tail + [text])[-80:]
|
||||
logger.warning("compute host stderr: %s", text)
|
||||
|
||||
def _handle_host_frame(self, frame: dict[str, Any]) -> None:
|
||||
ftype = str(frame.get("type") or "")
|
||||
if ftype == "hello":
|
||||
self._hello = dict(frame)
|
||||
self._hello_event.set()
|
||||
return
|
||||
if ftype == "hb":
|
||||
self._last_progress_counter = int(frame.get("progress_counter") or self._last_progress_counter)
|
||||
logger.debug("compute host heartbeat: %s", frame)
|
||||
return
|
||||
if ftype == "rpc":
|
||||
message = frame.get("message")
|
||||
if isinstance(message, dict):
|
||||
self.rpc_sink(message)
|
||||
return
|
||||
if ftype in {"turn.end", "turn.error"}:
|
||||
self._complete_turn(frame)
|
||||
return
|
||||
if ftype in {"control.ack", "control.error", "respond.ack", "respond.error", "interrupt.ack", "reload_mcp.ack", "shutdown.ack"}:
|
||||
self._deliver_control_frame(str(frame.get("request_id") or ""), frame)
|
||||
return
|
||||
if ftype == "error" and frame.get("request_id"):
|
||||
self._deliver_control_frame(str(frame.get("request_id") or ""), frame)
|
||||
|
||||
def _complete_turn(self, frame: dict[str, Any]) -> None:
|
||||
request_id = str(frame.get("request_id") or "")
|
||||
with self._lock:
|
||||
pending = self._pending_turns.pop(request_id, None)
|
||||
if pending is None:
|
||||
return
|
||||
_sid, cb = pending
|
||||
if cb is not None:
|
||||
try:
|
||||
cb(frame)
|
||||
except Exception:
|
||||
logger.exception("compute host turn completion callback failed")
|
||||
|
||||
def _wait_for_exit(self, proc: subprocess.Popen[str]) -> None:
|
||||
code = proc.wait()
|
||||
if self._closing:
|
||||
return
|
||||
with self._lock:
|
||||
if self._proc is not proc:
|
||||
return
|
||||
self._proc = None
|
||||
self._remove_registry()
|
||||
self._fail_pending_turns(reason="crash", message=f"compute host exited with code {code}")
|
||||
self._maybe_respawn_after_crash()
|
||||
|
||||
def _fail_pending_turns(self, *, reason: str, message: str) -> None:
|
||||
with self._lock:
|
||||
pending = self._pending_turns
|
||||
self._pending_turns = {}
|
||||
for request_id, (sid, cb) in pending.items():
|
||||
frame = {
|
||||
"type": "turn.error",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
}
|
||||
self.rpc_sink(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "event",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"session_id": sid,
|
||||
"payload": {"message": message, "reason": reason},
|
||||
},
|
||||
}
|
||||
)
|
||||
if cb is not None:
|
||||
try:
|
||||
cb(frame)
|
||||
except Exception:
|
||||
logger.exception("compute host error callback failed")
|
||||
# A crashed host will never emit the late acks the timed-out control
|
||||
# waiters are still expecting; fail them the same way so the client's
|
||||
# "still running in the background" notice does not hang forever.
|
||||
with self._lock:
|
||||
late = self._late_control_handlers
|
||||
self._late_control_handlers = {}
|
||||
for request_id, (_registered_at, handler) in late.items():
|
||||
try:
|
||||
handler({"type": "control.error", "request_id": request_id, "reason": reason, "message": message})
|
||||
except Exception:
|
||||
logger.exception("compute host late control error handler failed")
|
||||
|
||||
def _maybe_respawn_after_crash(self) -> None:
|
||||
now = time.monotonic()
|
||||
self._restart_times = [t for t in self._restart_times if now - t <= _RESPAWN_WINDOW_SECS]
|
||||
if len(self._restart_times) >= self.respawn_max:
|
||||
self._stopped_respawning = True
|
||||
logger.error("compute host crash loop: max %s restarts per 5min reached; not respawning", self.respawn_max)
|
||||
return
|
||||
self._restart_times.append(now)
|
||||
# Small bounded backoff; tests and first recovery stay quick.
|
||||
delay = min(5.0, 0.25 * (2 ** max(0, len(self._restart_times) - 1)))
|
||||
|
||||
def _respawn() -> None:
|
||||
time.sleep(delay)
|
||||
with self._lock:
|
||||
if self._closing or self._stopped_respawning or self._proc is not None:
|
||||
return
|
||||
try:
|
||||
self._spawn_locked(reason="crash")
|
||||
except Exception:
|
||||
logger.exception("compute host respawn failed")
|
||||
|
||||
_Thread(target=_respawn, name="compute-host-respawn", daemon=True).start()
|
||||
|
||||
def _pid_matches_compute_host(self, pid: int) -> bool:
|
||||
return is_compute_host_identity(pid)
|
||||
|
||||
def _terminate_pid(self, pid: int, *, timeout: float = _SHUTDOWN_TIMEOUT_SECS) -> None:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except Exception:
|
||||
logger.debug("failed to SIGTERM compute host pid=%s", pid, exc_info=True)
|
||||
return
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_alive(pid):
|
||||
return
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except Exception:
|
||||
logger.debug("failed to SIGKILL compute host pid=%s", pid, exc_info=True)
|
||||
|
||||
def _terminate_process(self, proc: subprocess.Popen[str]) -> None:
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=_SHUTDOWN_TIMEOUT_SECS)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MUTATOR_ROUTE_TABLE",
|
||||
"HostSupervisor",
|
||||
"append_log_record",
|
||||
"is_compute_host_identity",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
||||
"""Peer-backed session transport for one hosted-room member task.
|
||||
|
||||
This adapter implements :class:`InternalSessionRPC` without using canonical
|
||||
Bot Chat. The remote client must resolve a hidden ``Group: <room_id>`` session
|
||||
with ``source=bot_room`` and verify the scoped grant at admission.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from gateway.hosted_room_driver import TaskIdentity
|
||||
from gateway.hosted_room_peer import HostedMemberDispatch, PROTOCOL_VERSION
|
||||
from tui_gateway.hosted_room_driver import (
|
||||
ROOM_SESSION_SOURCE,
|
||||
HostedRoomBinding,
|
||||
InternalSessionRPC,
|
||||
room_session_title,
|
||||
)
|
||||
|
||||
|
||||
class HostedRoomPeerClient(Protocol):
|
||||
"""Authenticated client for a target gateway's narrow room-member API."""
|
||||
|
||||
def bind_room_scope(self, **scope: Any) -> None: ...
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
*,
|
||||
room_id: str,
|
||||
profile: str,
|
||||
source: str,
|
||||
grant: str,
|
||||
create: bool,
|
||||
expected_session_id: str | None = None,
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
*,
|
||||
dispatch: Mapping[str, Any],
|
||||
grant: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def history(
|
||||
self,
|
||||
*,
|
||||
room_id: str,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
grant: str,
|
||||
) -> Sequence[Mapping[str, Any]]: ...
|
||||
|
||||
def status(
|
||||
self,
|
||||
*,
|
||||
room_id: str,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
grant: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def stop(
|
||||
self,
|
||||
*,
|
||||
dispatch: Mapping[str, Any],
|
||||
grant: str,
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def stop_receipt(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
execution_generation: int,
|
||||
grant: str,
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomLinkCandidate:
|
||||
"""One address/provider for the same authenticated target gateway."""
|
||||
|
||||
name: str
|
||||
mode: str
|
||||
target_install_id: str
|
||||
client: HostedRoomPeerClient
|
||||
|
||||
|
||||
class FailoverHostedRoomPeerClient:
|
||||
"""Try alternate links without changing target or logical task identity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
candidates: Sequence[RoomLinkCandidate],
|
||||
*,
|
||||
reprobe_interval_seconds: float = 60,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
if not candidates:
|
||||
raise ValueError("at least one RoomLink candidate is required")
|
||||
targets = {candidate.target_install_id for candidate in candidates}
|
||||
if len(targets) != 1:
|
||||
raise ValueError("RoomLink candidates must target one installation")
|
||||
if reprobe_interval_seconds <= 0:
|
||||
raise ValueError("reprobe_interval_seconds must be positive")
|
||||
self.candidates = tuple(candidates)
|
||||
self._active = 0
|
||||
self.reprobe_interval_seconds = float(reprobe_interval_seconds)
|
||||
self.clock = clock
|
||||
self._last_primary_probe = 0.0
|
||||
|
||||
@property
|
||||
def active_link(self) -> RoomLinkCandidate:
|
||||
return self.candidates[self._active]
|
||||
|
||||
def _call(self, method: str, **kwargs):
|
||||
now = self.clock()
|
||||
probe_primary = (
|
||||
self._active != 0
|
||||
and now - self._last_primary_probe >= self.reprobe_interval_seconds
|
||||
)
|
||||
if probe_primary:
|
||||
self._last_primary_probe = now
|
||||
order = [0, self._active]
|
||||
else:
|
||||
order = [self._active]
|
||||
order.extend(
|
||||
index for index in range(len(self.candidates)) if index not in order
|
||||
)
|
||||
last_error = None
|
||||
for index in order:
|
||||
candidate = self.candidates[index]
|
||||
try:
|
||||
result = getattr(candidate.client, method)(**kwargs)
|
||||
except Exception as exc:
|
||||
if bool(getattr(exc, "ambiguous", False)):
|
||||
raise
|
||||
if not bool(getattr(exc, "retryable", False)):
|
||||
raise
|
||||
last_error = exc
|
||||
continue
|
||||
self._active = index
|
||||
return result
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise RuntimeError("no RoomLink candidate was attempted")
|
||||
|
||||
def prepare(self, **kwargs):
|
||||
return self._call("prepare", **kwargs)
|
||||
|
||||
def dispatch(self, **kwargs):
|
||||
return self._call("dispatch", **kwargs)
|
||||
|
||||
def history(self, **kwargs):
|
||||
return self._call("history", **kwargs)
|
||||
|
||||
def status(self, **kwargs):
|
||||
return self._call("status", **kwargs)
|
||||
|
||||
def stop(self, **kwargs):
|
||||
return self._call("stop", **kwargs)
|
||||
|
||||
def bind_room_scope(self, **kwargs):
|
||||
for candidate in self.candidates:
|
||||
bind = getattr(candidate.client, "bind_room_scope", None)
|
||||
if callable(bind):
|
||||
bind(**kwargs)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeerMemberRoute:
|
||||
"""Secret-free target coordinates plus a separately stored room grant."""
|
||||
|
||||
home_install_id: str
|
||||
member_id: str
|
||||
target_install_id: str
|
||||
target_profile: str
|
||||
capability_digest: str
|
||||
cancellation_scope_id: str
|
||||
trace_id: str
|
||||
grant: str
|
||||
execution_policy_digest: str = ""
|
||||
|
||||
|
||||
class PeerHostedRoomTransport(InternalSessionRPC):
|
||||
"""Translate runtime session operations into recipient-validated peer RPC."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
binding: HostedRoomBinding,
|
||||
route: PeerMemberRoute,
|
||||
client: HostedRoomPeerClient,
|
||||
source_event_seq: int = 1,
|
||||
task_id: str | None = None,
|
||||
execution_generation: int | None = None,
|
||||
) -> None:
|
||||
self.binding = binding
|
||||
self.route = route
|
||||
self.client = client
|
||||
if isinstance(source_event_seq, bool) or source_event_seq < 1:
|
||||
raise ValueError("peer room source_event_seq must be positive")
|
||||
self.source_event_seq = int(source_event_seq)
|
||||
self.task_id = task_id
|
||||
self.execution_generation = execution_generation
|
||||
self._session_id: str | None = None
|
||||
self._dispatch: HostedMemberDispatch | None = None
|
||||
bind_scope = getattr(self.client, "bind_room_scope", None)
|
||||
if callable(bind_scope):
|
||||
bind_scope(
|
||||
room_id=self.binding.room_id,
|
||||
home_install_id=self.route.home_install_id,
|
||||
authority_gateway_id=self.binding.gateway_id,
|
||||
authority_epoch=self.binding.authority_epoch,
|
||||
member_id=self.route.member_id,
|
||||
target_install_id=self.route.target_install_id,
|
||||
target_profile=self.route.target_profile,
|
||||
)
|
||||
|
||||
def _validate_coordinates(self, *, profile: str, source: str) -> None:
|
||||
if source != ROOM_SESSION_SOURCE:
|
||||
raise ValueError("peer room transport requires source=bot_room")
|
||||
if profile != self.route.target_profile:
|
||||
raise ValueError("peer room transport profile does not match its grant")
|
||||
|
||||
def resolve_exact(
|
||||
self, *, profile: str, title: str, source: str
|
||||
) -> Mapping[str, Any] | None:
|
||||
self._validate_coordinates(profile=profile, source=source)
|
||||
if title != room_session_title(self.binding.room_id):
|
||||
raise ValueError("peer room transport title does not match room identity")
|
||||
return self.client.prepare(
|
||||
room_id=self.binding.room_id,
|
||||
profile=profile,
|
||||
source=source,
|
||||
grant=self.route.grant,
|
||||
create=False,
|
||||
)
|
||||
|
||||
def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]:
|
||||
self._validate_coordinates(profile=profile, source=source)
|
||||
if title != room_session_title(self.binding.room_id):
|
||||
raise ValueError("peer room transport title does not match room identity")
|
||||
session = self.client.prepare(
|
||||
room_id=self.binding.room_id,
|
||||
profile=profile,
|
||||
source=source,
|
||||
grant=self.route.grant,
|
||||
create=True,
|
||||
)
|
||||
if session is None:
|
||||
raise RuntimeError("peer did not create the room session")
|
||||
self._session_id = str(session.get("session_id") or session.get("id") or "")
|
||||
return session
|
||||
|
||||
def resume(
|
||||
self, *, profile: str, session_id: str, source: str
|
||||
) -> Mapping[str, Any]:
|
||||
self._validate_coordinates(profile=profile, source=source)
|
||||
session = self.client.prepare(
|
||||
room_id=self.binding.room_id,
|
||||
profile=profile,
|
||||
source=source,
|
||||
grant=self.route.grant,
|
||||
create=False,
|
||||
expected_session_id=session_id,
|
||||
)
|
||||
if session is None:
|
||||
raise RuntimeError("peer room session is unavailable")
|
||||
self._session_id = session_id
|
||||
return session
|
||||
|
||||
def submit(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
prompt: str,
|
||||
source: str,
|
||||
task: TaskIdentity,
|
||||
execution_generation: int,
|
||||
on_terminal: Callable[[Mapping[str, Any]], None],
|
||||
) -> Mapping[str, Any]:
|
||||
self._validate_coordinates(profile=profile, source=source)
|
||||
if self._session_id not in {None, session_id}:
|
||||
raise ValueError("peer room session changed during admission")
|
||||
dispatch = HostedMemberDispatch.from_mapping({
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"room_id": task.room_id,
|
||||
"home_install_id": self.route.home_install_id,
|
||||
"authority_gateway_id": self.binding.gateway_id,
|
||||
"authority_epoch": self.binding.authority_epoch,
|
||||
"member_id": self.route.member_id,
|
||||
"target_install_id": self.route.target_install_id,
|
||||
"target_profile": profile,
|
||||
"task_id": task.task_id,
|
||||
"execution_generation": execution_generation,
|
||||
"source_event_seq": self.source_event_seq,
|
||||
"cancellation_scope_id": self.route.cancellation_scope_id,
|
||||
"prompt": prompt,
|
||||
"prompt_digest": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
|
||||
"capability_digest": self.route.capability_digest,
|
||||
"execution_policy_digest": self.route.execution_policy_digest,
|
||||
"trace_id": self.route.trace_id or f"trace-{uuid.uuid4().hex}",
|
||||
})
|
||||
self._dispatch = dispatch
|
||||
self._session_id = session_id
|
||||
result = self.client.dispatch(
|
||||
dispatch=dispatch.as_mapping(),
|
||||
grant=self.route.grant,
|
||||
)
|
||||
if result.get("status") in {"settled", "failed", "cancelled"}:
|
||||
on_terminal(result)
|
||||
return result
|
||||
|
||||
def history(
|
||||
self, *, profile: str, session_id: str, source: str
|
||||
) -> Sequence[Mapping[str, Any]]:
|
||||
self._validate_coordinates(profile=profile, source=source)
|
||||
return self.client.history(
|
||||
room_id=self.binding.room_id,
|
||||
profile=profile,
|
||||
session_id=session_id,
|
||||
grant=self.route.grant,
|
||||
)
|
||||
|
||||
def info(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]:
|
||||
self._validate_coordinates(profile=profile, source=source)
|
||||
return self.client.status(
|
||||
room_id=self.binding.room_id,
|
||||
profile=profile,
|
||||
session_id=session_id,
|
||||
grant=self.route.grant,
|
||||
)
|
||||
|
||||
def interrupt(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
source: str,
|
||||
expected_task_id: str,
|
||||
) -> Mapping[str, Any] | None:
|
||||
self._validate_coordinates(profile=profile, source=source)
|
||||
dispatch = self._dispatch
|
||||
if dispatch is None:
|
||||
if (
|
||||
self.task_id != expected_task_id
|
||||
or not self.execution_generation
|
||||
or not hasattr(self.client, "stop_receipt")
|
||||
):
|
||||
return None
|
||||
return self.client.stop_receipt(
|
||||
task_id=expected_task_id,
|
||||
execution_generation=self.execution_generation,
|
||||
grant=self.route.grant,
|
||||
)
|
||||
if dispatch.task_id != expected_task_id:
|
||||
return None
|
||||
return self.client.stop(
|
||||
dispatch=dispatch.as_mapping(),
|
||||
grant=self.route.grant,
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""In-process session adapter for the hosted room driver.
|
||||
|
||||
The room worker must not depend on a Desktop/WebSocket transport, but it should
|
||||
still use the same session handlers as every other TUI/Desktop turn. This
|
||||
adapter calls the installed handler registry directly and keeps the extra
|
||||
task proof as an in-process-only Python object that JSON clients cannot forge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import threading
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import ModuleType
|
||||
from typing import Any, Callable
|
||||
|
||||
from gateway import hosted_room_driver as state
|
||||
|
||||
|
||||
class HostedRoomSessionError(RuntimeError):
|
||||
"""Raised when an in-process session operation is rejected."""
|
||||
|
||||
def __init__(self, method: str, code: int, message: str) -> None:
|
||||
super().__init__(f"{method} failed: {message}")
|
||||
self.method = method
|
||||
self.code = code
|
||||
|
||||
|
||||
class HostedRoomServerRPC:
|
||||
"""Normalize the installed server handlers for :class:`HostedRoomRuntime`."""
|
||||
|
||||
def __init__(self, server: ModuleType) -> None:
|
||||
self.server = server
|
||||
self._ids = itertools.count(1)
|
||||
|
||||
def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
handler = self.server._methods[method]
|
||||
envelope = handler(f"hosted-room-{next(self._ids)}", params)
|
||||
error = envelope.get("error") if isinstance(envelope, dict) else None
|
||||
if isinstance(error, dict):
|
||||
raise HostedRoomSessionError(
|
||||
method,
|
||||
int(error.get("code") or 5000),
|
||||
str(error.get("message") or "gateway rejected the request"),
|
||||
)
|
||||
result = envelope.get("result") if isinstance(envelope, dict) else None
|
||||
if not isinstance(result, dict):
|
||||
raise HostedRoomSessionError(method, 5000, "gateway returned no result")
|
||||
return result
|
||||
|
||||
def resolve_exact(
|
||||
self, *, profile: str, title: str, source: str
|
||||
) -> Mapping[str, Any] | None:
|
||||
del source
|
||||
result = self._call(
|
||||
"session.list",
|
||||
{"profile": profile, "title": title, "include_hidden": True},
|
||||
)
|
||||
rows = result.get("sessions")
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
session_id = row.get("resolved_id") or row.get("id")
|
||||
return {"session_id": session_id, "title": row.get("title") or title}
|
||||
|
||||
def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]:
|
||||
return self._call(
|
||||
"session.create",
|
||||
{
|
||||
"profile": profile,
|
||||
"title": title,
|
||||
"source": source,
|
||||
"hidden": True,
|
||||
"room_plumbing": True,
|
||||
"follow_profile_config": True,
|
||||
"close_on_disconnect": False,
|
||||
},
|
||||
)
|
||||
|
||||
def resume(
|
||||
self, *, profile: str, session_id: str, source: str
|
||||
) -> Mapping[str, Any]:
|
||||
return self._call(
|
||||
"session.resume",
|
||||
{
|
||||
"profile": profile,
|
||||
"session_id": session_id,
|
||||
"omit_messages": True,
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
|
||||
def submit(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
prompt: str,
|
||||
source: str,
|
||||
task: state.TaskIdentity,
|
||||
execution_generation: int,
|
||||
on_terminal: Callable[[Mapping[str, Any]], None],
|
||||
) -> Mapping[str, Any]:
|
||||
try:
|
||||
return self._call(
|
||||
"prompt.submit",
|
||||
{
|
||||
"profile": profile,
|
||||
"session_id": session_id,
|
||||
"text": prompt,
|
||||
"source": source,
|
||||
"_hosted_task": {
|
||||
"room_id": task.room_id,
|
||||
"task_id": task.task_id,
|
||||
"thread_id": task.thread_id,
|
||||
"turn_id": task.turn_id,
|
||||
"execution_generation": execution_generation,
|
||||
},
|
||||
"_hosted_terminal_callback": on_terminal,
|
||||
},
|
||||
)
|
||||
except HostedRoomSessionError as exc:
|
||||
# In-process prompt.submit error envelopes are returned before the
|
||||
# background turn is admitted. Preserve that proof so the driver
|
||||
# can defer or requeue without waiting out an ambiguity lease.
|
||||
exc.not_admitted = True
|
||||
raise
|
||||
|
||||
def history(
|
||||
self, *, profile: str, session_id: str, source: str
|
||||
) -> Sequence[Mapping[str, Any]]:
|
||||
del source
|
||||
result = self._call(
|
||||
"session.history",
|
||||
{"profile": profile, "session_id": session_id},
|
||||
)
|
||||
rows = result.get("messages")
|
||||
return tuple(row for row in rows if isinstance(row, dict)) if isinstance(rows, list) else ()
|
||||
|
||||
def _session_record(self, session_id: str) -> dict[str, Any] | None:
|
||||
with self.server._sessions_lock:
|
||||
record = self.server._sessions.get(session_id)
|
||||
if record is not None:
|
||||
return record
|
||||
for candidate in self.server._sessions.values():
|
||||
if str(candidate.get("session_key") or "") == session_id:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def info(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]:
|
||||
del profile, source
|
||||
record = self._session_record(session_id)
|
||||
if record is None:
|
||||
return {"active": False, "task_id": None}
|
||||
lock = record.get("history_lock")
|
||||
if not isinstance(lock, type(threading.Lock())):
|
||||
return {"active": bool(record.get("running")), "task_id": None}
|
||||
with lock:
|
||||
task = record.get("_hosted_room_task")
|
||||
result = {
|
||||
"active": bool(record.get("running")),
|
||||
"task_id": task.get("task_id") if isinstance(task, dict) else None,
|
||||
}
|
||||
pending_reader = getattr(
|
||||
self.server, "_pending_approval_request_payload", None
|
||||
)
|
||||
pending = (
|
||||
pending_reader(str(record.get("session_key") or ""))
|
||||
if callable(pending_reader)
|
||||
else None
|
||||
)
|
||||
if pending:
|
||||
result["status"] = "waiting_for_approval"
|
||||
result["pending_approval"] = pending
|
||||
return result
|
||||
|
||||
def approve(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
request_id: str,
|
||||
choice: str,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Resolve one exact local room approval without broad policy changes."""
|
||||
return self._call(
|
||||
"approval.respond",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"request_id": request_id,
|
||||
"choice": choice,
|
||||
"all": False,
|
||||
},
|
||||
)
|
||||
|
||||
def interrupt(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
source: str,
|
||||
expected_task_id: str,
|
||||
) -> Mapping[str, Any] | None:
|
||||
del source
|
||||
return self._call(
|
||||
"session.interrupt",
|
||||
{
|
||||
"profile": profile,
|
||||
"session_id": session_id,
|
||||
"expected_hosted_task_id": expected_task_id,
|
||||
},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
"""Suppress benign event-loop teardown noise on the gateway serving loop.
|
||||
|
||||
When the Desktop client forcibly closes its WebSocket while the gateway still
|
||||
has pending socket operations, asyncio's transport teardown logs a full
|
||||
traceback for every pending ``_call_connection_lost`` callback. On Windows this
|
||||
surfaces as ``ConnectionResetError: [WinError 10054]`` (and the rarer
|
||||
``ConnectionAbortedError: [WinError 10053]``); on POSIX it is the equivalent
|
||||
``ConnectionResetError``/``BrokenPipeError``. A single client disconnect can
|
||||
emit 50+ identical tracebacks into ``errors.log`` (#50005).
|
||||
|
||||
These are not actionable — they are the expected side effect of the peer
|
||||
hanging up before our writes drained. We install a loop exception handler that
|
||||
collapses exactly this class of teardown error to one debug line and forwards
|
||||
everything else to asyncio's default handler unchanged, so genuine loop bugs
|
||||
still surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Connection-teardown errors that mean "the peer hung up mid-write". WinError
|
||||
# 10054 (connection reset) and 10053 (connection aborted) raise as these.
|
||||
_BENIGN_TEARDOWN_ERRORS = (
|
||||
ConnectionResetError,
|
||||
ConnectionAbortedError,
|
||||
BrokenPipeError,
|
||||
)
|
||||
|
||||
|
||||
def _is_benign_teardown(context: dict[str, Any]) -> bool:
|
||||
"""True when the loop error is a peer-hangup during transport teardown.
|
||||
|
||||
Gated on BOTH the exception type AND the ``_call_connection_lost``
|
||||
callback so we only swallow the disconnect flood — any other place these
|
||||
errors surface (a real handler, a custom callback) still goes to the
|
||||
default handler.
|
||||
"""
|
||||
exc = context.get("exception")
|
||||
if not isinstance(exc, _BENIGN_TEARDOWN_ERRORS):
|
||||
return False
|
||||
# The flood originates from the transport's connection-lost callback. Match
|
||||
# on its repr so we don't suppress the same error type raised elsewhere.
|
||||
callback = context.get("callback")
|
||||
handle = context.get("handle")
|
||||
marker = "_call_connection_lost"
|
||||
return marker in repr(callback) or marker in repr(handle)
|
||||
|
||||
|
||||
def install_loop_noise_filter(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Chain a teardown-noise filter ahead of the loop's existing handler.
|
||||
|
||||
Idempotent: re-installing on a loop that already has the filter is a no-op,
|
||||
so it's safe to call on every reconnect/serve entry.
|
||||
"""
|
||||
if getattr(loop, "_hermes_noise_filter_installed", False):
|
||||
return
|
||||
|
||||
previous = loop.get_exception_handler()
|
||||
|
||||
def _handler(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
||||
if _is_benign_teardown(context):
|
||||
_log.debug(
|
||||
"ws peer hangup during teardown (suppressed): %s",
|
||||
context.get("exception"),
|
||||
)
|
||||
return
|
||||
if previous is not None:
|
||||
previous(loop, context)
|
||||
else:
|
||||
loop.default_exception_handler(context)
|
||||
|
||||
loop.set_exception_handler(_handler)
|
||||
# Mark on the loop instance so a second install (reconnect, re-serve) is a
|
||||
# no-op rather than stacking handlers.
|
||||
try:
|
||||
loop._hermes_noise_filter_installed = True # type: ignore[attr-defined]
|
||||
except (AttributeError, TypeError): # pragma: no cover - exotic loop impls
|
||||
pass
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Session-backed MCP OAuth flows for the gateway (mcp.servers.oauth.*).
|
||||
|
||||
This mirrors the *provider* OAuth model used by the dashboard
|
||||
(``/api/providers/oauth/{id}/start`` + ``/poll/{session_id}``) rather than the
|
||||
FastAPI-request-coupled MCP dashboard flow: a ``start`` primitive kicks off a
|
||||
background worker and returns ``{session_id, auth_url, flow}``; a ``poll``
|
||||
primitive reports ``{status: pending|approved|error}`` until the tokens land on
|
||||
disk for that server in that profile.
|
||||
|
||||
The underlying token machinery is the *same* one the CLI ``hermes mcp login``
|
||||
uses — ``hermes_cli.mcp_config._probe_single_server`` under
|
||||
``tools.mcp_oauth.force_interactive_oauth`` — so no OAuth logic is reimplemented
|
||||
here. The only new piece is decoupling the two browser callbacks (authorization
|
||||
URL out, ``code``/``state`` back in) from a FastAPI ``Request``:
|
||||
|
||||
* ``tools.mcp_dashboard_oauth.DashboardOAuthFlow`` already provides the two
|
||||
thread-safe rendezvous points (``publish_authorization_url`` /
|
||||
``deliver_callback``). We reuse it verbatim as the bridge object.
|
||||
* Instead of routing the browser redirect through a FastAPI callback route, we
|
||||
run a tiny loopback HTTP listener on ``127.0.0.1:<port>/callback`` and set the
|
||||
flow's ``redirect_uri`` to it. When the provider redirects the user's browser
|
||||
there, the listener calls ``flow.deliver_callback(...)``. This is the same
|
||||
loopback strategy the CLI uses by default, just wired to the shared bridge.
|
||||
|
||||
Client contract (what the desktop plugin does):
|
||||
1. call ``mcp.servers.oauth.start(profile, name)`` → ``{session_id, auth_url}``
|
||||
2. open ``auth_url`` in the native browser (``openExternal``)
|
||||
3. poll ``mcp.servers.oauth.poll(profile, name, session_id)`` until
|
||||
``status == "approved"`` (tokens persisted) or ``"error"``.
|
||||
|
||||
Remote-backend variant (client-side callback): when the desktop app runs on a
|
||||
DIFFERENT machine than the gateway (SSH/Tailscale remote backend), the
|
||||
gateway-side ``127.0.0.1`` listener is unreachable from the user's browser —
|
||||
the redirect lands on the user's machine where nothing is listening, and the
|
||||
flow times out. For that topology the client binds its OWN loopback listener
|
||||
(same pattern as the desktop's native gateway login), passes its
|
||||
``redirect_uri`` to ``start`` (``client_redirect_uri``), and relays the
|
||||
provider redirect back via ``deliver_callback_flow`` /
|
||||
``mcp.servers.oauth.callback``. State verification stays server-side in
|
||||
``DashboardOAuthFlow.deliver_callback`` — a relayed code with the wrong
|
||||
``state`` is rejected exactly like a forged loopback hit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Session registry: session_id -> record. A record wraps the shared
|
||||
# DashboardOAuthFlow bridge plus a bit of gateway bookkeeping.
|
||||
_sessions: Dict[str, Dict[str, Any]] = {}
|
||||
_sessions_lock = threading.Lock()
|
||||
|
||||
# How long a completed/abandoned session lingers before GC (seconds).
|
||||
_SESSION_TTL_SECONDS = 900
|
||||
# Cap concurrent in-flight flows so a runaway client can't exhaust ports/threads.
|
||||
_MAX_PENDING = 12
|
||||
|
||||
|
||||
def _gc_sessions() -> None:
|
||||
"""Drop expired sessions. Called opportunistically on start."""
|
||||
cutoff = time.time() - _SESSION_TTL_SECONDS
|
||||
with _sessions_lock:
|
||||
stale = [sid for sid, rec in _sessions.items() if rec["created_at"] < cutoff]
|
||||
for sid in stale:
|
||||
rec = _sessions.pop(sid, None)
|
||||
if rec is not None:
|
||||
_shutdown_listener(rec)
|
||||
|
||||
|
||||
def _shutdown_listener(rec: Dict[str, Any]) -> None:
|
||||
server = rec.get("httpd")
|
||||
if server is not None:
|
||||
try:
|
||||
server.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
rec["httpd"] = None
|
||||
|
||||
|
||||
def _validate_client_redirect_uri(uri: str) -> str:
|
||||
"""Validate a client-supplied loopback redirect URI.
|
||||
|
||||
Only plain-http loopback URLs are accepted (``http://127.0.0.1:<port>/...``
|
||||
or ``http://localhost:<port>/...``), mirroring RFC 8252 native-app rules —
|
||||
the client hosts a one-shot listener on ITS machine, so anything else
|
||||
(public hosts, https proxies, schemes) is rejected to keep the gateway from
|
||||
pinning an attacker-controlled redirect into a DCR registration.
|
||||
"""
|
||||
parsed = urlparse(str(uri or "").strip())
|
||||
host = (parsed.hostname or "").lower()
|
||||
if (
|
||||
parsed.scheme != "http"
|
||||
or host not in ("127.0.0.1", "localhost", "::1")
|
||||
or not parsed.port
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"client_redirect_uri must be a loopback http URL like "
|
||||
"http://127.0.0.1:<port>/callback"
|
||||
)
|
||||
return f"http://{'[' + host + ']' if ':' in host else host}:{parsed.port}{parsed.path or '/callback'}"
|
||||
|
||||
|
||||
def _start_loopback_listener(flow) -> "http.server.HTTPServer":
|
||||
"""Bind a loopback callback listener that feeds the flow's deliver_callback.
|
||||
|
||||
Returns the running HTTPServer (already serving on a daemon thread). The
|
||||
bound port is read back off ``server.server_address`` so the caller can set
|
||||
``flow.redirect_uri`` to the matching ``/callback`` URL BEFORE the worker
|
||||
starts the OAuth flow (the redirect URI must be pinned at authorization).
|
||||
"""
|
||||
|
||||
class _Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802 — stdlib naming
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path.rstrip("/") not in ("/callback", ""):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
qs = parse_qs(parsed.query)
|
||||
code = (qs.get("code") or [None])[0]
|
||||
state = (qs.get("state") or [None])[0]
|
||||
error = (qs.get("error") or [None])[0]
|
||||
body = b"<h1>Authorization received</h1><p>You can close this tab and return to Hermes.</p>"
|
||||
status = 200
|
||||
try:
|
||||
flow.deliver_callback(code=code, state=state, error=error)
|
||||
except Exception:
|
||||
body = b"<h1>OAuth callback rejected</h1><p>The callback was invalid or already used.</p>"
|
||||
status = 400
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def log_message(self, *_a): # silence stdlib request logging
|
||||
return
|
||||
|
||||
httpd = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
threading.Thread(
|
||||
target=httpd.serve_forever,
|
||||
kwargs={"poll_interval": 0.5},
|
||||
daemon=True,
|
||||
name=f"mcp-oauth-cb-{flow.server_name}",
|
||||
).start()
|
||||
return httpd
|
||||
|
||||
|
||||
def _worker(session_id: str, hermes_home: str, server_name: str, cfg: dict, reconnect_live: bool) -> None:
|
||||
"""Drive the interactive MCP OAuth probe under the shared dashboard bridge.
|
||||
|
||||
Structurally identical to ``web_server._run_dashboard_mcp_oauth`` — the same
|
||||
HERMES_HOME override + secret-scope + force_interactive_oauth +
|
||||
dashboard_oauth_flow wrapping around ``_probe_single_server`` — but keyed to
|
||||
our session record instead of a FastAPI request. On success the token file
|
||||
exists on disk (verified via ``_oauth_tokens_present``) and the server config
|
||||
is (re)saved into the profile's config.yaml.
|
||||
"""
|
||||
from hermes_cli.mcp_config import (
|
||||
_oauth_tokens_present,
|
||||
_probe_single_server,
|
||||
_save_mcp_server,
|
||||
)
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
rec = _sessions.get(session_id)
|
||||
flow = rec["flow"] if rec else None
|
||||
try:
|
||||
from agent.secret_scope import (
|
||||
build_profile_secret_scope,
|
||||
reset_secret_scope,
|
||||
set_secret_scope,
|
||||
)
|
||||
from tools.mcp_dashboard_oauth import dashboard_oauth_flow
|
||||
from tools.mcp_oauth import force_interactive_oauth
|
||||
from tools.mcp_oauth_manager import get_manager
|
||||
|
||||
home_token = set_hermes_home_override(hermes_home)
|
||||
secret_token = set_secret_scope(build_profile_secret_scope(Path(hermes_home)))
|
||||
try:
|
||||
with force_interactive_oauth(), dashboard_oauth_flow(flow):
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
|
||||
manager = get_manager()
|
||||
storage = HermesTokenStorage(server_name)
|
||||
backup = storage.snapshot()
|
||||
previous_entry = None
|
||||
try:
|
||||
previous_entry = manager.remove(server_name, hermes_home=hermes_home)
|
||||
tools = _probe_single_server(
|
||||
server_name,
|
||||
cfg,
|
||||
connect_timeout=max(float(cfg.get("connect_timeout", 0) or 0), 315),
|
||||
)
|
||||
if not _oauth_tokens_present(server_name):
|
||||
raise RuntimeError(
|
||||
"The server responded, but no OAuth token was obtained — "
|
||||
"this provider may require a manually-registered OAuth client."
|
||||
)
|
||||
_save_mcp_server(server_name, cfg)
|
||||
if flow is not None:
|
||||
flow.tools = [{"name": t, "description": d} for t, d in tools]
|
||||
flow.mark_approved()
|
||||
if reconnect_live:
|
||||
from tools.mcp_tool import reconnect_mcp_server
|
||||
|
||||
reconnect_mcp_server(server_name)
|
||||
except Exception:
|
||||
storage.restore(backup, only_if_absent=True)
|
||||
manager.restore_entry(server_name, previous_entry, hermes_home=hermes_home)
|
||||
raise
|
||||
finally:
|
||||
reset_secret_scope(secret_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
try:
|
||||
from tools.mcp_oauth import humanize_oauth_registration_error
|
||||
|
||||
humanized = humanize_oauth_registration_error(
|
||||
server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None
|
||||
)
|
||||
if humanized:
|
||||
msg = humanized
|
||||
except Exception:
|
||||
pass
|
||||
if flow is not None:
|
||||
flow.mark_error(msg)
|
||||
finally:
|
||||
if flow is not None:
|
||||
flow.mark_worker_done()
|
||||
if rec is not None:
|
||||
_shutdown_listener(rec)
|
||||
|
||||
|
||||
def start_flow(
|
||||
hermes_home: str,
|
||||
server_name: str,
|
||||
cfg: dict,
|
||||
*,
|
||||
reconnect_live: bool = False,
|
||||
url_timeout: float = 30.0,
|
||||
client_redirect_uri: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Begin an MCP OAuth flow and return ``{session_id, auth_url, flow}``.
|
||||
|
||||
``cfg`` is the server's resolved config dict (must have ``url`` and be
|
||||
OAuth-capable). ``hermes_home`` is the already-resolved profile home dir
|
||||
string. Blocks up to ``url_timeout`` for the worker to publish the browser
|
||||
authorization URL, then returns it.
|
||||
|
||||
``client_redirect_uri`` (remote-backend variant): a loopback callback URL
|
||||
the CLIENT hosts on its own machine. When set (and valid), no gateway-side
|
||||
listener is bound — the OAuth ``redirect_uri`` is pinned to the client's
|
||||
listener, and the client relays the redirect's ``code``/``state`` back via
|
||||
``deliver_callback_flow``. Invalid values raise ``ValueError``.
|
||||
"""
|
||||
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
|
||||
|
||||
if client_redirect_uri is not None:
|
||||
client_redirect_uri = _validate_client_redirect_uri(client_redirect_uri)
|
||||
|
||||
_gc_sessions()
|
||||
|
||||
with _sessions_lock:
|
||||
pending = sum(
|
||||
1
|
||||
for r in _sessions.values()
|
||||
if not r["flow"].worker_done
|
||||
)
|
||||
if pending >= _MAX_PENDING:
|
||||
raise RuntimeError("Too many MCP OAuth flows are already in progress")
|
||||
if any(
|
||||
r["server_name"] == server_name
|
||||
and r["hermes_home"] == hermes_home
|
||||
and not r["flow"].worker_done
|
||||
for r in _sessions.values()
|
||||
):
|
||||
raise RuntimeError(f"MCP OAuth for '{server_name}' is already in progress")
|
||||
|
||||
session_id = secrets.token_urlsafe(24)
|
||||
flow = DashboardOAuthFlow(
|
||||
flow_id=session_id,
|
||||
server_name=server_name,
|
||||
profile=None,
|
||||
hermes_home=hermes_home,
|
||||
redirect_uri="", # set below once the loopback port is known
|
||||
reconnect_live=reconnect_live,
|
||||
)
|
||||
if client_redirect_uri:
|
||||
# Remote-backend variant: the CLIENT hosts the callback listener on its
|
||||
# own machine and relays the code via deliver_callback_flow(). No
|
||||
# gateway-side listener is bound — a 127.0.0.1 port here would be
|
||||
# unreachable from the user's browser anyway.
|
||||
httpd = None
|
||||
flow.redirect_uri = client_redirect_uri
|
||||
else:
|
||||
httpd = _start_loopback_listener(flow)
|
||||
port = httpd.server_address[1]
|
||||
flow.redirect_uri = f"http://127.0.0.1:{port}/callback"
|
||||
|
||||
rec = {
|
||||
"session_id": session_id,
|
||||
"server_name": server_name,
|
||||
"hermes_home": hermes_home,
|
||||
"flow": flow,
|
||||
"httpd": httpd,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
with _sessions_lock:
|
||||
_sessions[session_id] = rec
|
||||
|
||||
threading.Thread(
|
||||
target=_worker,
|
||||
args=(session_id, hermes_home, server_name, dict(cfg), reconnect_live),
|
||||
daemon=True,
|
||||
name=f"mcp-oauth-{server_name}",
|
||||
).start()
|
||||
|
||||
try:
|
||||
auth_url = None
|
||||
# wait_for_authorization_url is async; run its wait synchronously.
|
||||
deadline = time.time() + url_timeout
|
||||
while time.time() < deadline:
|
||||
snap = flow.snapshot()
|
||||
if snap.get("authorization_url"):
|
||||
auth_url = snap["authorization_url"]
|
||||
break
|
||||
if snap.get("status") == "error":
|
||||
raise RuntimeError(snap.get("error") or "MCP OAuth flow failed before authorization")
|
||||
time.sleep(0.1)
|
||||
if not auth_url:
|
||||
raise TimeoutError("Timed out waiting for MCP authorization URL")
|
||||
except Exception:
|
||||
flow.mark_error("Timed out waiting for MCP authorization URL")
|
||||
_shutdown_listener(rec)
|
||||
raise
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"auth_url": auth_url,
|
||||
# "pkce" mirrors the provider-OAuth ``flow`` discriminator: the client
|
||||
# opens a URL then polls (no user_code to type, unlike device_code).
|
||||
"flow": "pkce",
|
||||
}
|
||||
|
||||
|
||||
def poll_flow(session_id: str, server_name: str) -> Dict[str, Any]:
|
||||
"""Poll a session's status → ``{status, error_message?, auth_url?, tools?}``.
|
||||
|
||||
``status`` is one of ``pending`` | ``approved`` | ``error`` — the same
|
||||
vocabulary as the provider poll endpoint (``authorization_required`` from
|
||||
the underlying bridge maps to ``pending`` since the client only needs to
|
||||
know whether to keep waiting).
|
||||
"""
|
||||
with _sessions_lock:
|
||||
rec = _sessions.get(session_id)
|
||||
if rec is None:
|
||||
return {"status": "error", "error_message": "OAuth session not found or expired"}
|
||||
if rec["server_name"] != server_name:
|
||||
return {"status": "error", "error_message": "server name mismatch for session"}
|
||||
|
||||
flow = rec["flow"]
|
||||
snap = flow.snapshot()
|
||||
raw = snap.get("status")
|
||||
if raw == "approved":
|
||||
status = "approved"
|
||||
elif raw == "error":
|
||||
status = "error"
|
||||
else:
|
||||
status = "pending"
|
||||
out: Dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"status": status,
|
||||
"error_message": snap.get("error"),
|
||||
"auth_url": snap.get("authorization_url"),
|
||||
}
|
||||
if status == "approved":
|
||||
out["tools"] = list(getattr(flow, "tools", []) or [])
|
||||
return out
|
||||
|
||||
|
||||
def deliver_callback_flow(
|
||||
session_id: str,
|
||||
server_name: str,
|
||||
*,
|
||||
code: Optional[str],
|
||||
state: Optional[str],
|
||||
error: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Relay a client-captured OAuth redirect into a session's flow.
|
||||
|
||||
Remote-backend companion to ``start_flow(client_redirect_uri=...)``: the
|
||||
desktop app's loopback listener caught the provider redirect on the USER'S
|
||||
machine and forwards ``code``/``state`` (or ``error``) here. Security
|
||||
properties are unchanged from the gateway-listener path — the underlying
|
||||
``DashboardOAuthFlow.deliver_callback`` verifies ``state`` against the
|
||||
pinned authorization request (constant-time compare) and rejects replays,
|
||||
so a forged or replayed relay fails identically to a forged loopback hit.
|
||||
|
||||
Returns ``{ok: true}`` on acceptance or ``{ok: false, error_message}``.
|
||||
"""
|
||||
with _sessions_lock:
|
||||
rec = _sessions.get(session_id)
|
||||
if rec is None:
|
||||
return {"ok": False, "error_message": "OAuth session not found or expired"}
|
||||
if rec["server_name"] != server_name:
|
||||
return {"ok": False, "error_message": "server name mismatch for session"}
|
||||
|
||||
flow = rec["flow"]
|
||||
try:
|
||||
flow.deliver_callback(code=code, state=state, error=error)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error_message": str(exc)}
|
||||
return {"ok": True, "session_id": session_id}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Shared helpers for the per-profile MCP lifecycle RPCs (mcp.servers.*).
|
||||
|
||||
These live in their own module (not methods_tools) because methods_tools
|
||||
handlers are rebound onto ``tui_gateway.server``'s globals at install time
|
||||
(see method_ctx.HandlerRegistry.install); a plain module-level def in
|
||||
methods_tools would not be reachable from a rebound handler body. Handlers
|
||||
import these at call time instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
|
||||
def resolve_profile(rid, params, err_fn) -> Tuple[Optional[Any], Optional[dict]]:
|
||||
"""Resolve the optional ``profile`` param to a HERMES_HOME override token.
|
||||
|
||||
Returns ``(token, error)``: ``token`` is None for the launch profile (no
|
||||
override) or an opaque reset token; ``error`` is a JSON-RPC error dict
|
||||
(built via ``err_fn``) when the named profile doesn't exist. Callers reset
|
||||
``token`` in a finally via :func:`reset_profile`.
|
||||
"""
|
||||
profile = str(params.get("profile") or "").strip()
|
||||
if not profile:
|
||||
return None, None
|
||||
from hermes_cli.profiles import get_profile_dir
|
||||
from hermes_constants import set_hermes_home_override
|
||||
|
||||
profile_dir = get_profile_dir(profile)
|
||||
if not profile_dir or not profile_dir.is_dir():
|
||||
return None, err_fn(rid, 4064, f"profile '{profile}' not found")
|
||||
return set_hermes_home_override(str(profile_dir)), None
|
||||
|
||||
|
||||
def reset_profile(token) -> None:
|
||||
if token is not None:
|
||||
try:
|
||||
from hermes_constants import reset_hermes_home_override
|
||||
|
||||
reset_hermes_home_override(token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def summarize_server(name: str, cfg: dict) -> Dict[str, Any]:
|
||||
"""Serialize one server's config for a UI (no secret values).
|
||||
|
||||
Mirrors web_server._mcp_server_summary plus an ``oauth_tokens_present``
|
||||
flag so a UI can tell an OAuth server that still needs authentication from
|
||||
one already authenticated.
|
||||
"""
|
||||
from hermes_cli.mcp_config import _oauth_tokens_present
|
||||
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown")
|
||||
auth = cfg.get("auth")
|
||||
headers = cfg.get("headers") or {}
|
||||
if not auth and isinstance(headers, dict) and any(
|
||||
str(key).lower() == "authorization" for key in headers
|
||||
):
|
||||
auth = "header"
|
||||
tokens_present = _oauth_tokens_present(name) if auth == "oauth" else None
|
||||
return {
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"url": cfg.get("url"),
|
||||
"command": cfg.get("command"),
|
||||
"args": list(cfg.get("args") or []),
|
||||
"env": sorted(str(k) for k in (cfg.get("env") or {})),
|
||||
"auth": auth,
|
||||
"oauth_tokens_present": tokens_present,
|
||||
"enabled": cfg.get("enabled", True) is not False,
|
||||
"tools": cfg.get("tools"),
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Seam for the server.py @method handler split (mechanical move).
|
||||
|
||||
server.py's ~130 JSON-RPC handlers close over its module globals
|
||||
(``_sessions``, ``_ok``, ``_err``, config helpers, ...). To move them
|
||||
out of the 19K-line module without rewriting a single handler body,
|
||||
each ``methods_*`` module defines its handlers under a local
|
||||
:class:`HandlerRegistry` and server.py calls :meth:`HandlerRegistry.install`
|
||||
at the end of its own import, once every global the handlers close over
|
||||
exists. ``install()`` rebinds each handler's ``__globals__`` to
|
||||
server.py's namespace with ``types.FunctionType``, so handler bodies
|
||||
stay byte-identical and ``global X`` statements inside handlers keep
|
||||
mutating server.py state exactly as before the split.
|
||||
|
||||
No import cycle: ``methods_*`` modules never import server at module
|
||||
level — server imports them and passes itself to ``register()``.
|
||||
"""
|
||||
|
||||
import types
|
||||
|
||||
|
||||
class HandlerRegistry:
|
||||
"""Deferred @method registrar used by the methods_* split modules."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: list[tuple[str, types.FunctionType]] = []
|
||||
|
||||
def method(self, name: str):
|
||||
"""Drop-in for server.py's ``@method`` decorator (defers registration)."""
|
||||
|
||||
def dec(fn):
|
||||
self._pending.append((name, fn))
|
||||
return fn
|
||||
|
||||
return dec
|
||||
|
||||
def profile_scoped(self, fn):
|
||||
"""Drop-in for server.py's ``@_profile_scoped`` (applied at install)."""
|
||||
fn._hermes_profile_scoped = True
|
||||
return fn
|
||||
|
||||
def install(self, server) -> None:
|
||||
"""Rebind pending handlers onto ``server``'s globals and register them."""
|
||||
g = vars(server)
|
||||
for name, fn in self._pending:
|
||||
real = types.FunctionType(
|
||||
fn.__code__, g, fn.__name__, fn.__defaults__, fn.__closure__
|
||||
)
|
||||
real.__kwdefaults__ = fn.__kwdefaults__
|
||||
real.__doc__ = fn.__doc__
|
||||
real.__dict__.update(fn.__dict__)
|
||||
if getattr(fn, "_hermes_profile_scoped", False):
|
||||
real = server._profile_scoped(real)
|
||||
server._methods[name] = real
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Bot-relay JSON-RPC handlers — the gateway side of cross-connection A2A.
|
||||
|
||||
Connections ARE the peer set: every gateway the Desktop holds a socket to
|
||||
(local, remote URL, SSH, Hermes Cloud, docker) must be able to find every
|
||||
other connection's agents and message them. The Desktop is the relay — it
|
||||
owns every socket — and these four methods are the door it uses on EACH
|
||||
connected gateway:
|
||||
|
||||
- ``bot_relay.roster.sync`` — Desktop pushes the union roster of agents on
|
||||
the OTHER connections into this gateway's ``bot_relay/roster.json``, so
|
||||
``message_agent`` can resolve cross-connection targets and Bot Chat
|
||||
prompts list them (capability-epoch refresh picks up changes).
|
||||
- ``bot_relay.outbox.drain`` — Desktop collects envelopes queued here by
|
||||
``message_agent`` for targets on other connections.
|
||||
- ``bot_relay.deliver`` — Desktop hands an envelope to the TARGET
|
||||
gateway; this method runs the same one-turn Bot Chat delivery local DMs
|
||||
use and returns the reply text.
|
||||
- ``bot_relay.reply`` — Desktop writes the reply (or a delivery
|
||||
error) back on the SENDER gateway; the waiter spawned at send time picks
|
||||
it up and wakes the sending agent via the standard completion path.
|
||||
|
||||
Storage/validation plumbing lives in ``tools/bot_relay.py``. Handlers are
|
||||
rebound onto server.py's globals at install time (see method_ctx.py) and may
|
||||
reference server module globals (``_ok``, ``_err``) not imported here.
|
||||
"""
|
||||
|
||||
from .method_ctx import HandlerRegistry
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
|
||||
|
||||
@method("bot_relay.roster.sync")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Replace this gateway's view of agents on OTHER connections.
|
||||
|
||||
Params: ``agents`` — list of rows ``{profile, handle, connection_id,
|
||||
connection_label?, title?, description?}``. Rows failing validation are
|
||||
dropped, not fatal. Result: ``{count}`` (accepted rows).
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from tools.bot_relay import write_remote_roster
|
||||
|
||||
home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes"))
|
||||
root = home.parent.parent if home.parent.name == "profiles" else home
|
||||
count = write_remote_roster(root, params.get("agents"))
|
||||
return _ok(rid, {"count": count})
|
||||
except Exception as e:
|
||||
return _err(rid, 5090, str(e))
|
||||
|
||||
|
||||
@method("bot_relay.outbox.drain")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Claim every pending cross-connection envelope queued on this gateway.
|
||||
|
||||
Claimed envelopes move to ``claimed/`` atomically, so concurrent drains
|
||||
(two Desktop windows) can't double-deliver. Result: ``{envelopes}``.
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from tools.bot_relay import claim_pending_envelopes
|
||||
|
||||
home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes"))
|
||||
root = home.parent.parent if home.parent.name == "profiles" else home
|
||||
return _ok(rid, {"envelopes": claim_pending_envelopes(root)})
|
||||
except Exception as e:
|
||||
return _err(rid, 5091, str(e))
|
||||
|
||||
|
||||
@method("bot_relay.deliver")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Deliver a relayed DM into a profile's Bot Chat ON THIS GATEWAY.
|
||||
|
||||
Params: ``profile`` (target on this install), ``message`` (already
|
||||
attribution-prefixed by the sender gateway). Runs the same one-turn
|
||||
``hermes -p <profile> chat -c "Bot Chat"`` transport local DMs use and
|
||||
returns ``{reply}`` — the target agent's response text. Blocking by
|
||||
design (the Desktop calls it from its relay worker, off any UI path;
|
||||
the RPC pool keeps it off the WS reader thread).
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
profile = str(params.get("profile") or "").strip()
|
||||
message = str(params.get("message") or "").strip()
|
||||
if not profile or not message:
|
||||
return _err(rid, 4090, "profile and message required")
|
||||
try:
|
||||
from tools.bot_mode_dm import MESSAGE_MAX_CHARS
|
||||
from tools.bot_relay import acquire_turn_lock, local_delivery_command
|
||||
|
||||
if len(message) > MESSAGE_MAX_CHARS + 200: # + attribution headroom
|
||||
return _err(rid, 4091, "message too long")
|
||||
|
||||
home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes"))
|
||||
root = home.parent.parent if home.parent.name == "profiles" else home
|
||||
known = {"default"}
|
||||
profiles_dir = root / "profiles"
|
||||
if profiles_dir.is_dir():
|
||||
known.update(c.name for c in profiles_dir.iterdir() if c.is_dir())
|
||||
resolved = "default" if profile.lower() == "hermes" else profile
|
||||
if resolved not in known:
|
||||
return _err(rid, 4092, f"no profile '{profile}' on this gateway")
|
||||
|
||||
# #100523: when THIS gateway already hosts the target's Bot Chat live
|
||||
# (the Desktop has it open), the subprocess transport is fenced out by
|
||||
# the single-owner lease ("already has a live owner") and the payload
|
||||
# is dropped. Land the DM in the live session as a normal user turn
|
||||
# via prompt.submit instead — same choke point the composer uses, so
|
||||
# role alternation, persistence and streaming all behave as a typed
|
||||
# message would. (Nested per method_ctx rebinding.)
|
||||
def _live_bot_chat_sid(profile_name: str) -> str:
|
||||
from tools.bot_mode_probe import BOT_CHAT_TITLE
|
||||
|
||||
live_home = _profile_home(profile_name)
|
||||
want_home = str(live_home) if live_home is not None else None
|
||||
for live_sid, record in list(_sessions.items()):
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
if (record.get("profile_home") or None) != want_home:
|
||||
continue
|
||||
key = _session_lookup_key(record, fallback=live_sid)
|
||||
if _session_live_title(record, key) == BOT_CHAT_TITLE:
|
||||
return live_sid
|
||||
return ""
|
||||
|
||||
live_sid = _live_bot_chat_sid(resolved)
|
||||
if live_sid:
|
||||
# queued=True: a teammate's DM runs as the NEXT turn. It must never
|
||||
# interrupt or steer a turn already in flight (the default busy
|
||||
# mode does); hundreds of arrivals simply queue in arrival order.
|
||||
submitted = _methods["prompt.submit"](rid, {"session_id": live_sid, "text": message, "queued": True})
|
||||
if "error" in submitted:
|
||||
return submitted
|
||||
return _ok(
|
||||
rid,
|
||||
{"reply": f"Delivered into @{resolved}'s open Bot Chat; the reply will appear there."},
|
||||
)
|
||||
|
||||
fd, tmp = tempfile.mkstemp(prefix="hermes-relay-dm-", suffix=".txt", text=True)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(message)
|
||||
# Per-profile turn lock (#93091): serialize with any other
|
||||
# delivery turn into this profile (relay or local message_agent).
|
||||
# The lock covers only the turn execution window. Worst-case
|
||||
# handler hold is lock wait (bot_mode.turn_wait_seconds, default
|
||||
# 120s) + the 600s turn timeout below — doubled when the retry
|
||||
# policy grants one bounded re-run — so clients calling
|
||||
# bot_relay.deliver must tolerate ~1320s before assuming failure.
|
||||
with acquire_turn_lock(root, resolved):
|
||||
proc = subprocess.run(
|
||||
local_delivery_command(resolved, tmp),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=600,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
# Retry session policy (#93091 item 5): transient classes
|
||||
# re-run the SAME session once; context_overflow also
|
||||
# re-runs the same session — the retried turn's pre-API
|
||||
# compaction pass (agent/conversation_loop.py) compacts
|
||||
# the over-threshold Bot Chat transcript first, which is
|
||||
# the sanctioned compression lever (no fresh session is
|
||||
# ever minted). Auth/quota/config classes never retry.
|
||||
from tools.bot_failure_reasons import (
|
||||
RETRY_NONE,
|
||||
classify_agent_error,
|
||||
retry_action,
|
||||
)
|
||||
|
||||
first_detail = (proc.stderr or proc.stdout or "").strip()[-500:]
|
||||
if retry_action(classify_agent_error(first_detail)) != RETRY_NONE:
|
||||
proc = subprocess.run(
|
||||
local_delivery_command(resolved, tmp),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=600,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
if proc.returncode != 0:
|
||||
from tools.bot_failure_reasons import classify_agent_error
|
||||
|
||||
detail = (proc.stderr or proc.stdout or "").strip()[-500:]
|
||||
return _err(
|
||||
rid,
|
||||
5092,
|
||||
f"delivery turn failed: {detail or proc.returncode}",
|
||||
data={"reason": classify_agent_error(detail)},
|
||||
)
|
||||
return _ok(rid, {"reply": (proc.stdout or "").strip()})
|
||||
except subprocess.TimeoutExpired:
|
||||
return _err(rid, 5093, "delivery turn timed out")
|
||||
except Exception as e:
|
||||
# 'target_busy' extends the #93091 item-1 structured refusal enum.
|
||||
if getattr(e, "reason", "") == "target_busy":
|
||||
return _err(rid, 5096, str(e))
|
||||
return _err(rid, 5094, str(e))
|
||||
|
||||
|
||||
@method("bot_relay.reply")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Write a relayed reply (or delivery error) for a sender-side waiter.
|
||||
|
||||
Params: ``id`` (envelope id), ``reply`` and/or ``error``, optional
|
||||
``reason`` (typed failure code, see ``tools.bot_failure_reasons``).
|
||||
"""
|
||||
envelope_id = str(params.get("id") or "").strip()
|
||||
if not envelope_id:
|
||||
return _err(rid, 4093, "id required")
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from tools.bot_relay import write_reply
|
||||
|
||||
home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes"))
|
||||
root = home.parent.parent if home.parent.name == "profiles" else home
|
||||
write_reply(
|
||||
root,
|
||||
envelope_id,
|
||||
reply=str(params.get("reply") or ""),
|
||||
error=str(params.get("error") or ""),
|
||||
reason=str(params.get("reason") or ""),
|
||||
)
|
||||
return _ok(rid, {"ok": True})
|
||||
except ValueError as e:
|
||||
return _err(rid, 4094, str(e))
|
||||
except Exception as e:
|
||||
return _err(rid, 5095, str(e))
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
_registry.install(server)
|
||||
from . import methods_groups
|
||||
|
||||
server._LONG_HANDLERS = server._LONG_HANDLERS | methods_groups.LONG_HANDLERS
|
||||
server.get_hosted_room_service = methods_groups.get_hosted_room_service
|
||||
server._WORKER_UNAVAILABLE = methods_groups._WORKER_UNAVAILABLE
|
||||
server._profile_name = methods_groups._profile_name
|
||||
server._requested_profile = methods_groups._requested_profile
|
||||
server._api_server_key = methods_groups._api_server_key
|
||||
server._room_link_run_storage_durable = (
|
||||
methods_groups._room_link_run_storage_durable
|
||||
)
|
||||
methods_groups.bind_server(server)
|
||||
methods_groups.register(server)
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Browser controller registration and result routing for the dashboard.
|
||||
|
||||
The dashboard's browser controller (the extension that physically drives a
|
||||
browser) registers itself over the authenticated ``/api/ws`` JSON-RPC
|
||||
gateway. Everything here is bound to the **server-minted identity** that the
|
||||
dashboard auth layer stamped onto the WS connection: ``hermes_cli.web_server``
|
||||
consumes the single-use ticket and records ``ws._hermes_auth_identity``, the
|
||||
WS transport carries it as ``WSTransport.auth_identity``, and the client can
|
||||
never name its own principal (a spoofed ``principal_id`` param is ignored and
|
||||
replaced by a server-derived digest of the authenticated identity).
|
||||
|
||||
Registration attaches the shared transport-neutral broker
|
||||
(:mod:`gateway.browser_control_broker`) with the calling transport as owner;
|
||||
broker command/cancel frames are wrapped as standard Gateway ``event`` frames
|
||||
(``type`` = broker method name, ``payload`` = broker params, plus the owning
|
||||
``session_id``) so the dashboard consumes the same envelope as every other
|
||||
gateway event. ``browser.controller.result`` resolves a pending command only
|
||||
when the request arrives on the same transport that owns the session, and
|
||||
only for the exact attached scope — the broker's exact-scope ``complete`` is
|
||||
the last line of defense against cross-tenant completion.
|
||||
|
||||
Both dashboard and local API transports use the broker's shared, explicit
|
||||
capability allowlist. Raw CDP, script evaluation, console access, uploads, and
|
||||
other privileged surfaces are not controller capabilities.
|
||||
|
||||
Note on handler globals: ``HandlerRegistry.install`` (method_ctx.py) rebinds
|
||||
each handler's ``__globals__`` onto server.py's namespace, so handler bodies
|
||||
may only reference names server.py defines/imports (``_ok``, ``_err``,
|
||||
``_sessions``, ``_sessions_lock``, ``current_transport``, ``logger``, ...).
|
||||
This module's own helpers and constants are therefore captured through
|
||||
keyword-default arguments, which ``install`` preserves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from gateway.browser_control_broker import (
|
||||
BROWSER_CONTROL_PROTOCOL_VERSION,
|
||||
browser_control_protocol_supported,
|
||||
filter_browser_control_capabilities,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.ws_tickets import (
|
||||
INTERNAL_PROVIDER as _INTERNAL_PROVIDER,
|
||||
INTERNAL_USER_ID as _INTERNAL_USER_ID,
|
||||
)
|
||||
|
||||
from .method_ctx import HandlerRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
|
||||
#: Transport family stamped into every scope attached from this gateway. The
|
||||
#: broker's exact-match contract treats it as an identity field, so an API
|
||||
#: transport can never address a dashboard controller (and vice versa).
|
||||
_CLOUD_TRANSPORT_FAMILY = "cloud-ticket-ws"
|
||||
|
||||
#: JSON-RPC error code for identity / session / flag denials (forbidden).
|
||||
_ERR_FORBIDDEN = 4403
|
||||
|
||||
|
||||
def _is_authenticated_identity(identity: object) -> bool:
|
||||
"""True for a server-minted, non-internal ``{user_id, provider}`` identity."""
|
||||
if not isinstance(identity, dict):
|
||||
return False
|
||||
user_id = identity.get("user_id")
|
||||
provider = identity.get("provider")
|
||||
if not isinstance(user_id, str) or not user_id.strip():
|
||||
return False
|
||||
if not isinstance(provider, str) or not provider.strip():
|
||||
return False
|
||||
if user_id == _INTERNAL_USER_ID and provider == _INTERNAL_PROVIDER:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _principal_digest(identity: dict) -> str:
|
||||
"""Server-derived principal id: a digest of the server-minted identity.
|
||||
|
||||
The client-supplied ``principal_id`` RPC param is never trusted; the
|
||||
digest is deterministic (stable across reconnects for the same user) but
|
||||
unspoofable by a peer that does not hold the authenticated identity.
|
||||
"""
|
||||
raw = f"{identity.get('provider')}\x00{identity.get('user_id')}"
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
return f"principal:dashboard:{digest[:32]}"
|
||||
|
||||
|
||||
def _broker_event_writer(transport: object, session_id: str):
|
||||
"""Wrap broker command/cancel frames as standard Gateway event frames.
|
||||
|
||||
The broker's send callback receives transport-neutral envelopes like
|
||||
``{"method": "browser.controller.command", "params": {...}}``; the
|
||||
dashboard speaks Gateway events, so we re-envelope them:
|
||||
``{"jsonrpc": "2.0", "method": "event", "params": {"type": <method>,
|
||||
"session_id": <owner>, "payload": <params>}}``.
|
||||
"""
|
||||
|
||||
def send(frame: dict) -> None:
|
||||
try:
|
||||
accepted = transport.write(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "event",
|
||||
"params": {
|
||||
"type": frame.get("method"),
|
||||
"session_id": session_id,
|
||||
"payload": frame.get("params"),
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"browser controller event write failed session=%s frame=%s",
|
||||
session_id,
|
||||
frame.get("method"),
|
||||
)
|
||||
raise
|
||||
if accepted is False:
|
||||
raise ConnectionError("browser controller event write failed")
|
||||
|
||||
return send
|
||||
|
||||
|
||||
@method("browser.controller.register")
|
||||
def _(
|
||||
rid,
|
||||
params: dict,
|
||||
_family=_CLOUD_TRANSPORT_FAMILY,
|
||||
_protocol_version=BROWSER_CONTROL_PROTOCOL_VERSION,
|
||||
_protocol_supported=browser_control_protocol_supported,
|
||||
_filter_capabilities=filter_browser_control_capabilities,
|
||||
_forbidden=_ERR_FORBIDDEN,
|
||||
_identity_ok=_is_authenticated_identity,
|
||||
_digest=_principal_digest,
|
||||
_event_writer=_broker_event_writer,
|
||||
) -> dict:
|
||||
"""Attach this connection as the browser controller for one session.
|
||||
|
||||
Fails closed (4403) unless *every* gate passes:
|
||||
|
||||
* the ``browser.extension_control.enabled`` feature flag is on;
|
||||
* the calling transport holds a server-authenticated, non-internal
|
||||
identity (``WSTransport.auth_identity`` — never the RPC params);
|
||||
* the named session exists in the live session registry and its
|
||||
``transport`` is exactly the calling transport;
|
||||
* at least one requested capability survives the shared allowlist.
|
||||
|
||||
The returned ``scope`` names a server-derived ``principal_id``, the
|
||||
``cloud-ticket-ws`` transport family, and the filtered capability set.
|
||||
"""
|
||||
from gateway import browser_control_broker
|
||||
|
||||
if not browser_control_broker.browser_control_enabled():
|
||||
return _err(
|
||||
rid,
|
||||
_forbidden,
|
||||
"browser.extension_control.enabled is not set",
|
||||
)
|
||||
|
||||
if not _protocol_supported(params.get("protocol_version")):
|
||||
return _err(
|
||||
rid,
|
||||
_forbidden,
|
||||
f"unsupported browser-control protocol version; expected {_protocol_version}",
|
||||
)
|
||||
|
||||
transport = current_transport()
|
||||
identity = getattr(transport, "auth_identity", None)
|
||||
if not _identity_ok(identity):
|
||||
return _err(
|
||||
rid,
|
||||
_forbidden,
|
||||
"browser.controller.register requires an authenticated "
|
||||
"non-internal identity",
|
||||
)
|
||||
|
||||
session_id = str(params.get("session_id") or "")
|
||||
with _sessions_lock:
|
||||
session = _sessions.get(session_id)
|
||||
if session is None or session.get("transport") is not transport:
|
||||
return _err(
|
||||
rid,
|
||||
_forbidden,
|
||||
"session is not owned by this transport",
|
||||
)
|
||||
|
||||
controller_id = str(params.get("controller_id") or "").strip()
|
||||
browser_profile_id = str(params.get("browser_profile_id") or "").strip()
|
||||
profile_id = str(session.get("profile") or "").strip()
|
||||
if not controller_id or not browser_profile_id or not profile_id:
|
||||
return _err(
|
||||
rid,
|
||||
_forbidden,
|
||||
"controller_id, browser_profile_id, and server session profile are required",
|
||||
)
|
||||
|
||||
capabilities = _filter_capabilities(params.get("capabilities"))
|
||||
if not capabilities:
|
||||
return _err(
|
||||
rid,
|
||||
_forbidden,
|
||||
"no permitted controller capabilities requested",
|
||||
)
|
||||
|
||||
scope = browser_control_broker.ControllerScope(
|
||||
principal_id=_digest(identity),
|
||||
profile_id=profile_id,
|
||||
session_id=session_id,
|
||||
controller_id=controller_id,
|
||||
browser_profile_id=browser_profile_id,
|
||||
transport_family=_family,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
broker = browser_control_broker.get_browser_control_broker()
|
||||
broker.attach(
|
||||
scope,
|
||||
_event_writer(transport, session_id),
|
||||
owner=transport,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"scope": {
|
||||
"principal_id": scope.principal_id,
|
||||
"profile_id": scope.profile_id,
|
||||
"session_id": scope.session_id,
|
||||
"controller_id": scope.controller_id,
|
||||
"browser_profile_id": scope.browser_profile_id,
|
||||
"transport_family": scope.transport_family,
|
||||
"capabilities": sorted(scope.capabilities),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("browser.controller.result")
|
||||
def _(
|
||||
rid,
|
||||
params: dict,
|
||||
_family=_CLOUD_TRANSPORT_FAMILY,
|
||||
_forbidden=_ERR_FORBIDDEN,
|
||||
_identity_ok=_is_authenticated_identity,
|
||||
_digest=_principal_digest,
|
||||
) -> dict:
|
||||
"""Deliver one controller command result back to the broker.
|
||||
|
||||
Only the transport that owns the session may resolve its commands, and
|
||||
only against the exact scope attached for that session (the broker's
|
||||
exact-scope ``complete`` rejects any other scope). ``accepted`` is
|
||||
``False`` for unknown / already-resolved / cancelled command ids — the
|
||||
broker's idempotent answer, surfaced verbatim.
|
||||
"""
|
||||
from gateway import browser_control_broker
|
||||
|
||||
transport = current_transport()
|
||||
identity = getattr(transport, "auth_identity", None)
|
||||
if not _identity_ok(identity):
|
||||
return _err(rid, _forbidden, "authenticated controller identity required")
|
||||
session_id = str(params.get("session_id") or "")
|
||||
with _sessions_lock:
|
||||
session = _sessions.get(session_id)
|
||||
if session is None or session.get("transport") is not transport:
|
||||
return _err(
|
||||
rid,
|
||||
_forbidden,
|
||||
"session is not owned by this transport",
|
||||
)
|
||||
|
||||
command_id = str(params.get("command_id") or "")
|
||||
if not command_id:
|
||||
return _err(rid, _forbidden, "command_id required")
|
||||
|
||||
broker = browser_control_broker.get_browser_control_broker()
|
||||
scope = broker.scope_for_session(
|
||||
session_id=session_id,
|
||||
principal_id=_digest(identity),
|
||||
transport_family=_family,
|
||||
)
|
||||
if scope is None:
|
||||
return _err(
|
||||
rid,
|
||||
_forbidden,
|
||||
"no controller registered for this session",
|
||||
)
|
||||
# Defense in depth: the exact-scope complete below already rejects any
|
||||
# foreign scope, but the owner check makes the "same transport" rule
|
||||
# explicit at this layer too.
|
||||
if not broker.is_owner(scope, transport):
|
||||
return _err(
|
||||
rid,
|
||||
_forbidden,
|
||||
"controller is not owned by this transport",
|
||||
)
|
||||
|
||||
ok = params.get("ok") is True
|
||||
accepted = broker.complete(
|
||||
command_id,
|
||||
scope=scope,
|
||||
ok=ok,
|
||||
result=params.get("result") if ok else params.get("error"),
|
||||
)
|
||||
return _ok(rid, {"accepted": accepted})
|
||||
|
||||
|
||||
@method("browser.controller.heartbeat")
|
||||
def _(
|
||||
rid,
|
||||
params: dict,
|
||||
_family=_CLOUD_TRANSPORT_FAMILY,
|
||||
_forbidden=_ERR_FORBIDDEN,
|
||||
_identity_ok=_is_authenticated_identity,
|
||||
_digest=_principal_digest,
|
||||
) -> dict:
|
||||
"""Acknowledge a heartbeat only for this transport's attached controller."""
|
||||
from gateway import browser_control_broker
|
||||
|
||||
transport = current_transport()
|
||||
identity = getattr(transport, "auth_identity", None)
|
||||
if not _identity_ok(identity):
|
||||
return _err(rid, _forbidden, "authenticated controller identity required")
|
||||
session_id = str(params.get("session_id") or "")
|
||||
with _sessions_lock:
|
||||
session = _sessions.get(session_id)
|
||||
if session is None or session.get("transport") is not transport:
|
||||
return _err(rid, _forbidden, "session is not owned by this transport")
|
||||
|
||||
broker = browser_control_broker.get_browser_control_broker()
|
||||
scope = broker.scope_for_session(
|
||||
session_id=session_id,
|
||||
principal_id=_digest(identity),
|
||||
transport_family=_family,
|
||||
)
|
||||
if scope is None:
|
||||
return _err(rid, _forbidden, "no controller registered for this session")
|
||||
if not broker.is_owner(scope, transport):
|
||||
return _err(rid, _forbidden, "controller is not owned by this transport")
|
||||
return _ok(rid, {"ok": True})
|
||||
|
||||
|
||||
@method("browser.controller.detach")
|
||||
def _(
|
||||
rid,
|
||||
params: dict,
|
||||
_family=_CLOUD_TRANSPORT_FAMILY,
|
||||
_forbidden=_ERR_FORBIDDEN,
|
||||
_identity_ok=_is_authenticated_identity,
|
||||
_digest=_principal_digest,
|
||||
) -> dict:
|
||||
"""Hard-detach only the controller owned by this authenticated transport."""
|
||||
from gateway import browser_control_broker
|
||||
|
||||
transport = current_transport()
|
||||
identity = getattr(transport, "auth_identity", None)
|
||||
if not _identity_ok(identity):
|
||||
return _err(rid, _forbidden, "authenticated controller identity required")
|
||||
session_id = str(params.get("session_id") or "")
|
||||
with _sessions_lock:
|
||||
session = _sessions.get(session_id)
|
||||
if session is None or session.get("transport") is not transport:
|
||||
return _err(rid, _forbidden, "session is not owned by this transport")
|
||||
|
||||
broker = browser_control_broker.get_browser_control_broker()
|
||||
scope = broker.scope_for_session(
|
||||
session_id=session_id,
|
||||
principal_id=_digest(identity),
|
||||
transport_family=_family,
|
||||
)
|
||||
if scope is None or not broker.is_owner(scope, transport):
|
||||
return _err(rid, _forbidden, "controller is not owned by this transport")
|
||||
broker.detach(scope, owner=transport, notify_controller=False)
|
||||
return _ok(rid, {"detached": True})
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
"""Bind this module's handlers onto ``server``'s globals and registry."""
|
||||
_registry.install(server)
|
||||
@@ -0,0 +1,627 @@
|
||||
"""Completion / model-key / paste JSON-RPC handlers (moved verbatim from server.py).
|
||||
|
||||
Handler bodies are byte-identical to their pre-split server.py form; they
|
||||
are rebound onto server.py's globals at install time — see method_ctx.py.
|
||||
"""
|
||||
|
||||
from .method_ctx import HandlerRegistry
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
_profile_scoped = _registry.profile_scoped
|
||||
|
||||
|
||||
@method("paste.collapse")
|
||||
def _(rid, params: dict) -> dict:
|
||||
global _paste_counter
|
||||
text = params.get("text", "")
|
||||
if not text:
|
||||
return _err(rid, 4004, "empty paste")
|
||||
|
||||
_paste_counter += 1
|
||||
line_count = text.count("\n") + 1
|
||||
paste_dir = _hermes_home / "pastes"
|
||||
paste_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
paste_file = (
|
||||
paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||
)
|
||||
paste_file.write_text(text, encoding="utf-8")
|
||||
|
||||
placeholder = (
|
||||
f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]"
|
||||
)
|
||||
return _ok(
|
||||
rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count}
|
||||
)
|
||||
|
||||
|
||||
@method("complete.path")
|
||||
def _(rid, params: dict) -> dict:
|
||||
word = params.get("word", "")
|
||||
if not word:
|
||||
return _ok(rid, {"items": []})
|
||||
|
||||
items: list[dict] = []
|
||||
|
||||
def _profile_mention_items(prefix: str) -> list[dict]:
|
||||
"""`@<profile>` completions: agent profiles as mentionable names.
|
||||
|
||||
Multi-agent UIs (and the Bot Mode plugin) route `@<profile>` text to
|
||||
another agent profile; completing profile names alongside path refs
|
||||
makes that discoverable. Bare-word matches only — never for
|
||||
`@kind:` directive queries. The primary profile is also offered
|
||||
under the 'hermes' alias when no real profile claims that name.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
try:
|
||||
from hermes_cli.profiles import list_profiles
|
||||
|
||||
seen: set[str] = set()
|
||||
for p in list_profiles():
|
||||
name = (p.name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
seen.add(name.lower())
|
||||
desc = (getattr(p, "description", "") or "").strip()
|
||||
if name.lower().startswith(prefix.lower()):
|
||||
out.append(
|
||||
{
|
||||
"text": f"@{name}",
|
||||
"display": f"@{name}",
|
||||
"meta": desc or "agent profile",
|
||||
}
|
||||
)
|
||||
if "hermes".startswith(prefix.lower()) and "hermes" not in seen:
|
||||
out.append(
|
||||
{
|
||||
"text": "@hermes",
|
||||
"display": "@hermes",
|
||||
"meta": "agent profile (primary)",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
return out
|
||||
|
||||
try:
|
||||
root = _completion_cwd(params)
|
||||
is_context = word.startswith("@")
|
||||
query = word[1:] if is_context else word
|
||||
|
||||
if is_context and not query:
|
||||
items = [
|
||||
{"text": "@diff", "display": "@diff", "meta": "git diff"},
|
||||
{"text": "@staged", "display": "@staged", "meta": "staged diff"},
|
||||
{"text": "@file:", "display": "@file:", "meta": "attach file"},
|
||||
{"text": "@folder:", "display": "@folder:", "meta": "attach folder"},
|
||||
{"text": "@url:", "display": "@url:", "meta": "fetch url"},
|
||||
{"text": "@git:", "display": "@git:", "meta": "git log"},
|
||||
]
|
||||
# Agent profiles are mentionable — list them alongside the
|
||||
# directive hints so `@` alone reveals them.
|
||||
items.extend(_profile_mention_items(""))
|
||||
# Append plugin-registered context reference prefixes
|
||||
try:
|
||||
from agent.context_references import get_context_reference_providers
|
||||
|
||||
for _pfx, _prov in sorted(get_context_reference_providers().items()):
|
||||
items.append(
|
||||
{
|
||||
"text": f"@{_pfx}:",
|
||||
"display": f"@{_pfx}:",
|
||||
"meta": _prov.description or f"plugin: {_pfx}",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return _ok(rid, {"items": items})
|
||||
|
||||
# Plugin context reference autocomplete: `@<prefix>:<query>` where the
|
||||
# prefix belongs to a plugin-registered ContextReferenceProvider.
|
||||
# Handled before the built-in file/folder branching so the elif/else
|
||||
# chain below stays intact for built-in prefixes.
|
||||
if is_context and ":" in query:
|
||||
_pfx, _, _qval = query.partition(":")
|
||||
if _pfx not in {"file", "folder", "url", "git", "diff", "staged"}:
|
||||
try:
|
||||
from agent.context_references import (
|
||||
get_context_reference_providers as _gcr,
|
||||
)
|
||||
|
||||
_prov = _gcr().get(_pfx)
|
||||
if _prov is not None:
|
||||
import asyncio as _asyncio
|
||||
|
||||
_coro = _prov.autocomplete(_qval, limit=20)
|
||||
try:
|
||||
_loop = _asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
_loop = None
|
||||
if _loop and _loop.is_running():
|
||||
import concurrent.futures as _cf
|
||||
|
||||
with _cf.ThreadPoolExecutor(max_workers=1) as _pool:
|
||||
_ac = _pool.submit(_asyncio.run, _coro).result()
|
||||
else:
|
||||
_ac = _asyncio.run(_coro)
|
||||
items = [
|
||||
{
|
||||
"text": f"@{_pfx}:{it.text}",
|
||||
"display": it.display,
|
||||
"meta": it.meta,
|
||||
}
|
||||
for it in _ac
|
||||
]
|
||||
return _ok(rid, {"items": items})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Accept both `@folder:path` and the bare `@folder` form so the user
|
||||
# sees directory listings as soon as they finish typing the keyword,
|
||||
# without first accepting the static `@folder:` hint.
|
||||
if is_context and query in {"file", "folder"}:
|
||||
prefix_tag, path_part = query, ""
|
||||
elif is_context and query.startswith(("file:", "folder:")):
|
||||
prefix_tag, _, tail = query.partition(":")
|
||||
path_part = tail
|
||||
else:
|
||||
prefix_tag = ""
|
||||
path_part = query if is_context else query
|
||||
|
||||
# `@/foo` almost always means "foo, from here" rather than the absolute
|
||||
# `/foo`: the `@` already says "this is a path", so the slash reads as a
|
||||
# separator people type out of habit. Take the absolute reading only
|
||||
# when something is actually there, else drop the slash and resolve
|
||||
# relative to the cwd — otherwise `@/Desktop` dead-ends on a directory
|
||||
# that exists one level down. Real absolute paths (`@/usr/local`,
|
||||
# `@/etc/hosts`) still resolve, since those prefixes do exist.
|
||||
if (
|
||||
is_context
|
||||
and path_part.startswith("/")
|
||||
and not path_part.startswith("//")
|
||||
and not _abs_completion_prefix_exists(path_part)
|
||||
):
|
||||
path_part = path_part.lstrip("/")
|
||||
|
||||
# Fuzzy basename search across the repo when the user types a bare
|
||||
# name with no path separator — `@appChrome` surfaces every file
|
||||
# whose basename matches, regardless of directory depth. Matches what
|
||||
# editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with
|
||||
# `/`, `./`, `~/`, `/abs`) fall through to the directory-listing
|
||||
# path so explicit navigation intent is preserved.
|
||||
if (
|
||||
is_context
|
||||
and path_part
|
||||
and len(path_part.strip()) >= 2
|
||||
and "/" not in path_part
|
||||
and prefix_tag != "folder"
|
||||
):
|
||||
ranked: list[tuple[tuple[int, int], str, str, bool]] = []
|
||||
walked_dirs: set[str] = set()
|
||||
seen: set[str] = set()
|
||||
want_hidden = path_part.startswith(".")
|
||||
|
||||
def _consider(rel: str, name: str, is_dir: bool) -> None:
|
||||
if rel in seen or (name.startswith(".") and not want_hidden):
|
||||
return
|
||||
rank = _fuzzy_basename_rank(name, path_part)
|
||||
if rank is not None:
|
||||
seen.add(rel)
|
||||
ranked.append((rank, rel, name, is_dir))
|
||||
|
||||
# Seed with root's immediate children. `_list_repo_files` is capped
|
||||
# at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback
|
||||
# walk can burn that whole budget on one deep subtree before ever
|
||||
# reaching a sibling — which is why `@Desk` in a non-repo $HOME
|
||||
# found nothing. One listdir keeps the top level always reachable.
|
||||
try:
|
||||
for entry in os.listdir(root):
|
||||
if entry not in _FUZZY_FALLBACK_EXCLUDES:
|
||||
_consider(entry, entry, os.path.isdir(os.path.join(root, entry)))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for rel in _list_repo_files(root):
|
||||
_consider(rel, os.path.basename(rel), False)
|
||||
|
||||
# Directories are only implied by the file listing, so rank each
|
||||
# ancestor too. Without this a bare `@Desktop` finds nothing —
|
||||
# a folder with no name-matching file inside it is invisible to
|
||||
# a file-only scan, which is the "can't @ a folder by name" bug.
|
||||
parent = os.path.dirname(rel)
|
||||
while parent and parent not in walked_dirs:
|
||||
walked_dirs.add(parent)
|
||||
_consider(parent, os.path.basename(parent), True)
|
||||
parent = os.path.dirname(parent)
|
||||
|
||||
# Same rank tier: folders first, so `@Desktop` leads with the folder
|
||||
# rather than a file that merely fuzzy-matches the same letters.
|
||||
ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1]))
|
||||
tag = prefix_tag or "file"
|
||||
for _, rel, basename, is_dir in ranked[:30]:
|
||||
items.append(
|
||||
{
|
||||
"text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}",
|
||||
"display": basename + ("/" if is_dir else ""),
|
||||
"meta": "dir" if is_dir else os.path.dirname(rel),
|
||||
}
|
||||
)
|
||||
|
||||
# Bare-word `@name` may equally be an agent mention — surface
|
||||
# matching profiles ABOVE file hits (there are at most a handful,
|
||||
# and a user typing `@tur` for a bot shouldn't have to dig).
|
||||
if not prefix_tag:
|
||||
items = _profile_mention_items(path_part) + items
|
||||
|
||||
return _ok(rid, {"items": items})
|
||||
|
||||
expanded = _normalize_completion_path(path_part) if path_part else "."
|
||||
if expanded == "." or not expanded:
|
||||
search_dir, match = ".", ""
|
||||
elif expanded.endswith("/"):
|
||||
search_dir, match = expanded, ""
|
||||
else:
|
||||
search_dir = os.path.dirname(expanded) or "."
|
||||
match = os.path.basename(expanded)
|
||||
|
||||
search_dir = (
|
||||
search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir)
|
||||
)
|
||||
if not os.path.isdir(search_dir):
|
||||
return _ok(rid, {"items": []})
|
||||
|
||||
want_dir = prefix_tag == "folder"
|
||||
match_lower = match.lower()
|
||||
for entry in sorted(os.listdir(search_dir)):
|
||||
if match and not entry.lower().startswith(match_lower):
|
||||
continue
|
||||
if is_context and entry in _FUZZY_FALLBACK_EXCLUDES:
|
||||
continue
|
||||
if is_context and not prefix_tag and entry.startswith("."):
|
||||
continue
|
||||
full = os.path.join(search_dir, entry)
|
||||
is_dir = os.path.isdir(full)
|
||||
# Explicit `@folder:` / `@file:` — honour the user's filter. Skip
|
||||
# the opposite kind instead of auto-rewriting the completion tag,
|
||||
# which used to defeat the prefix and let `@folder:` list files.
|
||||
if prefix_tag and want_dir != is_dir:
|
||||
continue
|
||||
rel = os.path.relpath(full, root).replace(os.sep, "/")
|
||||
suffix = "/" if is_dir else ""
|
||||
|
||||
if is_context and prefix_tag:
|
||||
text = f"@{prefix_tag}:{rel}{suffix}"
|
||||
elif is_context:
|
||||
kind = "folder" if is_dir else "file"
|
||||
text = f"@{kind}:{rel}{suffix}"
|
||||
elif word.startswith("~"):
|
||||
text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix
|
||||
elif word.startswith("./"):
|
||||
text = "./" + rel + suffix
|
||||
else:
|
||||
text = rel + suffix
|
||||
|
||||
items.append(
|
||||
{
|
||||
"text": text,
|
||||
"display": entry + suffix,
|
||||
"meta": "dir" if is_dir else "",
|
||||
}
|
||||
)
|
||||
if len(items) >= 30:
|
||||
break
|
||||
except Exception as e:
|
||||
return _err(rid, 5021, str(e))
|
||||
|
||||
# Bare-word `@name` (including single characters, which skip the fuzzy
|
||||
# branch) may be an agent mention — profiles rank above path entries.
|
||||
try:
|
||||
if is_context and not prefix_tag and path_part and "/" not in path_part:
|
||||
items = _profile_mention_items(path_part) + items
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _ok(rid, {"items": items})
|
||||
|
||||
|
||||
@method("complete.slash")
|
||||
def _(rid, params: dict) -> dict:
|
||||
text = params.get("text", "")
|
||||
if not text.startswith("/"):
|
||||
return _ok(rid, {"items": []})
|
||||
|
||||
try:
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.formatted_text import to_plain_text
|
||||
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from agent.skill_bundles import get_skill_bundles
|
||||
|
||||
completer = SlashCommandCompleter(
|
||||
skill_commands_provider=lambda: get_skill_commands(),
|
||||
skill_bundles_provider=lambda: get_skill_bundles(),
|
||||
)
|
||||
doc = Document(text, len(text))
|
||||
# Skill commands and bundles are the only completions offered for an
|
||||
# inline `/skill` reference typed mid-message, so the class has to
|
||||
# reach the TUI as data. Derived from the same providers the completer
|
||||
# uses — no sniffing the ⚡/▣ meta glyphs, which are display text.
|
||||
skill_names = {
|
||||
key.lstrip("/").lower()
|
||||
for key in (*get_skill_commands(), *get_skill_bundles())
|
||||
}
|
||||
items = [
|
||||
{
|
||||
"text": c.text,
|
||||
# prompt_toolkit gives us FormattedText (a list of (style,
|
||||
# text) tuples) for display/display_meta. Serialize both as
|
||||
# plain strings — the TUI's CompletionItem.display contract
|
||||
# is a string, and sending the raw list trips Ink's row
|
||||
# layout into 1-char truncation of the next column.
|
||||
"display": to_plain_text(c.display) if c.display else c.text,
|
||||
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
|
||||
"kind": (
|
||||
"skill"
|
||||
if c.text.strip().lstrip("/").lower() in skill_names
|
||||
else "command"
|
||||
),
|
||||
}
|
||||
for c in completer.get_completions(doc, None)
|
||||
]
|
||||
|
||||
# Rank and bound the list (see _rank_slash_completions) while a
|
||||
# `/token` is under the cursor — the one stage skills are offered at.
|
||||
# An argument stage (`/personality `, `/details c`) keeps the order
|
||||
# its own command chose.
|
||||
if text.rsplit(" ", 1)[-1].startswith("/"):
|
||||
score_of = None
|
||||
# Description-aware fuzzy scoring (ported from grok-cli's slash
|
||||
# menu) at the command-token stage: the completer above only
|
||||
# emits name-prefix matches, so merge in catalog entries whose
|
||||
# name SUBSTRING or DESCRIPTION words match the query — typing
|
||||
# `/summary` surfaces a command whose description mentions
|
||||
# summaries. Command matches always outrank description matches.
|
||||
if " " not in text and len(text) > 1:
|
||||
from tui_gateway.slash_fuzzy import (
|
||||
fuzzy_rank_slash_items,
|
||||
normalize_slash_search_query,
|
||||
)
|
||||
|
||||
universe = [
|
||||
{
|
||||
"text": c.text,
|
||||
"display": to_plain_text(c.display) if c.display else c.text,
|
||||
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
|
||||
"kind": (
|
||||
"skill"
|
||||
if c.text.strip().lstrip("/").lower() in skill_names
|
||||
else "command"
|
||||
),
|
||||
}
|
||||
for c in completer.get_completions(Document("/", 1), None)
|
||||
]
|
||||
items, score_of = fuzzy_rank_slash_items(
|
||||
items, universe, normalize_slash_search_query(text)
|
||||
)
|
||||
|
||||
usage, origin_of = _skill_usage_lookup()
|
||||
items = _rank_slash_completions(
|
||||
items, usage, origin_of, browsing=text == "/", score_of=score_of
|
||||
)
|
||||
else:
|
||||
items = items[:_SLASH_COMPLETION_LIMIT]
|
||||
|
||||
text_lower = text.lower()
|
||||
extras = [
|
||||
{
|
||||
"text": "/density",
|
||||
"display": "/density",
|
||||
"meta": "Toggle compact display mode",
|
||||
"kind": "command",
|
||||
},
|
||||
{
|
||||
"text": "/details",
|
||||
"display": "/details",
|
||||
"meta": "Control agent detail visibility",
|
||||
"kind": "command",
|
||||
},
|
||||
{
|
||||
"text": "/logs",
|
||||
"display": "/logs",
|
||||
"meta": "Show recent gateway log lines",
|
||||
"kind": "command",
|
||||
},
|
||||
{
|
||||
"text": "/mouse",
|
||||
"display": "/mouse",
|
||||
"meta": "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
|
||||
"kind": "command",
|
||||
},
|
||||
]
|
||||
for extra in extras:
|
||||
if extra["text"].startswith(text_lower) and not any(
|
||||
item["text"] == extra["text"] for item in items
|
||||
):
|
||||
items.append(extra)
|
||||
|
||||
details_items = _details_completions(text)
|
||||
if details_items is not None:
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"items": details_items,
|
||||
"replace_from": text.rfind(" ") + 1 if " " in text else len(text),
|
||||
},
|
||||
)
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5020, str(e))
|
||||
|
||||
|
||||
@method("model.options")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
try:
|
||||
from hermes_cli.inventory import build_model_options_payload
|
||||
|
||||
session = _sessions.get(params.get("session_id", ""))
|
||||
agent = session.get("agent") if session else None
|
||||
# Layer agent-session state on top of disk config — once an agent
|
||||
# is spawned, IT owns the live provider/model/base_url. Empty
|
||||
# agent attributes must NOT clobber disk config (with_overrides
|
||||
# is truthy-only).
|
||||
ctx = _model_picker_context(agent)
|
||||
payload = build_model_options_payload(
|
||||
ctx,
|
||||
explicit_only=bool(params.get("explicit_only")),
|
||||
include_unconfigured=bool(params.get("include_unconfigured")),
|
||||
refresh=bool(params.get("refresh")),
|
||||
)
|
||||
return _ok(rid, payload)
|
||||
except Exception as e:
|
||||
return _err(rid, 5033, str(e))
|
||||
|
||||
|
||||
@method("model.save_key")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Save an API key for a provider, then return its refreshed model list.
|
||||
|
||||
Params:
|
||||
slug: provider slug (e.g. "deepseek", "xai")
|
||||
api_key: the key value to save
|
||||
|
||||
Returns the provider dict with models populated (same shape as
|
||||
model.options entries) on success.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_cli.config import is_managed
|
||||
from hermes_cli.inventory import build_models_payload
|
||||
|
||||
slug = (params.get("slug") or "").strip()
|
||||
api_key = (params.get("api_key") or "").strip()
|
||||
if not slug or not api_key:
|
||||
return _err(rid, 4001, "slug and api_key are required")
|
||||
|
||||
if is_managed():
|
||||
return _err(rid, 4006, "managed install — credentials are read-only")
|
||||
|
||||
pconfig = PROVIDER_REGISTRY.get(slug)
|
||||
if not pconfig:
|
||||
return _err(rid, 4002, f"unknown provider: {slug}")
|
||||
if pconfig.auth_type != "api_key":
|
||||
return _err(
|
||||
rid,
|
||||
4003,
|
||||
f"{pconfig.name} uses {pconfig.auth_type} auth — "
|
||||
f"run `hermes model` to configure",
|
||||
)
|
||||
if not pconfig.api_key_env_vars:
|
||||
return _err(rid, 4004, f"no env var defined for {pconfig.name}")
|
||||
|
||||
# Save the key to ~/.hermes/.env via the unified credential lifecycle
|
||||
# so any stale config.yaml mirror of the previous key (model.api_key,
|
||||
# custom_providers[*].api_key) is rotated in the same action (#62269).
|
||||
env_var = pconfig.api_key_env_vars[0]
|
||||
from hermes_cli.credential_lifecycle import save_provider_env_credential
|
||||
|
||||
save_provider_env_credential(env_var, api_key)
|
||||
# Also set in current process so the refreshed inventory sees it.
|
||||
import os
|
||||
|
||||
os.environ[env_var] = api_key
|
||||
|
||||
# Refresh provider data via the shared inventory builder so this
|
||||
# surface stays in lock-step with model.options + dashboard
|
||||
# /api/model/options. picker_hints=True ensures the returned row
|
||||
# carries `authenticated` for the TUI frontend.
|
||||
session = _sessions.get(params.get("session_id", ""))
|
||||
agent = session.get("agent") if session else None
|
||||
ctx = _model_picker_context(agent)
|
||||
payload = build_models_payload(
|
||||
ctx, picker_hints=True, max_models=50,
|
||||
)
|
||||
provider_data = next(
|
||||
(p for p in payload["providers"] if p["slug"] == slug), None
|
||||
)
|
||||
if provider_data is None:
|
||||
# Key was saved but provider didn't appear — still return success.
|
||||
provider_data = {
|
||||
"slug": slug,
|
||||
"name": pconfig.name,
|
||||
"is_current": False,
|
||||
"models": [],
|
||||
"total_models": 0,
|
||||
"authenticated": True,
|
||||
}
|
||||
# picker_hints sets `authenticated` from the row state, but the
|
||||
# synthetic fallback above doesn't go through that path.
|
||||
provider_data["authenticated"] = True
|
||||
return _ok(rid, {"provider": provider_data})
|
||||
except Exception as e:
|
||||
return _err(rid, 5034, str(e))
|
||||
|
||||
|
||||
@method("model.disconnect")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Remove credentials for a provider.
|
||||
|
||||
Params:
|
||||
slug: provider slug (e.g. "deepseek", "xai")
|
||||
|
||||
Returns success status and the provider's slug.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth
|
||||
from hermes_cli.credential_lifecycle import remove_provider_env_credential
|
||||
|
||||
slug = (params.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return _err(rid, 4001, "slug is required")
|
||||
|
||||
pconfig = PROVIDER_REGISTRY.get(slug)
|
||||
cleared_env = False
|
||||
cleared_auth = False
|
||||
|
||||
# Remove API key env vars from .env and process, plus every mirror
|
||||
# (env-seeded credential_pool entries, provider model cache rows,
|
||||
# value-matched config.yaml api_key copies) via the unified helper —
|
||||
# otherwise the provider resurrects in the picker after restart
|
||||
# (#51071 / #59761).
|
||||
if pconfig and pconfig.api_key_env_vars:
|
||||
for ev in pconfig.api_key_env_vars:
|
||||
if remove_provider_env_credential(ev).get("found"):
|
||||
cleared_env = True
|
||||
|
||||
# Clear OAuth / credential pool state. This is a full provider
|
||||
# disconnect (TUI "disconnect" action), so removing OAuth grants
|
||||
# here is the documented intent — unlike the key-only delete paths.
|
||||
cleared_auth = clear_provider_auth(slug)
|
||||
|
||||
if not cleared_env and not cleared_auth:
|
||||
return _err(rid, 4005, f"no credentials found for {slug}")
|
||||
|
||||
provider_name = pconfig.name if pconfig else slug
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"slug": slug,
|
||||
"name": provider_name,
|
||||
"disconnected": True,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5035, str(e))
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
"""Bind this module's handlers onto ``server``'s globals and registry."""
|
||||
_registry.install(server)
|
||||
@@ -0,0 +1,623 @@
|
||||
"""Config / projects / setup JSON-RPC handlers (moved verbatim from server.py).
|
||||
|
||||
NOTE: ``config.set`` stays in server.py for now — the in-flight
|
||||
opt/model-resolution-core PR touches it; move it in a follow-up once merged.
|
||||
|
||||
Handler bodies are byte-identical to their pre-split server.py form; they
|
||||
are rebound onto server.py's globals at install time — see method_ctx.py.
|
||||
"""
|
||||
|
||||
from .method_ctx import HandlerRegistry
|
||||
|
||||
from hermes_constants import DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
_profile_scoped = _registry.profile_scoped
|
||||
|
||||
|
||||
@method("projects.discover_repos")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Repos for the desktop overview: scanned-from-disk (cached) ∪ session-derived."""
|
||||
try:
|
||||
with _profile_db(params) as db:
|
||||
if db is None:
|
||||
return _ok(rid, {"repos": []})
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
policy = _repo_discovery_policy()
|
||||
policy_key = _repo_discovery_policy_key(policy)
|
||||
with pdb.connect_closing() as conn:
|
||||
pdb.reconcile_discovered_repos_policy(
|
||||
conn,
|
||||
policy_key,
|
||||
preserve_unversioned=_repo_discovery_policy_is_default(policy),
|
||||
)
|
||||
# `scan=true` (set by the desktop in remote-gateway mode): run a
|
||||
# backend-side filesystem scan of the policy roots so repos with
|
||||
# zero Hermes sessions still surface. The desktop's native scan
|
||||
# only runs on the local filesystem; on a remote connection it
|
||||
# must ask the host to scan itself (#81723).
|
||||
if params.get("scan") and policy["enabled"]:
|
||||
_scan_discovered_repos_remote(conn, policy)
|
||||
repos = _discover_repos_payload(
|
||||
db, conn=conn, include_cached=policy["enabled"]
|
||||
)
|
||||
return _ok(rid, {"repos": repos, "discovery_policy": policy})
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
||||
@method("projects.record_repos")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Persist git repo roots found by the client's filesystem scan, then return
|
||||
the merged repo list. The native crawl runs on the desktop (local fs); this
|
||||
caches the result so later reads are instant instead of re-walking disk."""
|
||||
try:
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
policy = _repo_discovery_policy()
|
||||
policy_key = _repo_discovery_policy_key(policy)
|
||||
incoming_raw = params.get("discovery_policy")
|
||||
incoming_policy = (
|
||||
_repo_discovery_policy(incoming_raw)
|
||||
if isinstance(incoming_raw, dict)
|
||||
else None
|
||||
)
|
||||
incoming_matches = (
|
||||
incoming_policy is not None
|
||||
and _repo_discovery_policy_key(incoming_policy) == policy_key
|
||||
)
|
||||
accept_legacy_default = (
|
||||
incoming_policy is None and _repo_discovery_policy_is_default(policy)
|
||||
)
|
||||
|
||||
pairs: list[tuple[str, str | None]] = []
|
||||
for item in params.get("repos") or []:
|
||||
if isinstance(item, str):
|
||||
pairs.append((item, None))
|
||||
elif isinstance(item, dict) and item.get("root"):
|
||||
pairs.append((str(item["root"]), item.get("label")))
|
||||
|
||||
with pdb.connect_closing() as conn:
|
||||
pdb.reconcile_discovered_repos_policy(
|
||||
conn,
|
||||
policy_key,
|
||||
preserve_unversioned=_repo_discovery_policy_is_default(policy),
|
||||
)
|
||||
accepted = bool(
|
||||
policy["enabled"] and (incoming_matches or accept_legacy_default)
|
||||
)
|
||||
if accepted:
|
||||
pdb.record_discovered_repos(
|
||||
conn, pairs, replace=True, policy_key=policy_key
|
||||
)
|
||||
elif not policy["enabled"]:
|
||||
pdb.clear_discovered_repos(conn, policy_key=policy_key)
|
||||
|
||||
with _profile_db(params) as db:
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"repos": _discover_repos_payload(
|
||||
db, include_cached=policy["enabled"]
|
||||
)
|
||||
if db is not None
|
||||
else [],
|
||||
"accepted": accepted,
|
||||
"discovery_policy": policy,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
||||
@method("projects.tree")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Authoritative project overview: project -> repo -> lane structure with
|
||||
counts + a few preview sessions per project, plus the flat set of session
|
||||
ids claimed by any project (so the desktop excludes them from flat Recents).
|
||||
Lanes carry no session rows here; drill-in uses ``projects.project_sessions``.
|
||||
"""
|
||||
try:
|
||||
from tui_gateway.project_tree import stamp_profile
|
||||
from tui_gateway.server import _response_profile_name
|
||||
|
||||
with _profile_db(params) as db:
|
||||
if db is None:
|
||||
return _ok(
|
||||
rid, {"projects": [], "active_id": None, "scoped_session_ids": []}
|
||||
)
|
||||
|
||||
tree, active_id = _build_project_tree(
|
||||
db,
|
||||
preview_limit=int(params.get("preview_limit") or 3),
|
||||
hydrate=False,
|
||||
session_limit=int(params.get("session_limit") or 2000),
|
||||
include_discovered=True,
|
||||
)
|
||||
stamp_profile(
|
||||
tree["projects"], _response_profile_name(params.get("profile"))
|
||||
)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"projects": tree["projects"],
|
||||
"active_id": active_id,
|
||||
"scoped_session_ids": tree["scoped_session_ids"],
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
||||
@method("projects.project_sessions")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Fully hydrated lanes (repo -> lane -> session rows) for one project,
|
||||
built from the same authoritative grouping as ``projects.tree`` so ids and
|
||||
membership match exactly. Used when the user enters a project."""
|
||||
try:
|
||||
from tui_gateway.project_tree import stamp_profile
|
||||
from tui_gateway.server import _response_profile_name
|
||||
|
||||
project_id = str(params.get("project_id") or "")
|
||||
if not project_id:
|
||||
return _err(rid, 5063, "project_id required")
|
||||
|
||||
with _profile_db(params) as db:
|
||||
if db is None:
|
||||
return _ok(rid, {"project": None})
|
||||
|
||||
# Drill-in only needs the entered project (which has sessions), so skip
|
||||
# the zero-session discovery tier entirely.
|
||||
tree, _active = _build_project_tree(
|
||||
db,
|
||||
preview_limit=0,
|
||||
hydrate=True,
|
||||
session_limit=int(params.get("session_limit") or 5000),
|
||||
include_discovered=False,
|
||||
)
|
||||
stamp_profile(
|
||||
tree["projects"], _response_profile_name(params.get("profile"))
|
||||
)
|
||||
proj = next((p for p in tree["projects"] if p["id"] == project_id), None)
|
||||
return _ok(rid, {"project": proj})
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
||||
@method("config.get")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
key = params.get("key", "")
|
||||
if key == "provider":
|
||||
try:
|
||||
from hermes_cli.models import list_available_providers, normalize_provider
|
||||
|
||||
model = _resolve_model()
|
||||
parts = model.split("/", 1)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"model": model,
|
||||
"provider": (
|
||||
normalize_provider(parts[0]) if len(parts) > 1 else "unknown"
|
||||
),
|
||||
"providers": list_available_providers(),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5013, str(e))
|
||||
if key == "profile":
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()})
|
||||
if key == "project":
|
||||
cfg_terminal = _load_cfg().get("terminal") or {}
|
||||
raw = str(params.get("cwd", "") or cfg_terminal.get("cwd", "") or "").strip()
|
||||
cwd = _completion_cwd({"cwd": raw} if raw else {})
|
||||
return _ok(rid, {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)})
|
||||
if key == "full":
|
||||
return _ok(rid, {"config": _load_cfg()})
|
||||
if key == "prompt":
|
||||
return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")})
|
||||
if key == "skin":
|
||||
return _ok(
|
||||
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
|
||||
)
|
||||
if key == "indicator":
|
||||
# Normalize so a hand-edited config.yaml with stray casing or
|
||||
# an unknown value reads back the SAME value the TUI actually
|
||||
# rendered (frontend's `normalizeIndicatorStyle` falls back to
|
||||
# `DEFAULT_INDICATOR_STYLE` for the same inputs). Otherwise
|
||||
# `/indicator` would print one thing while the UI shows another.
|
||||
raw = (_load_cfg().get("display") or {}).get("tui_status_indicator", "")
|
||||
norm = str(raw).strip().lower()
|
||||
return _ok(
|
||||
rid,
|
||||
{"value": norm if norm in INDICATOR_STYLES else DEFAULT_INDICATOR_STYLE},
|
||||
)
|
||||
if key == "personality":
|
||||
# Report the EFFECTIVE personality via the single owner — a stale or
|
||||
# unknown name in config must not display as active.
|
||||
from hermes_cli.personality import active_personality_name
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{"value": active_personality_name(_load_cfg()) or "none"},
|
||||
)
|
||||
if key == "reasoning":
|
||||
cfg = _load_cfg()
|
||||
session = _sessions.get(params.get("session_id", ""))
|
||||
reasoning_config = None
|
||||
if session is not None:
|
||||
if isinstance(session.get("create_reasoning_override"), dict):
|
||||
reasoning_config = session.get("create_reasoning_override")
|
||||
else:
|
||||
agent = session.get("agent")
|
||||
agent_reasoning = getattr(agent, "reasoning_config", None)
|
||||
if isinstance(agent_reasoning, dict):
|
||||
reasoning_config = agent_reasoning
|
||||
|
||||
if isinstance(reasoning_config, dict):
|
||||
if reasoning_config.get("enabled") is False:
|
||||
effort = "none"
|
||||
else:
|
||||
effort = str(reasoning_config.get("effort") or "medium")
|
||||
else:
|
||||
raw_effort = (cfg.get("agent") or {}).get("reasoning_effort", "")
|
||||
if raw_effort is False:
|
||||
# YAML `reasoning_effort: false`/`off`/`no` — thinking
|
||||
# disabled, not "unset, show the medium default".
|
||||
effort = "none"
|
||||
else:
|
||||
effort = str(raw_effort or "medium")
|
||||
display = (
|
||||
"show"
|
||||
if bool((cfg.get("display") or {}).get("show_reasoning", True))
|
||||
else "hide"
|
||||
)
|
||||
return _ok(rid, {"value": effort, "display": display})
|
||||
if key == "fast":
|
||||
# Prefer the session's live/pinned value — `config.set fast` is
|
||||
# session-scoped, so the global key may not reflect this chat. A
|
||||
# pre-build session keeps its pin in create_service_tier_override.
|
||||
session = _sessions.get(params.get("session_id", ""))
|
||||
tier = None
|
||||
if session is not None:
|
||||
agent = session.get("agent")
|
||||
if agent is not None:
|
||||
tier = getattr(agent, "service_tier", None)
|
||||
elif session.get("create_service_tier_override") is not None:
|
||||
tier = session["create_service_tier_override"]
|
||||
if tier is None:
|
||||
tier = _load_service_tier()
|
||||
return _ok(rid, {"value": "fast" if tier == "priority" else "normal"})
|
||||
if key == "busy":
|
||||
return _ok(rid, {"value": _load_busy_input_mode()})
|
||||
if key in {"approval_mode", "approvals.mode"}:
|
||||
try:
|
||||
return _ok(rid, {"value": _load_approval_mode()})
|
||||
except Exception as e:
|
||||
return _err(rid, 5001, str(e))
|
||||
if key == "details_mode":
|
||||
allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
|
||||
raw = (
|
||||
str(
|
||||
(_load_cfg().get("display") or {}).get("details_mode", "collapsed")
|
||||
or "collapsed"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
nv = raw if raw in allowed_dm else "collapsed"
|
||||
return _ok(rid, {"value": nv})
|
||||
if key == "thinking_mode":
|
||||
allowed_tm = frozenset({"collapsed", "truncated", "full"})
|
||||
cfg = _load_cfg()
|
||||
raw = (
|
||||
str((cfg.get("display") or {}).get("thinking_mode", "") or "")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if raw in allowed_tm:
|
||||
nv = raw
|
||||
else:
|
||||
dm = (
|
||||
str(
|
||||
(cfg.get("display") or {}).get("details_mode", "collapsed")
|
||||
or "collapsed"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
nv = "full" if dm == "expanded" else "collapsed"
|
||||
return _ok(rid, {"value": nv})
|
||||
if key == "density":
|
||||
on = bool((_load_cfg().get("display") or {}).get("tui_compact", False))
|
||||
return _ok(rid, {"value": "on" if on else "off"})
|
||||
if key == "theme":
|
||||
display = _load_cfg().get("display")
|
||||
raw = (
|
||||
str(
|
||||
display.get("tui_theme", "auto")
|
||||
if isinstance(display, dict)
|
||||
else "auto"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
return _ok(rid, {"value": raw if raw in {"auto", "light", "dark"} else "auto"})
|
||||
if key == "statusbar":
|
||||
display = _load_cfg().get("display")
|
||||
raw = (
|
||||
display.get("tui_statusbar", "top") if isinstance(display, dict) else "top"
|
||||
)
|
||||
return _ok(rid, {"value": _coerce_statusbar(raw)})
|
||||
if key == "focus":
|
||||
display = _load_cfg().get("display")
|
||||
on = (
|
||||
bool(display.get("focus_view", False))
|
||||
if isinstance(display, dict)
|
||||
else False
|
||||
)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"value": "on" if on else "off",
|
||||
"tool_progress": _load_tool_progress_mode(),
|
||||
},
|
||||
)
|
||||
if key == "mouse":
|
||||
display = _load_cfg().get("display")
|
||||
return _ok(rid, {"value": _display_mouse_tracking(display)})
|
||||
if key == "mtime":
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
try:
|
||||
mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0
|
||||
except Exception:
|
||||
return _ok(rid, {"mtime": 0})
|
||||
# Revision hash of the MCP-relevant config sections. The TUI's
|
||||
# config-change poller uses it to reload MCP servers only when their
|
||||
# config actually changed — a /skin or /statusbar write bumps mtime
|
||||
# but must not cost a multi-second MCP reconnect.
|
||||
return _ok(rid, {"mtime": mtime, "mcp_rev": _compute_mcp_rev()})
|
||||
return _err(rid, 4002, f"unknown config key: {key}")
|
||||
|
||||
|
||||
def _readiness_profile_scope(params: dict):
|
||||
"""Resolve the optional ``profile`` param of the setup readiness RPCs.
|
||||
|
||||
Returns ``(profile, scope)`` where ``scope`` is a context manager binding
|
||||
that profile's HERMES_HOME and ``.env`` secret scope (ContextVars, so
|
||||
concurrent checks for different profiles stay isolated). The launch
|
||||
profile / no param yields ``("", nullcontext())``. A profile unknown to
|
||||
this host raises ``FileNotFoundError`` — a readiness check must never
|
||||
quietly answer for the launch profile instead (#94071).
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
profile = str(params.get("profile") or "").strip() if isinstance(params, dict) else ""
|
||||
if not profile:
|
||||
return "", contextlib.nullcontext()
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
from tui_gateway import server as _server
|
||||
|
||||
if not profiles_mod.profile_exists(profile):
|
||||
raise FileNotFoundError(f"Profile '{profile}' does not exist on this backend.")
|
||||
home = _server._profile_home(profile)
|
||||
if home is None:
|
||||
return profile, contextlib.nullcontext()
|
||||
return profile, _server._session_profile_runtime_scope({"profile_home": str(home)})
|
||||
|
||||
|
||||
@method("setup.status")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Loose provider check; ``profile`` (optional) scopes it to that profile's home."""
|
||||
try:
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
from tui_gateway.methods_config import _readiness_profile_scope
|
||||
|
||||
try:
|
||||
profile, scope = _readiness_profile_scope(params)
|
||||
except FileNotFoundError as e:
|
||||
return _ok(rid, {"ok": False, "profile": params.get("profile"), "error": str(e)})
|
||||
with scope:
|
||||
configured = bool(_has_any_provider_configured(strict_profile_scope=bool(profile)))
|
||||
payload = {"provider_configured": configured}
|
||||
if profile:
|
||||
payload["profile"] = profile
|
||||
return _ok(rid, payload)
|
||||
except Exception as e:
|
||||
return _err(rid, 5016, str(e))
|
||||
|
||||
|
||||
@method("setup.runtime_check")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Strict provider check: does the configured/default model actually resolve to a usable runtime?
|
||||
|
||||
Unlike setup.status (which returns True if ANY provider auth state is
|
||||
discoverable, including indirect fallbacks like ``gh auth token`` for
|
||||
Copilot), this runs the same resolve_runtime_provider() call the agent
|
||||
uses on session creation. It returns ok=False with the auth error message
|
||||
when the user's configured model cannot actually be served, so UIs can
|
||||
surface onboarding before the user submits a doomed prompt.
|
||||
|
||||
``profile`` (optional): answer for THAT profile's home on this host — its
|
||||
config.yaml model pin and its ``.env`` — instead of the launch profile's
|
||||
(#94071). A profile unknown to this backend answers ``ok=False`` rather
|
||||
than reporting the launch profile's readiness.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
from hermes_cli.auth import has_usable_secret
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
from tui_gateway.methods_config import _readiness_profile_scope
|
||||
|
||||
requested = str(params.get("provider") or "").strip() or None
|
||||
try:
|
||||
profile, scope = _readiness_profile_scope(params)
|
||||
except FileNotFoundError as e:
|
||||
return _ok(rid, {"ok": False, "profile": params.get("profile"), "error": str(e)})
|
||||
with scope:
|
||||
runtime = resolve_runtime_provider(requested=requested)
|
||||
provider_configured = bool(_has_any_provider_configured(strict_profile_scope=bool(profile)))
|
||||
scoped = {"profile": profile} if profile else {}
|
||||
provider = runtime.get("provider") or "provider"
|
||||
source = str(runtime.get("source") or "")
|
||||
if (
|
||||
not provider_configured
|
||||
and provider == "bedrock"
|
||||
and source
|
||||
in {
|
||||
"iam-role",
|
||||
"aws-sdk-default-chain",
|
||||
}
|
||||
):
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": False,
|
||||
"provider": provider,
|
||||
"model": runtime.get("model"),
|
||||
"source": source,
|
||||
"error": "No Hermes provider is configured.",
|
||||
**scoped,
|
||||
},
|
||||
)
|
||||
|
||||
api_key = runtime.get("api_key")
|
||||
api_key_text = "" if callable(api_key) else str(api_key or "").strip()
|
||||
credential_ok = (
|
||||
callable(api_key)
|
||||
or api_key_text in {"aws-sdk", "no-key-required"}
|
||||
or has_usable_secret(api_key_text)
|
||||
or bool(runtime.get("command"))
|
||||
)
|
||||
|
||||
if not credential_ok:
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": False,
|
||||
"provider": provider,
|
||||
"model": runtime.get("model"),
|
||||
"source": runtime.get("source"),
|
||||
"error": f"No usable credentials found for {provider}.",
|
||||
**scoped,
|
||||
},
|
||||
)
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": True,
|
||||
"provider": runtime.get("provider"),
|
||||
"model": runtime.get("model"),
|
||||
"source": runtime.get("source"),
|
||||
**scoped,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _ok(rid, {"ok": False, "error": str(e)})
|
||||
|
||||
|
||||
@method("diagnostics.share_nous")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Upload a redacted debug bundle to Nous-internal diagnostics storage.
|
||||
|
||||
Desktop's "Send Diagnostics" action (error card / diagnostics UI). Same
|
||||
collection + force-redaction pipeline as ``hermes debug share --nous``
|
||||
(collect_share_bundle → build_nous_bundle → share_to_nous); redaction is
|
||||
NOT client-controllable — this handler always redacts.
|
||||
|
||||
Params (all optional):
|
||||
- ``error_context``: short client-supplied text describing the failure
|
||||
that prompted the report (the error card's layer/code/message blob).
|
||||
Redacted server-side and attached as ``error-context.txt``.
|
||||
- ``extra_files``: {label → text} of client-side artifacts the backend
|
||||
can't see (e.g. the local desktop.log when this backend is remote).
|
||||
Each value is force-redacted server-side before inclusion; labels are
|
||||
sanitized and size-capped.
|
||||
- ``log_lines``: report excerpt length (default 200).
|
||||
|
||||
Consent lives with the CALLER: the desktop shows the privacy notice and
|
||||
an explicit Upload button before invoking this. Structured envelope
|
||||
(``ok``/``error``) rather than JSON-RPC errors so the client can render
|
||||
upload failures inline.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.debug import (
|
||||
_redact_log_text,
|
||||
build_nous_bundle,
|
||||
collect_share_bundle,
|
||||
)
|
||||
from hermes_cli.diagnostics_upload import share_to_nous
|
||||
|
||||
log_lines = params.get("log_lines")
|
||||
if not isinstance(log_lines, int) or not (10 <= log_lines <= 2000):
|
||||
log_lines = 200
|
||||
|
||||
bundle = collect_share_bundle(log_lines=log_lines, redact=True)
|
||||
|
||||
# Client-supplied text goes through the SAME upload-safe log redactor
|
||||
# as backend-collected logs (_redact_log_text = force secret redaction
|
||||
# + email masking) — never the weaker bare secret pass, so the remote
|
||||
# path can't upload what the CLI pipeline would have removed.
|
||||
error_context = params.get("error_context")
|
||||
if isinstance(error_context, str) and error_context.strip():
|
||||
bundle["error-context.txt"] = _redact_log_text(
|
||||
error_context.strip()[:8_000]
|
||||
)
|
||||
|
||||
# Client-side artifacts (local desktop.log on remote connections).
|
||||
# Bounded: at most 4 files, 512KB of text each, sanitized labels —
|
||||
# this is a diagnostics channel, not an arbitrary upload surface.
|
||||
extra_files = params.get("extra_files")
|
||||
if isinstance(extra_files, dict):
|
||||
for label, text in list(extra_files.items())[:4]:
|
||||
if not isinstance(label, str) or not isinstance(text, str):
|
||||
continue
|
||||
safe_label = "".join(
|
||||
ch for ch in label if ch.isalnum() or ch in "._- ()"
|
||||
).strip()[:64]
|
||||
# Collapse dot-runs and leading dots so traversal-shaped labels
|
||||
# ("../../etc/passwd") can't survive even cosmetically.
|
||||
while ".." in safe_label:
|
||||
safe_label = safe_label.replace("..", ".")
|
||||
safe_label = safe_label.lstrip(".").strip()
|
||||
if not safe_label or not text.strip():
|
||||
continue
|
||||
bundle[f"client/{safe_label}"] = _redact_log_text(text[:524_288])
|
||||
|
||||
blob = build_nous_bundle(bundle, redact=True)
|
||||
res = share_to_nous(blob)
|
||||
view_url = res.get("viewUrl") or res.get("view_url")
|
||||
upload_id = res.get("id")
|
||||
if not view_url and not upload_id:
|
||||
# An upload the user can't reference is useless to support —
|
||||
# surface it as a failure instead of a linkless success.
|
||||
return _ok(
|
||||
rid,
|
||||
{"ok": False, "error": "upload succeeded but returned no view URL or id"},
|
||||
)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": True,
|
||||
"view_url": view_url,
|
||||
"upload_id": upload_id,
|
||||
"expires_at": res.get("expiresAt") or res.get("expires_at"),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _ok(rid, {"ok": False, "error": str(e)})
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
"""Bind this module's handlers onto ``server``'s globals and registry."""
|
||||
_registry.install(server)
|
||||
@@ -0,0 +1,866 @@
|
||||
"""Hosted-room JSON-RPC contract.
|
||||
|
||||
These methods expose durable room identity, replay, and the process-owned
|
||||
same-gateway Discussion driver. ``groups.capabilities`` keeps that boundary
|
||||
machine-readable so older clients stay on the renderer-owned room path.
|
||||
"""
|
||||
|
||||
from .method_ctx import HandlerRegistry
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
|
||||
LONG_HANDLERS = frozenset({
|
||||
"groups.list",
|
||||
"groups.capabilities",
|
||||
"groups.create",
|
||||
"groups.state",
|
||||
"groups.send",
|
||||
"groups.rename",
|
||||
"groups.log",
|
||||
"groups.disband",
|
||||
"groups.replicate",
|
||||
"groups.replica_state",
|
||||
"groups.promote",
|
||||
"groups.demote",
|
||||
"groups.stop",
|
||||
"groups.retry",
|
||||
"groups.approve",
|
||||
"groups.peer.invite",
|
||||
"groups.peer.revoke",
|
||||
"groups.peer.register",
|
||||
})
|
||||
|
||||
_service_lock = threading.Lock()
|
||||
_run_store_lock = threading.Lock()
|
||||
_bound_server = None
|
||||
_service = None
|
||||
|
||||
|
||||
def bind_server(server) -> None:
|
||||
"""Bind the fully initialized server module without starting a worker."""
|
||||
|
||||
global _bound_server
|
||||
_bound_server = server
|
||||
server._profile_execution_policy = _profile_execution_policy
|
||||
|
||||
|
||||
def start_hosted_room_service():
|
||||
"""Start one process-owned hosted room service idempotently."""
|
||||
|
||||
global _service
|
||||
if _bound_server is None:
|
||||
return None
|
||||
from gateway.hosted_rooms import default_db_path
|
||||
from tui_gateway.hosted_room_service import HostedRoomService
|
||||
|
||||
db_path = default_db_path()
|
||||
with _service_lock:
|
||||
if _service is not None and _service.db_path != db_path:
|
||||
_service.stop(timeout=1.0)
|
||||
_service = None
|
||||
if _service is None:
|
||||
_service = HostedRoomService(_bound_server, db_path=db_path)
|
||||
_service.start()
|
||||
return _service
|
||||
|
||||
|
||||
def stop_hosted_room_service(*, timeout: float = 5.0) -> bool:
|
||||
"""Stop the process-owned worker without interrupting accepted turns."""
|
||||
|
||||
global _service
|
||||
with _service_lock:
|
||||
service = _service
|
||||
if service is None:
|
||||
return True
|
||||
stopped = service.stop(timeout=timeout)
|
||||
if stopped and _service is service:
|
||||
_service = None
|
||||
return stopped
|
||||
|
||||
|
||||
def get_hosted_room_service():
|
||||
"""Return the active service, if its lifecycle owner started it."""
|
||||
|
||||
service = _service
|
||||
if service is None:
|
||||
return None
|
||||
try:
|
||||
status = service.runtime.status()
|
||||
except Exception:
|
||||
return None
|
||||
return service if status.get("running") and not status.get("stopping") else None
|
||||
|
||||
|
||||
_WORKER_UNAVAILABLE = (
|
||||
"Group Chat worker is unavailable. Restart the Hermes gateway and try again."
|
||||
)
|
||||
|
||||
|
||||
def _profile_name() -> str:
|
||||
return (os.getenv("HERMES_PROFILE") or "default").strip() or "default"
|
||||
|
||||
|
||||
def _requested_profile(params: dict) -> str:
|
||||
requested = str(params.get("profile") or "").strip()
|
||||
if not requested:
|
||||
return _profile_name()
|
||||
if _bound_server is None:
|
||||
raise ValueError("profile routing is unavailable")
|
||||
current = str(_bound_server._current_profile_name() or "").strip()
|
||||
if requested == current:
|
||||
return current
|
||||
home = _bound_server._profile_home(requested)
|
||||
if home is None:
|
||||
raise ValueError(f"profile '{requested}' is unavailable")
|
||||
return str(_bound_server._response_profile_name(requested) or requested)
|
||||
|
||||
|
||||
def _api_server_key(profile: str | None = None) -> str:
|
||||
if profile and _bound_server is not None:
|
||||
current = str(_bound_server._current_profile_name() or "").strip()
|
||||
if profile != current:
|
||||
from agent.secret_scope import build_profile_secret_scope
|
||||
|
||||
home = _bound_server._profile_home(profile)
|
||||
if home is None:
|
||||
return ""
|
||||
# An explicit routed profile is authoritative. Never borrow the
|
||||
# process/default profile's API key on a multiplexed gateway.
|
||||
return str(
|
||||
build_profile_secret_scope(home).get("API_SERVER_KEY") or ""
|
||||
).strip()
|
||||
try:
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
scoped = (get_secret("API_SERVER_KEY", "") or "").strip()
|
||||
if scoped:
|
||||
return scoped
|
||||
except Exception:
|
||||
pass
|
||||
return (os.getenv("API_SERVER_KEY") or "").strip()
|
||||
|
||||
|
||||
def _profile_execution_policy(profile: str) -> dict:
|
||||
"""Resolve execution policy under the exact multiplexed profile home."""
|
||||
|
||||
from gateway.hosted_room_execution_policy import execution_policy_mapping
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
token = None
|
||||
if _bound_server is not None:
|
||||
current = str(_bound_server._current_profile_name() or "").strip()
|
||||
if profile not in {current, _profile_name()}:
|
||||
home = _bound_server._profile_home(profile)
|
||||
if home is None:
|
||||
raise ValueError(f"profile '{profile}' is unavailable")
|
||||
token = set_hermes_home_override(str(home))
|
||||
try:
|
||||
return execution_policy_mapping(target_profile=profile)
|
||||
finally:
|
||||
if token is not None:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
def _room_link_run_storage_durable() -> bool:
|
||||
"""Return whether peer-run replay survives this gateway process."""
|
||||
|
||||
if _bound_server is None:
|
||||
# Direct method-contract tests and embedded callers without a bound API
|
||||
# server do not expose peer-run transport. The production server always
|
||||
# binds before advertising capabilities.
|
||||
return True
|
||||
store = getattr(_bound_server, "_run_idempotency_store", None)
|
||||
if store is None:
|
||||
# The dashboard/TUI process owns groups.* but does not construct the
|
||||
# API adapter that normally owns this store. Open the same shared
|
||||
# SQLite-backed store lazily so capability negotiation reflects the
|
||||
# real /v1/runs replay boundary instead of depending on test-only
|
||||
# injection. A separately enabled API adapter uses the same file.
|
||||
from gateway.platforms.api_server import RunIdempotencyStore
|
||||
|
||||
with _run_store_lock:
|
||||
store = getattr(_bound_server, "_run_idempotency_store", None)
|
||||
if store is None:
|
||||
store = RunIdempotencyStore()
|
||||
_bound_server._run_idempotency_store = store
|
||||
return bool(getattr(store, "durable", False))
|
||||
|
||||
|
||||
@method("groups.capabilities")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Describe the hosted-room protocol implemented by this gateway."""
|
||||
from gateway.hosted_rooms import (
|
||||
MAX_LOG_LIMIT,
|
||||
PROTOCOL_VERSION,
|
||||
local_authority_gateway_id,
|
||||
)
|
||||
|
||||
service = get_hosted_room_service()
|
||||
driver_ready = bool(service and service.runtime.status()["running"])
|
||||
try:
|
||||
from gateway.hosted_room_peer import (
|
||||
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
|
||||
gateway_room_grant_secret,
|
||||
local_catalog_mapping,
|
||||
)
|
||||
|
||||
profile = _requested_profile(params)
|
||||
if not _room_link_run_storage_durable():
|
||||
raise ValueError("durable run idempotency storage is required")
|
||||
gateway_room_grant_secret()
|
||||
catalog = local_catalog_mapping(
|
||||
installation_id=local_authority_gateway_id(),
|
||||
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
|
||||
link_modes=("direct",),
|
||||
text=True,
|
||||
attachments=False,
|
||||
target_profile=profile,
|
||||
execution_policy=_profile_execution_policy(profile),
|
||||
)
|
||||
room_link = {
|
||||
"enabled": True,
|
||||
"profile": profile,
|
||||
"catalog": catalog,
|
||||
"endpoint": catalog["endpoint"],
|
||||
}
|
||||
except Exception:
|
||||
room_link = {
|
||||
"enabled": False,
|
||||
"reason": (
|
||||
"durable_run_storage_required"
|
||||
if not _room_link_run_storage_durable()
|
||||
else "gateway_roomlink_secret_unavailable"
|
||||
),
|
||||
}
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"driver": driver_ready,
|
||||
"persistent_process": bool(
|
||||
room_link.get("catalog", {}).get("persistent_process", False)
|
||||
),
|
||||
"authority_gateway_id": local_authority_gateway_id(),
|
||||
"room_link": room_link,
|
||||
"features": [
|
||||
"authority_epoch",
|
||||
"coordinator_fencing",
|
||||
"room_identity",
|
||||
"monotonic_log",
|
||||
"idempotent_send",
|
||||
"replayable_disband",
|
||||
"typed_events",
|
||||
"actor_identity",
|
||||
"log_replication",
|
||||
"authority_takeover",
|
||||
],
|
||||
"methods": [
|
||||
"groups.capabilities",
|
||||
"groups.list",
|
||||
"groups.create",
|
||||
"groups.state",
|
||||
"groups.send",
|
||||
"groups.rename",
|
||||
"groups.log",
|
||||
"groups.disband",
|
||||
"groups.replicate",
|
||||
"groups.replica_state",
|
||||
"groups.promote",
|
||||
"groups.demote",
|
||||
"groups.stop",
|
||||
"groups.retry",
|
||||
"groups.approve",
|
||||
"groups.peer.invite",
|
||||
"groups.peer.revoke",
|
||||
"groups.peer.register",
|
||||
],
|
||||
"max_log_limit": MAX_LOG_LIMIT,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("groups.peer.invite")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Mint one target-issued room/profile grant for a prospective home."""
|
||||
try:
|
||||
from gateway.hosted_room_peer import (
|
||||
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
|
||||
decode_room_grant,
|
||||
gateway_room_grant_secret,
|
||||
issue_room_grant,
|
||||
local_catalog_mapping,
|
||||
)
|
||||
from gateway import hosted_rooms
|
||||
|
||||
if not _room_link_run_storage_durable():
|
||||
raise ValueError("durable run idempotency storage is required")
|
||||
installation_id = hosted_rooms.local_authority_gateway_id()
|
||||
profile = _requested_profile(params)
|
||||
ttl = float(params.get("ttl_seconds", 3600))
|
||||
if not 60 <= ttl <= 24 * 60 * 60:
|
||||
raise ValueError("ttl_seconds must be between 60 and 86400")
|
||||
grant_secret = gateway_room_grant_secret()
|
||||
execution_policy = _profile_execution_policy(profile)
|
||||
token = issue_room_grant(
|
||||
grant_secret,
|
||||
grant_id=str(params.get("grant_id") or f"grant-{os.urandom(16).hex()}"),
|
||||
room_id=str(params.get("room_id") or ""),
|
||||
home_install_id=str(params.get("home_install_id") or ""),
|
||||
authority_gateway_id=str(
|
||||
params.get("authority_gateway_id") or ""
|
||||
),
|
||||
authority_epoch=int(params.get("authority_epoch") or 0),
|
||||
member_id=str(params.get("member_id") or ""),
|
||||
target_install_id=installation_id,
|
||||
target_profile=profile,
|
||||
execution_policy_digest=execution_policy["policy_digest"],
|
||||
ttl_seconds=ttl,
|
||||
)
|
||||
claims = decode_room_grant(grant_secret, token, permission="status")
|
||||
hosted_rooms.reserve_peer_room(
|
||||
hosted_rooms.default_db_path(),
|
||||
claims=claims,
|
||||
expires_at=float(claims.get("status_expires_at", claims["expires_at"])),
|
||||
)
|
||||
catalog = local_catalog_mapping(
|
||||
installation_id=installation_id,
|
||||
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
|
||||
link_modes=("direct",),
|
||||
text=True,
|
||||
attachments=False,
|
||||
target_profile=profile,
|
||||
execution_policy=execution_policy,
|
||||
)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"grant": token,
|
||||
"target_profile": profile,
|
||||
"catalog": catalog,
|
||||
"endpoint": catalog["endpoint"],
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
return _err(rid, 4120, str(exc))
|
||||
|
||||
|
||||
@method("groups.peer.revoke")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Revoke one target-issued grant using its exact profile scope."""
|
||||
try:
|
||||
from gateway import hosted_rooms
|
||||
from gateway.hosted_room_peer import decode_room_grant, gateway_room_grant_secret
|
||||
|
||||
profile = _requested_profile(params)
|
||||
claims = decode_room_grant(
|
||||
gateway_room_grant_secret(),
|
||||
str(params.get("grant") or ""),
|
||||
permission="status",
|
||||
)
|
||||
if (
|
||||
claims["target_profile"] != profile
|
||||
or claims["target_install_id"]
|
||||
!= hosted_rooms.local_authority_gateway_id()
|
||||
):
|
||||
raise ValueError("room grant target does not match this profile")
|
||||
hosted_rooms.revoke_room_grant_scope(
|
||||
hosted_rooms.default_db_path(),
|
||||
claims=claims,
|
||||
expires_at=float(
|
||||
claims.get("status_expires_at", claims["expires_at"])
|
||||
),
|
||||
)
|
||||
return _ok(rid, {"revoked": True})
|
||||
except Exception as exc:
|
||||
return _err(rid, 4122, str(exc))
|
||||
|
||||
|
||||
@method("groups.peer.register")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Register and probe one scoped target route on the room home."""
|
||||
service = get_hosted_room_service()
|
||||
if service is None:
|
||||
return _err(rid, 4121, "hosted room driver is unavailable")
|
||||
try:
|
||||
from gateway.hosted_room_peer import (
|
||||
GatewayRoomCatalog,
|
||||
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
|
||||
validate_room_link_url,
|
||||
)
|
||||
from gateway.hosted_rooms import local_authority_gateway_id, room_state
|
||||
from tui_gateway.hosted_room_peer_http import PeerRunsHTTPClient
|
||||
from tui_gateway.hosted_room_peer_transport import PeerMemberRoute
|
||||
|
||||
target_url, transport_security = validate_room_link_url(
|
||||
params.get("target_url")
|
||||
)
|
||||
catalog = GatewayRoomCatalog.from_mapping(params.get("catalog"))
|
||||
if ROOM_LINK_PROTOCOL_VERSION not in catalog.protocol_versions:
|
||||
raise ValueError(
|
||||
f"target does not support RoomLink protocol v{ROOM_LINK_PROTOCOL_VERSION}"
|
||||
)
|
||||
if "direct" not in catalog.link_modes:
|
||||
raise ValueError("target does not support a direct RoomLink")
|
||||
target_profile = str(params.get("target_profile") or "")
|
||||
grant = str(params.get("grant") or "")
|
||||
client = PeerRunsHTTPClient(
|
||||
base_url=target_url,
|
||||
api_key="",
|
||||
receipt_db_path=service.db_path,
|
||||
)
|
||||
probe = client.probe(grant=grant)
|
||||
live_catalog = GatewayRoomCatalog.from_mapping(probe.get("catalog"))
|
||||
if live_catalog != catalog:
|
||||
raise ValueError("target capability catalog changed during setup")
|
||||
if (
|
||||
ROOM_LINK_PROTOCOL_VERSION not in live_catalog.protocol_versions
|
||||
or "direct" not in live_catalog.link_modes
|
||||
):
|
||||
raise ValueError("target RoomLink capability is incompatible")
|
||||
room_id = str(params.get("room_id") or "")
|
||||
member_id = str(params.get("member_id") or "")
|
||||
home_install_id = local_authority_gateway_id()
|
||||
home_room = room_state(service.db_path, room_id=room_id)
|
||||
if (
|
||||
probe.get("room_id") != room_id
|
||||
or probe.get("home_install_id") != home_install_id
|
||||
or probe.get("authority_gateway_id")
|
||||
!= home_room.get("authority_gateway_id")
|
||||
or int(probe.get("authority_epoch") or 0)
|
||||
!= int(home_room.get("authority_epoch") or 0)
|
||||
or probe.get("member_id") != member_id
|
||||
or probe.get("target_profile") != target_profile
|
||||
):
|
||||
raise ValueError("room grant scope does not match this route")
|
||||
route = PeerMemberRoute(
|
||||
home_install_id=home_install_id,
|
||||
member_id=member_id,
|
||||
target_install_id=catalog.installation_id,
|
||||
target_profile=target_profile,
|
||||
capability_digest=catalog.catalog_digest,
|
||||
execution_policy_digest=catalog.execution_policy.policy_digest,
|
||||
cancellation_scope_id=str(
|
||||
params.get("cancellation_scope_id")
|
||||
or f"cancel-{params.get('room_id') or ''}"
|
||||
),
|
||||
trace_id=str(params.get("trace_id") or f"trace-{os.urandom(16).hex()}"),
|
||||
grant=grant,
|
||||
)
|
||||
service.register_peer_route(
|
||||
room_id=room_id,
|
||||
member_id=member_id,
|
||||
route=route,
|
||||
client=client,
|
||||
target_url=target_url,
|
||||
catalog=catalog,
|
||||
)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"registered": True,
|
||||
"mode": "direct",
|
||||
"transport_security": transport_security,
|
||||
"target_install_id": catalog.installation_id,
|
||||
"target_profile": target_profile,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5120, str(exc))
|
||||
|
||||
|
||||
@method("groups.list")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""List rooms hosted by this gateway."""
|
||||
try:
|
||||
from gateway.hosted_rooms import (
|
||||
MAX_ROOM_LIST_LIMIT,
|
||||
default_db_path,
|
||||
list_rooms,
|
||||
)
|
||||
|
||||
limit = params.get("limit", MAX_ROOM_LIST_LIMIT)
|
||||
offset = params.get("offset", 0)
|
||||
rooms = list_rooms(
|
||||
default_db_path(),
|
||||
include_disbanded=params.get("include_disbanded") is True,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"rooms": rooms,
|
||||
"next_offset": offset + limit if len(rooms) == limit else None,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5110, str(exc))
|
||||
|
||||
|
||||
@method("groups.create")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Create a hosted room idempotently.
|
||||
|
||||
Required params: ``room_id``, ``name``, and ``members``. Authority is
|
||||
derived from this gateway's stable install identity, never from the client.
|
||||
"""
|
||||
from gateway.hosted_rooms import HostedRoomError
|
||||
|
||||
try:
|
||||
service = get_hosted_room_service()
|
||||
if service is None:
|
||||
return _err(rid, 4123, _WORKER_UNAVAILABLE)
|
||||
room = service.create_room(
|
||||
room_id=params.get("room_id"),
|
||||
name=params.get("name"),
|
||||
members=params.get("members"),
|
||||
)
|
||||
return _ok(rid, {"room": room})
|
||||
except HostedRoomError as exc:
|
||||
reason = getattr(exc, "reason", None)
|
||||
return _err(rid, 4110, str(exc), {"reason": reason} if reason else None)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5111, str(exc))
|
||||
|
||||
|
||||
@method("groups.state")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Return one hosted room's replay cursor and fenced authority state."""
|
||||
from gateway.hosted_rooms import HostedRoomError, default_db_path, room_state
|
||||
|
||||
try:
|
||||
room = room_state(
|
||||
default_db_path(),
|
||||
room_id=params.get("room_id"),
|
||||
include_disbanded=params.get("include_disbanded") is True,
|
||||
)
|
||||
service = get_hosted_room_service()
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"room": room,
|
||||
**(
|
||||
{"driver_status": service.status(str(room["room_id"]))}
|
||||
if service is not None and room.get("disbanded_at") is None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
except HostedRoomError as exc:
|
||||
reason = getattr(exc, "reason", None)
|
||||
return _err(rid, 4114, str(exc), {"reason": reason} if reason else None)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5115, str(exc))
|
||||
|
||||
|
||||
@method("groups.send")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Append one typed event to a hosted room idempotently.
|
||||
|
||||
Required params: ``room_id``, ``event_id``, and object ``payload``. Only
|
||||
inert ``message.user`` events are accepted through this client-facing
|
||||
method. The actor is server-owned rather than trusted from params.
|
||||
Admission is durable; no Bot turn is started by this slice.
|
||||
"""
|
||||
from gateway.hosted_rooms import HostedRoomError, user_event_id
|
||||
|
||||
try:
|
||||
client_event_id = params.get("event_id")
|
||||
service = get_hosted_room_service()
|
||||
if service is None:
|
||||
return _err(rid, 4123, _WORKER_UNAVAILABLE)
|
||||
event = service.send(
|
||||
room_id=params.get("room_id"),
|
||||
event_id=user_event_id(client_event_id),
|
||||
payload=params.get("payload"),
|
||||
)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"event": event,
|
||||
"client_event_id": client_event_id,
|
||||
"accepted": True,
|
||||
"driver_started": True,
|
||||
},
|
||||
)
|
||||
except HostedRoomError as exc:
|
||||
reason = getattr(exc, "reason", None)
|
||||
return _err(rid, 4111, str(exc), {"reason": reason} if reason else None)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5112, str(exc))
|
||||
|
||||
|
||||
@method("groups.rename")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Rename one hosted room atomically with its replay event."""
|
||||
from gateway.hosted_rooms import HostedRoomError, default_db_path, rename_room
|
||||
|
||||
try:
|
||||
renamed = rename_room(
|
||||
default_db_path(),
|
||||
room_id=params.get("room_id"),
|
||||
event_id=params.get("event_id"),
|
||||
name=params.get("name"),
|
||||
)
|
||||
return _ok(rid, {"room": renamed})
|
||||
except HostedRoomError as exc:
|
||||
reason = getattr(exc, "reason", None)
|
||||
return _err(rid, 4117, str(exc), {"reason": reason} if reason else None)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5117, str(exc))
|
||||
|
||||
|
||||
@method("groups.disband")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Permanently tombstone a hosted room id."""
|
||||
from gateway.hosted_rooms import (
|
||||
AuthorityConflictError,
|
||||
HostedRoomError,
|
||||
RoomHistoryExpiredError,
|
||||
disband_room,
|
||||
local_authority_gateway_id,
|
||||
room_state,
|
||||
)
|
||||
|
||||
try:
|
||||
service = get_hosted_room_service()
|
||||
if service is None:
|
||||
return _err(rid, 4123, _WORKER_UNAVAILABLE)
|
||||
|
||||
def disband_with_state(state: dict | None = None) -> dict:
|
||||
local_gateway_id = local_authority_gateway_id()
|
||||
if state is not None and (
|
||||
str(state["authority_gateway_id"]) != local_gateway_id
|
||||
):
|
||||
raise AuthorityConflictError(
|
||||
"This Group Chat is managed by another gateway."
|
||||
)
|
||||
return disband_room(
|
||||
service.db_path,
|
||||
room_id=params.get("room_id"),
|
||||
expected_gateway_id=str(
|
||||
local_gateway_id
|
||||
),
|
||||
expected_epoch=int(
|
||||
state["authority_epoch"] if state is not None else 1
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
existing = room_state(
|
||||
service.db_path,
|
||||
room_id=params.get("room_id"),
|
||||
include_disbanded=True,
|
||||
)
|
||||
except RoomHistoryExpiredError:
|
||||
tombstone = disband_with_state()
|
||||
return _ok(rid, {"tombstone": tombstone})
|
||||
if existing.get("disbanded_at") is not None:
|
||||
tombstone = disband_with_state(existing)
|
||||
return _ok(rid, {"tombstone": tombstone})
|
||||
service.stop_room(
|
||||
str(params.get("room_id") or ""),
|
||||
cancel_id=str(params.get("cancel_id") or "room-disbanded"),
|
||||
require_acknowledged=True,
|
||||
)
|
||||
service.revoke_room_routes(str(params.get("room_id") or ""))
|
||||
tombstone = disband_with_state(existing)
|
||||
return _ok(rid, {"tombstone": tombstone})
|
||||
except HostedRoomError as exc:
|
||||
reason = getattr(exc, "reason", None)
|
||||
return _err(rid, 4113, str(exc), {"reason": reason} if reason else None)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5114, str(exc))
|
||||
|
||||
|
||||
@method("groups.stop")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Durably cancel queued or running work for one hosted room."""
|
||||
|
||||
service = get_hosted_room_service()
|
||||
if service is None:
|
||||
return _err(rid, 4115, "hosted room driver is unavailable")
|
||||
try:
|
||||
count = service.stop_room(
|
||||
str(params.get("room_id") or ""),
|
||||
cancel_id=str(params.get("cancel_id") or "desktop-stop"),
|
||||
)
|
||||
return _ok(rid, {"cancelled": count})
|
||||
except Exception as exc:
|
||||
return _err(rid, 5116, str(exc))
|
||||
|
||||
|
||||
@method("groups.approve")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Resolve one exact approval requested by a local or peer room member."""
|
||||
|
||||
service = get_hosted_room_service()
|
||||
if service is None:
|
||||
return _err(rid, 4115, "hosted room driver is unavailable")
|
||||
try:
|
||||
result = service.approve_room_task(
|
||||
str(params.get("room_id") or ""),
|
||||
member_id=str(params.get("member_id") or ""),
|
||||
task_id=str(params.get("task_id") or ""),
|
||||
execution_generation=int(params.get("execution_generation") or 0),
|
||||
choice=str(params.get("choice") or ""),
|
||||
request_id=str(params.get("request_id") or ""),
|
||||
)
|
||||
return _ok(rid, {"approved": True, "result": result})
|
||||
except Exception as exc:
|
||||
return _err(rid, 5119, str(exc))
|
||||
|
||||
|
||||
@method("groups.retry")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Retry one indeterminate room task after explicit user confirmation."""
|
||||
service = get_hosted_room_service()
|
||||
if service is None:
|
||||
return _err(rid, 4115, "hosted room driver is unavailable")
|
||||
try:
|
||||
task = service.retry_room_task(
|
||||
str(params.get("room_id") or ""),
|
||||
task_id=str(params.get("task_id") or ""),
|
||||
)
|
||||
identity = task.get("identity") if isinstance(task, dict) else None
|
||||
receipt = {
|
||||
"room_id": str(getattr(identity, "room_id", "") or ""),
|
||||
"task_id": str(getattr(identity, "task_id", "") or ""),
|
||||
"thread_id": str(getattr(identity, "thread_id", "") or ""),
|
||||
"turn_id": str(getattr(identity, "turn_id", "") or ""),
|
||||
"status": str(task.get("status") or "") if isinstance(task, dict) else "",
|
||||
"execution_generation": int(task.get("execution_generation") or 0)
|
||||
if isinstance(task, dict)
|
||||
else 0,
|
||||
"cancel_generation": int(task.get("cancel_generation") or 0)
|
||||
if isinstance(task, dict)
|
||||
else 0,
|
||||
}
|
||||
return _ok(rid, {"retried": True, "task": receipt})
|
||||
except Exception as exc:
|
||||
return _err(rid, 5118, str(exc))
|
||||
|
||||
|
||||
@method("groups.log")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Return a monotonic room-log delta after ``since_seq``."""
|
||||
from gateway.hosted_rooms import HostedRoomError, default_db_path, read_events
|
||||
|
||||
try:
|
||||
delta = read_events(
|
||||
default_db_path(),
|
||||
room_id=params.get("room_id"),
|
||||
since_seq=params.get("since_seq", 0),
|
||||
limit=params.get("limit", 100),
|
||||
include_disbanded=params.get("include_disbanded") is True,
|
||||
)
|
||||
return _ok(rid, delta)
|
||||
except HostedRoomError as exc:
|
||||
reason = getattr(exc, "reason", None)
|
||||
return _err(rid, 4112, str(exc), {"reason": reason} if reason else None)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5113, str(exc))
|
||||
|
||||
|
||||
@method("groups.replicate")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Persist one authority-stamped replay page into the local replica store.
|
||||
|
||||
``page`` is the verbatim ``groups.log`` result read from the room's
|
||||
authority gateway; ingest is idempotent and refuses sequence gaps and
|
||||
authority-epoch regressions.
|
||||
"""
|
||||
from gateway.hosted_room_replicas import ReplicaError, ingest_page
|
||||
from gateway.hosted_rooms import default_db_path
|
||||
|
||||
try:
|
||||
result = ingest_page(
|
||||
default_db_path(),
|
||||
room_id=params.get("room_id"),
|
||||
room_name=params.get("room_name"),
|
||||
members=params.get("members"),
|
||||
page=params.get("page"),
|
||||
)
|
||||
return _ok(rid, result)
|
||||
except ReplicaError as exc:
|
||||
return _err(rid, 4116, str(exc))
|
||||
except Exception as exc:
|
||||
return _err(rid, 5116, str(exc))
|
||||
|
||||
|
||||
@method("groups.replica_state")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Report the local replica's coverage and authority lineage."""
|
||||
from gateway.hosted_room_replicas import ReplicaError, replica_state
|
||||
from gateway.hosted_rooms import default_db_path
|
||||
|
||||
try:
|
||||
return _ok(rid, replica_state(default_db_path(), room_id=params.get("room_id")))
|
||||
except ReplicaError as exc:
|
||||
return _err(rid, 4117, str(exc))
|
||||
except Exception as exc:
|
||||
return _err(rid, 5117, str(exc))
|
||||
|
||||
|
||||
@method("groups.promote")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Continue a replicated room on THIS gateway at ``epoch + 1``.
|
||||
|
||||
Requires ``confirm: true`` — the caller asserts the previous authority can
|
||||
no longer commit (explicit user action; a lease/quorum driver later).
|
||||
"""
|
||||
from gateway.hosted_room_replicas import ReplicaError, promote_replica
|
||||
from gateway.hosted_rooms import HostedRoomError, default_db_path
|
||||
|
||||
if params.get("confirm") is not True:
|
||||
return _err(
|
||||
rid,
|
||||
4118,
|
||||
"promotion requires confirm=true acknowledging the previous "
|
||||
"authority can no longer commit",
|
||||
)
|
||||
try:
|
||||
result = promote_replica(
|
||||
default_db_path(),
|
||||
room_id=params.get("room_id"),
|
||||
reason=params.get("reason", "authority-unreachable"),
|
||||
)
|
||||
return _ok(rid, result)
|
||||
except ReplicaError as exc:
|
||||
return _err(rid, 4118, str(exc))
|
||||
except HostedRoomError as exc:
|
||||
return _err(rid, 4118, str(exc))
|
||||
except Exception as exc:
|
||||
return _err(rid, 5118, str(exc))
|
||||
|
||||
|
||||
@method("groups.demote")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Fence this gateway's stale room authority against a proven newer epoch."""
|
||||
from gateway.hosted_room_replicas import ReplicaError, demote_room
|
||||
from gateway.hosted_rooms import default_db_path
|
||||
|
||||
try:
|
||||
result = demote_room(
|
||||
default_db_path(),
|
||||
room_id=params.get("room_id"),
|
||||
observed_gateway_id=params.get("observed_gateway_id"),
|
||||
observed_epoch=params.get("observed_epoch"),
|
||||
)
|
||||
return _ok(rid, result)
|
||||
except ReplicaError as exc:
|
||||
return _err(rid, 4119, str(exc))
|
||||
except Exception as exc:
|
||||
return _err(rid, 5119, str(exc))
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
_registry.install(server)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Image-generation JSON-RPC handler (ws twin of the image_generate tool).
|
||||
|
||||
Desktop plugins reach the backend only through ws JSON-RPC; the image
|
||||
generation capability existed solely as a model tool. ``image.generate``
|
||||
lets UI surfaces (avatar pickers, artifact panes) generate directly.
|
||||
|
||||
The result image is returned as a data URL (``image_data``): a remote
|
||||
desktop client cannot read a file path on the gateway host, and hosted
|
||||
result URLs are often CORS-opaque to a renderer canvas. Data URLs work
|
||||
identically over local and remote gateways.
|
||||
|
||||
Handlers are rebound onto server.py's globals at install time (see
|
||||
method_ctx.py) — helpers must stay nested inside the handler body.
|
||||
"""
|
||||
|
||||
from .method_ctx import HandlerRegistry
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
|
||||
|
||||
@method("image.generate")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Generate an image with the configured backend.
|
||||
|
||||
Params: ``prompt`` (required unless ``probe``), ``aspect_ratio``
|
||||
(landscape|square|portrait), ``probe`` (return availability only),
|
||||
``max_bytes`` (cap on the returned data URL payload, default 8MB).
|
||||
|
||||
Result: ``{available, success, image, image_data, error}`` where
|
||||
``image`` is the backend's URL/path and ``image_data`` is a data URL
|
||||
of the downloaded bytes (omitted when the download fails — callers
|
||||
should fall back to ``image``).
|
||||
"""
|
||||
|
||||
def _availability() -> bool:
|
||||
try:
|
||||
from tools.image_generation_tool import check_image_generation_requirements
|
||||
|
||||
return bool(check_image_generation_requirements())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _to_data_url(ref: str, cap: int):
|
||||
"""Fetch a URL or read a local path into a data URL, size-capped."""
|
||||
import base64
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
try:
|
||||
if ref.startswith(("http://", "https://")):
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(ref, headers={"User-Agent": "hermes-agent"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
if resp.length is not None and resp.length > cap:
|
||||
return None
|
||||
data = resp.read(cap + 1)
|
||||
mime = resp.headers.get_content_type() or "image/png"
|
||||
elif os.path.isfile(ref):
|
||||
if os.path.getsize(ref) > cap:
|
||||
return None
|
||||
with open(ref, "rb") as fh:
|
||||
data = fh.read(cap + 1)
|
||||
mime = mimetypes.guess_type(ref)[0] or "image/png"
|
||||
else:
|
||||
return None
|
||||
if len(data) > cap:
|
||||
return None
|
||||
if not mime.startswith("image/"):
|
||||
mime = "image/png"
|
||||
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
available = _availability()
|
||||
if is_truthy_value(params.get("probe", False)):
|
||||
return _ok(rid, {"available": available})
|
||||
if not available:
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"available": False,
|
||||
"success": False,
|
||||
"error": "No image generation backend configured (run `hermes tools` to enable one).",
|
||||
},
|
||||
)
|
||||
|
||||
prompt = str(params.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return _err(rid, 4071, "prompt required")
|
||||
|
||||
aspect = str(params.get("aspect_ratio") or "square").strip().lower()
|
||||
try:
|
||||
cap = min(int(params.get("max_bytes", 8_000_000) or 8_000_000), 16_000_000)
|
||||
except (TypeError, ValueError):
|
||||
cap = 8_000_000
|
||||
|
||||
try:
|
||||
from tools.image_generation_tool import _handle_image_generate
|
||||
|
||||
# Full provider dispatcher — the same path the model tool takes:
|
||||
# source-image confinement, plugin-registered providers, managed
|
||||
# Krea routing, then the in-tree FAL fallback. Calling the FAL leaf
|
||||
# (image_generate_tool) directly here bypassed configured providers.
|
||||
raw = _handle_image_generate({"prompt": prompt, "aspect_ratio": aspect})
|
||||
result = json.loads(raw)
|
||||
except Exception as e:
|
||||
return _err(rid, 5071, str(e))
|
||||
|
||||
if not result.get("success"):
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"available": True,
|
||||
"success": False,
|
||||
"error": str(result.get("error") or "generation failed"),
|
||||
},
|
||||
)
|
||||
|
||||
image_ref = str(result.get("image") or "")
|
||||
payload = {"available": True, "success": True, "image": image_ref}
|
||||
data_url = _to_data_url(image_ref, cap) if image_ref else None
|
||||
if data_url:
|
||||
payload["image_data"] = data_url
|
||||
return _ok(rid, payload)
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
_registry.install(server)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,808 @@
|
||||
"""Authoritative project -> repo -> lane -> session tree builder.
|
||||
|
||||
This is the single source of truth for how the desktop sidebar groups sessions
|
||||
into projects, repos, and lanes. It is pure (all git resolution is injected via
|
||||
``resolve``) so it can be unit-tested with fixtures and reused by the gateway's
|
||||
``projects.tree`` / ``projects.project_sessions`` RPCs.
|
||||
|
||||
It deliberately mirrors the desktop's former client-side grouping (the old
|
||||
``workspace-groups.ts``) so the emitted ids and lane keys stay byte-compatible
|
||||
with the renderer's persisted state (pins, manual ordering, dismissal), which
|
||||
all key off these exact strings:
|
||||
|
||||
- explicit project id .......... ``p_<hex>`` (from projects.db)
|
||||
- auto/discovered project id ... the repo root path
|
||||
- home (no-project) bucket ..... ``__no_project__``
|
||||
- repo node id ................. the repo root path
|
||||
- main branch lane id .......... ``<repoRoot>::branch::<branch>`` (or ``::branch::``)
|
||||
- kanban bucket lane id ........ ``<repoRoot>::kanban``
|
||||
- linked worktree lane id ...... the worktree path
|
||||
|
||||
The one correctness upgrade over the client version: linked worktrees are folded
|
||||
under their MAIN repo via a git common-dir probe (injected as ``resolve``),
|
||||
instead of being treated as separate repos (``git rev-parse --show-toplevel``
|
||||
returns the worktree's own root, which is why the client double-counted them).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
# A cwd -> git identity resolver. Returns ``{"repo_root", "worktree_root"}`` where
|
||||
# ``repo_root`` is the COMMON (main) repo root shared across worktrees and
|
||||
# ``worktree_root`` is this cwd's own checkout root. Returns ``None`` when the
|
||||
# cwd is not in a git repo (or cannot be probed, e.g. a remote backend).
|
||||
Resolve = Callable[[str], Optional[dict]]
|
||||
|
||||
# A "does this directory still exist?" predicate, injected for the same reason
|
||||
# ``Resolve`` is: the builder stays pure and unit-testable. Defaults to assuming
|
||||
# everything exists, which preserves the previous behavior for callers that
|
||||
# can't stat (remote backends), where guessing "gone" would wrongly hide a
|
||||
# project that lives on the other host.
|
||||
Exists = Callable[[str], bool]
|
||||
|
||||
# Only KANBAN-TASK worktrees (`<repo>/.worktrees/t_<hex>`, the `t_…` id kanban_db
|
||||
# mints) collapse into one lane; user-named "New worktree" dirs under
|
||||
# `.worktrees/` stay as their own lanes.
|
||||
_KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$")
|
||||
_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"}
|
||||
DEFAULT_BRANCH_LABEL = "main"
|
||||
|
||||
# The synthetic bucket holding every session no project claimed — a chat with no
|
||||
# cwd at all, or one whose folder can't be promoted (the bare home dir, HERMES
|
||||
# state, a workspace that has since been deleted). Without it those sessions are
|
||||
# invisible in the grouped view. The desktop labels it "Home"; the id/flag stay
|
||||
# named for what the bucket MEANS, since that's what membership keys off.
|
||||
NO_PROJECT_ID = "__no_project__"
|
||||
NO_PROJECT_LABEL = "Home"
|
||||
|
||||
# How many sibling candidates to try when recovering a deleted worktree's parent
|
||||
# repo (see ``_probe_sibling_worktree``). Each miss costs a git probe, so keep it
|
||||
# tight — real suffixes are one or two segments.
|
||||
_MAX_SIBLING_PROBES = 4
|
||||
|
||||
|
||||
def stamp_profile(projects: list[dict], profile: str) -> None:
|
||||
"""Make every session row self-describing for cross-profile routing.
|
||||
|
||||
A scoped project tree is built from one profile's state.db, so the request
|
||||
scope is authoritative even for legacy rows whose ``profile_name`` is NULL.
|
||||
"""
|
||||
for project in projects:
|
||||
for session in project.get("previewSessions") or []:
|
||||
session["profile"] = profile
|
||||
for repo in project.get("repos") or []:
|
||||
for group in repo.get("groups") or []:
|
||||
for session in group.get("sessions") or []:
|
||||
session["profile"] = profile
|
||||
|
||||
|
||||
def _branch_lane_id(repo_root: str, branch: str = "") -> str:
|
||||
"""The one definition of a main-checkout lane id (must match the desktop)."""
|
||||
return f"{repo_root}::branch::{(branch or '').strip()}"
|
||||
|
||||
|
||||
def _kanban_lane_id(repo_root: str) -> str:
|
||||
return f"{repo_root}::kanban"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path helpers (match the TS segment logic so labels/ids line up)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _segments(path: str) -> list[str]:
|
||||
return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s]
|
||||
|
||||
|
||||
def _is_windows_path(path: str) -> bool:
|
||||
value = (path or "").strip()
|
||||
# Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any backslash-rooted path
|
||||
# — the root-relative `\wsl.localhost\…` / `\Users\…` spellings included. A
|
||||
# single leading `/` stays POSIX (case-sensitive).
|
||||
return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\", "//"))
|
||||
|
||||
|
||||
def _comparison_segments(path: str) -> list[str]:
|
||||
"""Path segments suitable for identity comparisons on any host.
|
||||
|
||||
Windows paths remain case-insensitive even when tests or remote backends run
|
||||
on POSIX. Display paths and emitted IDs keep their original spelling.
|
||||
"""
|
||||
segs = _segments(path)
|
||||
return [segment.casefold() for segment in segs] if _is_windows_path(path) else segs
|
||||
|
||||
|
||||
def _path_key(path: str) -> str:
|
||||
"""Canonical comparison key (separator/trailing-slash agnostic)."""
|
||||
return "/".join(_comparison_segments(path))
|
||||
|
||||
|
||||
def _lane_key(path_or_lane: str) -> str:
|
||||
"""Canonicalize only the path portion of a lane id.
|
||||
|
||||
Branch labels remain byte-preserved; repo/worktree paths follow platform path
|
||||
identity so equivalent Windows spellings do not create duplicate lanes.
|
||||
"""
|
||||
for marker in ("::branch::", "::kanban"):
|
||||
if marker in path_or_lane:
|
||||
root, suffix = path_or_lane.split(marker, 1)
|
||||
return f"{_path_key(root)}{marker}{suffix}"
|
||||
return _path_key(path_or_lane)
|
||||
|
||||
|
||||
def base_name(path: str) -> str:
|
||||
segs = _segments(path)
|
||||
return segs[-1] if segs else ""
|
||||
|
||||
|
||||
def kanban_worktree_dir(path: str) -> Optional[str]:
|
||||
"""The ``<repo>/.worktrees`` dir for a ``.../.worktrees/<task>`` path, else None."""
|
||||
m = _KANBAN_DIR_RE.match(path or "")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _is_path_under(folder: str, target: str) -> bool:
|
||||
"""True when ``target`` equals ``folder`` or is nested under it (segment-wise)."""
|
||||
f = _comparison_segments(folder)
|
||||
t = _comparison_segments(target)
|
||||
if not f or len(f) > len(t):
|
||||
return False
|
||||
return all(f[i] == t[i] for i in range(len(f)))
|
||||
|
||||
|
||||
def _with_base_name(path: str, name: str) -> str:
|
||||
stripped = re.sub(r"[/\\]+$", "", path)
|
||||
return re.sub(r"[^/\\]+$", name, stripped)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lane placement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _placement(
|
||||
repo_root: str,
|
||||
lane_key: str,
|
||||
lane_label: str,
|
||||
lane_path: str,
|
||||
is_main: bool,
|
||||
is_kanban: bool,
|
||||
) -> dict:
|
||||
return {
|
||||
"repo_key": repo_root,
|
||||
"repo_label": base_name(repo_root) or repo_root,
|
||||
"repo_path": repo_root,
|
||||
"lane_key": lane_key,
|
||||
"lane_label": lane_label,
|
||||
"lane_path": lane_path,
|
||||
"is_main": is_main,
|
||||
"is_kanban": is_kanban,
|
||||
}
|
||||
|
||||
|
||||
def _parent_dir(path: str) -> str:
|
||||
"""The containing directory of ``path`` (``""`` once the root is passed)."""
|
||||
stripped = re.sub(r"[/\\]+$", "", path or "")
|
||||
return re.sub(r"[/\\]+$", "", re.sub(r"[^/\\]+$", "", stripped))
|
||||
|
||||
|
||||
def _probe_sibling_worktree(cwd: str, resolve: Resolve) -> str:
|
||||
"""The parent repo root of a deleted ``<repo>-<suffix>`` worktree, else ``""``.
|
||||
|
||||
A deleted worktree dir can't be probed, so walk back up its name — trimming
|
||||
one ``-<segment>`` at a time — and return the first sibling that resolves.
|
||||
|
||||
The session's cwd is frequently a SUBDIR of the deleted worktree (an agent
|
||||
that ``cd``-ed into ``<repo>-<suffix>/apps/desktop``), whose basename shares
|
||||
nothing with the repo. So the trim is applied to each ANCESTOR, deepest
|
||||
first, not just to the leaf — otherwise the probe silently no-ops and the
|
||||
dead path gets minted as its own top-level project. Probes are bounded in
|
||||
total (each costs a git invocation) and served from the shared probe cache.
|
||||
"""
|
||||
probes = 0
|
||||
path = re.sub(r"[/\\]+$", "", cwd or "")
|
||||
|
||||
while path and probes < _MAX_SIBLING_PROBES:
|
||||
parts = base_name(path).split("-")
|
||||
|
||||
for i in range(len(parts) - 1, 0, -1):
|
||||
if probes >= _MAX_SIBLING_PROBES:
|
||||
break
|
||||
probes += 1
|
||||
info = resolve(_with_base_name(path, "-".join(parts[:i])))
|
||||
if info and info.get("repo_root"):
|
||||
return (info["repo_root"] or "").strip()
|
||||
|
||||
path = _parent_dir(path)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _place_by_heuristic(path: str) -> Optional[dict]:
|
||||
"""Path-only fallback when there is no git probe and no persisted root."""
|
||||
base = base_name(path)
|
||||
if not base:
|
||||
return None
|
||||
|
||||
kanban_dir = kanban_worktree_dir(path)
|
||||
if kanban_dir:
|
||||
repo_path = re.sub(r"[/\\]+$", "", _with_base_name(kanban_dir, ""))
|
||||
return _placement(repo_path, _kanban_lane_id(repo_path), "kanban", kanban_dir, False, True)
|
||||
|
||||
m = re.match(r"^(.+)-wt-(.+)$", base)
|
||||
if m:
|
||||
repo_path = _with_base_name(path, m.group(1))
|
||||
return _placement(repo_path, path, m.group(2), path, False, False)
|
||||
|
||||
return _placement(path, _branch_lane_id(path, DEFAULT_BRANCH_LABEL), base, path, True, False)
|
||||
|
||||
|
||||
def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]:
|
||||
info = resolve(cwd) if resolve else None
|
||||
|
||||
if info and info.get("repo_root") and info.get("worktree_root"):
|
||||
repo_root = info["repo_root"]
|
||||
worktree_root = info["worktree_root"]
|
||||
is_main = _path_key(worktree_root) == _path_key(repo_root) or bool(info.get("is_main"))
|
||||
|
||||
if is_main:
|
||||
# Unrecorded branch folds into the one trunk lane, so a repo never
|
||||
# shows two "main" lanes (recorded "main" + the empty-branch bucket).
|
||||
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
|
||||
return _placement(repo_root, _branch_lane_id(repo_root, b), b, repo_root, True, False)
|
||||
|
||||
kanban_dir = kanban_worktree_dir(worktree_root)
|
||||
if kanban_dir:
|
||||
return _placement(repo_root, _kanban_lane_id(repo_root), "kanban", kanban_dir, False, True)
|
||||
|
||||
label = base_name(worktree_root) or worktree_root
|
||||
return _placement(repo_root, worktree_root, label, worktree_root, False, False)
|
||||
|
||||
# No live probe: trust the backend-persisted root (group by it, split main by
|
||||
# the session's recorded branch). Kanban tasks still collapse by path shape.
|
||||
if persisted_root:
|
||||
kanban_dir = kanban_worktree_dir(cwd)
|
||||
if kanban_dir:
|
||||
return _placement(persisted_root, _kanban_lane_id(persisted_root), "kanban", kanban_dir, False, True)
|
||||
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
|
||||
return _placement(persisted_root, _branch_lane_id(persisted_root, b), b, persisted_root, True, False)
|
||||
|
||||
# Unresolvable cwd: a deleted ``<repo>-<suffix>`` worktree still belongs to
|
||||
# its parent. It has no checkout to return to, so absorb it into the trunk
|
||||
# lane rather than stranding a dead-path lane in the project forever.
|
||||
sibling_root = _probe_sibling_worktree(cwd, resolve) if resolve else ""
|
||||
if sibling_root:
|
||||
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
|
||||
return _placement(sibling_root, _branch_lane_id(sibling_root, b), b, sibling_root, True, False)
|
||||
|
||||
return _place_by_heuristic(cwd)
|
||||
|
||||
|
||||
def _session_repo_root(session: dict, resolve: Optional[Resolve]) -> str:
|
||||
"""The COMMON repo root a session belongs to (folds linked worktrees)."""
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if cwd and resolve:
|
||||
info = resolve(cwd)
|
||||
if info and info.get("repo_root"):
|
||||
return info["repo_root"]
|
||||
return (session.get("git_repo_root") or "").strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ordering + label disambiguation (parity with the old client tree)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _lane_sort_key(group: dict) -> tuple:
|
||||
# Trunk pins to the top; the kanban aggregate sinks to the bottom; the rest
|
||||
# (branches + linked worktrees) sort by most-recent activity, then label.
|
||||
is_trunk = bool(group.get("isMain")) and group["label"].lower() in _TRUNK_BRANCHES
|
||||
is_kanban = bool(group.get("isKanban"))
|
||||
activity = max((_session_time(s) for s in group.get("sessions") or []), default=0.0)
|
||||
return (
|
||||
0 if is_trunk else 1,
|
||||
1 if is_kanban else 0,
|
||||
-activity,
|
||||
group["label"].lower(),
|
||||
)
|
||||
|
||||
|
||||
def _sort_lanes(groups: list[dict]) -> list[dict]:
|
||||
return sorted(groups, key=_lane_sort_key)
|
||||
|
||||
|
||||
def _disambiguate_labels(items: list[dict]) -> None:
|
||||
"""Grow colliding basenames into path-prefixed labels (in place)."""
|
||||
by_label: dict[str, list[dict]] = {}
|
||||
for item in items:
|
||||
by_label.setdefault(item["label"], []).append(item)
|
||||
|
||||
for bucket in by_label.values():
|
||||
pathed = [g for g in bucket if g.get("path")]
|
||||
if len(pathed) < 2:
|
||||
continue
|
||||
|
||||
parents = {id(g): _segments(g["path"])[:-1] for g in pathed}
|
||||
max_depth = max(len(p) for p in parents.values())
|
||||
depth = 1
|
||||
while depth <= max_depth:
|
||||
counts: dict[str, int] = {}
|
||||
for g in pathed:
|
||||
segs = parents[id(g)]
|
||||
prefix = "/".join(segs[-depth:]) if depth else ""
|
||||
base = base_name(g["path"]) or g["path"]
|
||||
g["label"] = f"{prefix}/{base}" if prefix else base
|
||||
counts[g["label"]] = counts.get(g["label"], 0) + 1
|
||||
if all(c == 1 for c in counts.values()):
|
||||
break
|
||||
depth += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repo subtree assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _session_time(session: dict) -> float:
|
||||
return float(session.get("last_active") or session.get("started_at") or 0)
|
||||
|
||||
|
||||
def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool) -> list[dict]:
|
||||
"""Build the ``repo -> lane -> sessions`` subtree for a set of sessions."""
|
||||
lanes: dict[str, dict] = {} # lane_key -> {group, repo_key, repo_label, repo_path}
|
||||
|
||||
for session in sessions:
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if not cwd:
|
||||
continue
|
||||
|
||||
placement = _place(
|
||||
cwd,
|
||||
(session.get("git_branch") or "").strip(),
|
||||
resolve,
|
||||
(session.get("git_repo_root") or "").strip(),
|
||||
)
|
||||
if not placement:
|
||||
continue
|
||||
|
||||
lane_identity = _lane_key(placement["lane_key"])
|
||||
entry = lanes.get(lane_identity)
|
||||
if entry is None:
|
||||
entry = {
|
||||
"group": {
|
||||
"id": placement["lane_key"],
|
||||
"label": placement["lane_label"],
|
||||
"path": placement["lane_path"],
|
||||
"isMain": placement["is_main"],
|
||||
"isKanban": placement["is_kanban"],
|
||||
"sessions": [],
|
||||
},
|
||||
"repo_key": placement["repo_key"],
|
||||
"repo_label": placement["repo_label"],
|
||||
"repo_path": placement["repo_path"],
|
||||
}
|
||||
lanes[lane_identity] = entry
|
||||
entry["group"]["sessions"].append(session)
|
||||
|
||||
repos: dict[str, dict] = {}
|
||||
for entry in lanes.values():
|
||||
group = entry["group"]
|
||||
group["sessions"].sort(key=_session_time, reverse=True)
|
||||
count = len(group["sessions"])
|
||||
|
||||
repo_identity = _path_key(entry["repo_key"])
|
||||
repo = repos.get(repo_identity)
|
||||
if repo is None:
|
||||
repo = {
|
||||
"id": entry["repo_key"],
|
||||
"label": entry["repo_label"],
|
||||
"path": entry["repo_path"],
|
||||
"groups": [],
|
||||
"sessionCount": 0,
|
||||
}
|
||||
repos[repo_identity] = repo
|
||||
repo["groups"].append(group)
|
||||
repo["sessionCount"] += count
|
||||
|
||||
repo_list = list(repos.values())
|
||||
for repo in repo_list:
|
||||
repo["groups"] = _sort_lanes(repo["groups"])
|
||||
_disambiguate_labels(repo["groups"])
|
||||
# Drop per-lane session rows only AFTER sorting: _lane_sort_key ranks
|
||||
# non-trunk lanes by most-recent activity, which it derives from the
|
||||
# session rows. Clearing them earlier makes every lane look inactive on
|
||||
# the overview (hydrate=False) path and collapses the sort to
|
||||
# alphabetical. Counts were already captured above, so the payload stays
|
||||
# slim without losing the recency order.
|
||||
if not hydrate:
|
||||
for group in repo["groups"]:
|
||||
group["sessions"] = []
|
||||
_disambiguate_labels(repo_list)
|
||||
return repo_list
|
||||
|
||||
|
||||
def _seed_folder_repos(
|
||||
repos: list[dict], folders: list[dict], resolve: Optional[Resolve]
|
||||
) -> list[dict]:
|
||||
"""Ensure every declared project folder shows as a repo, even with 0 sessions.
|
||||
|
||||
A brand-new project (or any project whose sessions haven't loaded yet) has an
|
||||
empty session-derived ``repos`` list. That breaks two things on the desktop:
|
||||
the entered-project view renders blank (it early-returns on no repos), and the
|
||||
optimistic live-session overlay has no lane to drop a freshly-created session
|
||||
into — so a new session in the project only appears after a full tree refresh.
|
||||
Seeding each folder as an empty repo fixes both: the overlay matches a new
|
||||
session's cwd under the folder root, and the drill-in renders a real (if
|
||||
empty) project body. Folders already covered by a session-derived repo (same
|
||||
git root) are left untouched.
|
||||
"""
|
||||
seen = {
|
||||
_path_key(value)
|
||||
for repo in repos
|
||||
for value in (repo.get("id"), repo.get("path"))
|
||||
if value
|
||||
}
|
||||
seeded = list(repos)
|
||||
|
||||
for folder in folders or []:
|
||||
raw = (folder.get("path") or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
info = resolve(raw) if resolve else None
|
||||
root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw)
|
||||
root_key = _path_key(root)
|
||||
if not root_key or root_key in seen:
|
||||
continue
|
||||
seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0})
|
||||
seen.add(root_key)
|
||||
|
||||
if len(seeded) != len(repos):
|
||||
_disambiguate_labels(seeded)
|
||||
|
||||
return seeded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Explicit-project ownership
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FolderIndex:
|
||||
"""Maps a normalized folder path → (owning project, depth), so a session is
|
||||
matched to its project by walking its cwd's ancestors (O(path depth) dict
|
||||
lookups) instead of scanning every project × folder per session — the
|
||||
difference between O(sessions × projects) and O(sessions) at power-user scale.
|
||||
"""
|
||||
|
||||
def __init__(self, projects: list[dict]) -> None:
|
||||
self._by_path: dict[str, tuple[dict, int]] = {}
|
||||
for project in projects:
|
||||
for folder in project.get("folders") or []:
|
||||
segs = _comparison_segments(folder.get("path") or "")
|
||||
if not segs:
|
||||
continue
|
||||
key = "/".join(segs)
|
||||
depth = len(segs)
|
||||
# Deepest folder wins; ties keep the first project (scan order).
|
||||
existing = self._by_path.get(key)
|
||||
if existing is None or depth > existing[1]:
|
||||
self._by_path[key] = (project, depth)
|
||||
|
||||
def match(self, target: str) -> tuple[Optional[dict], int]:
|
||||
"""Owning project for ``target`` by longest ancestor folder, + its depth."""
|
||||
segs = _comparison_segments(target or "")
|
||||
# Longest prefix first → deepest (most specific) folder wins.
|
||||
for end in range(len(segs), 0, -1):
|
||||
hit = self._by_path.get("/".join(segs[:end]))
|
||||
if hit:
|
||||
return hit
|
||||
return None, -1
|
||||
|
||||
|
||||
def _project_for_path(index: _FolderIndex, target: str) -> Optional[dict]:
|
||||
return index.match(target)[0]
|
||||
|
||||
|
||||
def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]:
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if not cwd:
|
||||
return None
|
||||
repo_root = _session_repo_root(session, resolve)
|
||||
candidates = [cwd, repo_root] if repo_root and repo_root != cwd else [cwd]
|
||||
|
||||
best: Optional[dict] = None
|
||||
best_len = -1
|
||||
for target in candidates:
|
||||
match, length = index.match(target)
|
||||
if match and length > best_len:
|
||||
best_len = length
|
||||
best = match
|
||||
return best
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _session_cost(session: dict) -> float:
|
||||
"""A session's spend, billed if the provider reported it, else estimated."""
|
||||
for key in ("actual_cost_usd", "estimated_cost_usd"):
|
||||
value = session.get(key)
|
||||
if value:
|
||||
return float(value)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _project_node(
|
||||
*,
|
||||
pid: str,
|
||||
label: str,
|
||||
path: Optional[str],
|
||||
repos: list[dict],
|
||||
session_count: int,
|
||||
last_active: float,
|
||||
preview_sessions: list[dict],
|
||||
sessions: Optional[list[dict]] = None,
|
||||
color: Any = None,
|
||||
icon: Any = None,
|
||||
is_auto: bool = False,
|
||||
is_no_project: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": pid,
|
||||
"label": label,
|
||||
"path": path,
|
||||
"color": color,
|
||||
"icon": icon,
|
||||
"isAuto": is_auto,
|
||||
"isNoProject": is_no_project,
|
||||
"sessionCount": session_count,
|
||||
"lastActive": last_active,
|
||||
# Totals over the same sessions `sessionCount` counts, so a project's
|
||||
# header can add up what its rows show. The window the caller loaded is
|
||||
# the whole truth either way — count and totals can't disagree.
|
||||
"totalTokens": sum((s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in sessions or []),
|
||||
"totalCostUsd": sum(_session_cost(s) for s in sessions or []),
|
||||
"repos": repos,
|
||||
"previewSessions": preview_sessions,
|
||||
}
|
||||
|
||||
|
||||
def build_tree(
|
||||
projects: list[dict],
|
||||
sessions: list[dict],
|
||||
discovered_repos: list[dict],
|
||||
resolve: Optional[Resolve] = None,
|
||||
*,
|
||||
preview_limit: int = 3,
|
||||
hydrate: bool = False,
|
||||
is_junk_root: Optional[Callable[[str], bool]] = None,
|
||||
is_junk_cwd: Optional[Callable[[str], bool]] = None,
|
||||
exists: Optional[Exists] = None,
|
||||
) -> dict:
|
||||
"""Build the authoritative project tree.
|
||||
|
||||
``projects`` are ``projects_db.Project.to_dict()`` shapes (non-archived).
|
||||
``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``,
|
||||
``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``).
|
||||
``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``.
|
||||
``is_junk_root`` flags git roots that must never become an AUTO project (the
|
||||
bare home dir, the HERMES_HOME subtree). ``is_junk_cwd`` is the narrower
|
||||
policy for non-git session folders: selected descendants may be intentional
|
||||
workspaces even when their parent tree contains Hermes state. User-created
|
||||
projects are honored regardless. ``exists`` reports whether a directory is
|
||||
still on disk, so a session whose workspace was DELETED (a removed worktree,
|
||||
a scratch dir under /tmp) doesn't get promoted to a phantom AUTO project;
|
||||
omit it (remote backends) to keep every candidate.
|
||||
|
||||
Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When
|
||||
``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but
|
||||
every count is preserved and each project carries up to ``preview_limit``
|
||||
``previewSessions``. When True (drill-in), lanes carry full session rows.
|
||||
"""
|
||||
active_projects = [p for p in projects if not p.get("archived")]
|
||||
_junk = is_junk_root or (lambda _root: False)
|
||||
_junk_cwd = is_junk_cwd or (lambda _cwd: False)
|
||||
_exists = exists or (lambda _path: True)
|
||||
folder_index = _FolderIndex(active_projects)
|
||||
|
||||
by_project: dict[str, list[dict]] = {}
|
||||
unowned: list[dict] = []
|
||||
for session in sessions:
|
||||
owner = _project_for_session(session, folder_index, resolve)
|
||||
if owner:
|
||||
by_project.setdefault(owner["id"], []).append(session)
|
||||
else:
|
||||
unowned.append(session)
|
||||
|
||||
scoped_ids: list[str] = []
|
||||
|
||||
def _previews(project_sessions: list[dict]) -> list[dict]:
|
||||
if preview_limit <= 0:
|
||||
return []
|
||||
ordered = sorted(project_sessions, key=_session_time, reverse=True)
|
||||
return ordered[:preview_limit]
|
||||
|
||||
def _last_active(project_sessions: list[dict]) -> float:
|
||||
return max((_session_time(s) for s in project_sessions), default=0.0)
|
||||
|
||||
result: list[dict] = []
|
||||
|
||||
# Tier 1: explicit, user-created projects (always shown, even with 0 sessions).
|
||||
for project in active_projects:
|
||||
psessions = by_project.get(project["id"], [])
|
||||
scoped_ids.extend(s["id"] for s in psessions if s.get("id"))
|
||||
repos = _seed_folder_repos(
|
||||
_build_repos(psessions, resolve, hydrate), project.get("folders") or [], resolve
|
||||
)
|
||||
result.append(
|
||||
_project_node(
|
||||
pid=project["id"],
|
||||
label=project.get("name") or project["id"],
|
||||
path=project.get("primary_path"),
|
||||
color=project.get("color"),
|
||||
icon=project.get("icon"),
|
||||
repos=repos,
|
||||
session_count=len(psessions),
|
||||
last_active=_last_active(psessions),
|
||||
preview_sessions=_previews(psessions),
|
||||
sessions=psessions,
|
||||
)
|
||||
)
|
||||
|
||||
# Tier 2: auto projects from leftover sessions. Prefer the common git repo
|
||||
# root, then fall back to the session cwd for historical/non-git workspaces.
|
||||
# The pre-Projects desktop grouped every non-empty cwd; keeping that fallback
|
||||
# prevents upgrades from flattening those sessions into Recents.
|
||||
by_auto_root: dict[str, dict] = {}
|
||||
# Every session no tier could place. These are the Home bucket's rows.
|
||||
homeless: list[dict] = []
|
||||
|
||||
def _add_auto(root: str, session: dict) -> None:
|
||||
key = _path_key(root)
|
||||
if not key:
|
||||
homeless.append(session)
|
||||
return
|
||||
bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []})
|
||||
bucket["sessions"].append(session)
|
||||
|
||||
for session in unowned:
|
||||
root = _session_repo_root(session, resolve)
|
||||
if root:
|
||||
# A real git root uses the stricter repo policy. Do not reinterpret a
|
||||
# filtered internal repo as a cwd-only project. A root that no longer
|
||||
# exists is a stale persisted value (the repo was deleted after the
|
||||
# session ran) and must not resurrect as a project.
|
||||
if not _junk(root) and _exists(root):
|
||||
_add_auto(root, session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
continue
|
||||
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if not cwd or _junk_cwd(cwd):
|
||||
homeless.append(session)
|
||||
continue
|
||||
placement = _place(
|
||||
cwd,
|
||||
(session.get("git_branch") or "").strip(),
|
||||
resolve,
|
||||
(session.get("git_repo_root") or "").strip(),
|
||||
)
|
||||
# A placement that only echoes back the unresolvable cwd is the
|
||||
# path-only heuristic guessing — it never found a repo. When that dir is
|
||||
# also gone from disk (a deleted worktree whose name shares no prefix
|
||||
# with its parent, a removed /tmp scratch dir), promoting it mints a
|
||||
# phantom project that can never be opened and can only be dismissed by
|
||||
# hand. The session goes to Home instead.
|
||||
if placement and _exists(placement["repo_key"]):
|
||||
_add_auto(placement["repo_key"], session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
|
||||
seen: set[str] = set()
|
||||
for bucket in by_auto_root.values():
|
||||
auto_root = bucket["root"]
|
||||
auto_sessions = bucket["sessions"]
|
||||
auto_key = _path_key(auto_root)
|
||||
repos = _build_repos(auto_sessions, resolve, hydrate)
|
||||
repo_node = next(
|
||||
(
|
||||
repo
|
||||
for repo in repos
|
||||
if _path_key(repo.get("id") or repo.get("path") or "") == auto_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if repo_node is None:
|
||||
homeless.extend(auto_sessions)
|
||||
continue
|
||||
seen.add(auto_key)
|
||||
scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id"))
|
||||
result.append(
|
||||
_project_node(
|
||||
pid=auto_root,
|
||||
label=base_name(auto_root) or auto_root,
|
||||
path=auto_root,
|
||||
repos=repos,
|
||||
session_count=repo_node["sessionCount"],
|
||||
last_active=_last_active(auto_sessions),
|
||||
preview_sessions=_previews(auto_sessions),
|
||||
sessions=auto_sessions,
|
||||
is_auto=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Tier 3: repos discovered from full history / disk scan with no loaded
|
||||
# sessions, folded to their common root and not owned by an explicit project.
|
||||
for repo in discovered_repos or []:
|
||||
raw_root = (repo.get("root") or "").strip()
|
||||
if not raw_root:
|
||||
continue
|
||||
info = resolve(raw_root) if resolve else None
|
||||
root = (info or {}).get("repo_root") or raw_root
|
||||
root_key = _path_key(root)
|
||||
if root_key in seen or _junk(root) or _project_for_path(folder_index, root):
|
||||
continue
|
||||
seen.add(root_key)
|
||||
label = repo.get("label") or base_name(root) or root
|
||||
result.append(
|
||||
_project_node(
|
||||
pid=root,
|
||||
label=label,
|
||||
path=root,
|
||||
repos=[{"id": root, "label": label, "path": root, "groups": [], "sessionCount": 0}],
|
||||
session_count=int(repo.get("sessions") or 0),
|
||||
last_active=float(repo.get("last_active") or 0),
|
||||
preview_sessions=[],
|
||||
is_auto=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Auto projects are labelled by repo basename, which can collide (two "app"
|
||||
# repos in different parents). Grow path prefixes so each is distinct.
|
||||
# Explicit projects keep their user-chosen names untouched.
|
||||
_disambiguate_labels([p for p in result if p.get("isAuto")])
|
||||
|
||||
# Tier 0: everything the tiers above could not place, so the grouped view
|
||||
# loses no session. It has no folder, hence no repo/lane structure — the one
|
||||
# synthetic lane exists purely to carry the rows in the tree's shape. Leads
|
||||
# the list; omitted entirely when empty, so a project-less install is blank.
|
||||
if homeless:
|
||||
homeless.sort(key=_session_time, reverse=True)
|
||||
scoped_ids.extend(s["id"] for s in homeless if s.get("id"))
|
||||
lane = {
|
||||
"id": NO_PROJECT_ID,
|
||||
"label": NO_PROJECT_LABEL,
|
||||
"path": None,
|
||||
"isMain": False,
|
||||
"isKanban": False,
|
||||
"sessions": homeless if hydrate else [],
|
||||
}
|
||||
result.insert(
|
||||
0,
|
||||
_project_node(
|
||||
pid=NO_PROJECT_ID,
|
||||
label=NO_PROJECT_LABEL,
|
||||
path=None,
|
||||
repos=[
|
||||
{
|
||||
"id": NO_PROJECT_ID,
|
||||
"label": NO_PROJECT_LABEL,
|
||||
"path": None,
|
||||
"groups": [lane],
|
||||
"sessionCount": len(homeless),
|
||||
}
|
||||
],
|
||||
session_count=len(homeless),
|
||||
last_active=_last_active(homeless),
|
||||
preview_sessions=_previews(homeless),
|
||||
sessions=homeless,
|
||||
is_no_project=True,
|
||||
),
|
||||
)
|
||||
|
||||
return {"projects": result, "scoped_session_ids": scoped_ids}
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Rendering bridge — routes TUI content through Python-side renderers.
|
||||
|
||||
When agent.rich_output exists, its functions are used. When it doesn't,
|
||||
everything returns None and the TUI falls back to its own markdown.tsx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def render_message(text: str, cols: int = 80) -> str | None:
|
||||
try:
|
||||
from agent.rich_output import format_response
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return format_response(text, cols=cols)
|
||||
except TypeError:
|
||||
return format_response(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def render_diff(text: str, cols: int = 80) -> str | None:
|
||||
try:
|
||||
from agent.rich_output import render_diff as _rd
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return _rd(text, cols=cols)
|
||||
except TypeError:
|
||||
return _rd(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def make_stream_renderer(cols: int = 80):
|
||||
try:
|
||||
from agent.rich_output import StreamingRenderer
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return StreamingRenderer(cols=cols)
|
||||
except TypeError:
|
||||
return StreamingRenderer()
|
||||
except Exception:
|
||||
return None
|
||||
+18517
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
"""Description-aware fuzzy scoring for slash-menu completions.
|
||||
|
||||
Ported from superagent-ai/grok-cli ``src/ui/slash-menu.ts`` (mirrored on the
|
||||
TUI client in ``ui-tui/src/app/slash/fuzzyScore.ts``): candidates are scored
|
||||
in tiers — exact match on the command token (0), prefix (1), substring (2) —
|
||||
and the DESCRIPTION text is tokenized and matched at a +3 offset (exact word
|
||||
3, word prefix 4, word substring 5). Typing ``/summary`` thus surfaces a
|
||||
command whose description mentions summaries even though no command name
|
||||
starts with it. Lower score wins; ``math.inf`` means no match.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
_TOKEN_SPLIT = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def tokenize_search_text(value: str) -> list[str]:
|
||||
"""Lowercase ``value`` and return it alongside its alphanumeric words."""
|
||||
normalized = value.lower()
|
||||
return [normalized, *[t for t in _TOKEN_SPLIT.split(normalized) if t]]
|
||||
|
||||
|
||||
def normalize_slash_search_query(query: str) -> str:
|
||||
"""Trim, drop leading slashes, lowercase — ``/Model`` and ``model`` alike."""
|
||||
return query.strip().lstrip("/").lower()
|
||||
|
||||
|
||||
def _score_fields(fields: list[str], query: str, offset: int) -> float:
|
||||
for field in fields:
|
||||
if field == query or f"/{field}" == query:
|
||||
return offset
|
||||
for field in fields:
|
||||
if field.startswith(query) or f"/{field}".startswith(query):
|
||||
return offset + 1
|
||||
for field in fields:
|
||||
if query in field:
|
||||
return offset + 2
|
||||
return math.inf
|
||||
|
||||
|
||||
def score_slash_completion_item(item: dict, query: str) -> float:
|
||||
"""Score one completion item dict (``text`` + ``meta``) against ``query``.
|
||||
|
||||
``text`` is the replacement token (may carry a leading slash or trailing
|
||||
space); ``meta`` is the human description. Lower is better; ``math.inf``
|
||||
means no match at all.
|
||||
"""
|
||||
name = str(item.get("text", "")).strip().lstrip("/")
|
||||
command_fields = tokenize_search_text(name)
|
||||
description_fields = tokenize_search_text(str(item.get("meta", "")))
|
||||
return min(
|
||||
_score_fields(command_fields, query, 0),
|
||||
_score_fields(description_fields, query, 3),
|
||||
)
|
||||
|
||||
|
||||
def fuzzy_rank_slash_items(
|
||||
items: list[dict], catalog: list[dict], query: str
|
||||
) -> tuple[list[dict], Callable[[dict], float]]:
|
||||
"""Merge description/substring matches into ``items`` and sort by score.
|
||||
|
||||
``items`` are the completer's own (prefix-filtered) rows and keep their
|
||||
identity; ``catalog`` is the full command/skill universe, from which any
|
||||
entry the prefix filter missed but the fuzzy scorer matches is appended.
|
||||
Returns the score-sorted rows (stable within a tier) plus a ``score_of``
|
||||
lookup for downstream rankers to use as a leading sort key.
|
||||
"""
|
||||
seen = {str(item.get("text", "")).strip() for item in items}
|
||||
merged = list(items)
|
||||
for item in catalog:
|
||||
if str(item.get("text", "")).strip() in seen:
|
||||
continue
|
||||
if not math.isinf(score_slash_completion_item(item, query)):
|
||||
merged.append(item)
|
||||
|
||||
scores: dict[int, float] = {}
|
||||
scored: list[tuple[float, int, dict]] = []
|
||||
for index, item in enumerate(merged):
|
||||
score = score_slash_completion_item(item, query)
|
||||
if math.isinf(score):
|
||||
continue
|
||||
scores[id(item)] = score
|
||||
scored.append((score, index, item))
|
||||
scored.sort(key=lambda entry: (entry[0], entry[1]))
|
||||
|
||||
ranked = [item for _, _, item in scored]
|
||||
return ranked, lambda item: scores.get(id(item), math.inf)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Persistent slash-command worker — one HermesCLI per TUI session.
|
||||
|
||||
Protocol: reads JSON lines from stdin {id, command}, writes {id, ok, output|error} to stdout.
|
||||
"""
|
||||
|
||||
# Stop a ``utils/`` (or ``proxy/``, ``ui/``) package in the launch directory
|
||||
# from shadowing Hermes's own top-level modules. This worker is spawned as
|
||||
# ``-m tui_gateway.slash_worker`` and inherits the user's CWD, so the ``import
|
||||
# cli`` below would otherwise resolve ``utils`` to a colliding local package
|
||||
# and crash the child in a retry loop (issue #51286). ``hermes_bootstrap``
|
||||
# lives at the repo root, so importing it is safe before the guard runs (its
|
||||
# name won't collide with a user package), and it owns the canonical
|
||||
# path-hardening logic shared with the other entry points — #51693 added the
|
||||
# guard to ``entry.py``/``acp_adapter/entry.py`` but missed this child.
|
||||
import hermes_bootstrap
|
||||
|
||||
hermes_bootstrap.harden_import_path()
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import cli as cli_mod
|
||||
from cli import HermesCLI
|
||||
from tui_gateway._stdin_recovery import handle_spurious_eof
|
||||
from rich.console import Console
|
||||
|
||||
# Env-overridable so the integration test can drive sub-second timing.
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
"""Parse a float env knob, falling back to ``default`` on absent/malformed
|
||||
values. A bare ``float(os.environ.get(...))`` would raise ValueError at
|
||||
import time on a typo (e.g. ``HERMES_SLASH_WATCHDOG_POLL_S=2s``) and kill
|
||||
the worker before it can serve a single command."""
|
||||
raw = os.environ.get(name)
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
_WATCHDOG_POLL_S = max(0.05, _env_float("HERMES_SLASH_WATCHDOG_POLL_S", 2.0))
|
||||
_ORPHAN_GRACE_S = max(0.0, _env_float("HERMES_SLASH_WATCHDOG_GRACE_S", 5.0))
|
||||
_in_flight = threading.Event() # set while a command is executing
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_orphaned(original_ppid, getppid=os.getppid) -> bool:
|
||||
"""Return whether this worker no longer has its original POSIX parent."""
|
||||
return getppid() != original_ppid
|
||||
|
||||
|
||||
def _prepare_slash_worker_runtime() -> None:
|
||||
"""Start bounded MCP discovery before HermesCLI snapshots tools.
|
||||
|
||||
Each slash_worker child is its own process — the parent ``hermes serve``
|
||||
discovery thread does not populate this registry (issue #61891).
|
||||
"""
|
||||
import logging
|
||||
|
||||
from hermes_cli.mcp_startup import (
|
||||
start_background_mcp_discovery,
|
||||
wait_for_mcp_discovery,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
start_background_mcp_discovery(
|
||||
logger=logger,
|
||||
thread_name="slash-worker-mcp-discovery",
|
||||
)
|
||||
wait_for_mcp_discovery()
|
||||
|
||||
|
||||
def _start_parent_death_watchdog(original_ppid) -> None:
|
||||
def _loop():
|
||||
while not _is_orphaned(original_ppid):
|
||||
time.sleep(_WATCHDOG_POLL_S)
|
||||
deadline = time.monotonic() + _ORPHAN_GRACE_S
|
||||
while _in_flight.is_set() and time.monotonic() < deadline:
|
||||
time.sleep(0.05) # let an in-flight command finish/flush
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_loop, daemon=True).start()
|
||||
|
||||
|
||||
def _run(cli: HermesCLI, command: str) -> str:
|
||||
cmd = (command or "").strip()
|
||||
if not cmd:
|
||||
return ""
|
||||
if not cmd.startswith("/"):
|
||||
cmd = f"/{cmd}"
|
||||
|
||||
buf = io.StringIO()
|
||||
|
||||
# Rich Console captures its file handle at construction time, so
|
||||
# contextlib.redirect_stdout won't affect it. Swap the console's
|
||||
# underlying file to our buffer so self.console.print() is captured.
|
||||
cli.console = Console(file=buf, force_terminal=True, width=120)
|
||||
|
||||
old = getattr(cli_mod, "_cprint", None)
|
||||
if old is not None:
|
||||
cli_mod._cprint = lambda text: print(text)
|
||||
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||||
cli.process_command(cmd)
|
||||
finally:
|
||||
if old is not None:
|
||||
cli_mod._cprint = old
|
||||
|
||||
# Desktop chat bubbles render plain text, not ANSI. A worker-routed command
|
||||
# that emits Rich color (e.g. /journey building its own Console, which picks
|
||||
# up truecolor from the gateway's inherited COLORTERM) would otherwise leak
|
||||
# raw escapes; strip them at the single choke point. (The TUI opens /journey
|
||||
# as an overlay, so it never travels this path.)
|
||||
from tools.ansi_strip import strip_ansi
|
||||
|
||||
return strip_ansi(buf.getvalue().rstrip())
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(add_help=False)
|
||||
p.add_argument("--session-key", required=True)
|
||||
p.add_argument("--model", default="")
|
||||
args = p.parse_args()
|
||||
|
||||
os.environ["HERMES_SESSION_KEY"] = args.session_key
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
|
||||
# Start before the (hundreds-of-ms) HermesCLI build — that window is itself
|
||||
# an orphan risk if the gateway dies mid-spawn.
|
||||
orig_ppid = os.getppid()
|
||||
_start_parent_death_watchdog(orig_ppid)
|
||||
_prepare_slash_worker_runtime()
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False)
|
||||
|
||||
# Spurious stdin-EOF recovery (same O_NONBLOCK shared file-description
|
||||
# issue as the gateway entry point — any child inheriting fd 0 can flip
|
||||
# the flag and launder EAGAIN into an apparent EOF).
|
||||
_sw_recovery_times: list[float] = []
|
||||
|
||||
def _sw_log(reason: str) -> None:
|
||||
print(f"[slash-worker] {reason}", file=sys.stderr, flush=True)
|
||||
|
||||
while True:
|
||||
raw = sys.stdin.readline()
|
||||
if not raw:
|
||||
if not handle_spurious_eof(_sw_recovery_times, _sw_log):
|
||||
break
|
||||
continue
|
||||
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
_in_flight.set()
|
||||
rid = None
|
||||
try:
|
||||
req = json.loads(line)
|
||||
rid = req.get("id")
|
||||
out = _run(cli, req.get("command", ""))
|
||||
sys.stdout.write(json.dumps({"id": rid, "ok": True, "output": out}) + "\n")
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n")
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
_in_flight.clear()
|
||||
# Workers persist for the TUI session, so release allocator pages at
|
||||
# the same command boundary as other long-lived gateway processes.
|
||||
# trim_memory's shared cooldown coalesces this with nearby activity.
|
||||
try:
|
||||
from hermes_cli.mem_trim import trim_memory
|
||||
|
||||
trim_memory(reason="slash worker command completion")
|
||||
except Exception as exc:
|
||||
# debug, not warning — a persistent failure would repeat on
|
||||
# every slash command forever.
|
||||
logger.debug(
|
||||
"slash worker memory trim failed: %s: %s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Synthetic GIL-heavy turn driver for the AC-4 isolation certify harness.
|
||||
|
||||
Mechanism B (the class ``docs/desktop/2026-07-04-dashboard-process-isolation-PRD.md``
|
||||
targets) is interpreter-wide GIL starvation: concurrent heavy agent turns run
|
||||
compute in threads of the SERVING process, and CPython's single GIL lets those
|
||||
threads starve the event loop that flushes WebSocket frames for MINUTES. A
|
||||
2026-07-04 ``sample`` showed the loop thread parked in ``take_gil`` while worker
|
||||
threads burned the interpreter — NOT blocked on I/O.
|
||||
|
||||
To certify the fix (AC-4) without spending real tokens on 6 concurrent 100K+
|
||||
context model calls, the harness needs a turn driver that reproduces THAT
|
||||
regime: sustained pure-Python CPU that holds the GIL for the turn's duration.
|
||||
A network/sleep stub is WRONG here — it would release the GIL during I/O and
|
||||
never reproduce ``take_gil`` contention, so a dry-run green off it is a fake
|
||||
green (the spec says so explicitly).
|
||||
|
||||
This module is a **test seam**: it is dead unless ``HERMES_ISO_CERTIFY_SYNTH_TURN``
|
||||
is set. When armed, ``tui_gateway.server._make_agent`` returns a
|
||||
:class:`SyntheticHeavyAgent` instead of a real ``AIAgent``. Because both the
|
||||
in-process ``_pool`` path (isolation OFF) and the compute-host child path
|
||||
(isolation ON) build their agent through ``_make_agent``, the SAME synthetic
|
||||
turn exercises whichever dispatch path is under test — the isolation boundary
|
||||
is the only variable between an OFF run and an ON run.
|
||||
|
||||
The per-turn intensity (wall duration, CPU chunk size, streamed-delta cadence,
|
||||
token accounting) is carried in the prompt text as a small JSON spec so the
|
||||
harness has full control and the server seam stays dumb. Any prompt that is not
|
||||
a JSON object falls back to env / built-in defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
def synth_turn_armed() -> bool:
|
||||
"""True when the synthetic-turn test seam is armed via env."""
|
||||
return os.environ.get("HERMES_ISO_CERTIFY_SYNTH_TURN") == "1"
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ.get(name, "") or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, "") or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
class SyntheticHeavyAgent:
|
||||
"""An AIAgent-shaped object whose turn is a GIL-holding CPU burn.
|
||||
|
||||
Presents only the surface ``tui_gateway.server``'s turn path and status
|
||||
helpers read: ``run_conversation``/``interrupt``/``clear_interrupt`` plus a
|
||||
handful of ``model``/``provider``/``session_*`` attributes consumed by
|
||||
``_get_usage`` and ``_session_info``. It never opens a socket or spawns a
|
||||
subprocess, so the only work it does is the deterministic Python loop below —
|
||||
exactly the ``take_gil`` regime under test.
|
||||
"""
|
||||
|
||||
def __init__(self, session_id: str, *, model: str = "synthetic-heavy") -> None:
|
||||
self.session_id = session_id
|
||||
self.model = model
|
||||
self.provider = "synthetic"
|
||||
self.api_mode = "chat_completions"
|
||||
self.base_url = ""
|
||||
self.api_key = ""
|
||||
self.platform = ""
|
||||
self.tools: list[Any] = []
|
||||
self.reasoning_config: dict | None = None
|
||||
self.service_tier: str | None = None
|
||||
self.context_compressor = None
|
||||
self._config_context_length = 200_000
|
||||
self._cached_system_prompt = ""
|
||||
# Cumulative session counters (read by _get_usage → status bar).
|
||||
self.session_input_tokens = 0
|
||||
self.session_output_tokens = 0
|
||||
self.session_prompt_tokens = 0
|
||||
self.session_completion_tokens = 0
|
||||
self.session_reasoning_tokens = 0
|
||||
self.session_total_tokens = 0
|
||||
self.session_api_calls = 0
|
||||
self.history: list[dict[str, str]] = []
|
||||
self._interrupt = threading.Event()
|
||||
|
||||
# ── interrupt contract (mirrors AIAgent) ───────────────────────────
|
||||
def clear_interrupt(self) -> None:
|
||||
self._interrupt.clear()
|
||||
|
||||
def interrupt(self) -> None:
|
||||
self._interrupt.set()
|
||||
|
||||
def _has_stream_consumers(self) -> bool: # defensive; not used by our loop
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
"""No-op teardown (session lifecycle calls agent.close() on some paths)."""
|
||||
self._interrupt.set()
|
||||
|
||||
# ── spec parsing ───────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _parse_spec(message: Any) -> dict[str, Any]:
|
||||
spec: dict[str, Any] = {}
|
||||
if isinstance(message, str):
|
||||
text = message.strip()
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, dict):
|
||||
spec = parsed
|
||||
except (ValueError, TypeError):
|
||||
spec = {}
|
||||
return {
|
||||
# Primary control: wall-clock seconds of GIL-holding compute.
|
||||
"duration_s": float(spec.get("duration_s", _env_float("HERMES_ISO_CERTIFY_DURATION_S", 8.0))),
|
||||
# Pure-Python integer ops per interrupt-check chunk. Small enough
|
||||
# that an interrupt is honored within a few ms; large enough that
|
||||
# the loop stays hot on the GIL between checks.
|
||||
"chunk": int(spec.get("chunk", _env_int("HERMES_ISO_CERTIFY_CHUNK", 20_000))),
|
||||
# Streamed-delta cadence (seconds). Each delta is a loop wakeup that
|
||||
# marshals a frame across the transport — the serving-path pressure.
|
||||
"delta_interval_s": float(spec.get("delta_interval_s", _env_float("HERMES_ISO_CERTIFY_DELTA_S", 0.05))),
|
||||
# Notional output tokens attributed per streamed delta (drives the
|
||||
# 100K+-token "heavy turn" proxy in usage/metadata).
|
||||
"tokens_per_delta": int(spec.get("tokens_per_delta", _env_int("HERMES_ISO_CERTIFY_TPD", 512))),
|
||||
# Optional per-chunk sleep to model a lighter/mixed regime (0 = pure
|
||||
# burn). --dry-run uses a short duration, NOT a sleep, so the smoke
|
||||
# path still exercises the real dispatch seam.
|
||||
"sleep_s": float(spec.get("sleep_s", 0.0)),
|
||||
}
|
||||
|
||||
# ── the turn ───────────────────────────────────────────────────────
|
||||
def run_conversation(
|
||||
self,
|
||||
message: Any,
|
||||
*,
|
||||
conversation_history: Optional[list[dict[str, str]]] = None,
|
||||
stream_callback: Optional[Callable[[str], None]] = None,
|
||||
task_id: Optional[str] = None,
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
spec = self._parse_spec(message)
|
||||
duration = max(0.0, spec["duration_s"])
|
||||
chunk = max(1, spec["chunk"])
|
||||
interval = max(0.001, spec["delta_interval_s"])
|
||||
tokens_per_delta = max(0, spec["tokens_per_delta"])
|
||||
sleep_s = max(0.0, spec["sleep_s"])
|
||||
|
||||
base_history = list(conversation_history if conversation_history is not None else self.history)
|
||||
start = time.monotonic()
|
||||
last_delta = start
|
||||
acc = 0
|
||||
deltas = 0
|
||||
interrupted = False
|
||||
|
||||
while True:
|
||||
if self._interrupt.is_set():
|
||||
interrupted = True
|
||||
break
|
||||
now = time.monotonic()
|
||||
if now - start >= duration:
|
||||
break
|
||||
# GIL-holding pure-Python work. A tight integer loop runs one
|
||||
# bytecode step per iteration and NEVER releases the GIL — this is
|
||||
# the exact interpreter contention that starves the serving loop.
|
||||
for _ in range(chunk):
|
||||
acc = (acc * 1_000_003 + 12_345) & 0xFFFFFFFFFFFFFFFF
|
||||
if sleep_s:
|
||||
time.sleep(sleep_s)
|
||||
if now - last_delta >= interval:
|
||||
deltas += 1
|
||||
self.session_output_tokens += tokens_per_delta
|
||||
self.session_completion_tokens += tokens_per_delta
|
||||
self.session_total_tokens += tokens_per_delta
|
||||
if stream_callback is not None:
|
||||
stream_callback(f"synthtok-{deltas:05d} ")
|
||||
last_delta = now
|
||||
|
||||
self.session_api_calls += 1
|
||||
# Fold the checksum into the reply so the loop is not dead-code-eliminated
|
||||
# and the turn produces a deterministic, inspectable result.
|
||||
final = (
|
||||
f"[synthetic heavy turn] deltas={deltas} "
|
||||
f"out_tokens={self.session_output_tokens} "
|
||||
f"interrupted={interrupted} checksum={acc & 0xFFFF:04x}"
|
||||
)
|
||||
messages = [
|
||||
*base_history,
|
||||
{"role": "user", "content": str(message)[:200]},
|
||||
{"role": "assistant", "content": final},
|
||||
]
|
||||
self.history = messages
|
||||
return {
|
||||
"final_response": final,
|
||||
"messages": messages,
|
||||
"interrupted": interrupted,
|
||||
"error": None,
|
||||
"last_reasoning": None,
|
||||
}
|
||||
|
||||
|
||||
def maybe_build_synthetic_agent(session_id: str, model_override: Any = None) -> SyntheticHeavyAgent | None:
|
||||
"""Return a :class:`SyntheticHeavyAgent` when the seam is armed, else ``None``.
|
||||
|
||||
``model_override`` (dict or str) only influences the reported ``model`` label
|
||||
so status frames look plausible; it never changes the compute.
|
||||
"""
|
||||
if not synth_turn_armed():
|
||||
return None
|
||||
model = "synthetic-heavy"
|
||||
if isinstance(model_override, dict) and model_override.get("model"):
|
||||
model = str(model_override["model"])
|
||||
elif isinstance(model_override, str) and model_override:
|
||||
model = model_override
|
||||
return SyntheticHeavyAgent(session_id, model=model)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SyntheticHeavyAgent",
|
||||
"maybe_build_synthetic_agent",
|
||||
"synth_turn_armed",
|
||||
]
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Transport abstraction for the tui_gateway JSON-RPC server.
|
||||
|
||||
Historically the gateway wrote every JSON frame directly to real stdout. This
|
||||
module decouples the I/O sink from the handler logic so the same dispatcher
|
||||
can be driven over stdio (``tui_gateway.entry``) or WebSocket
|
||||
(``tui_gateway.ws``) without duplicating code.
|
||||
|
||||
A :class:`Transport` is anything that can accept a JSON-serialisable dict and
|
||||
forward it to its peer. The active transport for the current request is
|
||||
tracked in a :class:`contextvars.ContextVar` so handlers — including those
|
||||
dispatched onto the worker pool — route their writes to the right peer.
|
||||
|
||||
Backward compatibility
|
||||
----------------------
|
||||
``tui_gateway.server.write_json`` still works without any transport bound.
|
||||
When nothing is on the contextvar and no session-level transport is found,
|
||||
it falls back to the module-level :class:`StdioTransport`, which wraps the
|
||||
original ``_real_stdout`` + ``_stdout_lock`` pair. Tests that monkey-patch
|
||||
``server._real_stdout`` continue to work because the stdio transport resolves
|
||||
the stream lazily through a callback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Callable, Optional, Protocol, runtime_checkable
|
||||
|
||||
# Errno values that mean "the peer is gone" rather than "the host has a
|
||||
# real I/O problem". Anything outside this set re-raises so it surfaces
|
||||
# in the crash log instead of looking like a clean disconnect.
|
||||
_PEER_GONE_ERRNOS = frozenset({
|
||||
errno.EPIPE, # write to closed pipe (POSIX)
|
||||
errno.ECONNRESET, # peer reset the connection
|
||||
errno.EBADF, # fd closed under us
|
||||
errno.ESHUTDOWN, # transport endpoint shut down
|
||||
getattr(errno, "WSAECONNRESET", -1), # win32 mapping (no-op on POSIX)
|
||||
getattr(errno, "WSAESHUTDOWN", -1),
|
||||
} - {-1})
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optional knob: when true, StdioTransport does not call ``stream.flush``
|
||||
# after writing. Use this on environments where a half-closed pipe (TUI
|
||||
# Node parent quit while the gateway is still emitting events) makes
|
||||
# flush block long enough to starve the rest of the worker pool.
|
||||
#
|
||||
# IMPORTANT: Python text stdout is fully buffered when attached to a
|
||||
# pipe (the TUI case), so this knob ONLY makes sense when the gateway
|
||||
# is launched with ``-u`` or ``PYTHONUNBUFFERED=1``. Without one of
|
||||
# those, JSON-RPC frames will accumulate in the buffer and the TUI
|
||||
# will hang waiting for ``gateway.ready``. Default stays off so the
|
||||
# existing flush-after-write behaviour is unchanged.
|
||||
_DISABLE_FLUSH = (os.environ.get("HERMES_TUI_GATEWAY_NO_FLUSH", "") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Transport(Protocol):
|
||||
"""Minimal interface every transport implements."""
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
"""Emit one JSON frame. Return ``False`` when the peer is gone."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release any resources owned by this transport."""
|
||||
|
||||
|
||||
_current_transport: contextvars.ContextVar[Optional[Transport]] = (
|
||||
contextvars.ContextVar(
|
||||
"hermes_gateway_transport",
|
||||
default=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def current_transport() -> Optional[Transport]:
|
||||
"""Return the transport bound for the current request, if any."""
|
||||
return _current_transport.get()
|
||||
|
||||
|
||||
def bind_transport(transport: Optional[Transport]):
|
||||
"""Bind *transport* for the current context. Returns a token for :func:`reset_transport`."""
|
||||
return _current_transport.set(transport)
|
||||
|
||||
|
||||
def reset_transport(token) -> None:
|
||||
"""Restore the transport binding captured by :func:`bind_transport`."""
|
||||
_current_transport.reset(token)
|
||||
|
||||
|
||||
class StdioTransport:
|
||||
"""Writes JSON frames to a stream (usually ``sys.stdout``).
|
||||
|
||||
The stream is resolved via a callable so runtime monkey-patches of the
|
||||
underlying stream continue to work — this preserves the behaviour the
|
||||
existing test suite relies on (``monkeypatch.setattr(server, "_real_stdout", ...)``).
|
||||
"""
|
||||
|
||||
__slots__ = ("_stream_getter", "_lock")
|
||||
|
||||
def __init__(self, stream_getter: Callable[[], Any], lock: threading.Lock) -> None:
|
||||
self._stream_getter = stream_getter
|
||||
self._lock = lock
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
"""Return ``True`` on success, ``False`` ONLY when the peer is gone.
|
||||
|
||||
Returning ``False`` is the dispatcher's "broken stdout pipe" signal
|
||||
— ``entry.py`` calls ``sys.exit(0)`` when ``write_json`` reports
|
||||
``False``. So programming errors (non-JSON-safe payloads, encoding
|
||||
misconfig, unexpected ValueErrors, host I/O bugs like ENOSPC) MUST
|
||||
NOT return ``False``, otherwise a real bug looks like a clean
|
||||
disconnect and is harder to diagnose. Those re-raise so the
|
||||
existing crash-log infrastructure records the traceback.
|
||||
|
||||
Peer-gone branches:
|
||||
* ``BrokenPipeError``
|
||||
* ``ValueError("...closed file...")``
|
||||
* ``OSError`` whose errno is in :data:`_PEER_GONE_ERRNOS`
|
||||
(EPIPE / ECONNRESET / EBADF / ESHUTDOWN; plus WSA mappings
|
||||
on Windows). Other OSError errnos (ENOSPC, EACCES, ...) are
|
||||
real host problems and re-raise.
|
||||
"""
|
||||
# Serialization is OUTSIDE the lock so a large payload can't
|
||||
# block other threads emitting their own frames. A non-JSON-safe
|
||||
# payload is a programming error: re-raise so the crash log
|
||||
# captures it instead of silently exiting via the False path.
|
||||
line = json.dumps(obj, ensure_ascii=False) + "\n"
|
||||
|
||||
with self._lock:
|
||||
stream = self._stream_getter()
|
||||
try:
|
||||
stream.write(line)
|
||||
except BrokenPipeError:
|
||||
return False
|
||||
except ValueError as e:
|
||||
# ValueError("I/O operation on closed file") is the
|
||||
# ONLY ValueError that means "peer gone". Anything
|
||||
# else — including UnicodeEncodeError, which is a
|
||||
# ValueError subclass for misconfigured locales —
|
||||
# is a real bug; re-raise so it surfaces in the crash log.
|
||||
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
|
||||
raise
|
||||
return False
|
||||
except OSError as e:
|
||||
if e.errno not in _PEER_GONE_ERRNOS:
|
||||
raise
|
||||
logger.debug("StdioTransport write peer gone: %s", e)
|
||||
return False
|
||||
|
||||
# A flush that *raises* with a peer-gone errno means the
|
||||
# dispatcher should exit cleanly. A flush that *hangs* on
|
||||
# a half-closed pipe holds the lock until it returns — see
|
||||
# ``_DISABLE_FLUSH`` for the "skip flush entirely" escape
|
||||
# hatch.
|
||||
if not _DISABLE_FLUSH:
|
||||
try:
|
||||
stream.flush()
|
||||
except BrokenPipeError:
|
||||
return False
|
||||
except ValueError as e:
|
||||
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
|
||||
raise
|
||||
return False
|
||||
except OSError as e:
|
||||
if e.errno not in _PEER_GONE_ERRNOS:
|
||||
raise
|
||||
logger.debug("StdioTransport flush peer gone: %s", e)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class TeeTransport:
|
||||
"""Mirrors writes to one primary plus N best-effort secondaries.
|
||||
|
||||
The primary's return value (and exceptions) determine the result —
|
||||
secondaries swallow failures so a wedged sidecar never stalls the
|
||||
main IO path. Used by the PTY child so every dispatcher emit lands
|
||||
on stdio (Ink) AND on a back-WS feeding the dashboard sidebar.
|
||||
"""
|
||||
|
||||
__slots__ = ("_primary", "_secondaries")
|
||||
|
||||
def __init__(self, primary: "Transport", *secondaries: "Transport") -> None:
|
||||
self._primary = primary
|
||||
self._secondaries = secondaries
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
# Primary first so a slow sidecar (WS publisher) never delays Ink/stdio.
|
||||
ok = self._primary.write(obj)
|
||||
for sec in self._secondaries:
|
||||
try:
|
||||
sec.write(obj)
|
||||
except Exception:
|
||||
pass
|
||||
return ok
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._primary.close()
|
||||
finally:
|
||||
for sec in self._secondaries:
|
||||
try:
|
||||
sec.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Durable interrupted-turn markers for the desktop/TUI auto-continue path.
|
||||
|
||||
A running turn's progress lives only in process memory (the agent flushes to
|
||||
SQLite at turn end, not mid-turn), so an app/backend/machine death mid-turn
|
||||
leaves no durable trace of the interrupted prompt. This sidecar is that
|
||||
trace: a marker is written when a turn starts running and cleared when the
|
||||
turn concludes — success, handled error, or interrupt all clear it, so only
|
||||
a process death leaves one behind. ``session.resume`` reads the marker to
|
||||
decide whether to auto-continue the interrupted turn (see
|
||||
``_maybe_schedule_auto_continue`` in ``tui_gateway/server.py``).
|
||||
|
||||
Markers are stored per ``HERMES_HOME`` (callers pass the session's home so
|
||||
profile sessions keep their state in their own profile directory) and the
|
||||
file is bounded: writes prune entries older than ``_MAX_AGE_SECS`` and cap
|
||||
the total count, so an unlucky streak of crashes can't grow it unboundedly.
|
||||
|
||||
Every function is best-effort by design — marker bookkeeping must never
|
||||
break a turn — so I/O errors degrade to "no marker" instead of raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MARKER_DIR = "desktop"
|
||||
_MARKER_FILE = "interrupted_turns.json"
|
||||
_MAX_AGE_SECS = 24 * 3600
|
||||
_MAX_ENTRIES = 32
|
||||
# Enough to re-submit any realistic prompt; guards the sidecar against a
|
||||
# pathological multi-megabyte paste being journaled on every turn.
|
||||
_MAX_PROMPT_CHARS = 64_000
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _marker_path(home: Path | str) -> Path:
|
||||
return Path(home) / _MARKER_DIR / _MARKER_FILE
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, dict]:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except Exception:
|
||||
logger.debug("unreadable turn-marker file %s; starting fresh", path, exc_info=True)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {k: v for k, v in data.items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def _prune(entries: dict[str, dict], now: float) -> dict[str, dict]:
|
||||
fresh = {
|
||||
key: entry
|
||||
for key, entry in entries.items()
|
||||
if now - float(entry.get("started_at") or 0) <= _MAX_AGE_SECS
|
||||
}
|
||||
if len(fresh) <= _MAX_ENTRIES:
|
||||
return fresh
|
||||
newest = sorted(
|
||||
fresh.items(),
|
||||
key=lambda item: float(item[1].get("started_at") or 0),
|
||||
reverse=True,
|
||||
)[:_MAX_ENTRIES]
|
||||
return dict(newest)
|
||||
|
||||
|
||||
def _store(path: Path, entries: dict[str, dict]) -> None:
|
||||
if not entries:
|
||||
path.unlink(missing_ok=True)
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".turn-marker-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(entries, f)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def record_turn_start(
|
||||
home: Path | str, session_key: str, prompt: str, *, attempts: int = 0
|
||||
) -> None:
|
||||
"""Persist the marker for a turn that is about to run.
|
||||
|
||||
``attempts`` counts how many auto-continues led to this run: 0 for a
|
||||
user-initiated turn, N for the Nth automatic re-run — the crash-loop
|
||||
breaker reads it back on the next resume.
|
||||
"""
|
||||
if not session_key or not prompt:
|
||||
return
|
||||
now = time.time()
|
||||
entry = {
|
||||
"attempts": max(0, int(attempts)),
|
||||
"prompt": prompt[:_MAX_PROMPT_CHARS],
|
||||
"started_at": now,
|
||||
}
|
||||
try:
|
||||
with _lock:
|
||||
path = _marker_path(home)
|
||||
entries = _prune(_load(path), now)
|
||||
entries[session_key] = entry
|
||||
_store(path, entries)
|
||||
except Exception:
|
||||
logger.debug("failed to record turn marker for %s", session_key, exc_info=True)
|
||||
|
||||
|
||||
def clear_turn_marker(home: Path | str, session_key: str) -> None:
|
||||
"""Remove the marker once its turn concluded (any outcome the client saw)."""
|
||||
if not session_key:
|
||||
return
|
||||
try:
|
||||
with _lock:
|
||||
path = _marker_path(home)
|
||||
entries = _load(path)
|
||||
if session_key not in entries:
|
||||
return
|
||||
del entries[session_key]
|
||||
_store(path, entries)
|
||||
except Exception:
|
||||
logger.debug("failed to clear turn marker for %s", session_key, exc_info=True)
|
||||
|
||||
|
||||
def read_turn_marker(home: Path | str, session_key: str) -> dict[str, Any] | None:
|
||||
"""The marker left by a turn that never concluded, or None."""
|
||||
if not session_key:
|
||||
return None
|
||||
try:
|
||||
with _lock:
|
||||
entry = _load(_marker_path(home)).get(session_key)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
prompt = str(entry.get("prompt") or "")
|
||||
if not prompt.strip():
|
||||
return None
|
||||
try:
|
||||
started_at = float(entry.get("started_at") or 0)
|
||||
attempts = max(0, int(entry.get("attempts") or 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return {"attempts": attempts, "prompt": prompt, "started_at": started_at}
|
||||
@@ -0,0 +1,647 @@
|
||||
"""WebSocket transport for the tui_gateway JSON-RPC server.
|
||||
|
||||
Reuses :func:`tui_gateway.server.dispatch` verbatim so every RPC method, every
|
||||
slash command, every approval/clarify/sudo flow, and every agent event flows
|
||||
through the same handlers whether the client is Ink over stdio or an iOS /
|
||||
web client over WebSocket.
|
||||
|
||||
Wire protocol
|
||||
-------------
|
||||
Identical to stdio: newline-delimited JSON-RPC in both directions. The server
|
||||
emits a ``gateway.ready`` event immediately after connection accept, then
|
||||
echoes responses/events for inbound requests. No framing differences.
|
||||
|
||||
Mounting
|
||||
--------
|
||||
from fastapi import WebSocket
|
||||
from tui_gateway.ws import handle_ws
|
||||
|
||||
@app.websocket("/api/ws")
|
||||
async def ws(ws: WebSocket):
|
||||
await handle_ws(ws)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from tui_gateway import server
|
||||
from agent.message_sanitization import _sanitize_surrogates
|
||||
from tui_gateway.event_replay import replay_epoch
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Scale-to-zero: tell the (separate) gateway process that a dashboard/desktop/
|
||||
# TUI client is attached, via the mtime of a marker file it reads in its idle
|
||||
# predicate. Clients ping every 15s; one mtime write per 5s per process is
|
||||
# plenty and keeps the volume quiet. See gateway/scale_to_zero.py.
|
||||
_DASHBOARD_CLIENT_TOUCH_MIN_INTERVAL_S = 5.0
|
||||
_dashboard_client_touched_at = 0.0
|
||||
_dashboard_client_touch_lock = threading.Lock()
|
||||
|
||||
|
||||
def _note_dashboard_client_activity(*, force: bool = False) -> None:
|
||||
"""Refresh the dashboard-client liveness marker (throttled, best-effort)."""
|
||||
global _dashboard_client_touched_at
|
||||
now = time.monotonic()
|
||||
with _dashboard_client_touch_lock:
|
||||
if not force and now - _dashboard_client_touched_at < _DASHBOARD_CLIENT_TOUCH_MIN_INTERVAL_S:
|
||||
return
|
||||
_dashboard_client_touched_at = now
|
||||
try:
|
||||
from gateway.scale_to_zero import touch_dashboard_client_heartbeat
|
||||
|
||||
touch_dashboard_client_heartbeat()
|
||||
except Exception: # noqa: BLE001 - liveness garnish must never break the WS
|
||||
_log.debug("dashboard client heartbeat touch failed", exc_info=True)
|
||||
|
||||
|
||||
def _sanitize_ws_text(text: str) -> str:
|
||||
"""Return *text* that can be UTF-8 encoded for a WebSocket frame.
|
||||
|
||||
``json.dumps(..., ensure_ascii=False)`` happily emits lone UTF-16
|
||||
surrogates; Starlette's ``send_text`` then raises ``UnicodeEncodeError``,
|
||||
which used to latch the whole connection closed (#97288). Same U+FFFD
|
||||
replacement every other Hermes transport applies.
|
||||
"""
|
||||
return _sanitize_surrogates(text) if text else text
|
||||
|
||||
|
||||
# Max seconds a pool-dispatched handler will block waiting for the event loop
|
||||
# to flush a WS frame before we mark the transport dead. Protects handler
|
||||
# threads from a wedged socket.
|
||||
_WS_WRITE_TIMEOUT_S = 10.0
|
||||
_WS_LOG_PAYLOAD_PREVIEW = 240
|
||||
|
||||
# Per-token streaming frames are coalesced: buffered and flushed as a batch on
|
||||
# a short timer instead of waking the event loop once per token. A model reply
|
||||
# emits hundreds of these in a burst, and each one is a loop wakeup competing
|
||||
# with the agent turn for the GIL — coalescing cuts that churn (CF-2). The task
|
||||
# that introduced this called them "agent.token"/"agent.thinking"; in this
|
||||
# codebase the per-token frames are the ``*.delta`` stream events below. Keep
|
||||
# this set to genuinely high-frequency, display-only events — anything a client
|
||||
# must see promptly (tool/approval/status/completion frames) is non-streaming
|
||||
# and flushes the buffer ahead of itself, so ordering is preserved.
|
||||
_STREAMING_EVENT_TYPES = frozenset({
|
||||
"message.delta",
|
||||
"reasoning.delta",
|
||||
"thinking.delta",
|
||||
})
|
||||
# Max time a streamed token waits in the buffer before flush (~30 fps). Short
|
||||
# enough to stay imperceptible to the live token cadence.
|
||||
_TOKEN_COALESCE_S = 0.033
|
||||
|
||||
# Keep starlette optional at import time; handle_ws uses the real class when
|
||||
# it's available and falls back to a generic Exception sentinel otherwise.
|
||||
try:
|
||||
from starlette.websockets import WebSocketDisconnect as _WebSocketDisconnect
|
||||
except ImportError: # pragma: no cover - starlette is a required install path
|
||||
_WebSocketDisconnect = Exception # type: ignore[assignment]
|
||||
|
||||
|
||||
class WSTransport:
|
||||
"""Per-connection WS transport.
|
||||
|
||||
``write`` is safe to call from any thread *other than* the event loop
|
||||
thread that owns the socket. Pool workers (the only real caller) run in
|
||||
their own threads, so marshalling onto the loop via
|
||||
:func:`asyncio.run_coroutine_threadsafe` + ``future.result()`` is correct
|
||||
and deadlock-free there.
|
||||
|
||||
When called from the loop thread itself (e.g. by ``handle_ws`` for an
|
||||
inline response) the same call would deadlock: we'd schedule work onto
|
||||
the loop we're currently blocking. We detect that case and fire-and-
|
||||
forget instead. Callers that need to know when the bytes are on the wire
|
||||
should use :meth:`write_async` from the loop thread.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ws: Any,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
*,
|
||||
peer: str = "unknown",
|
||||
auth_identity: dict | None = None,
|
||||
) -> None:
|
||||
self._ws = ws
|
||||
self._loop = loop
|
||||
self._peer = peer
|
||||
#: Server-verified identity carried from the WS-upgrade credential
|
||||
#: (dashboard ticket / internal credential) — stamped by
|
||||
#: ``hermes_cli.web_server._ws_auth_reason`` onto the WS object and
|
||||
#: passed through ``handle_ws``. None for transports that
|
||||
#: authenticated via the legacy token path or stdio. RPC params can
|
||||
#: never populate this: it is the only identity authority for
|
||||
#: browser-controller registration.
|
||||
self.auth_identity = auth_identity
|
||||
self._closed = False
|
||||
self._last_inbound_at = time.monotonic()
|
||||
# Token-coalescing buffer (CF-2). Streamed token frames land here and a
|
||||
# short timer flushes the batch. The lock guards the buffer + the
|
||||
# "armed" flag against the worker threads that call write(); the timer
|
||||
# handle is only ever touched on the loop thread.
|
||||
self._token_lock = threading.Lock()
|
||||
self._pending_tokens: list[str] = []
|
||||
self._token_flush_handle: asyncio.TimerHandle | None = None
|
||||
self._token_flush_armed = False
|
||||
# Buffer mutation is protected by the thread lock above; actual socket
|
||||
# writes need an async boundary because several batches can be queued on
|
||||
# the owning loop while it recovers from a stall.
|
||||
self._send_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
@property
|
||||
def last_inbound_at(self) -> float:
|
||||
return self._last_inbound_at
|
||||
|
||||
def mark_inbound(self) -> None:
|
||||
self._last_inbound_at = time.monotonic()
|
||||
|
||||
@staticmethod
|
||||
def _is_streaming_frame(obj: dict) -> bool:
|
||||
"""True for high-frequency per-token frames eligible for coalescing."""
|
||||
params = obj.get("params") if isinstance(obj, dict) else None
|
||||
if not isinstance(params, dict):
|
||||
return False
|
||||
return params.get("type") in _STREAMING_EVENT_TYPES
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
if self._closed:
|
||||
return False
|
||||
|
||||
line = json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
on_loop = asyncio.get_running_loop() is self._loop
|
||||
except RuntimeError:
|
||||
on_loop = False
|
||||
|
||||
# Coalesce streamed token frames: buffer this frame and arm a short
|
||||
# flush timer instead of waking the loop right now. Cheap and
|
||||
# non-blocking — the worker returns immediately. Ordering is preserved
|
||||
# because every non-streaming frame (below) drains the buffer ahead of
|
||||
# itself.
|
||||
if self._is_streaming_frame(obj):
|
||||
with self._token_lock:
|
||||
self._pending_tokens.append(line)
|
||||
if not self._token_flush_armed:
|
||||
self._token_flush_armed = True
|
||||
# call_soon_threadsafe arms the call_later timer on the loop
|
||||
# thread and is safe to call from a worker or the loop.
|
||||
self._loop.call_soon_threadsafe(self._arm_token_flush)
|
||||
return not self._closed
|
||||
|
||||
# Non-streaming frame (RPC response, control frame, non-token event):
|
||||
# append it behind any buffered tokens and flush the whole batch NOW so
|
||||
# it can never overtake the tokens that preceded it. The send is
|
||||
# scheduled INSIDE the lock so the on-the-wire order matches the buffer
|
||||
# order even if the coalesce timer fires on the loop at the same moment.
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
with self._token_lock:
|
||||
self._pending_tokens.append(line)
|
||||
batch = self._pending_tokens
|
||||
self._pending_tokens = []
|
||||
if on_loop:
|
||||
# Fire-and-forget — don't block the loop waiting on itself.
|
||||
self._loop.create_task(self._safe_send_many(batch))
|
||||
return True
|
||||
fut = safe_schedule_threadsafe(
|
||||
self._safe_send_many(batch), self._loop
|
||||
)
|
||||
if fut is None:
|
||||
self._closed = True
|
||||
return False
|
||||
|
||||
try:
|
||||
fut.result(timeout=_WS_WRITE_TIMEOUT_S)
|
||||
return not self._closed
|
||||
except concurrent.futures.TimeoutError: # builtin TimeoutError on 3.11+
|
||||
# The event loop is stalled (GIL-heavy agent turn, delegation
|
||||
# running N children), NOT the socket dead. The send coroutine is
|
||||
# already scheduled and will flush once the loop breathes — latching
|
||||
# _closed here permanently silenced live windows after one slow
|
||||
# write (the "subagent window shows zero streaming" bug). Unblock
|
||||
# the worker thread and keep the transport alive; _safe_send_many
|
||||
# latches on a real socket error when the frame actually fails.
|
||||
_log.warning(
|
||||
"ws write slow (loop stalled >%ss) peer=%s — frame left in flight",
|
||||
_WS_WRITE_TIMEOUT_S, self._peer,
|
||||
)
|
||||
return not self._closed
|
||||
except Exception as exc:
|
||||
self._closed = True
|
||||
_log.warning(
|
||||
"ws write failed peer=%s error_type=%s error=%s",
|
||||
self._peer, type(exc).__name__, exc,
|
||||
)
|
||||
return False
|
||||
|
||||
def _arm_token_flush(self) -> None:
|
||||
"""Arm the coalesce timer. Runs on the loop thread (call_soon_threadsafe)."""
|
||||
if self._closed:
|
||||
return
|
||||
self._token_flush_handle = self._loop.call_later(
|
||||
_TOKEN_COALESCE_S, self._flush_tokens
|
||||
)
|
||||
|
||||
def _flush_tokens(self) -> None:
|
||||
"""Send buffered tokens as one batch. Runs on the loop thread (timer).
|
||||
|
||||
The send is scheduled under the lock so its wire order is fixed relative
|
||||
to a concurrent non-streaming flush in :meth:`write`.
|
||||
"""
|
||||
with self._token_lock:
|
||||
self._token_flush_handle = None
|
||||
self._token_flush_armed = False
|
||||
if not self._pending_tokens or self._closed:
|
||||
self._pending_tokens = []
|
||||
return
|
||||
batch = self._pending_tokens
|
||||
self._pending_tokens = []
|
||||
self._loop.create_task(self._safe_send_many(batch))
|
||||
|
||||
async def write_async(self, obj: dict) -> bool:
|
||||
"""Send from the owning event loop. Awaits until the frame is on the wire."""
|
||||
if self._closed:
|
||||
return False
|
||||
# Flush any buffered streamed tokens ahead of this frame (RPC response /
|
||||
# control frame) as ONE serialized batch. Sending them in two lock
|
||||
# acquisitions would let a later batch slip between the pending tokens
|
||||
# and the frame that drained them.
|
||||
with self._token_lock:
|
||||
batch = self._pending_tokens
|
||||
self._pending_tokens = []
|
||||
batch.append(json.dumps(obj, ensure_ascii=False))
|
||||
await self._safe_send_many(batch)
|
||||
return not self._closed
|
||||
|
||||
async def _safe_send_many(self, lines: list[str]) -> None:
|
||||
"""Send one indivisible batch of pre-serialized frames in wire order."""
|
||||
async with self._send_lock:
|
||||
if self._closed:
|
||||
return
|
||||
for line in lines:
|
||||
if self._closed:
|
||||
return
|
||||
payload = _sanitize_ws_text(line)
|
||||
try:
|
||||
await self._ws.send_text(payload)
|
||||
except UnicodeEncodeError as exc:
|
||||
# A single illegal UTF-8 frame (lone surrogate in a
|
||||
# status/ready payload) must not tear down the socket.
|
||||
# Fresh Desktop installs looped on this (#97288).
|
||||
_log.warning(
|
||||
"ws send skipped invalid utf-8 frame peer=%s error=%s",
|
||||
self._peer, exc,
|
||||
)
|
||||
continue
|
||||
except Exception as exc:
|
||||
# Latch while still holding the writer lock so queued
|
||||
# batches observe the failure before they touch the socket.
|
||||
self._closed = True
|
||||
_log.warning(
|
||||
"ws send failed peer=%s error_type=%s error=%s",
|
||||
self._peer, type(exc).__name__, exc,
|
||||
)
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
# Cancel any pending coalesce flush. close() runs on the loop thread
|
||||
# (the handle_ws finally), so touching the TimerHandle here is safe.
|
||||
handle = self._token_flush_handle
|
||||
if handle is not None:
|
||||
handle.cancel()
|
||||
self._token_flush_handle = None
|
||||
|
||||
|
||||
def _ws_peer_label(ws: Any) -> str:
|
||||
"""Return ``host:port`` when available, else a stable placeholder."""
|
||||
client = getattr(ws, "client", None)
|
||||
if client is None:
|
||||
return "unknown"
|
||||
host = getattr(client, "host", None) or "unknown"
|
||||
port = getattr(client, "port", None)
|
||||
return f"{host}:{port}" if port is not None else host
|
||||
|
||||
|
||||
def _disable_nagle(ws: Any) -> None:
|
||||
"""Disable Nagle so streamed JSON-RPC frames go out individually.
|
||||
|
||||
Without it the kernel coalesces the small per-token frames, so a burst after
|
||||
the model's think-pause lands on the client in one tick and no client-side
|
||||
smoothing can recover the cadence. GUI/WS only; chat platforms don't hit
|
||||
this path. Best-effort — skip silently if the socket isn't reachable.
|
||||
"""
|
||||
try:
|
||||
scope = getattr(ws, "scope", None) or {}
|
||||
transport = (scope.get("extensions") or {}).get("transport") or getattr(ws, "transport", None)
|
||||
sock = transport.get_extra_info("socket") if transport is not None else None
|
||||
if sock is not None:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
# Dead-peer detection: without keepalive a silently-dropped client
|
||||
# (SSH tunnel reset, client sleep) leaves the TCP leg half-open
|
||||
# forever, receive_text() blocks indefinitely, and the disconnect
|
||||
# teardown (detach + orphan reap + resume replay) never runs.
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
if hasattr(socket, "TCP_KEEPIDLE"): # Linux
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
|
||||
elif hasattr(socket, "TCP_KEEPALIVE"): # macOS idle seconds
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 30)
|
||||
except Exception as exc: # pragma: no cover - best-effort tuning
|
||||
_log.debug("ws TCP_NODELAY skip: %s", exc)
|
||||
|
||||
|
||||
async def handle_ws(
|
||||
ws: Any,
|
||||
*,
|
||||
auth_identity: dict | None = None,
|
||||
subprotocol: str | None = None,
|
||||
) -> None:
|
||||
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``.
|
||||
|
||||
*auth_identity* is the server-minted ``{user_id, provider}`` recorded at
|
||||
WS-upgrade authentication (``hermes_cli.web_server._ws_auth_reason``); it
|
||||
is stored on the transport as ``WSTransport.auth_identity`` and is the
|
||||
only identity authority for browser-controller registration. Existing
|
||||
callers (stdio-free harnesses, the embedded TUI child) omit it and get a
|
||||
``None`` transport identity — unchanged behaviour.
|
||||
"""
|
||||
peer = _ws_peer_label(ws)
|
||||
transport: WSTransport | None = None
|
||||
messages = 0
|
||||
parse_errors = 0
|
||||
dispatch_crashes = 0
|
||||
send_failures = 0
|
||||
disconnect_reason = "not_connected"
|
||||
|
||||
try:
|
||||
if subprotocol:
|
||||
await ws.accept(subprotocol=subprotocol)
|
||||
else:
|
||||
await ws.accept()
|
||||
disconnect_reason = "connected"
|
||||
# A client is attached from the moment the upgrade is accepted — mark it
|
||||
# before the (possibly slow) ready/skin setup so scale-to-zero sees it.
|
||||
_note_dashboard_client_activity(force=True)
|
||||
# Push small streamed frames out immediately instead of letting Nagle
|
||||
# batch them — keeps the live token cadence intact for GUI clients.
|
||||
_disable_nagle(ws)
|
||||
_log.info("ws accepted peer=%s", peer)
|
||||
|
||||
transport = WSTransport(
|
||||
ws,
|
||||
asyncio.get_running_loop(),
|
||||
peer=peer,
|
||||
auth_identity=auth_identity,
|
||||
)
|
||||
|
||||
# resolve_skin() reads config + initializes the skin engine —
|
||||
# synchronous I/O + CPU work that should not block the event loop
|
||||
# during the cold-start window. Run it in the thread pool so the
|
||||
# WS read loop stays free to drain the frontend's initial RPC
|
||||
# burst (setup.status, session.list, ...) without a stall
|
||||
# (#60800). The skin payload is small (a dict of strings/arrays),
|
||||
# so the to_thread overhead is negligible.
|
||||
skin_payload = await asyncio.to_thread(server.resolve_skin)
|
||||
ready_ok = await transport.write_async(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "event",
|
||||
"params": {
|
||||
"type": "gateway.ready",
|
||||
# change_events: this backend broadcasts pet.changed /
|
||||
# cron.changed / sessions.changed, so clients can demote
|
||||
# their legacy polls to slow backstops.
|
||||
"payload": {
|
||||
"skin": skin_payload,
|
||||
"change_events": True,
|
||||
"heartbeat": True,
|
||||
# Replay-contract process identity: lets reconnecting
|
||||
# clients detect a backend restart and reset their
|
||||
# per-session seq watermarks (see event_replay).
|
||||
"replay_epoch": replay_epoch(),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
if ready_ok:
|
||||
# Live-apply skins Hermes activates mid-conversation.
|
||||
server._ensure_skin_watcher()
|
||||
# Track this peer for session-less global broadcasts (skin.changed
|
||||
# from the background watcher) — write_json can't route those.
|
||||
server.register_live_transport(transport)
|
||||
# Cross-backend liveness (#94895): register a heartbeat row so
|
||||
# the startup orphan sweep can distinguish "row owned by a live
|
||||
# but idle backend" from "row truly orphaned". The stdio TUI's
|
||||
# entry.main() does the same; idempotent + once-per-process so a
|
||||
# stdio TUI that already started the refresher is a no-op here.
|
||||
try:
|
||||
server._start_backend_heartbeat_refresher()
|
||||
except Exception:
|
||||
_log.warning("backend heartbeat refresher start failed", exc_info=True)
|
||||
# Same once-per-process startup pass for session rows orphaned by a
|
||||
# previous gateway process (#65194): the desktop app and web dashboard
|
||||
# reach the agent through this WS sidecar, not entry.main(). Idempotent
|
||||
# + config-gated inside, so a stdio TUI that already scheduled is a
|
||||
# no-op.
|
||||
try:
|
||||
server._schedule_startup_orphan_sweep()
|
||||
except Exception:
|
||||
_log.warning("startup orphan sweep scheduling failed", exc_info=True)
|
||||
if not ready_ok:
|
||||
disconnect_reason = "ready_send_failed"
|
||||
send_failures += 1
|
||||
_log.error("ws ready frame send failed peer=%s", peer)
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
raw = await ws.receive_text()
|
||||
_note_dashboard_client_activity()
|
||||
except _WebSocketDisconnect as exc:
|
||||
disconnect_reason = (
|
||||
"client_disconnect("
|
||||
f"code={getattr(exc, 'code', None)},"
|
||||
f"reason={getattr(exc, 'reason', None)})"
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
disconnect_reason = "receive_failed"
|
||||
_log.exception("ws receive failed peer=%s", peer)
|
||||
break
|
||||
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
transport.mark_inbound()
|
||||
messages += 1
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
parse_errors += 1
|
||||
_log.warning(
|
||||
"ws parse error peer=%s index=%d error=%s payload=%r",
|
||||
peer,
|
||||
messages,
|
||||
exc,
|
||||
line[:_WS_LOG_PAYLOAD_PREVIEW],
|
||||
)
|
||||
ok = await transport.write_async(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32700, "message": "parse error"},
|
||||
"id": None,
|
||||
}
|
||||
)
|
||||
if not ok:
|
||||
disconnect_reason = "send_failed_after_parse_error"
|
||||
send_failures += 1
|
||||
_log.warning("ws parse-error reply send failed peer=%s", peer)
|
||||
break
|
||||
continue
|
||||
|
||||
# dispatch() may schedule long handlers on the pool; it returns
|
||||
# None in that case and the worker writes the response itself via
|
||||
# the transport we pass in (a separate thread, so transport.write
|
||||
# is the safe path there). For inline handlers it returns the
|
||||
# response dict, which we write here from the loop.
|
||||
req_id = req.get("id") if isinstance(req, dict) else None
|
||||
req_method = req.get("method") if isinstance(req, dict) else None
|
||||
|
||||
if req_method == "gateway.ping":
|
||||
ok = await transport.write_async(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {"ok": True},
|
||||
"id": req_id,
|
||||
}
|
||||
)
|
||||
if not ok:
|
||||
disconnect_reason = "send_failed_after_heartbeat"
|
||||
send_failures += 1
|
||||
_log.warning("ws heartbeat reply send failed peer=%s id=%s", peer, req_id)
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
resp = await asyncio.to_thread(server.dispatch, req, transport)
|
||||
except Exception:
|
||||
dispatch_crashes += 1
|
||||
_log.exception(
|
||||
"ws dispatch crash peer=%s id=%s method=%s",
|
||||
peer,
|
||||
req_id,
|
||||
req_method,
|
||||
)
|
||||
ok = await transport.write_async(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32603, "message": "internal error"},
|
||||
"id": req_id if req_id is not None else None,
|
||||
}
|
||||
)
|
||||
if not ok:
|
||||
disconnect_reason = "send_failed_after_dispatch_crash"
|
||||
send_failures += 1
|
||||
_log.warning(
|
||||
"ws dispatch-crash reply send failed peer=%s id=%s method=%s",
|
||||
peer,
|
||||
req_id,
|
||||
req_method,
|
||||
)
|
||||
break
|
||||
continue
|
||||
if resp is not None and not await transport.write_async(resp):
|
||||
disconnect_reason = "send_failed_after_response"
|
||||
send_failures += 1
|
||||
_log.warning(
|
||||
"ws response send failed peer=%s id=%s method=%s",
|
||||
peer,
|
||||
req_id,
|
||||
req_method,
|
||||
)
|
||||
break
|
||||
finally:
|
||||
reaped_sessions = 0
|
||||
detached_sessions = 0
|
||||
if transport is not None:
|
||||
server.unregister_live_transport(transport)
|
||||
|
||||
# Owner-safely park browser controllers this transport registered.
|
||||
# A reconnect with the same stable identity may deliver a terminal
|
||||
# result for work already in flight; no new dispatch is admitted
|
||||
# while the controller is offline.
|
||||
#
|
||||
# Offloaded via to_thread: disconnect acquires the controller's
|
||||
# send_lock, which a worker-thread dispatch may hold while blocking
|
||||
# on THIS loop to transmit its frame (run_coroutine_threadsafe +
|
||||
# result(timeout=10)). Acquiring it synchronously here would park
|
||||
# the whole event loop behind that 10s send bridge.
|
||||
try:
|
||||
from gateway.browser_control_broker import (
|
||||
get_browser_control_broker,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
get_browser_control_broker().disconnect_owner, transport
|
||||
)
|
||||
except Exception:
|
||||
_log.exception("ws browser-controller disconnect failed peer=%s", peer)
|
||||
|
||||
transport.close()
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(server._release_wake_for_transport, transport)
|
||||
except Exception:
|
||||
_log.exception("ws wake-word teardown failed peer=%s", peer)
|
||||
|
||||
# Reap sessions this transport owned (close_on_disconnect sidecar
|
||||
# sessions) or detach the rest to the drop sentinel so later emits
|
||||
# don't crash into a closed socket or fall through to desktop stdout
|
||||
# logs. Detached sessions are handed to the grace-windowed WS-orphan
|
||||
# reaper inside _close_sessions_for_transport (a quick reconnect /
|
||||
# session.resume cancels it). This is the single WS-disconnect
|
||||
# teardown path.
|
||||
#
|
||||
# Offloaded: _close_session_by_id does a blocking worker.close()
|
||||
# (terminate + waits) plus a synchronous DB write — inline that
|
||||
# would freeze the uvicorn event loop for every other live
|
||||
# connection.
|
||||
try:
|
||||
reaped_sessions, detached_sessions = await asyncio.to_thread(
|
||||
server._close_sessions_for_transport,
|
||||
transport,
|
||||
end_reason="ws_disconnect",
|
||||
)
|
||||
except Exception:
|
||||
_log.exception("ws transport teardown failed peer=%s", peer)
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception as exc:
|
||||
_log.debug("ws close failed peer=%s error=%s", peer, exc)
|
||||
_log.info(
|
||||
"ws closed peer=%s reason=%s messages=%d parse_errors=%d "
|
||||
"dispatch_crashes=%d send_failures=%d reaped_sessions=%d detached_sessions=%d",
|
||||
peer,
|
||||
disconnect_reason,
|
||||
messages,
|
||||
parse_errors,
|
||||
dispatch_crashes,
|
||||
send_failures,
|
||||
reaped_sessions,
|
||||
detached_sessions,
|
||||
)
|
||||
Reference in New Issue
Block a user