fix(pm): an unreadable secondary profile config cannot fail an update

Venv.apply refused the whole graph when any secondary profile's
config.yaml was unreadable, so one broken sibling config failed every
update. Update syncs now leave that profile's plugins out of the union,
report it (stderr + receipt warning), and build the rest; the profile's
plugins rejoin on the next sync once its config is fixed. Boot currency
already skips broken secondaries, so the result reads as current.
Ordinary syncs keep refusing to shrink the recorded graph.
This commit is contained in:
ethernet
2026-09-24 21:28:00 -04:00
parent ccf631701a
commit cae75f0ead
4 changed files with 64 additions and 15 deletions

View File

@@ -656,13 +656,14 @@ def _target_selection(package, fact: dict, *, extras, inputs: dict, repair: bool
def _commit_selection(package, facts: Facts, change, *, enabled: list[str], stamp: str, inputs: dict,
current: bool, repair: bool, explicit: bool) -> None:
current: bool, repair: bool, explicit: bool, skip_invalid_secondary: bool = False) -> None:
"""Build (unless current), publish the plugin change, then record the selection."""
from pm import receipt
from hermes_cli.runtime_state import finish_publication, recover_publication
try:
result = {} if current else (package.apply(enabled, explicit=explicit, **inputs) or {})
result = {} if current else (package.apply(enabled, explicit=explicit,
skip_invalid_secondary=skip_invalid_secondary, **inputs) or {})
if not repair and package.expected_stamp(enabled, **inputs) != stamp:
raise ValueError("Dependency inputs changed while preparing publication; retry.")
if change is not None:

View File

@@ -385,8 +385,13 @@ class Venv(StatePackage):
h.update(members_stamp(enabled_member_dirs() if plugin_dirs is None else plugin_dirs).encode())
return h.hexdigest()
def apply(self, extras: list[str], *, plugin_dirs=None, repair: bool = False, explicit: bool = False) -> dict:
"""Prepare one complete environment; the caller commits its selection."""
def apply(self, extras: list[str], *, plugin_dirs=None, repair: bool = False, explicit: bool = False,
skip_invalid_secondary: bool = False) -> dict:
"""Prepare one complete environment; the caller commits its selection.
``skip_invalid_secondary`` is the update's contract: an unreadable secondary profile
is left out (the caller reports it) instead of refusing the whole graph.
"""
import uuid
from pm.environments import install_state_dir, runtime_facts_path
from pm.environment import managed_environment
@@ -402,8 +407,9 @@ class Venv(StatePackage):
if not repair:
# Inspection may skip a broken secondary profile, but publishing a replacement
# graph must not silently evict its recorded members (including passed candidates).
# An update does evict them, loudly: it must not fail on another profile's config.
from pm.plugins_state import enabled_plugins_ordered
enabled_plugins_ordered()
enabled_plugins_ordered(skip_invalid_secondary=skip_invalid_secondary)
members = [] if repair else (enabled_member_dirs() if plugin_dirs is None else plugin_dirs)
try:
generation.mkdir(parents=True)

View File

