fix(pm): a lazy extra sync refuses to run from an interpreter PM did not select

Generations are immutable and selection is a boot decision, so a mid-process
sync never changes the running process — it changes what every process booted
AFTERWARDS activates. That is correct when the caller runs on the selected
environment (the new generation is that environment plus the extra). From any
other interpreter (a build_environment test venv, a developer venv, a Nix store
Python) the same sync commits a selection the caller never activates while
later processes swap onto a generation missing whatever the foreign interpreter
carried. CI hit exactly this: bedrock/vertex adapters synced [bedrock] at import
from the test venv, the checkout gained its first selection, and every later
per-file subprocess booted into a generation without the test group.

pm.client.sync_venv now refuses a non-explicit extras sync unless the selected
environment's site-packages is on sys.path (pm.environments.
running_from_selected_environment). Explicit installs, repairs and plugin
member syncs are unchanged; the refusal names `hermes pm install` as the remedy
and is recorded on the receipt like every other lazy-install refusal.
This commit is contained in:
ethernet
2026-09-21 01:10:29 -04:00
parent 9f2ba1b74d
commit 06d2843983
3 changed files with 71 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ from collections.abc import Mapping, Sequence
import json
from pathlib import Path
import subprocess
import sys
import threading
import uuid
@@ -183,6 +184,20 @@ def ensure(name, *, base_env=None, explicit=False, progress=None, pause_event=No
def sync_venv(extras=None, *, explicit=False, plugin_dirs=None, extra_plugin_dirs=(), selection=None, staged_plugin=None, repair=False,
project_root: Path | None = None) -> None:
from pm.environments import running_from_selected_environment
if extras and not explicit and not repair and not running_from_selected_environment(
paths.repo_root() if project_root is None else Path(project_root)):
# A lazy extra may only extend the environment this process runs from. From any other
# interpreter (a build_environment test venv, a developer venv, a Nix Python) the sync would
# commit a selection this process never activates while every process booted afterwards
# swaps onto it — a generation without whatever the foreign interpreter carried.
from pm.install import _refuse_lazy
raise _refuse_lazy(
"venv",
f"{list(extras)}: this process is not running from the install's dependency environment "
f"({sys.prefix}); only an explicit install may change what later processes boot into",
)
if selection is not None and "expected_config" not in selection:
from hermes_cli.runtime_state import _digest
selection = {**selection, "expected_config": _digest(Path(selection["home"]) / "config.yaml") or "missing"}

View File

@@ -169,6 +169,27 @@ def site_packages(venv: Path) -> Path:
return venv / f"lib/python{version[0]}.{version[1]}/site-packages"
def running_from_selected_environment(project_root: Path) -> bool:
"""Does this process run on the environment PM selected for the install (base venv or committed
generation)?
A lazy sync from any other interpreter — a build_environment test venv, a developer's own venv,
a Nix store Python — must not commit the install's selection: activation is a boot decision, so
this process keeps running unchanged while every process booted afterwards swaps onto a
generation that lacks whatever the foreign interpreter carried.
activate_dependencies puts the selection's site-packages on sys.path without changing
sys.prefix, so sys.path is the signal (the same one ensure_import reads after a sync).
"""
import sys
try:
selected = site_packages(selected_venv(project_root)).resolve()
except (OSError, RuntimeError, ValueError):
return False
return any(Path(entry).resolve() == selected for entry in sys.path if entry)
def activate_dependencies(project_root: Path) -> None:
"""Select the committed tree at process boot, before third-party imports.

View File

@@ -88,3 +88,38 @@ def test_sync_venv_allows_frozen_extras_when_lazy_off(rooted, monkeypatch):
from pm.package import InstallError
with pytest.raises(InstallError, match="lazy installs are disabled"):
ensure_mod.sync_venv(["web"])
def test_lazy_sync_never_creates_the_first_selection_for_a_foreign_interpreter(rooted, monkeypatch):
"""A process running from an environment PM did not select (a build_environment test venv,
a dev venv, nix) imports an adapter whose extra is missing. Letting that lazy sync commit
the install's FIRST selection strands every later process: they boot into a generation
that lacks whatever the foreign interpreter carried (the CI "anthropic/aiohttp vanished"
class). Only an explicit install may create it. Exercised at the client seam every
ensure_import caller goes through, in the in-process (is_runtime) shape."""
import pm.client as client
import pm.install as ensure_mod
from pm import paths
from pm.package import InstallError
from pm.environments import runtime_facts_path
repo = rooted / "repo"
repo.mkdir()
(repo / "venv").mkdir() # the install's own base venv, which this pytest process is NOT running from
monkeypatch.setattr(paths, "repo_root", lambda: repo)
monkeypatch.setattr(client, "is_runtime", lambda: True)
monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True)
venv_pkg = ensure_mod.get_package("venv")
monkeypatch.setattr(venv_pkg, "expected_stamp", lambda extras, **kwargs: "stamp")
monkeypatch.setattr(
venv_pkg, "apply", lambda *args, **kwargs: pytest.fail("lazy sync built a generation for a foreign interpreter"))
assert not runtime_facts_path(repo).exists()
with pytest.raises(InstallError, match="not running from the install's dependency environment"):
client.sync_venv(["bedrock"])
assert not runtime_facts_path(repo).exists(), "a refused sync must not commit a selection"
# The remedy the refusal names still works: an explicit install creates the selection.
monkeypatch.setattr(venv_pkg, "apply", lambda *args, **kwargs: {})
client.sync_venv(["bedrock"], explicit=True)
assert runtime_facts_path(repo).is_file()