Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10111,6 +10111,7 @@ def _install(args: list[str]) -> None:
try:
_install(["install", "-e", f".[{group}]"])
_verify_console_scripts_installed(install_cmd_prefix, env=env)
_reconcile_venv_with_lockfile(install_cmd_prefix, env=env, extras=[group])
return
except subprocess.CalledProcessError:
print(
Expand Down Expand Up @@ -10149,6 +10150,91 @@ def _install(args: list[str]) -> None:
# downstream.
_verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group)
_verify_console_scripts_installed(install_cmd_prefix, env=env)
# Lockfile reconciliation runs on every successful path, including the
# individual-extras fallback above, so the next `uv run hermes` always
# validates against an already-synced venv (#8744). Only the extras that
# actually landed are synced: the fallback path deliberately skipped the
# ones that failed to build here (e.g. Android extras), and a sync that
# asked for them again would undo that.
_reconcile_venv_with_lockfile(
install_cmd_prefix, env=env, extras=installed_extras
)


def _reconcile_venv_with_lockfile(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None,
extras: list[str],
) -> None:
"""Run ``uv sync --locked`` against the venv hermes actually runs from (#8744).

``uv pip install -e .[all]`` writes the package but does not enforce
lockfile pinning, so the next ``uv run hermes`` is free to re-validate
and re-resolve — which means a network round-trip for git-pinned
extras (tinker / yc-bench / atropos) and an offline launch dies with
"Could not resolve host: github.com". A locked sync that runs after
the install pins the venv to the lockfile state so subsequent
``uv run hermes`` validates against an already-synced venv.

The sync must be pointed at that venv explicitly: ``uv sync`` operates on
the *project* environment (``PROJECT_ROOT/.venv``) and ignores a
``VIRTUAL_ENV`` that does not match it — it warns
(``VIRTUAL_ENV=... does not match the project environment path .venv and
will be ignored``) and creates ``.venv`` instead. Installers here write
``venv/``, so the drift this exists to remove would survive the sync
entirely, and the lock would be pinned into an environment nothing runs.
``UV_PROJECT_ENVIRONMENT`` (the same lever ``managed_uv`` uses) redirects
the sync at the real venv; ``--active`` would work too but errors out
when no venv is active. No target venv (site-packages / pip install) means
there is nothing to reconcile, and a bare sync would only strand a new
``.venv``, so it is skipped.

``extras`` are the optional-dependency groups the install actually asked
for — syncing a hardcoded ``all`` would drag the full set into a curated
Termux profile (and uninstall the deps its extras pinned instead).

Failures are logged but not raised: the install itself succeeded, and a
drifted lockfile is recoverable on the next update / doctor run. Skipped
when uv isn't the install tool or uv.lock isn't present (e.g. ZIP-swap /
bare checkout — uv would refuse --locked).
"""
if not _is_uv_command(install_cmd_prefix):
return
if not (PROJECT_ROOT / "uv.lock").is_file():
return

target: str | None = None
if env and env.get("VIRTUAL_ENV") and Path(env["VIRTUAL_ENV"]).is_dir():
target = env["VIRTUAL_ENV"]
if target is None:
from hermes_constants import project_venv_dir

venv_dir = project_venv_dir(PROJECT_ROOT)
target = str(venv_dir) if venv_dir else None
if target is None:
logger.debug("post-install uv sync skipped: no project venv to reconcile")
return

sync_env = dict(env) if env is not None else dict(os.environ)
sync_env["UV_PROJECT_ENVIRONMENT"] = target
sync_env["VIRTUAL_ENV"] = target
sync_cmd = [install_cmd_prefix[0], "sync"]
for extra in extras:
sync_cmd += ["--extra", extra]
sync_cmd.append("--locked")
try:
result = subprocess.run(sync_cmd, cwd=PROJECT_ROOT, env=sync_env, check=False)
except Exception as exc: # pragma: no cover - defensive
logger.warning("post-install uv sync skipped: %s", exc)
return
if result.returncode != 0:
# A stale lockfile refuses --locked and changes nothing; say so
# instead of leaving the venv silently un-reconciled (#8744).
logger.warning(
"post-install uv sync failed (rc=%d); venv left as-is",
result.returncode,
)


