From 4f6c04cd07cd978d047a2994495b70a2b72f7e2a Mon Sep 17 00:00:00 2001 From: finn763 <165816600+finn763@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:52:55 -0700 Subject: [PATCH] fix(update): resolve the live venv from the running interpreter, not /venv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An install whose interpreter lives outside the checkout ($HERMES_HOME/venvs/ — the layout the shipped Windows launchers assume) has neither venv/ nor .venv/ in-tree, so project_venv_dir() returned None and every `project_venv_dir(root) or root / "venv"` call site in the updater handed uv a VIRTUAL_ENV that does not exist (#116148): two uv interpreter errors per update, the import probe skipped, every `hermes tools` dependency reclassified as missing and reinstalled into a dead pointer, the staleness probe a no-op. project_venv_dir() — the single choke point — now falls back to the venv of the interpreter running this module, for the checkout it was loaded from only, so a foreign root (test temp dir, another clone) still resolves to None. In-tree venv/.venv still wins. Tests trimmed to two invariants in tests/test_hermes_constants.py. Salvages #116293; supersedes #116300 (same fix at the two VIRTUAL_ENV export sites). Co-authored-by: S --- hermes_constants.py | 24 +++++++++++++++++++-- tests/test_hermes_constants.py | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/hermes_constants.py b/hermes_constants.py index 36e6656334..5cd0294918 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -1458,15 +1458,35 @@ def venv_bin_dir(venv_dir, *, windows: bool | None = None) -> Path: def project_venv_dir(project_root) -> Path | None: - """The project's ``venv`` or ``.venv`` dir when one exists (``uv venv`` defaults to ``.venv``). + """The project's ``venv`` or ``.venv`` dir when one exists (``uv venv`` defaults to ``.venv``); + for an install whose interpreter lives outside the checkout, the running interpreter's venv. ``uv venv`` defaults to ``.venv`` while our installers create ``venv``, so both layouts are in the wild. Call sites that only knew about ``venv`` silently no-oped on a ``.venv`` install — that is how the Windows shim-lock preflight skipped itself entirely (#79542). ``venv`` wins when both exist, matching what the installers write. + + Installers that keep the interpreter out of the checkout (``$HERMES_HOME/venvs/``, the layout the + shipped Windows launchers assume) have neither, and the ``project_venv_dir(root) or root / "venv"`` + idiom those call sites share then handed ``uv`` a ``VIRTUAL_ENV`` that does not exist: that one invented + path skipped the import probe, reclassified every ``hermes tools`` dependency as missing and failed the + reinstall with interpreter errors (#116148). The interpreter running this module is the only truthful + answer to "which venv is live", so fall back to it — but only for the checkout it was loaded from. A + foreign root (test temp dir, another clone) still resolves to ``None``: handing it someone else's venv + would point the callers' writes at the wrong environment. """ root = Path(project_root) - return next((root / n for n in ("venv", ".venv") if (root / n).is_dir()), None) + in_tree = next((root / n for n in ("venv", ".venv") if (root / n).is_dir()), None) + if in_tree is not None: + return in_tree + # Out-of-tree install: the path is real by construction (never invented), and non-venv installs + # keep today's ``None`` so the ``or root / "venv"`` fallback cannot install into a base interpreter. + running = Path(sys.prefix) + if (Path(__file__).resolve().parent == root.resolve() + and sys.prefix != sys.base_prefix + and venv_python_path(running).is_file()): + return running + return None def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path: diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 9de605fb01..9e75e371e2 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -1,6 +1,7 @@ """Tests for hermes_constants module.""" import os +import sys from pathlib import Path from types import SimpleNamespace @@ -1240,3 +1241,40 @@ class TestHealAttemptFlagSemantics: # The flag is set, so the once-per-process budget is spent. assert heal_hermes_managed_node() is False assert calls["n"] == 1 + +class TestProjectVenvDirOutOfTree: + """#116148: a checkout with no in-tree venv whose interpreter lives in ``$HERMES_HOME/venvs/`` + (the layout the shipped Windows launchers pin) must resolve to the running interpreter's venv, + never ``None`` — every updater call site turns ``None`` into a fabricated ``/venv`` that + uv cannot inspect, so tool dependencies are never refreshed.""" + + @staticmethod + def _running_from(monkeypatch, checkout, venv): + monkeypatch.setattr(hermes_constants, "__file__", str(checkout / "hermes_constants.py")) + monkeypatch.setattr(sys, "prefix", str(venv)) + monkeypatch.setattr(sys, "base_prefix", str(checkout / "no-such-base")) + + def test_out_of_tree_install_resolves_the_running_interpreter_venv(self, monkeypatch, tmp_path): + checkout = tmp_path / "hermes-agent" + checkout.mkdir() + venv = tmp_path / "venvs" / "hermes" + hermes_constants.venv_python_path(venv).parent.mkdir(parents=True) + hermes_constants.venv_python_path(venv).write_text("", encoding="utf-8") + self._running_from(monkeypatch, checkout, venv) + + assert hermes_constants.project_venv_dir(checkout) == venv + + def test_foreign_root_and_in_tree_venv_are_unchanged(self, monkeypatch, tmp_path): + """A temp dir / another clone never claims the running venv; an in-tree venv still wins.""" + checkout = tmp_path / "hermes-agent" + checkout.mkdir() + venv = tmp_path / "venvs" / "hermes" + hermes_constants.venv_python_path(venv).parent.mkdir(parents=True) + hermes_constants.venv_python_path(venv).write_text("", encoding="utf-8") + self._running_from(monkeypatch, checkout, venv) + other = tmp_path / "not-our-checkout" + other.mkdir() + + assert hermes_constants.project_venv_dir(other) is None + (checkout / ".venv").mkdir() + assert hermes_constants.project_venv_dir(checkout) == checkout / ".venv"