fix(pm): disable a plugin only on evidence about the plugin

An update disabled any plugin that failed a trial build or its
requires_hermes check. Both can be about us, not the plugin: an untagged
source checkout reads as an older release (#122054), so requires_hermes
misjudges a fine plugin, and a download failure says nothing about the
plugin's code. Those now sit the plugin out of the build: config stays
untouched and it rejoins once the cause clears.

Disabling still happens on evidence about the plugin: requires-python vs
the pinned interpreter, manifest_version, an invalid declaration, a uv
resolution conflict, or its own build backend failing (new BuildFailure,
keyed on uv's 'The build backend returned an error').

enabled_member_dirs now skips a requires_hermes misfit instead of
raising. Boot's currency check raised on it before any sync could run, so
the launch path never reached the update sync. The loader skips such a
plugin anyway; admission still refuses enabling one.
This commit is contained in:
ethernet
2026-09-24 22:15:46 -04:00
parent cae75f0ead
commit c500ee8771
5 changed files with 145 additions and 21 deletions

View File

@@ -45,20 +45,31 @@ _RESOLVER_MARKERS = (
)
# A package's own build ran and failed; a fetch/download failure never prints this.
_BUILD_MARKERS = ("the build backend returned an error",)
class ResolutionConflict(InstallError):
"""uv's resolver proved the union has no valid solution."""
class BuildFailure(InstallError):
"""A package's build backend ran and failed."""
def classify_uv_failure(stage: str, returncode: int, output: str) -> InstallError:
"""Turn a failed `uv <stage>` into the right classified error.
Resolver-conflict output → ResolutionConflict; anything else (fetch,
build, tooling) → plain InstallError with the tail of the output.
Resolver-conflict output → ResolutionConflict; a build backend that ran and
failed → BuildFailure; anything else (fetch, tooling) → plain InstallError
with the tail of the output.
"""
cause = f"uv {stage} exited {returncode}: {output.strip()[-600:]}"
lowered = output.lower()
if any(marker in lowered for marker in _RESOLVER_MARKERS):
return ResolutionConflict("venv", cause)
if any(marker in lowered for marker in _BUILD_MARKERS):
return BuildFailure("venv", cause)
return InstallError("venv", cause)

View File

@@ -5,9 +5,14 @@ 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. 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.
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. Anything that
might be about us or the moment instead (requires_hermes against a version identity that
can lag, a fetch or tooling failure) only sits the plugin out of this build: config is
untouched and it rejoins by itself. A secondary profile whose config cannot be read sits
out the same way until its config is fixed.
"""
from __future__ import annotations
@@ -19,6 +24,7 @@ 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
@@ -38,25 +44,36 @@ def _interpreter_version() -> str:
return probe.stdout.strip()
def static_reasons(entries: list[Entry], python_version: str) -> dict[Path, str]:
"""Plugins that cannot join this core without running a resolver, keyed by resolved dir."""
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:
if key in reasons or key in waiting:
continue
try:
declaration = read_python_declaration(plugin_dir)
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))
except (OSError, ValueError, TypeError) as exc:
reason = f"its dependency declaration cannot be read: {exc}"
except OSError as exc:
waiting[key] = f"its dependency declaration could not be read: {exc}"
continue
except (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
return reasons, waiting
class PluginEviction:
@@ -129,11 +146,12 @@ def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explic
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())
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 _is_member_candidate(plugin_dir)))
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()},
@@ -160,13 +178,21 @@ def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explic
for member in kept:
try:
package.apply(enabled, explicit=explicit, plugin_dirs=[*fitting, member], skip_invalid_secondary=True)
except InstallError as exc:
except (ResolutionConflict, BuildFailure) as exc:
reasons[member.resolve()] = f"the dependency environment no longer builds with it: {exc.cause[-400:]}"
except InstallError as exc:
# A fetch or tooling failure says nothing about the plugin: retry next sync.
waiting[member.resolve()] = f"its dependencies could not be prepared: {exc.cause[-400:]}"
else:
fitting.append(member)
commit()
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 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 that clears")
for message in notices:
print(f"⚠ {message}", file=sys.stderr, flush=True)
receipt.record_warning(message)

View File

@@ -204,12 +204,22 @@ def enabled_plugin_dirs(*, proposed_home=None, enabled=None, disabled=None,
def enabled_member_dirs(*, proposed_home=None, enabled=None, disabled=None) -> list[Path]:
"""Keep every selected member or refuse an incompatible selection."""
"""Keep every selected member or refuse an incompatible selection.
A member whose requires_hermes rejects the running version sits out instead: the
verdict is only as good as our version identity (an untagged source checkout reads
as an older release), the loader skips that plugin anyway, and the member rejoins
as soon as the verdict flips. Enabling one is still refused at admission.
"""
from hermes_cli.plugins_manifest import requires_hermes_error
selected = enabled_plugin_dirs(proposed_home=proposed_home, enabled=enabled, disabled=disabled,
skip_invalid_secondary=proposed_home is None)
members = []
for path in selected:
declaration = read_python_declaration(path)
if requires_hermes_error(declaration.manifest):
continue
reason = manifest_version_error(declaration.manifest, path.name)
if reason:
raise InstallError("venv", reason)

View File

@@ -425,6 +425,83 @@ def test_update_sync_survives_unreadable_secondary_profile(admission_env):
assert venv_is_current(project_root=core) is True
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_plugin_our_version_rejects_sits_out_without_being_disabled(admission_env):
"""requires_hermes is judged against our version identity, which can lag (an untagged
source checkout reads as an older release). Such a plugin sits out: config untouched,
boot's currency check neither raises nor loops, and it rejoins once the verdict flips."""
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"
for name in ("fits", "needs-newer"):
plugin = home / "plugins" / name
plugin.mkdir(parents=True)
(plugin / "pyproject.toml").write_text(
f'[project]\nname="{name}"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
manifest = home / "plugins" / "needs-newer" / "plugin.yaml"
manifest.write_text("name: needs-newer\nrequires_hermes: '>=999'\n", encoding="utf-8")
_write_enabled(home, ["fits", "needs-newer"])
before = (home / "config.yaml").read_bytes()
assert venv_is_current(project_root=core) is False # boot probes it before any sync
sync_venv(explicit=True, evict_incompatible_plugins=True)
assert (home / "config.yaml").read_bytes() == before
assert "Left plugin 'needs-newer'" in json.dumps(_latest_receipt(home).get("warnings"))
workspace = Path(Facts(runtime_facts_path(core), strict=True).get("venv")["resolved_lock"]).parent
assert "needs-newer" not in (workspace / "pyproject.toml").read_text()
assert venv_is_current(project_root=core) is True
manifest.write_text("name: needs-newer\nrequires_hermes: '>=0'\n", encoding="utf-8")
assert venv_is_current(project_root=core) is False
sync_venv(explicit=True, evict_incompatible_plugins=True)
workspace = Path(Facts(runtime_facts_path(core), strict=True).get("venv")["resolved_lock"]).parent
assert "needs-newer" in (workspace / "pyproject.toml").read_text()
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_update_sync_disables_only_on_evidence_about_the_plugin(admission_env, monkeypatch):
"""A plugin whose own build fails is disabled; one whose dependency cannot be fetched
says nothing about the plugin, so it stays enabled and the next sync retries it."""
from pm.install import sync_venv, venv_is_current
tmp_path, home = admission_env
monkeypatch.setenv("UV_HTTP_RETRIES", "0")
broken = tmp_path / "broken-lib"
broken.mkdir()
(broken / "pyproject.toml").write_text(
'[project]\nname="broken-lib"\ndynamic=["version"]\n'
'[build-system]\nrequires=[]\nbuild-backend="backend"\nbackend-path=["."]\n', encoding="utf-8")
(broken / "backend.py").write_text(
"def get_requires_for_build_wheel(config=None): return []\n"
"def prepare_metadata_for_build_wheel(directory, config=None): raise SystemExit('no build')\n"
"def build_wheel(directory, config=None, metadata=None): raise SystemExit('no build')\n", encoding="utf-8")
# Plugin requirements are index-only; local and remote inputs come through tool.uv.sources.
for name, dependency, source in (("wont-build", "broken-lib", f'{{ path = "{broken.as_posix()}" }}'),
("offline-dep", "gone", '{ url = "https://127.0.0.1:9/gone-1.0-py3-none-any.whl" }')):
plugin = home / "plugins" / name
plugin.mkdir(parents=True)
(plugin / "pyproject.toml").write_text(
f'[project]\nname="{name}"\nversion="1"\nrequires-python=">=3.11"\n'
f'dependencies=["{dependency}"]\n[tool.uv]\npackage=false\n'
f'[tool.uv.sources]\n{dependency} = {source}\n', encoding="utf-8",
)
_write_enabled(home, ["wont-build", "offline-dep"])
sync_venv(explicit=True, evict_incompatible_plugins=True)
cfg = yaml.safe_load((home / "config.yaml").read_text(encoding="utf-8"))
assert cfg["plugins"]["disabled"] == ["wont-build"]
assert "offline-dep" in cfg["plugins"]["enabled"]
assert "Left plugin 'offline-dep'" in json.dumps(_latest_receipt(home).get("warnings"))
assert venv_is_current(project_root=tmp_path / "core") is False # the next sync retries it
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

View File

@@ -136,7 +136,7 @@ For an admitted source checkout, `hermes update` runs these phases:
1. **Pre-update snapshot** — Hermes saves selected state files for every profile in that profile's `state-snapshots/` directory. These include pairing data, cron jobs, `config.yaml`, `.env`, and `auth.json`. Automatic quick snapshots skip individual files larger than 1 GiB. `updates.pre_update_backup` selects `quick`, `full`, or `off`. Full archives use the [backup exclusions](../reference/faq.md#hermes-backup-vs-hermes-profile-export). Recovery uses [Snapshots and rollback](../user-guide/checkpoints-and-rollback.md). Quick snapshots recover state files, not application code. The snapshot is best-effort: if it fails, the update prints a `⚠ Pre-update snapshot FAILED` warning and continues, and the receipt records `pre_update_backup` as a failed step (a deliberate `off`/`--no-backup` lands in the receipt's skips with its reason instead).
2. **Code update** — applies the configured source branch or stable release tag and updates submodules.
3. **Post-pull syntax validation + auto-rollback** — after the pull, Hermes compiles the nine critical files every `hermes` invocation imports at startup. If any fails to parse (e.g. an orphan merge-conflict marker, an accidentally truncated file), Hermes runs `git reset --hard <pre-pull-sha>` to roll the install back so your shell stays bootable. Re-run `hermes update` once the upstream fix lands.
4. **Dependency preparation** — PM provisions required tools and prepares a complete Python environment from the new lock, existing extras, and enabled plugin requirements. It validates that environment before publishing its selection. A plugin never fails the update: one that no longer fits the new core (its `requires-python` excludes Hermes's Python, its `requires_hermes`/`manifest_version` is out of range, or its dependencies no longer build alongside core and the plugins before it in config order) is added to `plugins.disabled` in every profile that enables it (a memory provider has `memory.provider` cleared). The update prints `⚠ Disabled plugin '<name>' in <home>: <reason>`, records it in the receipt's warnings, and continues. Re-enable it with `hermes plugins enable <name>` once the plugin ships a compatible release. Only a core that cannot build on its own fails this step.
4. **Dependency preparation** — PM provisions required tools and prepares a complete Python environment from the new lock, existing extras, and enabled plugin requirements. It validates that environment before publishing its selection. A plugin never fails the update. A plugin that no longer fits the new core is added to `plugins.disabled` in every profile that enables it (a memory provider has `memory.provider` cleared). That covers a `requires-python` that excludes Hermes's Python, a `manifest_version` newer than this Hermes supports, and dependencies that the resolver proves can't resolve alongside core and earlier plugins in config order, or that fail their own build. The update prints `⚠ Disabled plugin '<name>' in <home>: <reason>`, records it in the receipt's warnings, and continues. Re-enable it with `hermes plugins enable <name>` once the plugin ships a compatible release. Failures that might be about Hermes or the moment rather than the plugin never disable it. That means a `requires_hermes` range the running version misses (a source checkout without release tags can read as an older release), a download or network failure, or an unreadable secondary profile config. The plugin sits out of this build instead (`⚠ Left plugin '<name>' … out of this update`), stays enabled, and rejoins on the next sync once the cause clears. Only a core that cannot build on its own fails this step.
5. **Config migration** — detects new config options added since your version and prompts you to set them
6. **Desktop rebuild (stage-and-swap)** — if the Hermes Desktop app was built from this checkout, it is rebuilt so the GUI matches the new code. The rebuild packs into a temporary staging directory next to `apps/desktop/release/`, verifies the staged app, and only then renames it over the previous build (on Windows a real-time scanner briefly holding `release/win-unpacked` is ridden out with a few short retries). A rebuild that fails at any point — corrupt Electron download, missing dependency, disk full — leaves the previous app untouched and launchable; the update fails at that step, and `hermes desktop --build-only --force-build` or the next `hermes update` retries the rebuild. On macOS the rebuilt bundle is then copied (with `ditto`, signature intact) over a stale `/Applications/Hermes.app` or `~/Applications/Hermes.app`, so the copy Finder and the Dock launch matches the backend; an installed copy that is currently running is left alone and the update tells you to quit it and run `hermes update` again.
7. **Gateway auto-restart**: running gateways are refreshed after the update completes. Service-managed gateways (systemd on Linux, launchd on macOS) restart through the service manager. Manual gateways are relaunched when Hermes can map their PID to a profile. Manually launched `hermes serve` / `hermes dashboard` backends are different: the updater leaves them running and asks their owner to restart them. See [Manual backend restart reminders](#manual-backend-restart-reminders). Backends owned by a running Desktop app remain the app's responsibility.