def _load_console_script_names() -> list[str]:
Expand Down
224 changes: 224 additions & 0 deletions tests/hermes_cli/test_update_uv_lock_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"""hermes update must reconcile the venv with uv.lock so `uv run hermes` does
not re-resolve dependencies on the next launch (#8744).

Reproducer: after `hermes update` finishes its editable install, the venv
can drift from uv.lock because `uv pip install -e .[all]` does not enforce
lockfile pinning. The next `uv run hermes` launch then has to re-validate,
which is when the user sees a network round-trip for git-pinned extras like
tinker / yc-bench / atropos, and an offline launch dies with "Could not
resolve host: github.com".

The fix: when uv is the install tool, finish the update by running
`uv sync --extra all --locked` against the same project root. That sync is
the single source of truth for what the venv should contain, and a
following `uv run hermes` validates against the already-synced venv with no
network calls. Falling back to the legacy `uv pip install -e .[all]` path
when `uv.lock` is missing keeps ZIP-swap and bare-checkout installs working
the way they did before.
"""

import subprocess
from unittest.mock import patch

import pytest


@pytest.fixture(autouse=True)
def _isolate(monkeypatch, tmp_path):
"""A temp project with pyproject + uv.lock, subprocess.run recorded.

Skip the optional-extras loop entirely (it spawns N more processes and
we only want to assert on the post-install `uv sync`). Stub
managed_uv helpers so the install path sees a real uv and goes through
the uv branch.
"""
project = tmp_path / "proj"
project.mkdir()
(project / "pyproject.toml").write_text(
'[project]\nname = "hermes-agent"\nversion = "0"\ndependencies = []\n'
)
(project / "uv.lock").write_text("# fake lockfile\n")
# The install path's VIRTUAL_ENV guard (#71510) drops VIRTUAL_ENV when
# the pointed-to venv directory does not exist. Create the fake venv
# so the happy-path env passes through to subprocess.run.
(project / "venv").mkdir()

from hermes_cli import main as hermes_main

monkeypatch.setattr(hermes_main, "PROJECT_ROOT", project)
monkeypatch.setattr(hermes_main, "_is_windows", lambda: False)
monkeypatch.setattr(hermes_main, "_venv_scripts_dir", lambda: None)
monkeypatch.setattr(
hermes_main, "_load_installable_optional_extras", lambda group="all": []
)

fake_uv = "C:/fake/uv.exe"
monkeypatch.setattr(
"shutil.which", lambda name: fake_uv if name == "uv" else None
)
for fn in (
"resolve_uv",
"ensure_uv",
"update_managed_uv",
"managed_python_env",
):
if fn in ("resolve_uv", "ensure_uv"):
val = lambda **_kw: fake_uv
elif fn == "update_managed_uv":
val = lambda **_kw: None
else:
val = lambda: {}
monkeypatch.setattr(
"hermes_cli.managed_uv." + fn, val, raising=False
)

log: list[dict] = []

def _run(cmd, *args, **kwargs):
log.append(
{"cmd": list(cmd) if isinstance(cmd, (list, tuple)) else [cmd], **kwargs}
)
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

monkeypatch.setattr(subprocess, "run", _run)
return project, fake_uv, log


def test_uv_run_hermes_no_longer_re_resolves_after_update(_isolate):
"""After #8744, `hermes update` must finish with `uv sync --extra all
--locked` so the next `uv run hermes` validates against an already-synced
venv and does no network round-trips for git-pinned extras.
"""
project, fake_uv, log = _isolate
from hermes_cli import main as hermes_main

hermes_main._install_python_dependencies_with_optional_fallback(
[fake_uv, "pip"],
env={"VIRTUAL_ENV": str(project / "venv")},
group="all",
)

lockfile_synced = any(
{"sync", "--locked", "--extra", "all"}.issubset(set(c["cmd"]))
or {"sync", "--locked", "--all-extras"}.issubset(set(c["cmd"]))
for c in log
)
assert lockfile_synced, (
"after #8744, `hermes update` must finish with `uv sync --extra all "
"--locked` so the next `uv run hermes` does not re-resolve deps. "
f"Recorded commands: {[c['cmd'] for c in log]}"
)


def test_lockfile_sync_targets_the_venv_hermes_runs_from(_isolate):
"""The `uv sync` must run with cwd=PROJECT_ROOT and be *pointed at* the venv
the pip install just wrote to.

`uv sync` ignores a VIRTUAL_ENV that does not match the project environment
and creates/updates PROJECT_ROOT/.venv instead, so propagating VIRTUAL_ENV
alone reconciles the wrong environment (#8744 review). Assert the sync is
steered at `venv/` explicitly.
"""
project, fake_uv, log = _isolate
from hermes_cli import main as hermes_main

