From cae75f0ead22f8a757448e6f8867cdff84f1fada Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 24 Sep 2026 21:28:00 -0400 Subject: [PATCH] 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. --- pm/install.py | 5 ++-- pm/packages.py | 12 ++++++--- pm/plugin_eviction.py | 29 +++++++++++++------- tests/pm/test_plugin_survival_contract.py | 33 +++++++++++++++++++++++ 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/pm/install.py b/pm/install.py index f3921f8f7b..a1316de627 100644 --- a/pm/install.py +++ b/pm/install.py @@ -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: diff --git a/pm/packages.py b/pm/packages.py index 93d0f56a00..b58690bc67 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -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) diff --git a/pm/plugin_eviction.py b/pm/plugin_eviction.py index 295b476221..d9a1297d4e 100644 --- a/pm/plugin_eviction.py +++ b/pm/plugin_eviction.py @@ -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) diff --git a/tests/pm/test_plugin_survival_contract.py b/tests/pm/test_plugin_survival_contract.py index 9cd6cd799a..64fb029459 100644 --- a/tests/pm/test_plugin_survival_contract.py +++ b/tests/pm/test_plugin_survival_contract.py @@ -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