fix(plugins): name the plugin when admission hits a resolver conflict
No caller handled ResolutionConflict: enabling a plugin whose deps cannot co-install with core surfaced uv's raw lock output (hashed workspace member names) plus a "run hermes pm install" hint that cannot fix a conflict. admit_plugin_set_change now turns ResolutionConflict into DependencyConflict (an AdmissionRefused subclass, so every existing refusal path still applies) whose message names the plugin being admitted, says it conflicts with the pinned dependencies, and keeps the resolver's cause. The CLI prints that message without the misleading retry hint; the dashboard and TUI surfaces already relay str(exc). Selection and environment stay unchanged as before.
This commit is contained in:
@@ -9,6 +9,22 @@ class AdmissionRefused(RuntimeError):
|
||||
"""The candidate set was refused; config and environment untouched."""
|
||||
|
||||
|
||||
class DependencyConflict(AdmissionRefused):
|
||||
"""PM's resolver proved the candidate set cannot co-install with the pinned dependency set.
|
||||
|
||||
The raw ``uv lock`` output names hashed workspace members, not the plugin the user asked for, so
|
||||
the message leads with the plugin and keeps the resolver's cause for the details.
|
||||
"""
|
||||
|
||||
def __init__(self, cause: str, *, plugin: Optional[str] = None):
|
||||
self.cause = cause
|
||||
self.plugin = plugin
|
||||
who = f"Plugin '{plugin}'" if plugin else "The plugin selection"
|
||||
super().__init__(
|
||||
f"{who} conflicts with the dependencies pinned by Hermes core or an enabled plugin, "
|
||||
f"so it was not admitted. Resolver: {cause}")
|
||||
|
||||
|
||||
def candidate_member_dirs(
|
||||
candidate_enabled: Iterable[str],
|
||||
candidate_disabled: Iterable[str] = (),
|
||||
@@ -38,10 +54,12 @@ def admit_plugin_set_change(
|
||||
active_plugins_dir: Optional[Path] = None,
|
||||
extra_dirs: Iterable[Path] = (),
|
||||
expected_config: str | None = None,
|
||||
plugin: Optional[str] = None,
|
||||
) -> None:
|
||||
"""PM discovers and validates the proposed union under its install lock.
|
||||
|
||||
No config or dependency selection is written by this application process.
|
||||
No config or dependency selection is written by this application process. *plugin* names the
|
||||
plugin being admitted so a resolver conflict is reported against it (:class:`DependencyConflict`).
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
from pm.client import sync_venv
|
||||
@@ -54,4 +72,8 @@ def admit_plugin_set_change(
|
||||
**({"expected_config": expected_config} if expected_config is not None else {}),
|
||||
})
|
||||
except Exception as exc:
|
||||
from pm.workspace import ResolutionConflict
|
||||
|
||||
if isinstance(exc, ResolutionConflict):
|
||||
raise DependencyConflict(exc.cause, plugin=plugin) from exc
|
||||
raise AdmissionRefused(str(exc)) from exc
|
||||
|
||||
@@ -440,20 +440,31 @@ def _plugin_selection_version() -> str:
|
||||
|
||||
|
||||
def _admit_and_save_plugin_sets(
|
||||
enabled: set, disabled: set, *, extra_dirs=(), console=None, action: str = "enable", expected_config=None
|
||||
enabled: set, disabled: set, *, extra_dirs=(), console=None, action: str = "enable", expected_config=None,
|
||||
plugin: Optional[str] = None,
|
||||
) -> None:
|
||||
"""ONE admission authority for proposed enabled/disabled sets (C13):
|
||||
the candidate union is resolved against the ACTIVE environment and
|
||||
the config commits inside the same worker-owned PM transaction — a refusal or a config-write failure
|
||||
publishes nothing: previous config bytes AND previous environment
|
||||
stay exactly in place. Raises :class:`AdmissionRefused` (UI callers
|
||||
catch and surface it — admission never auto-disables to fit)."""
|
||||
from hermes_cli.plugins_admission import AdmissionRefused, admit_plugin_set_change
|
||||
catch and surface it — admission never auto-disables to fit); a resolver
|
||||
conflict raises its :class:`DependencyConflict` subclass naming *plugin*."""
|
||||
from rich.markup import escape
|
||||
|
||||
from hermes_cli.plugins_admission import AdmissionRefused, DependencyConflict, admit_plugin_set_change
|
||||
|
||||
try:
|
||||
admit_plugin_set_change(
|
||||
enabled, disabled, active_plugins_dir=_plugins_dir(), extra_dirs=extra_dirs, expected_config=expected_config
|
||||
enabled, disabled, active_plugins_dir=_plugins_dir(), extra_dirs=extra_dirs, expected_config=expected_config,
|
||||
plugin=plugin,
|
||||
)
|
||||
except DependencyConflict as exc:
|
||||
# `hermes pm install` cannot fix a conflict, so the retry hint below would mislead here.
|
||||
if console is not None:
|
||||
console.print(f"[red]✗[/red] {escape(str(exc))}")
|
||||
console.print("[dim]config.yaml and the active environment are unchanged.[/dim]")
|
||||
raise
|
||||
except AdmissionRefused as exc:
|
||||
if console is not None:
|
||||
console.print(f"[red]✗[/red] {action} refused: {exc}")
|
||||
@@ -464,6 +475,7 @@ def _admit_and_save_plugin_sets(
|
||||
raise
|
||||
|
||||
|
||||
|
||||
_BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"})
|
||||
|
||||
|
||||
@@ -560,7 +572,7 @@ def _set_plugin_enabled(name: str, *, enable: bool, aliases=(), console=None) ->
|
||||
(enabled if enable else disabled).add(name)
|
||||
_admit_and_save_plugin_sets(enabled, disabled, console=console,
|
||||
action=f"{'Enable' if enable else 'Disable'} '{name}'",
|
||||
expected_config=expected_config)
|
||||
expected_config=expected_config, plugin=name if enable else None)
|
||||
|
||||
|
||||
def _resolve_plugin_key(name: str) -> Optional[str]:
|
||||
|
||||
@@ -95,4 +95,23 @@ def test_ui_conflict_is_reported_without_changing_selection(plugin_world, surfac
|
||||
with pytest.raises(AdmissionRefused, match="plugin-proof-dep"):
|
||||
plugins_cmd._persist_plugin_selection(["plugin-worker-proof", "conflicting-ui-plugin"], {0, 1}, set())
|
||||
assert {path: path.read_bytes() for path in watched} == before
|
||||
world.imports()
|
||||
world.imports()
|
||||
|
||||
def test_core_conflict_names_the_plugin_and_keeps_selection(plugin_world, capsys):
|
||||
from hermes_cli.plugins_admission import AdmissionRefused
|
||||
from pm import paths
|
||||
|
||||
world = plugin_world
|
||||
# Core pins plugin-core-dep==1.0; this plugin demands 2.0, so no union can resolve.
|
||||
origin, sha = world.origin(name="core-conflict-plugin", dependency="plugin-core-dep", pin="2.0")
|
||||
world.command("install", identifier=origin.as_uri(), ref=sha, no_enable=True, allow_removed=True)
|
||||
watched = [world.home / "config.yaml", paths.runtime_facts_path()]
|
||||
before = {path: path.read_bytes() for path in watched if path.exists()}
|
||||
capsys.readouterr()
|
||||
with pytest.raises(AdmissionRefused, match="Plugin 'core-conflict-plugin' conflicts with") as refused:
|
||||
world.command("enable", name="core-conflict-plugin", no_allow_tool_override=True)
|
||||
assert "plugin-core-dep" in str(refused.value) # the resolver's own cause stays visible
|
||||
printed = " ".join(capsys.readouterr().out.split())
|
||||
assert "Plugin 'core-conflict-plugin' conflicts with" in printed and "plugin-core-dep" in printed
|
||||
assert {path: path.read_bytes() for path in watched if path.exists()} == before
|
||||
assert world.enabled() == []
|
||||
|
||||
Reference in New Issue
Block a user