Arm the source-completion tail and the fleet obligation in the parent before any tree move; cover the CP0 branch switch, the upstream fork ff and the syntax rollback with the interrupted-pull marker; settle (never drop) the marker on a non-kill git failure; refuse foreign-owned venvs and uncompilable targets before HEAD moves; push the fork only after a validated update; ZIP fallback only from an untouched checkout; journal the ZIP swap so start-of-run recovery finishes or rolls it back and no staging/backup sibling wedges the next run. (cherry picked from commit 584acad44f3d6c552b7c2767d12c612de0436498) (cherry picked from commit c6567ac2d28c421f384d95e1a6fc28912cb30f86)
145 lines
7.0 KiB
Python
145 lines
7.0 KiB
Python
"""Absolute-path bootstrap for historical updater takeover; no app imports yet."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
def prepare(request: dict) -> tuple[Path, dict[str, str]]:
|
|
"""Provision the new graph before entering its application interpreter."""
|
|
root = Path(request["root"])
|
|
sys.path.insert(0, str(root))
|
|
# The old shim's UI has been frozen since the pull; from here the new
|
|
# tree can publish stages (and pop the panel the old shim couldn't).
|
|
from hermes_cli.update_stage import ensure_panel, publish_stage
|
|
|
|
ensure_panel(root)
|
|
# An older updater hands off here instead of update_completion._prepare.
|
|
from hermes_cli.gitlock import convert_treeless_checkout_first
|
|
convert_treeless_checkout_first(root)
|
|
publish_stage("Updating Python dependencies (PM)")
|
|
from pm import receipt
|
|
from pm.client import ensure_tools_for_sync, sync_venv, venv_is_current
|
|
from pm.environments import activation_environment, install_state_dir, runtime_facts_path
|
|
from hermes_cli._launchers import resolve_store_python
|
|
from hermes_cli.venv_sync import (
|
|
arm_completion, collect_superseded_generations, publish_launchers, refuse_foreign_owned_venv)
|
|
|
|
# The historical updater already moved the tree: owe the tail and the fleet restart before the
|
|
# first slow step, exactly like a current updater's commit point, so a kill from here on leaves
|
|
# both for the next launch / `hermes update` instead of nothing.
|
|
refuse_foreign_owned_venv(root)
|
|
arm_completion(root)
|
|
_arm_fleet_obligation(root)
|
|
correlation = request["update_id"]
|
|
with receipt.worker_context(correlation):
|
|
# A pre-PM installation has no required-tool facts. A current Python
|
|
# generation alone does not prove its Node/Git/tool closure is ready.
|
|
ensure_tools_for_sync()
|
|
from pm.extras import legacy_selection
|
|
|
|
extras = legacy_selection(root) if not runtime_facts_path(root).is_file() else None
|
|
repair_marker = install_state_dir(root) / ".repair-incomplete"
|
|
# Repair preserves the old stamp. Changed source inputs instead need
|
|
# an ordinary sync, which builds and validates a fresh generation too.
|
|
repair = repair_marker.is_file() and venv_is_current(project_root=root)
|
|
# An update never fails because of a plugin: misfits are disabled and reported.
|
|
sync_venv(None if repair else extras, explicit=True, project_root=root, repair=repair,
|
|
evict_incompatible_plugins=not repair)
|
|
collect_superseded_generations(root)
|
|
request["pm_receipt"] = receipt.last_for_update(correlation)
|
|
publish_launchers(root)
|
|
repair_marker.unlink(missing_ok=True)
|
|
for name in (".update-incomplete", ".lazy-refresh-incomplete"):
|
|
(root / name).unlink(missing_ok=True)
|
|
python = resolve_store_python(root)
|
|
if python is None:
|
|
raise RuntimeError("updated installation has no managed interpreter")
|
|
return python, activation_environment(root)
|
|
|
|
|
|
def _arm_fleet_obligation(root: Path) -> None:
|
|
head = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, text=True, encoding="utf-8",
|
|
stdin=subprocess.DEVNULL, timeout=60)
|
|
sha = head.stdout.strip() if head.returncode == 0 else ""
|
|
if sha: # an SHA-less record names no code the fleet could be proven current on
|
|
from hermes_cli.update_cmd_fleet import _write_fleet_restart_pending_marker
|
|
|
|
_write_fleet_restart_pending_marker(expected_sha=sha)
|
|
|
|
|
|
def _record_failure(request: dict, result: Path, code: int, detail: str) -> None:
|
|
from hermes_cli import update_receipt
|
|
from hermes_constants import get_hermes_home
|
|
|
|
update_receipt.record_step("historical_takeover", False, detail)
|
|
saved = update_receipt.finalize_pending_update_receipt(code, detail)
|
|
if request.get("gateway_mode"):
|
|
(get_hermes_home() / ".update_exit_code").write_text(f"{code}\n", encoding="utf-8")
|
|
result.write_text(json.dumps({"receipt_handled": saved is not None, "resume_handled": False}), encoding="utf-8")
|
|
|
|
|
|
def main() -> int:
|
|
context, result = map(Path, sys.argv[1:3])
|
|
request = json.loads(context.read_text(encoding="utf-8-sig"))
|
|
sys.path.insert(0, request["root"])
|
|
if "stopped_serves" in request:
|
|
# Historical atexit cleanup may run after the update's result is fixed.
|
|
# It must reuse that installation, never start a second update/repair.
|
|
from hermes_cli._launchers import resolve_store_python
|
|
from pm.environments import activation_environment
|
|
|
|
root = Path(request["root"])
|
|
python = resolve_store_python(root)
|
|
if python is None:
|
|
print("Cannot resume stopped backends: no selected Hermes interpreter", file=sys.stderr)
|
|
return 1
|
|
return subprocess.run(
|
|
[str(python), "-I", "-B", "-X", "utf8", str(root / "hermes_cli/update_serve_resume.py"),
|
|
str(context), str(result)], cwd=root, env=activation_environment(root),
|
|
).returncode
|
|
from hermes_cli import update_receipt
|
|
from hermes_cli.update_lock import UpdateLock, describe_holder
|
|
|
|
# The takeover syncs dependencies and its update_finish child builds the checkout: hold
|
|
# (or join, inherited from the old updater) the checkout lock, not just the marker (R2).
|
|
lock = UpdateLock(install_root=Path(request["root"]))
|
|
if not lock.acquire():
|
|
print(describe_holder(lock.holder), file=sys.stderr)
|
|
return 2
|
|
old_receipt = request.get("receipt") or {}
|
|
update_receipt.begin_update_receipt(previous=old_receipt, correlation_id=old_receipt.get("update_id"))
|
|
request["update_id"] = update_receipt.current_correlation_id()
|
|
try:
|
|
python, env = prepare(request)
|
|
request["receipt"] = update_receipt._current.get().data
|
|
context.write_text(json.dumps(request), encoding="utf-8")
|
|
# This file is new too. Direct execution bypasses normal launch-time
|
|
# update liveness checks while the waiting parent still holds its lock.
|
|
command = [str(python), "-I", "-B", "-X", "utf8", str(Path(request["root"]) / "hermes_cli/update_finish.py"),
|
|
str(context), str(result)]
|
|
from hermes_cli.update_custody import popen_post_commit
|
|
|
|
# update_finish builds the checkout: POSIX lock fd; Windows bound to the job (or leased).
|
|
with popen_post_commit(command, label="update finish child", cwd=request["root"], env=env) as child:
|
|
try:
|
|
code = child.wait()
|
|
except BaseException:
|
|
child.kill()
|
|
raise
|
|
if code != 0 and not result.is_file():
|
|
_record_failure(request, result, code, f"completion child exited {code} without acknowledgement")
|
|
return code
|
|
except Exception as exc:
|
|
print(f"Update preparation failed: {exc}", file=sys.stderr, flush=True)
|
|
_record_failure(request, result, 1, f"historical takeover preparation failed: {exc}")
|
|
return 1
|
|
finally:
|
|
lock.release()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|