mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
- 2.1 hold recorded during the check: update_one/dashboard_update_user_plugin(unattended=True) re-read the install record under the per-plugin update lock and skip a --ref hold (HeldWithRef / held); _install_plugin_core takes the same lock, so an install --ref cannot publish between the re-check and the updater's publication. - 2.2 custom Git widening: the unattended gate compares the staged candidate's surface (tools, hooks, capabilities, ...) with the installed one before publication (git pull and subdirectory re-clone) and returns needs_review; a named update still applies it. - /plugins update <name|all|--yes> refuses extra arguments with a usage line instead of updating every plugin. - hermes plugins update (cmd_update_all) skips catalog installs held with --ref, like every other Update all.
253 lines
13 KiB
Python
253 lines
13 KiB
Python
"""Unattended plugin updates shared by every surface: the opt-in ``plugins.auto_apply`` switch, the
|
|
one-click "update all" (Desktop, dashboard, ``/plugins update``), the one-updater-per-plugin lock,
|
|
and the notice ``hermes update`` prints when it moved catalog pins.
|
|
|
|
Sibling of :mod:`hermes_cli.plugins_cmd` (the facade is imported late, never at module level).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from contextlib import ExitStack, contextmanager
|
|
from pathlib import Path
|
|
from typing import Any, Iterator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# A second updater of the same plugin is refused rather than queued: the first one's result already
|
|
# answers the second's question, and a queued pull would run against a tree the first just replaced.
|
|
_LOCK_WAIT_SECONDS = 1.0
|
|
_lock_holders: dict[str, threading.local] = {}
|
|
_lock_holders_guard = threading.Lock()
|
|
|
|
|
|
def _pc():
|
|
from hermes_cli import plugins_cmd
|
|
return plugins_cmd
|
|
|
|
|
|
def _holder(key: str) -> threading.local:
|
|
with _lock_holders_guard:
|
|
return _lock_holders.setdefault(key, threading.local())
|
|
|
|
|
|
@contextmanager
|
|
def plugin_update_lock(target: Path) -> Iterator[None]:
|
|
"""Hold the one-updater lock for the plugin at *target*, across threads and processes (the
|
|
gateway's auto-apply tick, a terminal ``hermes plugins update``, Desktop and the dashboard).
|
|
A busy lock raises ``PluginOperationError`` after a short wait, so every caller reports it like
|
|
any other refusal (nothing changed); reentrant per thread."""
|
|
from hermes_cli.auth import _file_lock
|
|
from hermes_constants import get_hermes_home
|
|
|
|
lock = get_hermes_home() / "cache" / "plugin-update-locks" / f"{target.name}.lock"
|
|
stack = ExitStack()
|
|
try:
|
|
stack.enter_context(_file_lock(lock, _holder(str(lock)), _LOCK_WAIT_SECONDS, "plugin update busy"))
|
|
except TimeoutError as exc:
|
|
raise _pc().PluginOperationError(
|
|
f"Plugin '{target.name}' is already being updated by another Hermes process; "
|
|
"nothing was changed. Try again when that update finishes.") from exc
|
|
with stack:
|
|
yield
|
|
|
|
|
|
# ── installs the user held ────────────────────────────────────────────────────────────────────
|
|
|
|
HELD_WITH_REF = "held with --ref (installed off the catalog pin); `hermes plugins update {name}` moves it"
|
|
|
|
|
|
class HeldWithRef(Exception):
|
|
"""An unattended update found the plugin held with ``--ref`` once it owned the plugin's update lock
|
|
(the hold was recorded after the check that offered the update). Nothing was changed."""
|
|
|
|
|
|
def held_with_ref(record: Any) -> bool:
|
|
"""A catalog install the user put on a commit of their choosing (``install <name> --ref <sha>``:
|
|
``pinned`` with an empty ``catalog.pin``). Only an explicit ``hermes plugins update <name>`` moves
|
|
it: unattended paths would otherwise silently undo a deliberate rollback."""
|
|
block = record.get("catalog") if isinstance(record, dict) else None
|
|
return isinstance(block, dict) and record.get("pinned") is True and not block.get("pin")
|
|
|
|
|
|
def unattended_gate(name: str, target: Path):
|
|
"""The unattended-update policy, checked under the plugin's update lock: raise :class:`HeldWithRef`
|
|
when the CURRENT install record is held (an ``install --ref`` that finished during the check), else
|
|
return the ``surface_gate(manifest, tree)`` a Git update runs on its candidate before publication.
|
|
The gate raises ``RepinConsentRequired`` when the candidate adds tools, hooks, capabilities or
|
|
other surface the installed plugin lacks, the same review a widened catalog pin gets."""
|
|
from hermes_cli.plugins_cmd_catalog import RepinConsentRequired, plugin_surface, surface_delta
|
|
pc = _pc()
|
|
if held_with_ref(pc._read_install_metadata().get(target.name)):
|
|
raise HeldWithRef(HELD_WITH_REF.format(name=name))
|
|
installed = plugin_surface(pc._read_manifest(target), target)
|
|
|
|
def gate(manifest: dict, tree: Path) -> None:
|
|
delta = surface_delta(installed, plugin_surface(manifest, tree))
|
|
if delta:
|
|
raise RepinConsentRequired(name, "", delta)
|
|
return gate
|
|
|
|
|
|
def apply_unattended(name: str) -> None:
|
|
"""The gateway auto-apply tick's update of one plugin: ``update_one`` with no terminal. Refusals
|
|
raise (``RepinConsentRequired`` for a widened pin, ``PluginOperationError``) so the tick can tell
|
|
a pending review from a failure; ``cmd_update`` would turn both into an exit. :class:`HeldWithRef`
|
|
when the install was held after the check."""
|
|
from hermes_cli.plugins_cmd_update import update_one
|
|
pc = _pc()
|
|
target = pc._user_installed_plugin_dir(name)
|
|
if target is None:
|
|
raise pc.PluginOperationError(f"Plugin '{name}' is no longer installed.")
|
|
update_one(name, target, pc._console(), interactive=False, unattended=True)
|
|
|
|
|
|
# ── the auto-update switch ────────────────────────────────────────────────────────────────────
|
|
|
|
def auto_update_state() -> dict[str, Any]:
|
|
"""``{"auto_apply", "check_hours"}`` for the active profile."""
|
|
from hermes_cli.plugins_cadence import auto_apply_enabled, check_interval_hours
|
|
return {"auto_apply": auto_apply_enabled(), "check_hours": check_interval_hours()}
|
|
|
|
|
|
def set_auto_update(enabled: bool) -> dict[str, Any]:
|
|
"""Write ``plugins.auto_apply`` for the active profile and return the new state. Turning it on also
|
|
clears the check marker, so the gateway's next housekeeping tick checks and applies instead of
|
|
waiting out the rest of the interval."""
|
|
from hermes_cli.config import get_config_path, is_managed
|
|
from hermes_cli.config_backend import write_config_key
|
|
from hermes_constants import get_hermes_home
|
|
|
|
if is_managed():
|
|
raise _pc().PluginOperationError(
|
|
"This Hermes install's configuration is managed by a package manager; set plugins.auto_apply there.")
|
|
write_config_key(get_config_path(), "plugins.auto_apply", bool(enabled))
|
|
if enabled:
|
|
_clear_check_marker(get_hermes_home())
|
|
return auto_update_state()
|
|
|
|
|
|
def _clear_check_marker(home: Path) -> None:
|
|
from hermes_cli.plugins_cadence import _MARKERS_DIR
|
|
(home / _MARKERS_DIR / "last-run").unlink(missing_ok=True)
|
|
|
|
|
|
# ── update all, unattended ────────────────────────────────────────────────────────────────────
|
|
|
|
def _apply_one(name: str) -> dict[str, str]:
|
|
"""One plan row through the dashboard's non-interactive update. A widened plugin (catalog pin or
|
|
Git update) or a caution scan of a git update is NOT accepted on the user's behalf: the row comes
|
|
back ``needs_review`` (a catalog re-pin accepts caution: a reviewer read that pin at admission).
|
|
An install held with ``--ref`` after the check comes back ``held``."""
|
|
from hermes_cli.plugins_cmd_update import dashboard_update_user_plugin
|
|
try:
|
|
res = dashboard_update_user_plugin(name, unattended=True)
|
|
except Exception as exc: # per-plugin batch boundary: one crash must not stop the rest
|
|
logger.warning("update of plugin %s failed", name, exc_info=True)
|
|
return {"name": name, "status": "failed", "detail": f"{type(exc).__name__}: {exc}"}
|
|
if res.get("held"):
|
|
return {"name": name, "status": "held", "detail": str(res.get("error") or "")}
|
|
if res.get("consent_required") or res.get("scan_blocked"):
|
|
return {"name": name, "status": "needs_review", "detail": str(res.get("error") or "")}
|
|
if not res.get("ok"):
|
|
return {"name": name, "status": "failed", "detail": str(res.get("error") or "")}
|
|
return {"name": str(res.get("name") or name), "status": "current" if res.get("unchanged") else "updated",
|
|
"detail": ""}
|
|
|
|
|
|
def update_all_unattended() -> dict[str, Any]:
|
|
"""Check every installed plugin, then update each one ``hermes plugins update`` would move (same
|
|
plan: catalog re-pins and tracked Git pulls; pinned, held and pip rows are skipped with why).
|
|
|
|
Returns ``{"results": [{name, status, detail}], "skipped": [{name, reason}], "current": n}``;
|
|
status is ``updated`` / ``current`` / ``failed`` / ``needs_review`` (nothing changed; the
|
|
plugin's own Update action walks the consent or scan review)."""
|
|
from hermes_cli.plugins_cadence import record_check_results, snapshot_installs
|
|
from hermes_cli.plugins_cmd_update_all import plan_update_all
|
|
from hermes_cli.plugins_updates import run_checks
|
|
|
|
plugins_dir = _pc()._plugins_dir()
|
|
checked = snapshot_installs(plugins_dir)
|
|
results = run_checks(plugins_dir)
|
|
record_check_results(results, checked)
|
|
plan = plan_update_all(results, checked)
|
|
applied = [_apply_one(row.name) for row in plan.movable]
|
|
return {"results": [r for r in applied if r["status"] != "held"],
|
|
"skipped": [{"name": row.name, "reason": reason} for row, reason in plan.skipped]
|
|
+ [{"name": r["name"], "reason": r["detail"]} for r in applied if r["status"] == "held"],
|
|
"current": plan.current}
|
|
|
|
|
|
_STATUS_MARKS = {"updated": "✓ updated", "current": "= already current", "failed": "✗ failed",
|
|
"needs_review": "! needs review"}
|
|
|
|
|
|
def summary_lines(outcome: dict[str, Any]) -> list[str]:
|
|
"""Plain-text lines for an :func:`update_all_unattended` result (``/plugins update``)."""
|
|
lines = [f" {_STATUS_MARKS[r['status']]} {r['name']}" + (f" ({r['detail']})" if r["detail"] else "")
|
|
for r in outcome["results"]]
|
|
lines += [f" - skipped {s['name']}: {s['reason']}" for s in outcome["skipped"]]
|
|
if not outcome["results"]:
|
|
lines.append(" No plugin updates to apply.")
|
|
if any(r["status"] == "needs_review" for r in outcome["results"]):
|
|
lines.append(" Review those with `hermes plugins update <name>` in a terminal.")
|
|
if any(r["status"] == "updated" for r in outcome["results"]):
|
|
lines.append(" Updated plugins were reloaded: MCP servers and skills are live now, Python tools load in new sessions.")
|
|
return lines
|
|
|
|
|
|
def plugins_slash_action(parts: list[str]) -> list[str]:
|
|
"""``/plugins update`` (update every plugin with an update, unattended) and ``/plugins auto-update
|
|
[on|off]`` for the classic CLI, and ``auto-update`` for the TUI's slash worker. The TUI sends
|
|
``/plugins update`` to ``plugins.manage update_all`` instead: the batch outlives the worker's 45 s."""
|
|
if parts[0] == "update":
|
|
if len(parts) > 1: # not `hermes plugins update <name>`: this form updates every plugin
|
|
return ["Usage: /plugins update (updates every plugin that has an update; "
|
|
"update one with `hermes plugins update <name>` in a terminal)"]
|
|
return ["Plugin updates:", *summary_lines(update_all_unattended())]
|
|
arg = parts[1].lower() if len(parts) > 1 else ""
|
|
if arg not in ("", "on", "off"):
|
|
return ["Usage: /plugins auto-update [on|off]"]
|
|
try:
|
|
state = auto_update_state() if not arg else set_auto_update(arg == "on")
|
|
except _pc().PluginOperationError as exc:
|
|
return [f" {exc}"]
|
|
word = "on" if state["auto_apply"] else "off"
|
|
return [f"Plugin auto-update: {word} (gateway check every {state['check_hours']:g}h; "
|
|
"updates load in new sessions)"]
|
|
|
|
|
|
# ── after `hermes update` ─────────────────────────────────────────────────────────────────────
|
|
|
|
def catalog_pins_behind(home: Path) -> list[str]:
|
|
"""Catalog installs under *home* whose installed pin is not the in-tree catalog's pin. Offline:
|
|
reads the install records and the catalog shipped with this checkout. An install held with
|
|
``--ref`` is not behind: the user chose that commit."""
|
|
from hermes_cli.plugin_catalog import get_catalog_entry
|
|
from hermes_cli.plugins_cmd_catalog import at_catalog_pin
|
|
from hermes_cli.plugins_provenance import read_sidecar_rows
|
|
|
|
behind = []
|
|
for name, row in read_sidecar_rows(home / "plugins").items():
|
|
block = row.get("catalog") if isinstance(row, dict) else None
|
|
if not isinstance(block, dict) or not block.get("name") or held_with_ref(row):
|
|
continue
|
|
entry = get_catalog_entry(str(block["name"]))
|
|
if entry is not None and not at_catalog_pin(block, entry.sha):
|
|
behind.append(str(name))
|
|
return sorted(behind)
|
|
|
|
|
|
def report_catalog_updates_after_hermes_update(home: Path, sibling_homes: list[tuple[str, Path]]) -> None:
|
|
"""An update can move catalog pins; installed plugins stay on their old pin until updated. Name
|
|
them per profile, and clear that profile's check marker so its gateway re-checks on the next tick
|
|
(which applies them when that profile opted into ``plugins.auto_apply``)."""
|
|
for label, profile_home in [("this profile", home), *sibling_homes]:
|
|
behind = catalog_pins_behind(profile_home)
|
|
if not behind:
|
|
continue
|
|
_clear_check_marker(profile_home)
|
|
print(f" → {len(behind)} plugin update(s) from the new catalog in {label}: {', '.join(behind)}")
|
|
print(" apply: hermes plugins update (or turn on: hermes plugins auto-update on)")
|