Files
hermes-agent/tests/plugins/memory/test_hindsight_embedded_runtime.py
ethernet eeb3da55da fix(install): restore safe path probes and portable fixtures
Normalize 8.3 paths before stage dispatch and support read-only resolved-path output. Dot-sourcing loads definitions without running the installer. Test the PM delegation contract instead of restoring the removed Node installer.

Use native environment layouts in PM and Hindsight tests. Give steering-test parents no database so child construction cannot create SQLite files at mock paths. Parent verification passed 145 Python tests with one skip and all native PowerShell path, delegation, and stage checks.
2026-09-06 13:42:36 -04:00

311 lines
12 KiB
Python

"""The isolated local_embedded Hindsight runtime (embedded_runtime).
Real imports of the plugin modules; the only fakes sit at the subprocess
boundary (the uv bridge invocations and the side-python bridge) — the URL the
daemon reports is proven against a real loopback HTTP /health server, never a
constant port. The boot-selected main environment is never touched.
"""
import http.server
import json
import subprocess
import threading
from types import SimpleNamespace
import pytest
import plugins.memory.hindsight.embedded_runtime as rt
_STATE = rt._STATE_FILE
@pytest.fixture()
def side_root(tmp_path, monkeypatch):
root = tmp_path / "hermes-home" / "profiles" / "Hindsight" / "env"
monkeypatch.setattr(rt, "sideenv_root", lambda: root)
return root
def _fake_completed(returncode=0, stdout="", stderr=""):
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
# -- pinned pyproject ---------------------------------------------------------
def test_sideenv_pyproject_pins_exact_pair():
text = rt.sideenv_pyproject()
assert 'hindsight-embed==0.9.2' in text
assert 'hindsight-api-slim[all]==0.9.2' in text # [all]: full feature parity
assert 'requires-python = ">=3.11"' in text
# -- ensure_sideenv: candidate build -> atomic commit via the pm uv bridge ----
def test_ensure_sideenv_builds_and_publishes_generation(side_root, monkeypatch):
calls = []
def fake_bridge(venv):
calls.append(("bridge", venv))
return "uv-bin", {"VIRTUAL_ENV": str(venv)}
def fake_run(uv_bin, env, args, timeout):
calls.append(("run", [uv_bin, *args], env))
return _fake_completed()
monkeypatch.setattr(rt, "_uv_bridge", fake_bridge)
monkeypatch.setattr(rt, "_run_uv", fake_run)
gen = rt.ensure_sideenv()
# The generation was built at its FINAL path (no rename), published by the
# selection record, and no candidate/prev leftovers exist.
assert gen.is_dir() and gen.name.startswith("gen-")
assert rt.sideenv_pyproject() in (gen / "pyproject.toml").read_text(encoding="utf-8")
assert json.loads((gen / _STATE).read_text(encoding="utf-8"))["pins"] == rt._EXPECTED
assert json.loads((side_root / "active.json").read_text(encoding="utf-8"))["generation"] == gen.name
assert [p for p in side_root.iterdir() if p.is_dir()] == [gen]
# uv invoked for lock+sync against the generation's own venv.
runs = [c for c in calls if c[0] == "run"]
assert [c[1][1] for c in runs] == ["lock", "sync"]
assert all(c[2]["VIRTUAL_ENV"].endswith(".venv") for c in runs)
assert calls[0][1] == gen / ".venv"
def test_ensure_sideenv_short_circuits_when_current(side_root, monkeypatch):
gen = side_root / "gen-1"
gen.mkdir(parents=True)
(side_root / "active.json").write_text(json.dumps({"generation": "gen-1", "pins": rt._EXPECTED}), encoding="utf-8")
(gen / _STATE).write_text(json.dumps({"pins": rt._EXPECTED}), encoding="utf-8")
from hermes_constants import venv_python_path
py = venv_python_path(gen / ".venv")
py.parent.mkdir(parents=True, exist_ok=True)
py.write_bytes(b"")
monkeypatch.setattr(rt, "_uv_bridge", lambda venv: pytest.fail("bridge used for an up-to-date env"))
assert rt.ensure_sideenv() == gen
def test_ensure_sideenv_failure_preserves_previous_generation(side_root, monkeypatch):
old = side_root / "gen-old"
old.mkdir(parents=True)
(old / "keep.txt").write_text("old", encoding="utf-8")
(side_root / "active.json").write_text(json.dumps({"generation": "gen-old"}), encoding="utf-8")
def boom(*a, **kw):
raise RuntimeError("uv sync failed: disk full")
monkeypatch.setattr(rt, "_uv_bridge", boom)
with pytest.raises(RuntimeError, match="disk full"):
rt.ensure_sideenv()
# Previous generation untouched and still the published one; the failed
# unpublished generation is gone.
assert (old / "keep.txt").read_text(encoding="utf-8") == "old"
assert json.loads((side_root / "active.json").read_text(encoding="utf-8"))["generation"] == "gen-old"
assert [p for p in side_root.iterdir() if p.is_dir()] == [old]
# -- runtime probe: side interpreter, never the boot env ----------------------
def test_check_local_runtime_probes_the_side_python(side_root, monkeypatch):
py = _install_side_python(side_root)
seen = {}
def fake_run(cmd, **kw):
seen["cmd"] = list(cmd)
return _fake_completed()
monkeypatch.setattr(rt.subprocess, "run", fake_run)
assert rt.check_local_runtime() == (True, None)
assert seen["cmd"][0] == str(py)
# The probe covers the API + manager + embedding stack (old-CPU NumPy class).
assert "hindsight_embed.daemon_embed_manager" in seen["cmd"][2]
assert "hindsight_api" in seen["cmd"][2]
assert "sentence_transformers" in seen["cmd"][2]
def test_check_local_runtime_reports_probe_failure(side_root, monkeypatch):
_install_side_python(side_root)
monkeypatch.setattr(
rt.subprocess, "run",
lambda cmd, **kw: _fake_completed(returncode=1, stderr="Illegal instruction (core dumped)"),
)
ok, reason = rt.check_local_runtime()
assert ok is False
assert "Illegal instruction" in reason
def test_check_local_runtime_missing_env_names_the_fix(side_root):
ok, reason = rt.check_local_runtime()
assert ok is False
assert "hermes memory setup" in reason
assert str(side_root) in reason
# -- daemon bridge: URL from the side env, loopback-proven --------------------
class _LoopbackHealth(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, *args):
pass
@pytest.fixture()
def loopback_health():
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _LoopbackHealth)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{server.server_address[1]}"
server.shutdown()
server.server_close()
def _install_side_python(side_root, name="gen-1"):
"""A minimal 'installed' generation: selection record + interpreter file
(laid out by the canonical venv_python_path, so the test matches the
platform layout the runtime itself resolves)."""
from hermes_constants import venv_python_path
gen = side_root / name
py = venv_python_path(gen / ".venv")
py.parent.mkdir(parents=True, exist_ok=True)
(side_root / "active.json").write_text(json.dumps({"generation": name}), encoding="utf-8")
py.write_bytes(b"")
return py
def test_ensure_daemon_url_from_sideenv_output_is_reachable(side_root, monkeypatch, loopback_health):
"""The bridge subprocess reports the URL the side env resolved (here: a real
loopback /health server) — the plugin never invents a port."""
py = _install_side_python(side_root)
seen = {}
def fake_run(cmd, *, env=None, **kw):
seen["cmd"], seen["env"] = list(cmd), env
# Manager noise on stdout; the marked JSON line is the authoritative tail.
return _fake_completed(
stdout=" Loading profile hermes\n"
f"{rt._BRIDGE_MARKER}" + json.dumps({"ok": True, "url": loopback_health})
)
monkeypatch.setattr(rt.subprocess, "run", fake_run)
url = rt.ensure_daemon_and_url({"profile": "hermes"})
assert url == loopback_health
# E2E: the reported daemon answers /health 200 on the real loopback.
import urllib.request
with urllib.request.urlopen(url + "/health", timeout=5) as resp:
assert resp.status == 200
assert seen["cmd"][0] == str(py)
assert seen["cmd"][3] == "hermes"
def test_ensure_daemon_env_carries_grace_and_version_pin(side_root, monkeypatch, loopback_health):
_install_side_python(side_root)
captured = {}
def fake_run(cmd, *, env=None, **kw):
captured["env"] = env
return _fake_completed(stdout=f"{rt._BRIDGE_MARKER}" + json.dumps({"ok": True, "url": loopback_health}))
monkeypatch.setattr(rt.subprocess, "run", fake_run)
monkeypatch.delenv("HINDSIGHT_EMBED_PORT_HEALTH_GRACE_TIMEOUT", raising=False)
monkeypatch.delenv("HINDSIGHT_EMBED_API_VERSION", raising=False)
rt.ensure_daemon_and_url({"profile": "hermes", "port_health_grace_timeout": 60})
# The grace window is read at IMPORT time of the side manager process, so it
# rides the subprocess env (not Hermes' own environment).
assert captured["env"]["HINDSIGHT_EMBED_PORT_HEALTH_GRACE_TIMEOUT"] == "60.0"
assert captured["env"]["HINDSIGHT_EMBED_API_VERSION"] == "0.9.2"
# Main-env interpreter markers are stripped so the side python resolves only
# its own environment.
assert "VIRTUAL_ENV" not in captured["env"]
assert "PYTHONPATH" not in captured["env"]
def test_ensure_daemon_missing_runtime_raises_with_hint(side_root, monkeypatch):
with pytest.raises(RuntimeError, match="hermes memory setup"):
rt.ensure_daemon_and_url({"profile": "hermes"})
def test_ensure_daemon_bridge_failure_surfaces(side_root, monkeypatch):
_install_side_python(side_root)
monkeypatch.setattr(
rt.subprocess, "run",
lambda cmd, **kw: _fake_completed(returncode=1, stderr="boom inside side env"),
)
with pytest.raises(RuntimeError, match="boom inside side env"):
rt.ensure_daemon_and_url({"profile": "hermes"})
def test_ensure_daemon_not_ok_surfaces_bridge_error(side_root, monkeypatch):
_install_side_python(side_root)
monkeypatch.setattr(
rt.subprocess, "run",
lambda cmd, **kw: _fake_completed(
stdout=f"{rt._BRIDGE_MARKER}" + json.dumps({"ok": False, "url": None, "error": "daemon did not start"})),
)
with pytest.raises(RuntimeError, match="daemon did not start"):
rt.ensure_daemon_and_url({"profile": "hermes"})
# -- local hint ---------------------------------------------------------------
def test_local_runtime_hint_points_at_isolated_install(side_root):
hint = rt._local_runtime_hint("No module named 'hindsight'")
assert "hermes memory setup" in hint
assert "hindsight-embed==0.9.2" in hint
assert "hindsight-api-slim[all]==0.9.2" in hint
assert str(side_root) in hint
# The main Hermes environment is never the target of the fix.
assert "uv pip install --python" not in hint
# -- trace logging on every local_embedded path -------------------------------
def test_trace_logs_on_daemon_start_and_ready(side_root, monkeypatch, caplog, loopback_health):
_install_side_python(side_root)
monkeypatch.setattr(
rt.subprocess, "run",
lambda cmd, **kw: _fake_completed(stdout=f"{rt._BRIDGE_MARKER}" + json.dumps({"ok": True, "url": loopback_health})),
)
with caplog.at_level("INFO", logger="plugins.memory.hindsight.embedded_runtime"):
rt.ensure_daemon_and_url({"profile": "hermes"})
messages = [r.getMessage() for r in caplog.records]
assert any("starting side-env daemon manager" in m for m in messages)
assert any("daemon ready" in m for m in messages)
def test_trace_logs_on_startup_failure(side_root, monkeypatch, caplog):
_install_side_python(side_root)
monkeypatch.setattr(
rt.subprocess, "run",
lambda cmd, **kw: _fake_completed(returncode=1, stderr="no dice"),
)
with caplog.at_level("WARNING", logger="plugins.memory.hindsight.embedded_runtime"):
with pytest.raises(RuntimeError):
rt.ensure_daemon_and_url({"profile": "hermes"})
assert any("bridge failed" in r.getMessage() for r in caplog.records)
def test_no_in_process_import_of_the_embedded_stack():
# The isolation contract: importing the plugin must not pull the heavy
# embedded stack into the boot env (it lives only in the side env).
import sys
import plugins.memory.hindsight # noqa: F401
assert "hindsight_embed" not in sys.modules
assert "hindsight_api" not in sys.modules
assert "hindsight" not in sys.modules