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.
This commit is contained in:
ethernet
2026-09-24 17:30:19 -04:00
parent 241bc72c1d
commit f67b3fcece
5 changed files with 36 additions and 15 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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).

View File

@@ -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"