Files
hermes-agent/plugins/memory/hindsight/embedded_runtime.py
ethernet 92686159d1 fix(pm): integrate audited runtime and lifecycle repairs
Prepare dependency generations before selecting them. Keep shipped tool
bytes separate from writable additions, and store facts beside their entries.
Validate proposed plugin sets before config publication. Restore the previous
config if the facts write fails.

Consolidate duplicate updater, backup, setup, and voice helpers. Repair
launcher selection, dependency consumers, download ownership, update feeds,
and native Windows process and file handling.

Verification: 206 changed/prior-failing Python files reported 4630 passed,
one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary.
The final targeted rerun reported 234 passed and two skipped. The store
review regression batch reported 83 passed and one skipped. Desktop
TypeScript checks, 56 selected Electron tests, 24 release tests, and the
removed-import/compatibility guards passed.

This is an integration checkpoint, not full audit acceptance. The complete
Python suite has not run on this fixed tree. Crash-atomic plugin publication,
generation cleanup, receipt correlation, and packaged lifecycle acceptance
remain open in docs/pm-audit-status.md.
2026-09-05 22:36:48 -04:00

322 lines
14 KiB
Python

"""Build an isolated Hindsight API runtime and start its daemon in that interpreter."""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import sys
import uuid
from pathlib import Path
from typing import Any
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
_EMBED_VERSION = "0.9.2"
_API_SLIM_VERSION = "0.9.2"
# Marker file written inside each generation recording what it was built from;
# a generation whose marker no longer matches is replaced (new generation) on
# the next ensure — never mutated in place.
_EXPECTED = {"hindsight-embed": _EMBED_VERSION, "hindsight-api-slim": _API_SLIM_VERSION}
_STATE_FILE = ".hermes-sideenv.json"
_ACTIVE_RECORD = "active.json"
# First successful daemon start downloads/loads ML models — minutes, not seconds.
_DEFAULT_DAEMON_START_TIMEOUT = 900.0
_UV_LOCK_TIMEOUT = 900.0
_UV_SYNC_TIMEOUT = 3600.0
_PROBE_TIMEOUT = 300.0
_PORT_HEALTH_GRACE_ENV = "HINDSIGHT_EMBED_PORT_HEALTH_GRACE_TIMEOUT"
_API_VERSION_ENV = "HINDSIGHT_EMBED_API_VERSION"
# Printed by the bridge as its LAST stdout line; the manager's Rich output and
# the daemon's own logs share the stream, so the marker makes parsing exact.
_BRIDGE_MARKER = "__HERMES_HINDSIGHT_BRIDGE__"
_PROBE_CODE = "import hindsight_embed.daemon_embed_manager, hindsight_api, sentence_transformers"
_DAEMON_BRIDGE_CODE = """\
import json, sys
from hindsight_embed import daemon_client
profile = sys.argv[1]
if len(sys.argv) > 2 and sys.argv[2] == "restart":
daemon_client.stop_daemon(profile)
result = {"ok": False, "url": None, "error": None}
try:
# {} config: the manager merges the profile's own .env (materialized by the
# plugin) over it, so the LLM keys live only in the 0600 profile file.
result["ok"] = bool(daemon_client.ensure_daemon_running({}, profile))
if result["ok"]:
result["url"] = daemon_client.get_daemon_url(profile)
else:
result["error"] = "daemon did not start (see the hindsight profile log)"
except Exception as exc: # bridge must always report, never crash the parse
result["error"] = f"{type(exc).__name__}: {exc}"
print("@MARKER@" + json.dumps(result))
""".replace("@MARKER@", _BRIDGE_MARKER)
def sideenv_root() -> Path:
"""Private side env for the embedded runtime (never the boot-selected venv)."""
return get_hermes_home() / "profiles" / "Hindsight" / "env"
def _active_generation(root: Path) -> Path | None:
"""The published generation directory, or None when nothing is active."""
try:
record = json.loads((root / _ACTIVE_RECORD).read_text(encoding="utf-8-sig"))
gen = root / str(record.get("generation", ""))
except Exception:
return None
return gen if gen.resolve().is_relative_to(root.resolve()) and gen != root and gen.is_dir() else None
def _publish_generation(root: Path, generation: Path) -> None:
"""Point the selection record at *generation* atomically (file replace —
never a directory rename: venv launchers embed absolute paths)."""
from utils import atomic_json_write
atomic_json_write(root / _ACTIVE_RECORD, {"generation": generation.name, "pins": _EXPECTED})
def sideenv_python(root: Path | None = None) -> Path | None:
"""The active generation's interpreter, or None when the side env is not
installed. Resolved through the selection record — the generation dir is
created at its final path and never renamed."""
root = Path(root) if root is not None else sideenv_root()
gen = _active_generation(root)
if gen is None:
return None
exe = "python.exe" if os.name == "nt" else "python"
for scripts in ("Scripts", "bin"):
candidate = gen / ".venv" / scripts / exe
if candidate.is_file():
return candidate
return None
def sideenv_pyproject() -> str:
"""Exact pinned requirements of the isolated runtime (public PyPI versions,
frozen into uv.lock at install time; never resolved against the main env)."""
return (
'[project]\n'
'name = "hermes-hindsight-embedded"\n'
'version = "0.9.2"\n'
'requires-python = ">=3.11"\n'
'dependencies = [\n'
f' "hindsight-embed=={_EMBED_VERSION}",\n'
f' "hindsight-api-slim[all]=={_API_SLIM_VERSION}",\n'
']\n'
)
def _generation_current(generation: Path) -> bool:
"""Installed and matching the pinned versions (marker written at build)."""
if generation is None:
return False
try:
state = json.loads((generation / _STATE_FILE).read_text(encoding="utf-8-sig"))
except Exception:
return False
return state.get("pins") == _EXPECTED
def _uv_bridge(venv: Path) -> tuple[str, dict[str, str]]:
"""The sanctioned pm bridge: pinned uv binary + sanitized env for *venv*."""
from pm.ensure import uv as pm_uv
uv_bin, env = pm_uv(venv=venv)
if not uv_bin:
raise RuntimeError(
"pm could not realize the pinned uv binary; run 'hermes pm install' "
"before installing the isolated Hindsight runtime"
)
return str(uv_bin), env
def _run_uv(uv_bin: str, env: dict[str, str], args: list[str], timeout: float) -> None:
# Pin the interpreter explicitly: pm's sanitized env strips UV_PYTHON, and
# without a pin uv's chooser can pick a different (e.g. PBS 3.14) runtime.
# sys.executable is the Hermes venv python — the side venv is fully
# isolated (own site-packages); this only fixes the BASE interpreter.
env = {**env, "UV_PYTHON": sys.executable}
result = subprocess.run( # noqa: S603 — fixed argv, no shell
[uv_bin, *args], env=env, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()[-2000:]
raise RuntimeError(f"uv {' '.join(args)} failed (exit {result.returncode}): {detail}")
def ensure_sideenv() -> Path:
"""Install/update the isolated runtime; returns the active generation dir.
Each install is an immutable generation built at its final path and
published via the ``active.json`` selection record. A failed build removes
only its own unpublished generation; the previous generation (and any
daemon running from it) stays untouched."""
root = sideenv_root()
active = _active_generation(root)
if _generation_current(active) and sideenv_python(root) is not None:
return active
generation = root / f"gen-{uuid.uuid4().hex}"
generation.parent.mkdir(parents=True, exist_ok=True)
logger.info("Hindsight embedded runtime: building generation %s (pins=%s)",
generation, _EXPECTED)
try:
generation.mkdir(parents=True)
(generation / "pyproject.toml").write_text(sideenv_pyproject(), encoding="utf-8")
uv_bin, env = _uv_bridge(generation / ".venv")
_run_uv(uv_bin, env, ["lock", "--project", str(generation)], _UV_LOCK_TIMEOUT)
_run_uv(uv_bin, env, ["sync", "--project", str(generation), "--frozen"], _UV_SYNC_TIMEOUT)
(generation / _STATE_FILE).write_text(
json.dumps({"pins": _EXPECTED}, sort_keys=True) + "\n", encoding="utf-8")
except BaseException:
# The failed generation was never published; nothing can be running
# from it, so removing it leaks nothing. Previous generations stay.
shutil.rmtree(generation, ignore_errors=True)
raise
_publish_generation(root, generation)
logger.info("Hindsight embedded runtime: generation %s published (side env %s)",
generation.name, root)
return generation
def _probe_interpreter(python: Path, timeout: float = _PROBE_TIMEOUT) -> tuple[bool, str | None]:
"""Import the embedded stack IN THE SIDE ENV (its own interpreter). Covers the
old-CPU NumPy failure class and a broken embedding stack the same way the
legacy in-process probe did — but inside the isolated environment."""
try:
result = subprocess.run( # noqa: S603 — fixed argv, no shell
[str(python), "-c", _PROBE_CODE], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout,
env=_daemon_subprocess_env({}),
)
except (OSError, subprocess.TimeoutExpired) as exc:
return False, f"side runtime probe failed: {exc}"
if result.returncode == 0:
return True, None
reason = (result.stderr or result.stdout or "").strip()
return False, reason[-500:] if reason else f"probe exited {result.returncode}"
def check_local_runtime() -> tuple[bool, str | None]:
"""(available, reason) for local_embedded — probes the side env, never the
boot-selected main environment."""
root = sideenv_root()
python = sideenv_python(root)
if python is None:
reason = (f"the isolated Hindsight runtime is not installed at {root}; "
"run 'hermes memory setup' and choose Local Embedded")
logger.debug("Hindsight local runtime unavailable: %s", reason)
return False, reason
available, reason = _probe_interpreter(python)
if available:
logger.debug("Hindsight side runtime probe OK (%s)", python)
else:
logger.debug("Hindsight side runtime probe failed: %s", reason)
return available, reason
def _local_runtime_hint(reason: str | None) -> str:
"""Install/reinstall guidance for an unavailable side runtime. The main
Hermes environment is never touched, so the fix is always the same one."""
text = f"{reason or ''}".strip()
return (
" The local_embedded runtime lives in an isolated environment"
f" ({sideenv_root()}; hindsight-embed=={_EMBED_VERSION} +"
f" hindsight-api-slim[all]=={_API_SLIM_VERSION}), separate from Hermes"
" itself. Reinstall it with 'hermes memory setup' (Local Embedded), or"
" switch to cloud / local_external mode."
+ (f" Last probe failure: {text}" if text else "")
)
def _daemon_subprocess_env(config: dict[str, Any]) -> dict[str, str]:
"""Env for the side-env bridge/manager process. daemon_embed_manager reads
the health-grace window AT IMPORT TIME of that process, so it rides here —
not in Hermes' own environment. Main-env interpreter markers are stripped so
the side python resolves only its own environment."""
env = dict(os.environ)
env["PYTHONUTF8"] = "1"
for key in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONEXECUTABLE"):
env.pop(key, None)
raw = config.get("port_health_grace_timeout")
if raw not in (None, ""):
try:
seconds = float(raw)
except (TypeError, ValueError):
logger.warning("Invalid Hindsight port_health_grace_timeout %r; ignoring.", raw)
else:
if seconds >= 0:
env.setdefault(_PORT_HEALTH_GRACE_ENV, repr(seconds))
else:
logger.warning("Negative Hindsight port_health_grace_timeout %r; ignoring.", raw)
# Freeze the daemon/API component version to the pinned pair (the manager
# reads this as the profile .env override's fallback).
env[_API_VERSION_ENV] = _API_SLIM_VERSION
return env
def _parse_bridge_output(stdout: str) -> dict[str, Any]:
"""The bridge's marked JSON line; Rich/log noise is ignored."""
for line in reversed(stdout.splitlines()):
if line.startswith(_BRIDGE_MARKER):
try:
return json.loads(line[len(_BRIDGE_MARKER):])
except ValueError:
continue
return {}
def ensure_daemon_and_url(config: dict[str, Any], *, restart: bool = False) -> str:
"""Start (or reuse) the side-env daemon and return its URL.
The URL is resolved by the side env's own ProfileManager/get_url — profile
``HINDSIGHT_API_PORT`` override, then the manager's metadata allocation —
and never hardcoded here. The daemon child runs the side env's hindsight-api
because DaemonEmbedManager resolves the API command from the interpreter it
runs under (the side python)."""
python = sideenv_python()
if python is None:
raise RuntimeError(_local_runtime_hint("the isolated runtime is not installed"))
profile = str(config.get("profile", "hermes") or "hermes")
env = _daemon_subprocess_env(config)
logger.info("Hindsight embedded: starting side-env daemon manager (profile=%s, side_python=%s)",
profile, python)
timeout = float(config.get("daemon_start_timeout") or _DEFAULT_DAEMON_START_TIMEOUT)
try:
result = subprocess.run( # noqa: S603 — fixed argv (python -c bridge), no shell
[str(python), "-c", _DAEMON_BRIDGE_CODE, profile, "restart" if restart else "start"],
env=env, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
logger.warning("Hindsight embedded daemon start timed out after %.0fs (profile=%s)", timeout, profile)
raise RuntimeError(
f"Hindsight daemon start timed out after {timeout:.0f}s; the first start "
"can take several minutes — check the hindsight profile log and retry."
) from exc
except OSError as exc:
raise RuntimeError(f"could not run the side-env runtime {python}: {exc}") from exc
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()[-2000:]
logger.warning("Hindsight embedded daemon bridge failed (exit %s): %s", result.returncode, detail)
raise RuntimeError(f"Hindsight daemon startup failed (exit {result.returncode}): {detail}")
payload = _parse_bridge_output(result.stdout)
url = payload.get("url")
if payload.get("ok") and url:
logger.info("Hindsight embedded: daemon ready at %s (profile=%s)", url, profile)
return str(url)
error = payload.get("error") or f"no bridge result in side-env output: {(result.stdout or '')[-500:]}"
logger.warning("Hindsight embedded daemon did not start: %s", error)
raise RuntimeError(f"Hindsight daemon startup failed: {error}")