From 46e36e22e152b52f262787836963f334dfd81246 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Fri, 25 Sep 2026 15:14:22 +0800 Subject: [PATCH] fix(kanban): carry the module argv's import root to the worker env The dispatcher's _resolve_hermes_argv proves hermes_cli importable in the gateway process, where a store-python shim has the repo root on sys.path in-process; the spawned bare 'sys.executable -m hermes_cli.main' child runs from the task workspace with a scrubbed PYTHONPATH and cannot import the package, dies before any work, and the board auto-blocks (#122299). Keep the module-over-PATH ordering (da18c20226, #111569) and instead propagate the running install's package root onto the worker's PYTHONPATH; a resolved shim path keeps owning its own imports. Fixes #122299 --- hermes_cli/kanban_db_dispatch.py | 24 +++++++++++ tests/hermes_cli/test_kanban_db.py | 68 ++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/hermes_cli/kanban_db_dispatch.py b/hermes_cli/kanban_db_dispatch.py index 7b71be5569ef0..f3b50bad1e5f8 100644 --- a/hermes_cli/kanban_db_dispatch.py +++ b/hermes_cli/kanban_db_dispatch.py @@ -2448,6 +2448,27 @@ def _module_hermes_argv() -> list[str]: return [sys.executable, "-m", "hermes_cli.main"] +def _propagate_module_import_root(cmd: list[str], env: dict[str, str]) -> None: + """Put the running install's package root on a module-form worker's path. + + ``_resolve_hermes_argv`` proves ``hermes_cli`` importable in THIS process, + where a store-python shim has the repo root on ``sys.path`` in-process; + the spawned child runs the bare ``sys.executable`` from the task workspace + with a scrubbed ``PYTHONPATH`` and cannot import the package the parent + just proved importable — it dies before any work and the board + auto-blocks (#122299). Same-interpreter child, so the root is version-safe + to propagate: the entries the scrub removes are dangerous only for a + different interpreter's compiled modules. ``hermes_cli.main``'s own + bootstrap then owns dependency activation as usual. + """ + if cmd[1:3] != ["-m", "hermes_cli.main"]: + return + root = str(Path(__file__).resolve().parents[1]) + parts = [p for p in env.get("PYTHONPATH", "").split(os.pathsep) if p] + if os.path.normcase(root) not in {os.path.normcase(p) for p in parts}: + env["PYTHONPATH"] = os.pathsep.join([root, *parts]) + + def _absolute_hermes_path(path: str) -> str: """Return an absolute filesystem path for a resolved Hermes shim.""" expanded = os.path.expanduser(path) @@ -2871,6 +2892,9 @@ def _default_spawn(task: Task, workspace: str, *, board: Optional[str] = None) - env.pop("HERMES_TUI", None) cmd = _worker_argv(task, profile_arg, env.get("HERMES_HOME")) + # The module argv must carry the import context that made it resolvable: + # the shim's in-process path injection is invisible to the bare child. + _propagate_module_import_root(cmd, env) # A worker spawned by a managed systemd gateway must leave the gateway's # cgroup before startup; otherwise restarting the service kills the worker # that is performing the handoff. diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index c9a8b172cbb9e..7d92da25b6703 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -1595,6 +1595,74 @@ def test_resolve_hermes_argv_module_actually_runs(): ) +def test_worker_module_argv_puts_package_root_on_child_pythonpath(): + """A module-form worker must carry the import context that selected it. + + ``_resolve_hermes_argv`` proves ``hermes_cli`` importable in the gateway, + where a store-python shim has the repo root on ``sys.path`` in-process; + the spawned bare ``sys.executable -m hermes_cli.main`` child cannot see + that injection and dies on import, auto-blocking the board (#122299). + The dispatcher must prepend the running install's package root to the + child's PYTHONPATH — never for a resolved shim path, which owns its own + imports. + """ + import os + import sys + from pathlib import Path + + from hermes_cli import kanban_db_dispatch as kbd + + root = str(Path(kbd.__file__).resolve().parents[1]) + + env: dict = {} + kbd._propagate_module_import_root([sys.executable, "-m", "hermes_cli.main"], env) + assert env["PYTHONPATH"].split(os.pathsep)[0] == root + + # Existing entries survive, the root lands first, and it is not duplicated. + env = {"PYTHONPATH": os.pathsep.join(("/opt/other", root))} + kbd._propagate_module_import_root([sys.executable, "-m", "hermes_cli.main"], env) + assert env["PYTHONPATH"].split(os.pathsep) == ["/opt/other", root] + + # A resolved shim path owns its own imports — no PYTHONPATH rewriting. + env = {} + kbd._propagate_module_import_root(["/usr/local/bin/hermes"], env) + assert "PYTHONPATH" not in env + + +def test_module_argv_importable_from_scrubbed_env_at_neutral_cwd(tmp_path): + """The propagated root is exactly what a bare child needs to import. + + Store-python repro shape (#122299): a bare interpreter launched from a + neutral cwd with no PYTHONPATH cannot import ``hermes_cli`` unless the + package root rides on PYTHONPATH. Execute the resolved module argv's + import question in a real subprocess to prove the propagated value works. + """ + import os + import subprocess + import sys + from hermes_cli import kanban_db_dispatch as kbd + + env: dict = {} + kbd._propagate_module_import_root([sys.executable, "-m", "hermes_cli.main"], env) + child_env = {"PYTHONPATH": env["PYTHONPATH"]} + if os.name == "nt": + # A Windows python.exe needs SystemRoot for crypto/DLL initialization. + child_env["SystemRoot"] = os.environ["SystemRoot"] + + probe = ( + "import importlib.util, sys; " + "sys.exit(0 if importlib.util.find_spec('hermes_cli') is not None else 1)" + ) + r = subprocess.run( + [sys.executable, "-c", probe], + cwd=tmp_path, env=child_env, capture_output=True, text=True, timeout=30, + ) + assert r.returncode == 0, ( + f"bare {sys.executable} could not find hermes_cli from {tmp_path} " + f"with only PYTHONPATH={child_env['PYTHONPATH']!r}; stderr={r.stderr[:200]!r}" + ) + + # --------------------------------------------------------------------------- # task_age — guard against corrupt timestamp values #