Files
hermes-agent/hermes_cli/venv_sync.py
ethernet ccf631701a fix(pm): disable plugins that no longer fit instead of failing the update
An update resolves the enabled plugin union against the NEW core. A plugin
admitted against the old core can stop fitting when core moves (managed
Python 3.13 -> 3.14 vs a member's requires-python <3.14, a requires_hermes
upper bound, a bumped pin), and the whole update then died after the
source swap with a non-resolver InstallError whose 'retry' hint failed the
same way every time.

Update syncs now pass evict_incompatible_plugins=True (update completion,
historical takeover, launch-time completion, venv_sync, post-update
drift). PM screens statically first (requires-python vs the target
interpreter, manifest/requires_hermes), then, if the rest still fails,
builds core alone to prove the plugins are the cause and re-adds members
in config order, disabling each one that breaks the build. Misfits land in
plugins.disabled (memory.provider cleared) in every home that enables
them, published through the existing journaled change hook (the journal
now carries several configs), and are reported on stderr + receipt
warnings. Admission and ordinary syncs still refuse; only a core that
cannot build on its own fails an update.
2026-09-25 00:00:09 -04:00

408 lines
18 KiB
Python

"""Pre-venv entry point for PM's dependency transaction.
Stdlib-only at import: installers call this before dependencies exist.
All checkout roots use PM's selected generation and facts; sealed payloads
remain build-owned. ``--check`` is passive and never provisions tools.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
from pathlib import Path
from hermes_cli.steward import UPDATE_MECHANISMS
def _project_root() -> Path:
return Path(__file__).resolve().parents[1]
def _is_sealed(project_root: Path) -> bool:
"""A sealed tree ships its interpreter; only checkouts own a venv.
The stamp file is the authority (shared with hermes_cli.steward).
A tree with BOTH a stamp and .git is a dev tree — treat as checkout.
A stamp without a valid ``updateMechanism`` is a build-lane bug and
must not be silently read as "not sealed" (that is exactly the
misclassification that made sealed trees look updatable) — same
guard as hermes_cli.version_info._stamp_version_info.
"""
if (project_root / ".git").exists():
return False
from hermes_cli.steward import read_install_stamp
from pm.paths import install_stamp_path
stamp_path = install_stamp_path(project_root)
data = read_install_stamp(project_root)
if not data:
return False
if data.get("updateMechanism") not in UPDATE_MECHANISMS:
raise RuntimeError(
f"install-stamp.json at {stamp_path.parent} is missing a valid "
f"'updateMechanism' (one of {', '.join(UPDATE_MECHANISMS)}). The "
"build lane that wrote this stamp must pass --update-mechanism to "
"scripts/write_install_stamp.py."
)
return True
def check_runtime(project_root: Path) -> str | None:
"""One passive startup verdict; callers only choose stderr or logging."""
import pm
from hermes_cli.steward import read_install_stamp, sealed_steward
if (Path(project_root) / ".git").exists() and read_install_stamp(project_root).get("updateMechanism") != "self":
return None # A developer's checkout does not owe managed products.
problems = pm.activate()
if not problems:
return None
steward = sealed_steward(Path(project_root))
remedy = (f"this {steward}-managed install must rebuild the artifact to fix"
if steward else "run `hermes pm install`")
return f"install out of sync ({'; '.join(problems)}) — {remedy}"
def publish_launchers(project_root: Path, *, create: bool = True) -> None:
"""Refresh durable commands; bootstrap repairs only existing PATH exposure."""
import logging
from hermes_cli._launchers import ENTRY_POINTS, ensure_install_launchers, expose_cli, resolve_store_python
from hermes_cli.steward import read_install_stamp
root = Path(project_root)
log = logging.getLogger(__name__)
if _is_sealed(root):
log.info("launchers: sealed tree at %s keeps its own", root)
return # Sealed and external/Nix interpreters retain their own launchers.
if read_install_stamp(root).get("updateMechanism") == "external":
log.info("launchers: external runtime at %s keeps its own", root)
return
if resolve_store_python(root) is None:
# A PM tree promises its launchers (tests/install/e2e-assets/
# source-driver.sh refuses to let --version paper over the gap), so
# this skip is a half-finished update, never a quiet no-op.
log.warning("launchers: no managed interpreter under %s; %s not published",
root, root / ".hermes" / "bin")
return
written = ensure_install_launchers(root, root / ".hermes" / "bin")
if len(written) != len(ENTRY_POINTS):
from pm.package import InstallError
raise InstallError("launchers", "source launcher publication failed", "retry the source update")
result = expose_cli(root, create=create)
if not result["ok"]:
import logging
logging.getLogger(__name__).warning("CLI exposure failed: %s", result["error"])
def sync(project_root: Path | None = None, *, check: bool = False) -> dict:
"""Report or sync dependencies. A malformed install stamp is a build error."""
from hermes_cli.update_stage import publish_stage
root = Path(project_root) if project_root is not None else _project_root()
if _is_sealed(root):
return {"state": "sealed", "ok": True}
if not (root / "pyproject.toml").is_file():
return {"state": "failed", "ok": False, "detail": f"no pyproject.toml under {root}"}
try:
import pm
if pm.venv_is_current(project_root=root):
if not check:
publish_launchers(root)
return {"state": "current", "ok": True}
if check:
return {"state": "would-sync", "ok": True}
publish_stage("Updating Python dependencies")
refuse_foreign_owned_venv(root)
pm.sync_venv(explicit=True, project_root=root, evict_incompatible_plugins=True)
collect_superseded_generations(root)
publish_launchers(root)
return {"state": "synced", "ok": True}
except Exception as exc:
return {"state": "failed", "ok": False, "detail": str(exc)}
def collect_superseded_generations(project_root: Path) -> None:
"""Collect what a publish just superseded, as the Docker boot already does.
Without this only a manual `hermes pm gc` reclaimed old environments. Safe
right after a sync: the collectors skip leased, selected and day-young
generations and yield to any in-flight install instead of waiting.
"""
import logging
from hermes_cli.runtime_state import collect_generations
from pm.environments import install_state_dir
from pm.runtime import collect_runtime_generations
try:
removed = collect_generations(project_root) + collect_runtime_generations(
install_state_dir(project_root) / "pm-runtime")
except (OSError, ValueError) as exc:
# Reclaiming space must never turn a committed update into a failure.
logging.getLogger(__name__).warning("dependency generation cleanup skipped: %s", exc)
return
if removed:
logging.getLogger(__name__).info("collected %d unused dependency generations", len(removed))
#: Answered from the tree alone; a metadata query must never wait on (or fail with)
#: a network-bound source-update completion.
_METADATA_FLAGS = frozenset({"-h", "--help", "-V", "--version"})
def completion_pending_path(project_root: Path) -> Path:
"""Marker for a source update whose dependency sync committed but whose tail
(launchers, products, maintenance) has not finished.
Lives beside PM's facts, not in the checkout: it is per-install state, and a
root-level file would trip the ZIP updater's dirty-tree check.
"""
from pm.environments import install_state_dir
return install_state_dir(project_root) / "source-completion-pending"
def arm_completion(project_root: Path) -> Path:
"""Persist the tail obligation before selecting a new dependency generation."""
pending = completion_pending_path(project_root)
pending.parent.mkdir(parents=True, exist_ok=True)
pending.write_text("source update tail not finished\n", encoding="utf-8")
return pending
def clear_completion(project_root: Path) -> None:
completion_pending_path(project_root).unlink(missing_ok=True)
def refuse_foreign_owned_venv(project_root: Path) -> None:
"""Refuse cross-user mutation before PM changes the selected environment (#83529)."""
if not hasattr(os, "geteuid"):
return
uid = os.geteuid() # windows-footgun: ok — guarded POSIX ownership check
root = Path(project_root)
# A root-run update on a user's checkout is not safe even if a fresh
# generation would be allocated: it publishes root-owned state for them.
from pm.environments import selected_venv
candidates = [root, root / "venv", root / ".venv", root / ".hermes", selected_venv(root)]
for venv in (root / "venv", root / ".venv", candidates[-1]):
for directory in (venv / ("Scripts" if os.name == "nt" else "bin"),
*venv.glob("lib/python*/site-packages")):
if directory.is_dir():
candidates.append(directory)
for entry in list(directory.iterdir())[:2000]:
candidates.append(entry)
if entry.name.endswith(".dist-info") and entry.is_dir():
candidates.extend(list(entry.iterdir())[:100])
for path in candidates:
try:
owner = path.lstat().st_uid
except FileNotFoundError:
continue
if owner != uid:
raise RuntimeError(
f"refusing to update {root}: {path} is owned by uid {owner}, "
f"not the current uid {uid}; repair ownership before retrying"
)
def prepare_launch(project_root: Path, argv: list[str]) -> Path | None:
"""Finish a self-managed source update before importing app dependencies.
PM's successful input stamp signals a finished dependency sync; the
``source-completion-pending`` marker signals the tail still owed after it,
so a tail that failed is retried on the next launch WITHOUT rebuilding
dependencies that are already current. Old updaters need not write a
marker (and cannot accidentally clear this obligation).
Return the store interpreter when this process must restart cleanly.
"""
import os
import sys
from hermes_cli._parser import command_argv
from hermes_cli.steward import read_install_stamp
root = Path(project_root).resolve()
if (command_argv(argv)[:1] == ["pm"]
or _METADATA_FLAGS & set(argv)
or os.environ.get("HERMES_DISABLE_LAZY_INSTALLS", "").lower() in ("1", "true", "yes")
or not (root / ".git").exists()
or not (root / "pyproject.toml").is_file()):
return None
stamp = read_install_stamp(root)
if not stamp:
from hermes_cli.post_update import step_adopt_blessed_checkout
step_adopt_blessed_checkout(root)
stamp = read_install_stamp(root)
if stamp.get("updateMechanism") != "self":
return None # Developer checkouts and packaged runtimes retain their owner.
import pm
from hermes_cli._launchers import resolve_store_python
from hermes_cli.update_lock import UpdateLock, read_live_update
current = pm.venv_is_current(project_root=root)
pending = completion_pending_path(root)
if not current or pending.is_file():
lock = UpdateLock()
if not lock.acquire():
raise RuntimeError("an update is still running; wait for it to exit, then relaunch Hermes")
try:
# The tail imports the application, whose entry point runs this very function:
# under the launching process's own claim (its pid is our ancestor) we ARE that
# tail and owe nothing — without this, a pending marker recurses forever.
if not lock.acquired and read_live_update() is not None:
if current:
return None
# A process the update spawns before its dependencies are current (a restarted
# gateway) would boot on a tree built for another interpreter. Sync — never the
# tail, which is the updater's — then relaunch below into a current install.
_sync_source_dependencies(root, arm=False)
if not pm.venv_is_current(project_root=root):
# Relaunching would land back here and sync again, forever.
raise RuntimeError("dependency sync left this install out of date")
else:
_finish_source_update(root, current=current, pending=pending)
finally:
lock.release()
python = resolve_store_python(root)
if python is None:
raise RuntimeError("source update has no managed Python; run `hermes pm install`")
if not current or python.absolute() != Path(sys.executable).absolute():
publish_launchers(root)
return python
return None
def _finish_source_update(root: Path, *, current: bool, pending: Path) -> None:
"""Sync dependencies when they are stale, then run the tail the marker still owes."""
import sys
from hermes_cli._early_recovery import _marker_owner_is_live
from pm.environments import activation_environment
if not current:
# Existing markers guard liveness, never create the completion obligation.
# Current post-sync verification children can boot under a live updater.
legacy_markers = (root / ".update-incomplete", root / ".lazy-refresh-incomplete")
if any(_marker_owner_is_live(marker) for marker in legacy_markers):
raise RuntimeError("an update is still running; wait for it to exit, then relaunch Hermes")
print("hermes: completing source-update dependencies...", file=sys.stderr, flush=True)
_sync_source_dependencies(root, arm=True)
else:
print("hermes: finishing an interrupted source update...", file=sys.stderr, flush=True)
# Sync commits the dependency generation, but a source update also owes
# the product builds and the post-build maintenance -- the tail every
# install and finished update shares (hermes_cli/source_completion.py).
# Those builds need PM's selected interpreter with its dependencies
# activated, so hand that file THIS interpreter and let it re-exec
# itself, exactly as the installers do.
desktop_app = root / "apps/desktop"
desktop = ((desktop_app / "dist/index.html").is_file()
or any((desktop_app / "release").glob("*")))
# The tail's progress lines go to stderr: this is an automatic repair in
# front of whatever command the user ran, and that command may be
# emitting machine-readable stdout (a JSON probe, a piped query).
code = subprocess.call(
[sys.executable, "-I", "-B", "-u",
str(root / "hermes_cli/source_completion.py"),
"--source", str(root), "--finish-update",
*(("--desktop",) if desktop else ())],
cwd=root, env=activation_environment(root), stdout=sys.__stderr__,
)
if code != 0:
raise RuntimeError(
"source update completion failed; run `hermes update` to finish it"
)
clear_completion(root)
def _sync_source_dependencies(root: Path, *, arm: bool) -> None:
"""Commit the tree's dependency generation; *arm* also owes the tail afterwards."""
import sys
import pm
from pm.client import ensure_tools_for_sync
from pm.environments import runtime_facts_path
from pm.extras import legacy_selection
if not arm:
print("hermes: preparing dependencies for this update...", file=sys.stderr, flush=True)
refuse_foreign_owned_venv(root)
if arm:
# Owed from before the sync commits: a crash between the commit and the
# tail must leave the tail, not a "current" install with nothing built.
arm_completion(root)
# Main-era installs have no PM ledger; carry what their venv held.
# Established PM installs retain their recorded extras and plugin union instead.
extras = legacy_selection(root) if not runtime_facts_path(root).is_file() else None
# Same order as `hermes update`: an interrupted update or a hand-run
# `git pull` leaves this tree's lockfile ahead of the installed tools.
ensure_tools_for_sync()
pm.sync_venv(extras, explicit=True, project_root=root, evict_incompatible_plugins=True)
collect_superseded_generations(root)
# These can predate the swap. Once PM commits the replacement they
# must not make early recovery immediately rebuild it a second time.
for name in (".update-incomplete", ".lazy-refresh-incomplete"):
(root / name).unlink(missing_ok=True)
def relaunch_command(
python: Path, root: Path, argv: list[str], original: list[str], module: str | None,
) -> list[str]:
"""Re-enter the same script/module/launcher with the managed interpreter.
An old venv may use a different Python ABI. Do not add the new generation
to that interpreter, and do not depend on its obsolete editable finder.
"""
# Preserve interpreter options, not application flags with the same names.
options: list[str] = []
index = 1
while index < len(original):
option = original[index]
if option in ("-c", "-m", "--", "-") or not option.startswith("-"):
break
options.append(option)
index += 1
if option in ("-W", "-X") and index < len(original):
options.append(original[index])
index += 1
prefix = f"import sys, runpy; sys.path.insert(0, {str(root)!r}); sys.argv = {argv!r}; "
if argv[0] == "-c":
body = f"exec({original[index + 1]!r})"
elif module and module != "__main__":
body = f"runpy.run_module({module!r}, run_name='__main__', alter_sys=True)"
else:
# distlib .exe launchers are executable zip files with __main__, not
# importable modules named '__main__'. run_path handles both shapes.
body = f"runpy.run_path({str(Path(argv[0]).absolute())!r}, run_name='__main__')"
return [str(python), *options, "-I", "-c", prefix + body]
def main(argv: list | None = None) -> int:
parser = argparse.ArgumentParser(prog="hermes_cli.venv_sync")
parser.add_argument("--project-root", default=None)
parser.add_argument(
"--check", action="store_true", help="report; change nothing"
)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
result = sync(
Path(args.project_root) if args.project_root else None, check=args.check
)
if args.json:
print(json.dumps(result))
else:
detail = f" ({result['detail']})" if result.get("detail") else ""
print(f"venv sync: {result['state']}{detail}")
return 0 if result["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())