findPythonForRoot() prefers `.venv` over `venv`, but createPythonBackend()
hardcoded `<root>/venv` when it built the backend environment. A checkout
carrying both environments therefore launched the `.venv` interpreter with
`venv`'s site-packages on PYTHONPATH. The two trees routinely hold different
Python versions (3.13 vs 3.11 here), so every compiled extension resolved to a
wheel built for the wrong ABI: the backend exited(1) at import with
"No module named 'pydantic_core._pydantic_core'", the desktop app surfaced it as
"Web UI dependencies not installed (need fastapi + uvicorn)", and Electron
respawned the backend in a loop.
backend-env.ts gains venvRootForInterpreter(), which maps an interpreter path
back to its environment root (`<root>/bin/python`, `<root>\Scripts\python.exe`)
and returns null for anything not laid out like a venv, so the caller keeps its
own fallback. createPythonBackend() uses that root when it holds a pyvenv.cfg
and falls back to `<root>/venv` otherwise.
Windows was never affected — its branch already forced the matching `venv`
python — and its behavior is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What does this PR do?
The desktop app could launch the Python backend with one environment's interpreter and another environment's site-packages, which made the backend unstartable.
findPythonForRoot()prefers.venvovervenv(matching the convention in AGENTS.md), butcreatePythonBackend()hardcodedpath.join(root, 'venv')when it built the child environment'sPYTHONPATHandPATH. A checkout carrying both environments therefore ran the.venvinterpreter againstvenv's site-packages.The two trees routinely hold different Python versions — 3.13 and 3.11 on the machine where this was found. Pure-Python packages still import, so the failure surfaces late and points at the wrong thing: the first compiled extension resolves to a wheel built for the wrong ABI, and the backend exits(1) at import with
which the app reports as
Web UI dependencies not installed (need fastapi + uvicorn). Electron then respawns the backend in a loop. Neither environment is broken — each works perfectly on its own — so the usual debugging instinct (reinstall or delete a venv) chases a ghost.The fix follows the interpreter that was actually selected instead of guessing. Windows was never affected, because its branch already forced the matching
venvinterpreter; that behavior is unchanged.Type of Change
Changes Made
apps/desktop/electron/backend-env.ts— new exportedvenvRootForInterpreter(). Maps an interpreter path back to its environment root (<root>/bin/pythonon POSIX,<root>\Scripts\python.exeon Windows) and returnsnullfor anything not laid out like a venv, so the caller keeps control of its own fallback. Pure path logic, withplatform/pathModuleinjectable to match the rest of the module.apps/desktop/electron/main.ts—createPythonBackend()derives the venv root from the chosen interpreter, using it when it holds apyvenv.cfgand falling back to the previous<root>/venvotherwise (system interpreters, first-run bootstrap).apps/desktop/electron/backend-env.test.ts— two regression tests: the helper resolves.venvandvenvinterpreters to their own roots on POSIX and Windows, and declines interpreters that are not venv-shaped.createActiveBackend()was audited and left alone: it already takes both the interpreter and the site-packages fromVENV_ROOT, so it cannot mismatch. Those are the only two call sites ofgetVenvSitePackagesEntries().How to Test
Reproducing needs a checkout with two environments of different Python versions. With a 3.13
.venvand a 3.11venvin the same root, this is what the pre-fix code assembled:env PYTHONPATH="$PWD:$PWD/venv/lib/python3.11/site-packages" ./.venv/bin/python -m hermes_cli.main serve --host 127.0.0.1 --port 0No module named 'pydantic_core._pydantic_core'.PYTHONPATHentry for.venv/lib/python3.13/site-packages(what this PR now builds) — the backend reachesHERMES_BACKEND_READY port=<n>../.venv/bin/python -c "import pydantic_core"passes on its own in both cases. That is the trap: the environment is only broken in combination.Screenshots / Logs
Both runs against the same interpreter, differing only in the
PYTHONPATHthe desktop app builds:Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -q— N/A, this is an Electron main-process change with no Python surface. The equivalent gates were run instead, re-run on the rebased branch (base7b09c55):vitest run --project electron(49 files, 490 passed, 1 skipped),tsc -p tsconfig.electron.json --noEmitclean, andeslint electron/at its unchanged 24-warning baseline with none introduced.Documentation & Housekeeping
cli-config.yaml.example— N/ACONTRIBUTING.md/AGENTS.md— N/A, this makes the code match the.venv-preferred convention AGENTS.md already documentsRisks / gaps
pyvenv.cfg) still falls back to<root>/venv, exactly as before. When that venv is absent,getVenvSitePackagesEntries()already returns nothing, so no foreign site-packages can be injected. Accepted tradeoff — preserves the bootstrap path..venvon the affected machine is stale in a separate way (editable install pinned to an older version). Out of scope for this PR; auv syncis the operator-side cleanup.