From 990923ba9e7c1a27e83192ba70733b8fcb323cff Mon Sep 17 00:00:00 2001 From: Matheus Date: Mon, 28 Sep 2026 01:58:31 -0300 Subject: [PATCH] fix(cron): pin the external worker's runtime site-packages, not just repo root _launch_external_cron_worker builds the worker env through the shared subprocess sanitizer, which strips Hermes-owned PYTHONPATH entries -- the repo root AND the runtime's own site-packages -- correctly for a genuine user child (#3bbec90f2, tools/environments/local_pythonpath.py). The external cron worker is not a user child, it IS Hermes (`sys.executable -m cron.scheduler`), and needs both back. pin_hermes_tree_on_pythonpath (#112729) already re-pins the repo root, which is why cron/utils/agent.* import fine, but left site-packages stripped. The moment anything on that import chain needs a third-party dependency -- hermes_yaml.py importing ruamel.yaml (#284dbaf5), hermes_cli/env_loader.py importing dotenv -- the worker dies with ModuleNotFoundError before it can send its ownership acknowledgement. This broke nearly every cron job on a managed (systemd) gateway install. Fix: also pin tools.environments.local_pythonpath::_get_hermes_site_packages onto the worker's PYTHONPATH, alongside the repo root. Fixes #125999. --- cron/scheduler_worker_env.py | 11 +++++- ...test_scheduler_worker_env_site_packages.py | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/cron/test_scheduler_worker_env_site_packages.py diff --git a/cron/scheduler_worker_env.py b/cron/scheduler_worker_env.py index ef13355fcac42..7cd0b608ef050 100644 --- a/cron/scheduler_worker_env.py +++ b/cron/scheduler_worker_env.py @@ -33,10 +33,19 @@ def pin_hermes_tree_on_pythonpath(worker_env: dict, repo_root: Path) -> dict: Skipped when ``repo_root`` is the interpreter's ``purelib``: under a wheel / pipx / uv-tool install ``cron/`` lives in site-packages itself, which is already importable, and pinning it would move site-packages ahead of the stdlib on ``sys.path``. + + The sanitizer also strips the runtime's own site-packages (third-party deps such as + ``ruamel.yaml``/``dotenv`` that ``cron``/``hermes_yaml``/``hermes_cli.env_loader`` import), + correctly for a genuine user child but not for this one -- this worker IS Hermes, so those + dirs are re-pinned here too, right alongside the repo root (#112729 follow-up: repo root + alone left every third-party import broken for the external cron worker). """ root = str(repo_root) if _installed_purelib() == Path(root).resolve(): return worker_env + from tools.environments.local_pythonpath import _get_hermes_site_packages + + site_packages = [str(p) for p in _get_hermes_site_packages(worker_env)] existing = [e for e in worker_env.get("PYTHONPATH", "").split(os.pathsep) if e] - worker_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys([root, *existing])) + worker_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys([root, *site_packages, *existing])) return worker_env diff --git a/tests/cron/test_scheduler_worker_env_site_packages.py b/tests/cron/test_scheduler_worker_env_site_packages.py new file mode 100644 index 0000000000000..f5b9ce8a76796 --- /dev/null +++ b/tests/cron/test_scheduler_worker_env_site_packages.py @@ -0,0 +1,37 @@ +"""Regression for #125999: the cron external worker's PYTHONPATH pin restored the repo +root but not the runtime's own site-packages, so any third-party import on the worker's +bootstrap path (``ruamel.yaml`` via ``hermes_yaml``, ``dotenv`` via +``hermes_cli.env_loader``) died with ModuleNotFoundError before the worker could send its +ownership acknowledgement (#112729 follow-up).""" + +from cron.scheduler_worker_env import pin_hermes_tree_on_pythonpath + + +def test_pin_restores_site_packages_alongside_repo_root(monkeypatch, tmp_path): + repo_root = tmp_path / "repo" + repo_root.mkdir() + site_packages = tmp_path / "venv" / "lib" / "python3.14" / "site-packages" + site_packages.mkdir(parents=True) + + monkeypatch.setattr( + "tools.environments.local_pythonpath._get_hermes_site_packages", + lambda env: [site_packages], + ) + monkeypatch.setattr("cron.scheduler_worker_env._installed_purelib", lambda: None) + + env = pin_hermes_tree_on_pythonpath({"PYTHONPATH": "/existing/user/path"}, repo_root) + + entries = env["PYTHONPATH"].split(":") + assert str(repo_root) in entries + assert str(site_packages) in entries + assert "/existing/user/path" in entries + + +def test_pin_is_a_noop_under_a_purelib_install(monkeypatch, tmp_path): + repo_root = tmp_path / "repo" + repo_root.mkdir() + monkeypatch.setattr("cron.scheduler_worker_env._installed_purelib", lambda: repo_root) + + env = pin_hermes_tree_on_pythonpath({}, repo_root) + + assert "PYTHONPATH" not in env