From 6d44d9bd2f66bc7a89c055502ca2c3cb885498e1 Mon Sep 17 00:00:00 2001 From: Halldrix Date: Sat, 26 Sep 2026 06:36:03 -0500 Subject: [PATCH] test(cron): two invariants plus the Windows lane marker (#122183) platforms("windows") instead of a bare skipif: list_os_marked_tests.py gates the Windows lane on that marker, so the skipif version ran on no CI lane at all -- the antipattern root AGENTS.md calls out. Four tests collapse to the two invariants that actually matter: the committed generation wins, and no-generation/unreadable-record both fall through to the handed venv. The legacy non-managed test is gone -- it passes on main by design, and the fall-through test's positive control now covers that path inside the managed arm. (cherry picked from commit 30c649f16bce08eeaabd5a407ae0429cf422556a) --- tests/cron/test_cron_windows_venv_abi.py | 124 +++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/cron/test_cron_windows_venv_abi.py diff --git a/tests/cron/test_cron_windows_venv_abi.py b/tests/cron/test_cron_windows_venv_abi.py new file mode 100644 index 0000000000..a5cb1389a1 --- /dev/null +++ b/tests/cron/test_cron_windows_venv_abi.py @@ -0,0 +1,124 @@ +"""Windows cron scripts resolve their dependency tree, and fall through when there is none. + +``cron/scheduler_script.py::_windows_cron_python_invocation`` answers "which interpreter and +which site-packages?" for a cron script child. On a PM-managed install it read the dependency +tree through ``pm.environments.selected_venv``, which falls back to ``base_venv`` and answers +the leftover pre-PM ``/venv`` when no generation is recorded. That tree is built for +whichever interpreter created it, so overlaying it on the managed store Python loads a cp311 +``pydantic_core`` on 3.14 and every script dies with +``ModuleNotFoundError: No module named 'pydantic_core._pydantic_core'`` — the cron sibling of +the gateway crash in #122183/#123650, which no PR covered. + +Two invariants: + +1. With a generation committed, that generation is the tree the child gets — never the in-tree + venv. +2. With NO generation, or a record too corrupt to read, the managed branch falls through to the + uv overlay: the handed venv keeps its own interpreter and its own site-packages. Handing back + the bare store Python instead would be worse than main (``_require_own_dependencies`` refuses + it with "hermes pm repair" and a cron script has no bootstrap to refuse cleanly), and it + strips the overlay's site-packages entry, warning on every spawn. +""" + +import os +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.platforms("windows") + + +def _write_venv(venv: Path, base: Path, *, version: str | None = None) -> Path: + """A fake Windows venv with the ``Scripts/python.exe`` shim a cron child is handed.""" + (venv / "Lib" / "site-packages").mkdir(parents=True, exist_ok=True) + (venv / "Scripts").mkdir(parents=True, exist_ok=True) + base.mkdir(parents=True, exist_ok=True) + (venv / "Scripts" / "python.exe").write_text("", encoding="utf-8") + (base / "python.exe").write_text("", encoding="utf-8") + lines = [f"home = {base}", "uv = 0.11.14"] + if version: + lines.append(f"version_info = {version}") + (venv / "pyvenv.cfg").write_text("\n".join(lines) + "\n", encoding="utf-8") + return venv + + +def _site_packages_in(env_overlay: dict) -> Path | None: + for entry in env_overlay.get("PYTHONPATH", "").split(os.pathsep): + if Path(entry).name == "site-packages": + return Path(entry) + return None + + +def _raise_runtime_error(_root): + raise RuntimeError("cannot read dependency environment: facts.json") + + +def test_managed_install_uses_the_committed_generation_not_the_in_tree_venv( + tmp_path, monkeypatch, +): + """The committed generation wins; the leftover pre-PM venv never reaches the child.""" + from cron import scheduler_script as sched_script + + stale = _write_venv(tmp_path / "repo" / "venv", tmp_path / "base") + committed = _write_venv( + tmp_path / "installs" / "gen" / "venv", tmp_path / "genbase", + version=f"{sys.version_info[0]}.{sys.version_info[1]}.7", + ) + store = tmp_path / "store" / "python.exe" + store.parent.mkdir(parents=True) + store.write_text("", encoding="utf-8") + child = _write_venv(tmp_path / "child", tmp_path / "childbase") + + monkeypatch.setattr("hermes_cli._launchers.resolve_store_python", lambda _root: store) + monkeypatch.setattr("pm.environments.committed_venv", lambda _root: committed) + monkeypatch.setattr("pm.environments.selected_venv", lambda _root: stale) + + interpreter, env_overlay = sched_script._windows_cron_python_invocation( + str(child / "Scripts" / "python.exe") + ) + + assert interpreter == str(store) + overlay = _site_packages_in(env_overlay) + assert overlay is not None, "the committed generation must supply a site-packages entry" + assert overlay == committed / "Lib" / "site-packages" + assert not overlay.is_relative_to(stale) + + +def test_managed_install_without_a_generation_falls_through_to_the_handed_venv( + tmp_path, monkeypatch, +): + """No generation, or an unreadable record: the handed venv keeps its own packages. + + Both arms must land identically — the child gets its own base interpreter and its own + site-packages, not the bare store Python, which cannot serve a script with nothing + committed. Positive control for the fall-through: the handed venv here IS a uv venv + carrying its own site-packages, exactly the case that worked on main. + """ + from cron import scheduler_script as sched_script + + stale = _write_venv(tmp_path / "repo" / "venv", tmp_path / "base") + store = tmp_path / "store" / "python.exe" + store.parent.mkdir(parents=True) + store.write_text("", encoding="utf-8") + + for label, committed in (("none", lambda _root: None), + ("unreadable", _raise_runtime_error)): + case = tmp_path / label + child = _write_venv(case / "child", case / "childbase") + monkeypatch.setattr("hermes_cli._launchers.resolve_store_python", + lambda _root, s=store: s) + monkeypatch.setattr("pm.environments.committed_venv", committed) + monkeypatch.setattr("pm.environments.selected_venv", lambda _root, t=stale: t) + + interpreter, env_overlay = sched_script._windows_cron_python_invocation( + str(child / "Scripts" / "python.exe") + ) + + assert interpreter == str(case / "childbase" / "python.exe"), label + assert env_overlay["VIRTUAL_ENV"] == str(child), label + overlay = _site_packages_in(env_overlay) + assert overlay is not None, f"{label}: the overlay must carry site-packages" + assert overlay == child / "Lib" / "site-packages", label + assert not overlay.is_relative_to(stale), label + assert interpreter != str(store), label