@@ -5,7 +5,9 @@ choose again. An update has nobody to ask and must never fail because of a plugi
core moved (a newer Python, a bumped pin, a newer manifest contract) under a plugin
that was admitted against the old core. Such a plugin is disabled in every home that
enables it, the reason reaches the operator and the receipt, and the update continues
with the rest. Only a core that cannot build on its own still fails.
with the rest. A secondary profile whose config cannot be read is left out of the union
the same way (there is nothing to edit in it) until its config is fixed. Only a core that
cannot build on its own still fails.
"""
from __future__ import annotations
@@ -117,8 +119,15 @@ def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explic
"""
from pm import receipt
from pm.install import _commit_selection, _runtime_state_matches, _target_selection
from pm.plugins_state import dependency_homes, read_home_selection
from pm.workspace import _is_member_candidate, enabled_plugin_entries
notices: list[str] = []
for home in dependency_homes()[1:]:
try:
read_home_selection(home)
except ValueError as exc:
notices.append(f"Skipped the plugins of profile {home}: {exc}; they rejoin once its config.yaml is fixed")
entries = enabled_plugin_entries(skip_invalid_secondary=True)
reasons = static_reasons(entries, _interpreter_version())
@@ -132,7 +141,8 @@ def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explic
receipt.record_feature_list(enabled)
_commit_selection(package, facts, PluginEviction(entries, reasons) if reasons else None,
enabled=enabled, stamp=stamp, inputs=inputs,
current=_runtime_state_matches(fact, stamp), repair=False, explicit=explicit)
current=_runtime_state_matches(fact, stamp), repair=False, explicit=explicit,
skip_invalid_secondary=True)
kept = members()
try:
@@ -143,21 +153,20 @@ def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explic
enabled = _target_selection(package, fact, extras=extras, inputs={"plugin_dirs": []},
repair=False, shipped=shipped, frozen=frozen)[0]
try:
package.apply(enabled, explicit=explicit, plugin_dirs=[])
package.apply(enabled, explicit=explicit, plugin_dirs=[], skip_invalid_secondary=True)
except InstallError:
raise failure from None
fitting: list[Path] = []
for member in kept:
try:
package.apply(enabled, explicit=explicit, plugin_dirs=[*fitting, member])
package.apply(enabled, explicit=explicit, plugin_dirs=[*fitting, member], skip_invalid_secondary=True)
except InstallError as exc:
reasons[member.resolve()] = f"the dependency environment no longer builds with it: {exc.cause[-400:]}"
else:
fitting.append(member)
commit()
for plugins_dir, name, plugin_dir in entries:
reason = reasons.get(plugin_dir.resolve())
if reason:
message = f"Disabled plugin '{name}' in {plugins_dir.parent}: {reason}"
print(f"⚠ {message}", file=sys.stderr, flush=True)
receipt.record_warning(message)
notices += [f"Disabled plugin '{name}' in {plugins_dir.parent}: {reasons[plugin_dir.resolve()]}"
for plugins_dir, name, plugin_dir in entries if plugin_dir.resolve() in reasons]
for message in notices:
print(f"⚠ {message}", file=sys.stderr, flush=True)
receipt.record_warning(message)

View File

@@ -392,6 +392,39 @@ def test_update_sync_disables_later_plugin_of_unresolvable_union(admission_env):
assert venv_is_current(project_root=tmp_path / "core") is True
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_update_sync_survives_unreadable_secondary_profile(admission_env):
"""A secondary profile's broken config.yaml cannot fail an update: its plugins sit out
(reported), the rest build, and the next boot sees a current venv."""
from pm.environments import runtime_facts_path
from pm.install import sync_venv, venv_is_current
from pm.lock import Facts
tmp_path, home = admission_env
core = tmp_path / "core"
member = home / "plugins" / "primary-dep"
member.mkdir(parents=True)
(member / "pyproject.toml").write_text(
'[project]\nname="primary-dep"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
_write_enabled(home, ["primary-dep"])
broken = home / "profiles" / "work" / "config.yaml"
broken.parent.mkdir(parents=True)
broken.write_text("plugins: [broken]\n", encoding="utf-8")
with pytest.raises(ValueError, match="config.yaml"):
sync_venv(explicit=True) # an ordinary sync still refuses to shrink the graph
sync_venv(explicit=True, evict_incompatible_plugins=True)
workspace = Path(Facts(runtime_facts_path(core), strict=True).get("venv")["resolved_lock"]).parent
assert "primary-dep" in (workspace / "pyproject.toml").read_text()
assert broken.read_text(encoding="utf-8") == "plugins: [broken]\n"
assert str(broken.parent) in json.dumps(_latest_receipt(home).get("warnings"))
assert venv_is_current(project_root=core) is True
def test_active_context_home_exported_to_wrapper_subprocess(monkeypatch, tmp_path):
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from tools.environments.local import build_subprocess_env