From f67b3fcecea63273fc1119111100441d49402312 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 24 Sep 2026 17:30:19 -0400 Subject: [PATCH] fix(update): install required PM tools before the venv sync on every update A normal `hermes update` only re-synced the venv. The sync pulls uv/python in through its own dependency, but a ripgrep/ffmpeg/node/npm pin bump in pm/lock.json was never installed, so PATH activation warned and skipped the managed tool dirs on every CLI start and gateway boot. Only the takeover route for historical releases ensured the tool roots. Move the takeover's loop into pm.client.ensure_tools_for_sync() and call it from both routes before the sync: update_completion._prepare (the CLI and Desktop route, running from the new tree so the new lockfile applies) and _update_takeover.prepare. It uses explicit=True like the takeover (an update is an explicit user action) and a failed download fails the update. post_update.step_provision_runtimes / MACHINE_STEPS stay: `python -m hermes_cli.post_update --scope machine` and tests still reference them. The ffmpeg docstring no longer claims that step re-ensures it. --- hermes_cli/_update_takeover.py | 15 +++---------- hermes_cli/update_completion.py | 4 ++++ pm/client.py | 22 +++++++++++++++++++ pm/packages.py | 6 ++--- .../test_update_completion_process.py | 4 ++++ 5 files changed, 36 insertions(+), 15 deletions(-) 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"