939a2f64b4
Gateway, dashboard, ACP server and the CLI already hold one registry-shared SessionDB per state.db path, yet several in-process call paths still opened a bare SessionDB() beside it. Each one is a full writer: schema init, write lock, token-writer thread and a close-time WAL checkpoint. On a dashboard serving overlapping requests that stacked up to the "5 live SessionDB handles" precursor within seconds; per #110544 they are now harmless to each other's WAL generation, but the leak itself remained. Pure readers attach read_only=True (no writer connection, no write lock): - plugins/hermes-achievements/dashboard/plugin_api.py::scan_sessions (dashboard, per background scan and per /rescan; highest-frequency site) - hermes_cli/console_engine.py::_session_db (dashboard console; list, stats and export are reads; rename/optimize opt in to a writer) - tools/process_registry_results.py::_owns_result (gateway, per retained result load) - hermes_cli/main.py::_session_db (last-session / title / cwd lookups) - hermes_cli/terminal_breadcrumbs.py, hermes_cli/status.py, hermes_cli/main_tui_launch.py (lookup one-shots) Writers share the process's registry handle (hermes_state_registry.acquire; close()/release_or_close release one refcount): - acp_adapter/session.py::SessionManager._get_db — the AIAgent it builds acquires the same path, so the ACP server held two writers per process - hermes_cli/kanban_db_dispatch.py::_retag_legacy_worker_sessions (gateway dispatcher tick) - hermes_cli/main.py::_create_titled_session, hermes_cli/oneshot.py, hermes_cli/foreign_sessions.py — the CLI acquires the same handle a moment later The "N live SessionDB handles" warning now counts only writable members: read-only attaches are the sanctioned per-request shape for dashboard routers and CLI lookups, and counting them turned a healthy topology into an operator alarm (#100896 field reports of restart loops keyed on it). Live repro (one registry writer + 4 overlapping dashboard/gateway paths in one process): before 5 writable opens, 5 live handles, warning fired; after 1 writable open (the registry handle), 0 from the request paths, no warning. Refs #100896 #103339
146 lines
5.1 KiB
Python
146 lines
5.1 KiB
Python
"""Per-terminal session breadcrumbs for ``hermes -c`` / ``--continue``. Strictly best-effort: no
|
|
function raises; without a stable terminal identity (no tty, no known multiplexer env var) ``-c``
|
|
falls back to latest-session. Gated by ``session.terminal_continue`` (default true)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from utils import atomic_json_write
|
|
|
|
# Multiplexer / terminal-emulator identity env vars, checked in order when no real tty path is
|
|
# available (e.g. stdin piped but stdout still a pty owned by a known terminal).
|
|
_TERMINAL_ENV_VARS = ("ZELLIJ_PANE_ID", "TMUX_PANE", "KITTY_WINDOW_ID", "WEZTERM_PANE", "TERM_SESSION_ID", "WT_SESSION")
|
|
|
|
# Breadcrumbs older than this are pruned opportunistically on each write — a pane id from a tmux
|
|
# server restarted last month means nothing today.
|
|
_STALE_AFTER_SECONDS = 30 * 24 * 60 * 60
|
|
|
|
_SANITIZE_RE = re.compile(r"[^A-Za-z0-9._-]")
|
|
|
|
|
|
def _breadcrumbs_dir() -> Path:
|
|
from hermes_constants import get_hermes_home
|
|
return get_hermes_home() / "terminal-sessions"
|
|
|
|
|
|
def _sanitize(raw: str) -> str:
|
|
"""Make an id safe to use as a filename (``/dev/pts/3`` -> ``dev-pts-3``)."""
|
|
return _SANITIZE_RE.sub("-", raw.strip().strip("/"))[:120]
|
|
|
|
|
|
def get_terminal_id() -> Optional[str]:
|
|
"""Stable identity for this terminal: the tty device path (stdin, then stdout), else the first
|
|
present multiplexer/emulator env var; ``None`` when neither exists (callers skip breadcrumbs)."""
|
|
for fd in (sys.stdin, sys.stdout):
|
|
try:
|
|
name = os.ttyname(fd.fileno())
|
|
except Exception:
|
|
continue
|
|
if name:
|
|
return f"tty-{_sanitize(name)}"
|
|
for var in _TERMINAL_ENV_VARS:
|
|
val = os.environ.get(var)
|
|
if val:
|
|
return f"{var.lower()}-{_sanitize(val)}"
|
|
return None
|
|
|
|
|
|
def is_enabled() -> bool:
|
|
"""Config gate: ``session.terminal_continue`` (default true)."""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
|
|
return bool((load_config().get("session") or {}).get("terminal_continue", True))
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
def _prune_stale(directory: Path, now: float) -> None:
|
|
"""Best-effort removal of breadcrumbs older than the staleness window."""
|
|
try:
|
|
for entry in directory.iterdir():
|
|
try:
|
|
if entry.is_file() and now - entry.stat().st_mtime > _STALE_AFTER_SECONDS:
|
|
entry.unlink()
|
|
except OSError:
|
|
continue
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def write_breadcrumb(session_id: str, cwd: Optional[str] = None) -> None:
|
|
"""Record that this terminal's live session is ``session_id``. Never raises; no-op when the
|
|
feature is disabled, the session id is empty, or no terminal identity exists."""
|
|
try:
|
|
if not session_id or not is_enabled():
|
|
return
|
|
terminal_id = get_terminal_id()
|
|
if not terminal_id:
|
|
return
|
|
directory = _breadcrumbs_dir()
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
now = time.time()
|
|
payload = {"session_id": session_id, "cwd": cwd or os.getcwd(), "ts": now}
|
|
atomic_json_write(directory / terminal_id, payload, indent=None)
|
|
_prune_stale(directory, now)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def read_breadcrumb() -> Optional[dict]:
|
|
"""This terminal's breadcrumb payload, or ``None`` (missing, corrupt, or stale). Never raises."""
|
|
try:
|
|
terminal_id = get_terminal_id()
|
|
if not terminal_id:
|
|
return None
|
|
path = _breadcrumbs_dir() / terminal_id
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict) or not str(data.get("session_id") or "").strip():
|
|
return None
|
|
ts = data.get("ts")
|
|
if isinstance(ts, (int, float)) and time.time() - ts > _STALE_AFTER_SECONDS:
|
|
return None
|
|
return data
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def resolve_breadcrumb_session() -> Optional[str]:
|
|
"""Resolve a bare ``-c`` for this terminal, or ``None`` to fall back. The breadcrumb's session
|
|
id counts only if it still exists in the DB, projected through the compression chain so the
|
|
resume lands on the live tip (same projection as ``main._resolve_session_by_name_or_id``)."""
|
|
if not is_enabled():
|
|
return None
|
|
crumb = read_breadcrumb()
|
|
if not crumb:
|
|
return None
|
|
session_id = str(crumb.get("session_id") or "").strip()
|
|
if not session_id:
|
|
return None
|
|
try:
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(read_only=True) # existence + lineage lookup only; no writer connection
|
|
except Exception:
|
|
return None
|
|
try:
|
|
if not db.get_session(session_id):
|
|
return None # session was deleted — fall back to latest
|
|
try:
|
|
return db.get_compression_tip(session_id) or session_id
|
|
except Exception:
|
|
return session_id
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
try:
|
|
db.close()
|
|
except Exception:
|
|
pass
|