diff --git a/hermes_cli/_update_takeover.py b/hermes_cli/_update_takeover.py index 61d70283b0..516f32bab1 100644 --- a/hermes_cli/_update_takeover.py +++ b/hermes_cli/_update_takeover.py @@ -17,26 +17,17 @@ def prepare(request: dict) -> tuple[Path, dict[str, str]]: ensure_panel(root) publish_stage("Updating Python dependencies (PM)") - from pm import paths, receipt - from pm.client import ensure, sync_venv, venv_is_current - from pm.lock import Lockfile - from pm.registry import tool_roots + from pm import receipt + from pm.client import ensure_tools_for_sync, sync_venv, venv_is_current from pm.environments import activation_environment, install_state_dir, runtime_facts_path from hermes_cli._launchers import resolve_store_python from hermes_cli.venv_sync import publish_launchers correlation = request["update_id"] with receipt.worker_context(correlation): - lock = Lockfile(paths.lockfile_path()) # A pre-PM installation has no required-tool facts. A current Python # generation alone does not prove its Node/Git/tool closure is ready. - for name in tool_roots(lock.names()): - ensure(name, explicit=True) - from pm.install import activate - - problems = [problem for problem in activate(allow_incomplete=True) if not problem.startswith("venv:")] - if problems: - raise RuntimeError(f"tools not on PATH before venv sync: {'; '.join(problems)}") + ensure_tools_for_sync() from pm.extras import legacy_selection extras = legacy_selection(root) if not runtime_facts_path(root).is_file() else None diff --git a/hermes_cli/update_completion.py b/hermes_cli/update_completion.py index 6920324e3c..cd7ebc0f05 100644 --- a/hermes_cli/update_completion.py +++ b/hermes_cli/update_completion.py @@ -135,6 +135,7 @@ def _read_terminal_receipt(request: dict) -> dict | None: def _prepare(request: dict, request_path: Path, result_path: Path) -> int: import pm from pm import receipt + from pm.client import ensure_tools_for_sync from pm.environments import activation_environment, project_python root = Path(request["source"]) @@ -145,6 +146,9 @@ def _prepare(request: dict, request_path: Path, result_path: Path) -> int: arm_completion(root) with receipt.worker_context(update_id): try: + # This file runs from the new tree, so its lockfile carries the new + # pins; tools (incl. bumped uv/python) land before the sync uses them. + ensure_tools_for_sync() pm.sync_venv(explicit=True, project_root=root) finally: request["pm_receipt"] = receipt.last_for_update(update_id) diff --git a/pm/client.py b/pm/client.py index 9bb89d517c..f196abadd1 100644 --- a/pm/client.py +++ b/pm/client.py @@ -230,6 +230,28 @@ def sync_venv(extras=None, *, explicit=False, plugins: PluginInput | None = None "plugins": plugin_inputs.encode(plugins)}, project_root=project_root) +def ensure_tools_for_sync() -> None: + """Publish every required tool in this tree's lockfile, then put them on PATH. + + Updates call this before the venv sync: the sync only pulls uv/python in + through its own dependency, so a bumped ripgrep/ffmpeg/node pin was never + installed and activation skipped the managed tool dirs on every start. + Publishing tools first also lets native builds resolve compilers and git + from the pinned store instead of the host (as `hermes pm install` does). + An update is an explicit user action, so the lazy-install policy does not + gate it; a failed download fails the update. + """ + from pm.install import activate + from pm.lock import Lockfile + from pm.registry import tool_roots + + for name in tool_roots(Lockfile(paths.lockfile_path()).names()): + ensure(name, explicit=True) + problems = activate(allow_incomplete=True) + if problems: + raise RuntimeError(f"tools not on PATH before venv sync: {'; '.join(problems)}") + + def stage_only(name, target, *, progress=None) -> Path: if is_runtime(): from pm.install import stage_only as direct diff --git a/pm/packages.py b/pm/packages.py index 0e36e923b4..93d0f56a00 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -663,9 +663,9 @@ class Gh(BinaryPackage): class Ffmpeg(_BionicDebArm, BinaryPackage, DebPackage): """Static ffmpeg. GPLv3 builds; always bundled. optional=False: ffmpeg is a required runtime tool. Sealed bundles ship - it baked into the payload (post_update skips provisioning sealed - installs — the artifact is atomic); dev installs get it re-ensured by - step_provision_runtimes when the pin bumps. Windows + Linux: + it baked into the payload; every `hermes update` and `hermes pm install` + re-ensures it from the new lockfile before the venv sync + (pm.client.ensure_tools_for_sync), so a pin bump lands. Windows + Linux: BtbN/FFmpeg-Builds (dated autobuild tag; ships ffprobe too). macOS: ffmpeg.martin-riedl.de (uniform ZIP, published sha256; single-binary — no ffprobe). diff --git a/tests/hermes_cli/test_update_completion_process.py b/tests/hermes_cli/test_update_completion_process.py index 406f2e1dc0..617e26369e 100644 --- a/tests/hermes_cli/test_update_completion_process.py +++ b/tests/hermes_cli/test_update_completion_process.py @@ -49,6 +49,10 @@ def transition(tmp_path): "def accept_worker_receipt(data, update_id):\n" " assert data['update_id'] == update_id\n" ) + (root / "pm/client.py").write_text( + "from hermes_cli.probe import event\n" + "ensure_tools_for_sync = lambda: event('tools')\n" + ) (package / "probe.py").write_text( "import json, os, pathlib, sys\n" "def event(name, **values):\n"