Files
hermes-agent/pm/build_env.py
unsupportedpastels fe2b130b8b fix(pm): record the Docker image's baked extras and restore them on boot
The image bakes eight extras into /opt/hermes/.venv but never recorded
them as PM's dependency selection. The first generation a container built
(a lazy extra such as fal, a plugin's dependencies) started from an empty
selection, so it carried only what was requested and dropped aiohttp, the
messaging SDKs and mcp. After the next restart the API server, platform
adapters and MCP servers failed to start.

- pm/build_env.py, Dockerfile: --record-selection writes the frozen
  build's --extra list to the store facts as the venv selection, as a
  bundle build does. enabled-features.json is not written, so the frozen
  lazy-install policy stays out of Docker.
- pm/recovery.py: stage2's refresh restores extras the image ships that a
  volume's recorded selection lacks, through the normal explicit sync. On
  failure the image venv boots and the missing extras stay recorded for
  the next attempt. A malformed recorded fact is refused, as
  venv_is_current does.
- pm/install.py: share the PEP 685 extra-name key between
  _still_declared and the boot reconciliation.

Fixes #135329
2026-10-09 17:35:25 +00:00

143 lines
7.3 KiB
Python

"""Build and inspect explicit Python dependency environments through PM."""
from __future__ import annotations
import argparse
from collections.abc import Sequence
from pathlib import Path
import sys
def _record_selection(source: Path, extras: list[str]) -> None:
"""Publish the built extras as the store's venv selection, as a bundle build does.
A sync that finds no runtime selection starts from this one, so the first opt-in
extra installed on use extends the shipped environment instead of replacing it
with a generation that carries only that extra. The frozen build installed exactly
this list from the lock, so it is recorded as given; an anchor inventory
(``installed_extras``) misses meta extras such as ``all`` and claims extras whose
anchors other packages happen to provide.
"""
from pm.lock import Facts
from pm.packages import Venv
from pm.paths import facts_path
selected = sorted(set(extras))
stamp = Venv(source.resolve()).expected_stamp(selected, plugin_dirs=[])
Facts(facts_path()).record_state("venv", stamp, selected)
def _check_record_selection(parser: argparse.ArgumentParser, args: argparse.Namespace, *,
build: bool, requirements: list[str]) -> None:
"""Only a project build from an explicit --extra list has a selection to record."""
if args.record_selection and (not build or requirements or args.manager_runtime
or args.all_extras or args.groups or not args.extras):
parser.error("--record-selection records the --extra list of a project environment build")
def main(argv: Sequence[str] | None = None) -> int:
import pm
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path)
parser.add_argument("--out", type=Path)
parser.add_argument("--python", type=Path)
parser.add_argument("--cache", type=Path)
parser.add_argument("--extra", dest="extras", action="append", default=[])
parser.add_argument("--group", dest="groups", action="append", default=[])
parser.add_argument("--all-extras", action="store_true")
parser.add_argument("--no-install-project", action="store_true")
parser.add_argument("--resolve", action="store_true", help="resolve the source lock before building")
operation = parser.add_mutually_exclusive_group()
operation.add_argument("--lock-only", action="store_true")
operation.add_argument("--check-lock", action="store_true")
operation.add_argument("--export-requirements", type=Path)
operation.add_argument("--prune-cache", action="store_true")
operation.add_argument("--exact-lock", action="store_true",
help="prune to the project lock: entries the uv.lock cannot resolve are deleted")
operation.add_argument("--manager-runtime", action="store_true")
parser.add_argument("--lock-source", type=Path, default=None,
help="repo whose uv.lock selects the kept entries for --exact-lock (default: cwd)")
parser.add_argument("--upgrade", action="store_true")
parser.add_argument("--ci", action="store_true")
parser.add_argument("--sealed", action="store_true")
parser.add_argument("--offline", action="store_true")
parser.add_argument("--explicit", action="store_true", help="CLI builds are always explicit requests")
parser.add_argument("--record-selection", action="store_true",
help="record the --extra list as this install's baseline dependency selection")
parser.add_argument("--wheelhouse", type=Path)
parser.add_argument("--requirements", type=Path)
parser.add_argument("--requirement", action="append", default=[])
args = parser.parse_args(argv)
requirements = list(args.requirement)
if args.requirements is not None:
requirements.extend(line.strip() for line in args.requirements.read_text(encoding="utf-8-sig").splitlines()
if line.strip() and not line.lstrip().startswith("#"))
if args.exact_lock:
if args.ci or args.prune_cache:
parser.error("--exact-lock replaces --ci/--prune-cache: downloaded wheels the lock keeps must survive")
if args.lock_source is None:
parser.error("--exact-lock requires --lock-source")
if args.cache is None:
parser.error("--exact-lock requires --cache")
elif args.prune_cache:
if args.cache is None:
parser.error("--prune-cache requires --cache")
elif not requirements and args.source is None:
parser.error("--source is required for project operations")
build = not (args.lock_only or args.check_lock or args.export_requirements
or args.prune_cache or args.exact_lock)
if build and args.out is None:
parser.error("--out is required when building an environment")
if args.manager_runtime and args.python is None:
parser.error("--manager-runtime requires the target --python")
_check_record_selection(parser, args, build=build, requirements=requirements)
try:
if args.exact_lock:
from pm.uv_cache_prune import prune_uv_cache_to_lock
pruned = prune_uv_cache_to_lock(args.cache, args.lock_source)
print(f"pruned {pruned} cache entries outside the lock")
return 0
if args.prune_cache:
pm.prune_cache(args.cache, ci=args.ci)
return 0
if args.check_lock:
pm.check_project_lock(args.source, python=args.python, cache=args.cache,
offline=args.offline, explicit=True)
return 0
if args.export_requirements:
pm.export_requirements(args.source, args.export_requirements, extras=args.extras,
python=args.python, cache=args.cache, explicit=True)
return 0
if args.lock_only:
pm.lock_project(args.source, upgrade=args.upgrade, python=args.python,
cache=args.cache, offline=args.offline, explicit=True)
return 0
if args.manager_runtime:
executable = pm.stage_manager_runtime(python=args.python, destination=args.out,
project=args.source, cache=args.cache,
offline=args.offline, wheelhouse=args.wheelhouse)
elif requirements:
executable = pm.build_requirements_environment(
requirements, out=args.out, python=args.python, cache=args.cache,
wheelhouse=args.wheelhouse, offline=args.offline, sealed=args.sealed, explicit=True,
)
else:
executable = pm.build_environment(
source=args.source, out=args.out, python=args.python, cache=args.cache,
extras=args.extras, groups=args.groups, all_extras=args.all_extras,
no_install_project=args.no_install_project, frozen=not args.resolve,
sealed=args.sealed, offline=args.offline, explicit=True,
)
if args.record_selection:
_record_selection(args.source, args.extras)
except (pm.InstallError, OSError, ValueError) as exc:
print(f"python environment: {exc}", file=sys.stderr)
return 1
print(executable)
return 0
if __name__ == "__main__":
raise SystemExit(main())