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