fix(update): resolve the live venv from the running interpreter, not <checkout>/venv

An install whose interpreter lives outside the checkout ($HERMES_HOME/venvs/<name> —
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 <superb-cation@users.noreply.github.com>
This commit is contained in:
finn763
2026-09-19 23:52:55 -07:00
committed by Teknium
parent a7462e0f66
commit 4f6c04cd07
2 changed files with 60 additions and 2 deletions

View File

@@ -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/<name>``, 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:

View File

@@ -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/<name>``
(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 ``<checkout>/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"