fix(pm): re-seed the uv cache when an update ships a new lock

uv_cache_dir() wrote a bare ".seeded" marker after the first seed and
never compared against the payload again, so an updated install never
received the new payload's wheels. Enabling a plugin re-resolves without
--offline, so uv fetched the new pins from PyPI instead; for
cryptography 50.0.1 on Windows ARM64, which has no wheel on PyPI, that
is an sdist Rust build.

The marker now records the running uv.lock's digest, so the first call
under a new lock merges the new payload into the existing cache.
This commit is contained in:
ethernet
2026-10-09 01:36:35 -04:00
parent 941d527a8c
commit 29416e2146
2 changed files with 34 additions and 6 deletions

View File

@@ -320,24 +320,33 @@ def uv_cache_dir() -> Path:
"""The hermes-owned uv cache: machine-scoped and shared (keyed by
content — two profiles reuse one cache), anchored to the DEFAULT
hermes root like partials_root(). A bundle ships a seeded copy at
the payload root (uv-cache/); the first call on a sealed install
copies it out to the writable machine cache (the read-only payload
the payload root (uv-cache/); the first call under each uv.lock
merges it into the writable machine cache (the read-only payload
can't serve uv's working cache), and a warm `uv sync --offline`
from it is near-free (probed: 0.4s vs 1.2s cold) — the blow-away-
on-update contract depends on it. uv's default cache location is
per-user/platform-opinionated and never used by pm."""
from hermes_constants import get_default_hermes_root
from pm.paths import repo_root, store_root
machine_cache = get_default_hermes_root() / "cache" / "uv"
marker = machine_cache / ".seeded"
if not marker.is_file():
# The marker names the lock whose payload was merged. An update ships a new payload cache for
# a new lock; a bare "done" marker skipped it forever, so plugin rebuilds fetched the new pins
# from the network (and built sdists where the index has no wheel for the target).
lock = repo_root() / "uv.lock"
seed = _uv_lock_digest(lock).hex() if lock.is_file() else "1"
try:
current = marker.read_text(encoding="utf-8-sig").strip() == seed
except OSError:
current = False
if not current:
# Seed from a shipped bundle cache when present (payload root =
# store_root().parent on a sealed install). Record completion ONLY after a clean copy: a
# partial seed that marked itself done would never be retried, and every later offline
# sync that needs the missing entries fails closed.
try:
from pm.paths import store_root
# uv's cache trees run deep; both roots get the long spelling so
# the copy is not cut at MAX_PATH. The returned path stays ordinary.
payload_cache = long_root(store_root().parent / "uv-cache")
@@ -359,7 +368,7 @@ def uv_cache_dir() -> Path:
else:
try:
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("1", encoding="utf-8")
marker.write_text(seed, encoding="utf-8")
except OSError:
pass
return machine_cache

View File

@@ -30,6 +30,25 @@ def payload_cache(tmp_path, monkeypatch):
return get_default_hermes_root() / "cache" / "uv"
def test_update_merges_the_new_payload_into_an_existing_seed(payload_cache, tmp_path, monkeypatch):
"""An update ships a payload cache for a new lock; its wheels must reach the machine cache.
Regression: ``.seeded`` meant "done forever", so an updated install never received the new
pins' wheels and plugin rebuilds fetched them from the index.
"""
code = tmp_path / "code"
code.mkdir()
(code / "uv.lock").write_text("version = 1 # old\n", encoding="utf-8")
monkeypatch.setattr(paths, "repo_root", lambda: code)
packages.uv_cache_dir()
added = paths.store_root().parent / "uv-cache" / "archive-v0" / "bucket" / "new.py"
added.write_text("new pin\n", encoding="utf-8")
(code / "uv.lock").write_text("version = 1 # new\n", encoding="utf-8")
packages.uv_cache_dir()
assert (payload_cache / "archive-v0" / "bucket" / "new.py").read_text(encoding="utf-8-sig") == "new pin\n"
def test_partial_seed_is_retried_on_the_next_install(payload_cache, monkeypatch):
real_copytree = shutil.copytree
attempts: list[int] = []