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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user