Files
hermes-agent/pm/plugin_eviction.py
ethernet 0978aca962 fix(pm): retry a plugin fetch failure once, then disable it
Sitting a plugin out after a fetch failure left the recorded stamp stale on
purpose, so every launch resynced, and the spawned-process guard in
prepare_launch raised "dependency sync left this install out of date".
One retry covers a blip; after that the plugin is disabled with the reason,
and hermes plugins enable restores it. Only requires_hermes still sits out,
which boot skips the same way.
2026-09-25 00:00:09 -04:00

208 lines
10 KiB
Python

"""Keep an update alive when an enabled plugin no longer fits the new core.
Admission refuses a plugin that does not fit, because the user is choosing and can
choose again. An update has nobody to ask and must never fail because of a plugin:
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.
Disabling needs evidence about the plugin itself: its requires-python against the pinned
interpreter, its manifest contract, a resolver proof, or its build failing. A fetch or
tooling failure could be the moment, so the plugin gets one retry before it is disabled.
requires_hermes is judged against a version identity that can lag (a checkout without its
release tags), so a misfit there only sits out: config is untouched, boot skips it the same
way, and it rejoins when the verdict flips. A secondary profile whose config cannot be read
sits out until its config is fixed.
"""
from __future__ import annotations
import base64
import hashlib
import io
import json
import subprocess
import sys
from pathlib import Path
from pm.environment import BuildFailure, ResolutionConflict
from pm.environments import install_state_dir, runtime_facts_path
from pm.filesystem import durable_write_bytes, file_digest, read_bytes_or_none
from pm.package import InstallError
Entry = tuple[Path, str, Path] # (home plugins dir, selection key, plugin dir)
def _interpreter_version() -> str:
"""The Python the generation is built for; a plugin's requires-python is judged against it."""
from pm._uv import _toolchain
tools = _toolchain(explicit=True)
if tools is None:
raise InstallError("venv", "PM's pinned toolchain is unavailable")
probe = subprocess.run([str(tools[1]), "-I", "-c", "import platform; print(platform.python_version())"],
capture_output=True, text=True, check=True, timeout=60)
return probe.stdout.strip()
def static_verdicts(entries: list[Entry], python_version: str) -> tuple[dict[Path, str], dict[Path, str]]:
"""``(disable, sit out)`` reasons found without a resolver, keyed by resolved dir."""
from hermes_cli.plugins_manifest import requires_hermes_error
from pm.plugin_declarations import manifest_version_error, read_python_declaration
reasons: dict[Path, str] = {}
waiting: dict[Path, str] = {}
for _plugins_dir, _name, plugin_dir in entries:
key = plugin_dir.resolve()
if key in reasons or key in waiting:
continue
try:
declaration = read_python_declaration(plugin_dir)
except (OSError, ValueError, TypeError) as exc:
reasons[key] = f"its dependency declaration is invalid: {exc}"
continue
# Mirrors enabled_member_dirs, so the recorded stamp is the one boot expects.
hermes = requires_hermes_error(declaration.manifest)
if hermes:
waiting[key] = hermes
continue
manifest = manifest_version_error(declaration.manifest, plugin_dir.name)
reason = (manifest.removeprefix(f"Plugin '{plugin_dir.name}' ") if manifest
else declaration.python_error(python_version))
if reason:
reasons[key] = reason
return reasons, waiting
class PluginEviction:
"""Config edits disabling the plugins in *reasons*; published like a plugin selection."""
def __init__(self, entries: list[Entry], reasons: dict[Path, str]):
from hermes_yaml import roundtrip_yaml
from pm.publication import selection_snapshot
self.configs = selection_snapshot()
by_home: dict[Path, list[str]] = {}
for plugins_dir, name, plugin_dir in entries:
if plugin_dir.resolve() in reasons:
by_home.setdefault(plugins_dir.parent, []).append(name)
self.edits: list[tuple[Path, bytes | None, bytes]] = []
for home, names in by_home.items():
path = home / "config.yaml"
previous = read_bytes_or_none(path)
yaml = roundtrip_yaml()
config = (yaml.load(previous.decode("utf-8-sig")) if previous else None) or {}
# read_home_selection already proved plugins/memory are mappings and the lists are lists.
plugins = config.get("plugins")
if plugins is None:
plugins = config["plugins"] = {}
disabled = plugins.get("disabled")
if disabled is None:
disabled = plugins["disabled"] = []
memory = config.get("memory")
for name in names:
if name not in disabled:
disabled.append(name)
# plugins.disabled does not veto memory.provider; the provider joins the union on its own.
if isinstance(memory, dict) and str(memory.get("provider") or "").strip() == name:
memory["provider"] = ""
output = io.StringIO()
yaml.dump(config, output)
self.edits.append((path, previous, output.getvalue().encode("utf-8")))
def publish(self, project: Path) -> None:
from pm.publication import selection_snapshot
if selection_snapshot() != self.configs:
raise ValueError("plugin configuration changed while preparing publication; retry")
row = {"configs": [{"config": str(path),
"previous": base64.b64encode(previous).decode() if previous is not None else None,
"config_after": hashlib.sha256(proposed).hexdigest()}
for path, previous, proposed in self.edits],
"facts_before": file_digest(runtime_facts_path(project))}
durable_write_bytes(install_state_dir(project) / "publication.json", json.dumps(row).encode())
for path, _previous, proposed in self.edits:
durable_write_bytes(path, proposed)
def _trial(package, enabled, explicit: bool, plugin_dirs: list[Path]) -> str | None:
"""Why the last of *plugin_dirs* cannot join the build, or None when it builds."""
cause = ""
# A fetch or tooling failure can be the moment rather than the plugin: one more try.
for _attempt in range(2):
try:
package.apply(enabled, explicit=explicit, plugin_dirs=plugin_dirs, skip_invalid_secondary=True)
return None
except (ResolutionConflict, BuildFailure) as exc:
return f"the dependency environment no longer builds with it: {exc.cause[-400:]}"
except InstallError as exc:
cause = exc.cause
return f"its dependencies could not be prepared, twice: {cause[-400:]}"
def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explicit: bool) -> None:
"""Build the discovered selection, disabling whatever plugin keeps it from building.
Static misfits go first (no resolver needed). If the rest still fails, core alone is
built to prove the plugins are the cause, then members are re-added in config order
and each one that breaks the build is disabled too.
"""
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, waiting = static_verdicts(entries, _interpreter_version())
def members() -> list[Path]:
return list(dict.fromkeys(plugin_dir for _plugins_dir, _name, plugin_dir in entries
if plugin_dir.resolve() not in reasons and plugin_dir.resolve() not in waiting
and _is_member_candidate(plugin_dir)))
def commit() -> None:
enabled, stamp, inputs = _target_selection(package, fact, extras=extras, inputs={"plugin_dirs": members()},
repair=False, shipped=shipped, frozen=frozen)
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,
skip_invalid_secondary=True)
kept = members()
try:
commit()
except InstallError as failure:
if not kept:
raise
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=[], skip_invalid_secondary=True)
except InstallError:
raise failure from None
fitting: list[Path] = []
for member in kept:
reason = _trial(package, enabled, explicit, [*fitting, member])
if reason:
reasons[member.resolve()] = reason
else:
fitting.append(member)
commit()
for plugins_dir, name, plugin_dir in entries:
key = plugin_dir.resolve()
if key in reasons:
notices.append(f"Disabled plugin '{name}' in {plugins_dir.parent}: {reasons[key]}")
elif key in waiting:
notices.append(f"Left plugin '{name}' in {plugins_dir.parent} out of this update: {waiting[key]}; "
"it stays enabled and rejoins once Hermes reports a version it accepts")
for message in notices:
print(f"⚠ {message}", file=sys.stderr, flush=True)
receipt.record_warning(message)