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:
ethernet
2026-09-24 11:49:08 -04:00
parent 1e76db800c
commit d18382441f
3 changed files with 60 additions and 7 deletions

View File

@@ -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

View File

@@ -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]:

View File

@@ -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() == []