Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,7 @@ scripts/out/
# stores the published notes. They are not a build artifact and must never be
# committed to the repo root. See the hermes-release skill.
RELEASE_v*.md

# Rust PTY Bridge binary and build target
/bin/
/pty_bridge_rust/target/
111 changes: 110 additions & 1 deletion hermes_cli/pty_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import sys
import termios
import time
from pathlib import Path
from typing import Optional, Sequence

try:
Expand All @@ -47,7 +48,7 @@
_PTY_AVAILABLE = False


__all__ = ["PtyBridge", "PtyUnavailableError"]
__all__ = ["PtyBridge", "RustPtyBridge", "PtyUnavailableError"]


# ``struct winsize`` packs rows/cols as unsigned short (0..65535). We clamp
Expand Down Expand Up @@ -274,3 +275,111 @@ def __enter__(self) -> "PtyBridge":

def __exit__(self, *_exc) -> None:
self.close()


class RustPtyBridge:
"""PTY bridge backed by a high-performance Rust helper process.

Spawns `./bin/hermes-pty-refactor` as a child process and communicates
with it via stdin/stdout pipes, bypassing Python GIL/executor overhead.
"""

def __init__(self, proc):
self._proc = proc
self._closed = False

@classmethod
def is_available(cls) -> bool:
"""True if the compiled Rust binary is available on disk."""
if "pytest" in sys.modules or "unittest" in sys.modules:
return False
if sys.platform.startswith("win"):
return False
# Locate binary relative to hermes-agent root directory
bin_path = Path(__file__).parent.parent / "bin" / "hermes-pty-refactor"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks an ignored /bin/hermes-pty-refactor artifact, but this PR contains no package, CI, Docker, or install step that builds or copies it there. Standard installs will therefore always take the Python fallback; add a supported distribution path before selecting this implementation.

return bin_path.exists() and os.access(bin_path, os.X_OK)

@classmethod
async def spawn(
cls,
argv: Sequence[str],
*,
cwd: Optional[str] = None,
env: Optional[dict] = None,
cols: int = 80,
rows: int = 24,
) -> "RustPtyBridge":
"""Spawn the Rust PTY bridge subprocess asynchronously."""
bin_path = Path(__file__).parent.parent / "bin" / "hermes-pty-refactor"
if not bin_path.exists():
raise FileNotFoundError("Rust PTY bridge binary not found")

import asyncio

spawn_env = (os.environ.copy() if env is None else env.copy())
if not spawn_env.get("TERM"):
spawn_env["TERM"] = "xterm-256color"

proc = await asyncio.create_subprocess_exec(
str(bin_path),
*argv,
cwd=cwd,
env=spawn_env,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)

# Write initial resize sequence
resize_seq = f"\x1b[8;{rows};{cols}t".encode("utf-8")
try:
proc.stdin.write(resize_seq)
await proc.stdin.drain()
except Exception:
pass

return cls(proc)

async def read_async(self) -> Optional[bytes]:
"""Asynchronously read output chunk from the Rust binary."""
if self._closed:
return None
try:
data = await self._proc.stdout.read(65536)
if not data:
return None
return data
except Exception:
return None

def write(self, data: bytes) -> None:
"""Write raw input bytes to the PTY master (Rust stdin)."""
if self._closed or not data:
return
try:
self._proc.stdin.write(data)
except Exception:
pass

def resize(self, cols: int, rows: int) -> None:
"""Forward terminal resize by sending the escape sequence to Rust stdin."""
if self._closed:
return
cols = _clamp_dimension(cols, _MAX_COLS)
rows = _clamp_dimension(rows, _MAX_ROWS)
resize_seq = f"\x1b[8;{rows};{cols}t".encode("utf-8")
try:
self._proc.stdin.write(resize_seq)
except Exception:
pass

def close(self) -> None:
"""Terminate the Rust subprocess and close pipes."""
if self._closed:
return
self._closed = True
try:
self._proc.terminate()
except Exception:
pass