venv = str(project / "venv")
hermes_main._install_python_dependencies_with_optional_fallback(
[fake_uv, "pip"], env={"VIRTUAL_ENV": venv}, group="all"
)

sync_call = next((c for c in log if "sync" in c["cmd"]), None)
assert sync_call is not None, (
"expected a `uv sync` subprocess call; got: "
f"{[c['cmd'] for c in log]}"
)
assert sync_call.get("cwd") == project, (
f"`uv sync` must run with cwd=PROJECT_ROOT ({project}), "
f"got {sync_call.get('cwd')!r}"
)
env = sync_call.get("env")
assert env is not None, "`uv sync` must get an env carrying the target venv"
assert env.get("UV_PROJECT_ENVIRONMENT") == venv, (
"`uv sync` targets the project environment unless told otherwise, so it "
f"must be pointed at {venv}; got "
f"UV_PROJECT_ENVIRONMENT={env.get('UV_PROJECT_ENVIRONMENT')!r}"
)
assert env.get("VIRTUAL_ENV") == venv, (
f"`uv sync` must propagate VIRTUAL_ENV={venv}, "
f"got {env.get('VIRTUAL_ENV')!r}"
)


def test_lockfile_sync_uses_the_callers_extra_group(_isolate):
"""A Termux caller syncs its curated `termux-all` profile, not `all`.

The install asked for `.[termux-all]`; a hardcoded `--extra all` would pull
the full extra set into that profile and uninstall the curated pins
(#8744 review).
"""
project, fake_uv, log = _isolate
from hermes_cli import main as hermes_main

hermes_main._install_python_dependencies_with_optional_fallback(
[fake_uv, "pip"],
env={"VIRTUAL_ENV": str(project / "venv")},
group="termux-all",
)

sync_call = next((c for c in log if "sync" in c["cmd"]), None)
assert sync_call is not None, (
f"expected a `uv sync` subprocess call; got: {[c['cmd'] for c in log]}"
)
cmd = sync_call["cmd"]
extras = [cmd[i + 1] for i, tok in enumerate(cmd[:-1]) if tok == "--extra"]
assert extras == ["termux-all"], (
"`uv sync` must request the caller's group; "
f"got extras={extras!r} in {cmd}"
)


def test_lockfile_sync_skipped_when_no_project_venv_exists(_isolate):
"""A pip / site-packages install has no project venv to reconcile.

Running `uv sync` there targets PROJECT_ROOT/.venv and would strand a
brand-new environment nothing runs, so the sync must be skipped outright
(#8744 review).
"""
project, fake_uv, log = _isolate
# The caller still passes VIRTUAL_ENV=PROJECT_ROOT/venv, but on this install
# that directory does not exist (the helper pins the interpreter instead).
(project / "venv").rmdir()
from hermes_cli import main as hermes_main

hermes_main._install_python_dependencies_with_optional_fallback(
[fake_uv, "pip"],
env={"VIRTUAL_ENV": str(project / "venv")},
group="all",
)

sync_calls = [c["cmd"] for c in log if "sync" in c["cmd"]]
assert sync_calls == [], (
"without a project venv there is nothing to reconcile; a `uv sync` here "
f"only creates a stray PROJECT_ROOT/.venv. Got: {sync_calls}"
)


def test_zip_swap_without_lockfile_skips_lockfile_sync(_isolate):
"""A bare checkout (no uv.lock) must not crash trying to lockfile-sync —
fall back to the legacy pip-only path. Keeps ZIP-swap and minimal
installs working.
"""
project, fake_uv, log = _isolate
# Remove the fake lockfile to simulate a non-uv checkout.
(project / "uv.lock").unlink()
from hermes_cli import main as hermes_main

hermes_main._install_python_dependencies_with_optional_fallback(
[fake_uv, "pip"], env={"VIRTUAL_ENV": str(project / "venv")}, group="all"
)

sync_calls = [c for c in log if "sync" in c["cmd"]]
assert sync_calls == [], (
"without uv.lock, the lockfile-sync must be skipped (uv would refuse "
f"--locked). Got: {[c['cmd'] for c in sync_calls]}"
)