mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-16 02:46:38 +03:00
807b3f1081
`hermes update` started paused gateways (and restarted the fleet) from the service definitions the previous release wrote. A systemd user unit was only regenerated by the new gateway at boot, after systemd had already started it with the old unit's environment, and that boot-time rewrite ran while the Node dependency step was recreating node_modules/.bin, so the unit it wrote was already outdated. Add hermes_cli/update_launch_files.py: one step that regenerates every profile's launch files that differ from what the updated checkout generates (systemd user units with daemon-reload, the system unit when run as root, the plist of a launchd job that is not loaded, Windows .cmd/.vbs launcher scripts compared by content). Each file is independent; a failure is reported as the `launch_files` follow-up and the rest are still rewritten. The update calls it once, from a new `after_dependencies` hook in build_update_products: after the Python and Node dependencies are prepared and before the paused-gateway restart and the product builds. A tail that ends before that hook, and the historical-takeover finish, run it before the fleet restart. Generated file contents are unchanged.
224 lines
9.8 KiB
Python
224 lines
9.8 KiB
Python
"""The tail every source install and update shares.
|
|
|
|
A fresh install (``scripts/install.sh`` / ``scripts/install.ps1``) and a finished
|
|
update must land in the same state: launchers published, product builds current,
|
|
and the post-build maintenance (skills sync, config migration) applied. One
|
|
implementation, two callers -- an install and an update cannot drift apart.
|
|
|
|
``main()`` is the installer entry. It is handed the bootstrap interpreter the
|
|
installer happens to have (uv, PM and no application dependencies), so it
|
|
re-executes itself in PM's selected interpreter before touching any product.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# The installers run this file directly under an isolated interpreter, which
|
|
# leaves the checkout off sys.path. Same idiom as hermes_cli/_launchers.py.
|
|
if __name__ == "__main__":
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
#: Second-phase marker: the re-executed interpreter, not the bootstrap one.
|
|
_PREPARED = "--prepared"
|
|
|
|
|
|
def complete_source_checkout(
|
|
root: Path,
|
|
*,
|
|
desktop: bool,
|
|
assume_yes: bool,
|
|
gateway_mode: bool = False,
|
|
pre_update_snapshot_id: str | None = None,
|
|
pre_update_version: str | None = None,
|
|
completion_message: str | None = None,
|
|
announce: str | None = None,
|
|
followups: list[tuple[str, str]] | None = None,
|
|
after_dependencies=None,
|
|
) -> bool:
|
|
"""Publish commands, build the products, then run post-build maintenance.
|
|
|
|
``after_dependencies`` (an update's launch-file refresh and paused-gateway restart) runs once
|
|
the launchers are published and the Python and Node dependencies prepared, before the long
|
|
product builds start (``build_update_products``).
|
|
|
|
Every step runs even when an earlier one failed; each failure is printed as ``⚠``,
|
|
recorded on the open update receipt and appended to ``followups`` as ``(step, reason)``.
|
|
Returns True only when every step finished and the SQLite runtime is not unsafe.
|
|
"""
|
|
from hermes_cli.update_lock import UpdateLock, describe_holder
|
|
|
|
root = Path(root)
|
|
# This tail is the last mutating step of an install or update, and its product
|
|
# builds run for minutes. A gateway restarted while it runs (launchd KeepAlive,
|
|
# a service manager, a second `hermes` launch) reaches the same tail through
|
|
# venv_sync, and `hermes update` runs its own: two completions then build the
|
|
# same output directories concurrently and race on install-stamp.json (#123376).
|
|
# Claim the shared update lock so stacked completions serialize. A tail whose
|
|
# orchestrator already holds the lock (venv_sync's interrupted-update finish,
|
|
# the updater's completion child) runs under its parent's claim, exactly as
|
|
# `hermes update` does under the desktop handoff pid. The CHECKOUT lock is acquired, not
|
|
# sampled (R2): a completion from another home must not build a checkout an update owns;
|
|
# one inside the update's tree joins the lock it inherited.
|
|
lock = UpdateLock(install_root=root)
|
|
if not lock.acquire():
|
|
raise RuntimeError(
|
|
f"an update is still running ({describe_holder(lock.holder)}); "
|
|
"wait for it to exit, then relaunch Hermes"
|
|
)
|
|
try:
|
|
return _complete_locked(
|
|
root, desktop=desktop, assume_yes=assume_yes, gateway_mode=gateway_mode,
|
|
pre_update_snapshot_id=pre_update_snapshot_id,
|
|
pre_update_version=pre_update_version,
|
|
completion_message=completion_message, announce=announce, followups=followups,
|
|
after_dependencies=after_dependencies,
|
|
)
|
|
finally:
|
|
lock.release()
|
|
|
|
|
|
def _complete_locked(
|
|
root: Path,
|
|
*,
|
|
desktop: bool,
|
|
assume_yes: bool,
|
|
gateway_mode: bool,
|
|
pre_update_snapshot_id: str | None,
|
|
pre_update_version: str | None,
|
|
completion_message: str | None,
|
|
announce: str | None,
|
|
followups: list[tuple[str, str]] | None = None,
|
|
after_dependencies=None,
|
|
) -> bool:
|
|
"""The completion body; callers hold the update lock already."""
|
|
from hermes_cli.source_build import build_update_products
|
|
from hermes_cli.update_cmd_maint import _run_post_update_maintenance
|
|
from hermes_cli.venv_sync import publish_launchers
|
|
|
|
try:
|
|
from hermes_cli._subprocess_compat import expose_pm_git
|
|
|
|
# The builds, the release-history refresh and the install stamp all run
|
|
# git; a fresh Windows machine has only PM's.
|
|
expose_pm_git(root)
|
|
except Exception as exc:
|
|
print(f"⚠ Could not provide git for the source completion: {exc}", file=sys.stderr)
|
|
owed: list[tuple[str, str]] = []
|
|
|
|
def step(name: str, run) -> None:
|
|
# Each step is independent: a failed launcher publish or web build must not skip the
|
|
# config migration, the maintenance, or (in an update) the gateway restart after it.
|
|
try:
|
|
run()
|
|
except (Exception, SystemExit) as exc: # health: allow BLE001 -- reported as an owed follow-up
|
|
from hermes_cli.update_receipt import record_followup
|
|
|
|
reason = str(exc) or type(exc).__name__
|
|
record_followup(name, reason)
|
|
owed.append((name, reason))
|
|
|
|
step("launchers", lambda: publish_launchers(root))
|
|
step("build", lambda: build_update_products(root, desktop=desktop, after_dependencies=after_dependencies))
|
|
if announce:
|
|
print(announce)
|
|
verdict: list[bool] = []
|
|
step("maintenance", lambda: verdict.append(_run_post_update_maintenance(
|
|
assume_yes=assume_yes,
|
|
gateway_mode=gateway_mode,
|
|
pre_update_snapshot_id=pre_update_snapshot_id,
|
|
had_desktop_app_before_update=desktop,
|
|
pre_update_version=pre_update_version,
|
|
completion_message=completion_message,
|
|
followups=owed,
|
|
)))
|
|
tail_done = not owed
|
|
if tail_done:
|
|
from hermes_cli.source_stamp import write_source_stamp
|
|
|
|
try:
|
|
write_source_stamp(root)
|
|
except (OSError, ValueError) as exc:
|
|
print(f"⚠ Source update completed, but the install stamp could not be written: {exc}",
|
|
file=sys.stderr)
|
|
if followups is not None:
|
|
followups.extend(owed)
|
|
# The SQLite verdict is not tail work (re-running the tail cannot fix the interpreter); it
|
|
# is reported by the maintenance step itself and only withholds an install's success.
|
|
return tail_done and all(verdict)
|
|
|
|
|
|
def _bootstrap_command(root: Path, argv: list[str]) -> list[str]:
|
|
"""Re-enter the checkout on PM's selected interpreter with its environment."""
|
|
from pm.environments import project_python
|
|
|
|
return [str(project_python(root)), "-I", "-B", "-u",
|
|
str(Path(__file__).resolve()), "--source", str(root), *argv, _PREPARED]
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
prepared = _PREPARED in argv
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--source", type=Path, required=True)
|
|
parser.add_argument("--desktop", action="store_true",
|
|
help="Also build the packaged desktop app.")
|
|
parser.add_argument("--interactive", action="store_true",
|
|
help="Allow prompts; installers pass no flag and stay unattended.")
|
|
parser.add_argument("--finish-update", action="store_true",
|
|
help="Complete a source UPDATE tail instead of an install: the "
|
|
"same launchers/products/maintenance with update wording.")
|
|
args = parser.parse_args([argument for argument in argv if argument != _PREPARED])
|
|
root = args.source.resolve()
|
|
if not (root / "hermes_cli/source_completion.py").is_file():
|
|
print(f"✗ {root} is not a Hermes source checkout", file=sys.stderr)
|
|
return 1
|
|
|
|
if prepared:
|
|
# Dependencies are selected before any application import: this is the
|
|
# same ordering the update completion guarantees.
|
|
from pm.environments import activate_dependencies
|
|
|
|
activate_dependencies(root)
|
|
if args.finish_update:
|
|
# A source update that never reached its own completion -- a
|
|
# pre-handoff release cannot flip during `hermes update`, so its
|
|
# update ends with the tree at HEAD and nothing built -- lands here
|
|
# on the next ordinary startup. Same tail as an install, so the two
|
|
# states cannot drift apart. Only owed TAIL work keeps it pending: an
|
|
# unsafe SQLite runtime is reported, but re-running the tail cannot fix it.
|
|
owed: list[tuple[str, str]] = []
|
|
complete_source_checkout(
|
|
root, desktop=args.desktop, assume_yes=True,
|
|
completion_message=None, announce="\n✓ Code updated!", followups=owed,
|
|
)
|
|
ok = not owed
|
|
else:
|
|
ok = complete_source_checkout(
|
|
root, desktop=args.desktop, assume_yes=not args.interactive,
|
|
completion_message="✓ Install complete!",
|
|
)
|
|
return 0 if ok else 1
|
|
|
|
from pm.environments import activation_environment
|
|
|
|
# This interpreter is a bootstrap one: the work happens in the re-exec, so
|
|
# every flag that decides WHICH tail runs has to survive into it. Losing
|
|
# --finish-update here silently reports an update as an install.
|
|
passthrough = (["--desktop"] if args.desktop else []) + \
|
|
(["--finish-update"] if args.finish_update else [])
|
|
command = _bootstrap_command(root, passthrough)
|
|
from hermes_cli.update_lock import checkout_lock_fds
|
|
|
|
# Called inside an update tree (venv_sync's interrupted-update finish), the prepared child
|
|
# keeps the checkout lock this bootstrap inherited.
|
|
fds = checkout_lock_fds(root)
|
|
return subprocess.call(command, cwd=root, env=activation_environment(root),
|
|
**({"pass_fds": fds} if fds else {}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main()) |