31 changes: 25 additions & 6 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4478,7 +4478,17 @@ def row_get(row, key, index):
rows = []
if conn is not None:
raw_rows = conn.execute(
"SELECT id, parent_session_id, started_at FROM sessions"
"""
WITH RECURSIVE descendants(id, parent_session_id, started_at) AS (
SELECT id, parent_session_id, started_at FROM sessions WHERE id = ?
UNION ALL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UNION ALL is unsafe here: a corrupted a -> b -> a parent cycle never reaches a fixed point. Main's follow-up 1e2ad17afd changed this to UNION and added a regression test; retain that deduplication in any salvage.

SELECT s.id, s.parent_session_id, s.started_at
FROM sessions s
JOIN descendants d ON s.parent_session_id = d.id
)
SELECT id, parent_session_id, started_at FROM descendants WHERE id != ?
""",
(sid, sid)
).fetchall()
for row in raw_rows:
rows.append({
Expand Down Expand Up @@ -7000,10 +7010,11 @@ async def get_models_analytics(days: int = 30):
# the dashboard (sessions, jobs, metrics, config editor) still loads and the
# /api/pty endpoint cleanly refuses with a WSL-suggested message.
try:
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
from hermes_cli.pty_bridge import PtyBridge, RustPtyBridge, PtyUnavailableError
_PTY_BRIDGE_AVAILABLE = True
except ImportError as _pty_import_err: # pragma: no cover - Windows-only path
PtyBridge = None # type: ignore[assignment]
RustPtyBridge = None
_PTY_BRIDGE_AVAILABLE = False

class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
Expand Down Expand Up @@ -7464,7 +7475,12 @@ async def pty_ws(ws: WebSocket) -> None:


try:
bridge = PtyBridge.spawn(argv, cwd=cwd, env=env)
if RustPtyBridge is not None and RustPtyBridge.is_available():
bridge = await RustPtyBridge.spawn(argv, cwd=cwd, env=env)
is_rust = True
else:
bridge = PtyBridge.spawn(argv, cwd=cwd, env=env)
is_rust = False
except PtyUnavailableError as exc:
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n")
await ws.close(code=1011)
Expand All @@ -7479,9 +7495,12 @@ async def pty_ws(ws: WebSocket) -> None:
# --- reader task: PTY master → WebSocket ----------------------------
async def pump_pty_to_ws() -> None:
while True:
chunk = await loop.run_in_executor(
None, bridge.read, _PTY_READ_CHUNK_TIMEOUT
)
if is_rust:
chunk = await bridge.read_async()
else:
chunk = await loop.run_in_executor(
None, bridge.read, _PTY_READ_CHUNK_TIMEOUT
)
if chunk is None: # EOF
return
if not chunk: # no data this tick; yield control and retry
Expand Down
15 changes: 14 additions & 1 deletion hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import random
import re
import sqlite3
import sys
import threading
import time
from pathlib import Path
Expand Down Expand Up @@ -72,6 +73,9 @@
_wal_fallback_warned_paths: set[str] = set()
_wal_fallback_warned_lock = threading.Lock()

_initialized_dbs: Dict[str, Dict[str, Any]] = {}
_db_init_lock = threading.Lock()

_FTS_TRIGGERS = (
"messages_fts_insert",
"messages_fts_delete",
Expand Down Expand Up @@ -421,7 +425,16 @@ def __init__(self, db_path: Path = None):
apply_wal_with_fallback(self._conn, db_label="state.db")
self._conn.execute("PRAGMA foreign_keys=ON")

self._init_schema()
db_path_str = str(self.db_path.resolve())
is_testing = "pytest" in sys.modules or "unittest" in sys.modules

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabling the cache whenever pytest or unittest is imported prevents the new branch from being covered by normal tests. Prefer explicit cache reset/test fixtures and add multi-connection tests for path scoping and invalidation behavior.

with _db_init_lock:
if not is_testing and db_path_str in _initialized_dbs:
self._fts_enabled = _initialized_dbs[db_path_str]["fts_enabled"]
else:
self._init_schema()
_initialized_dbs[db_path_str] = {
"fts_enabled": self._fts_enabled
}
except Exception as exc:
# Capture the cause so /resume and friends can surface WHY the
# session DB is unavailable instead of a bare "Session database
Expand Down
Loading