Merge branch 'simp/r3-25-L' into simp/integration3
This commit is contained in:
+126
-130
@@ -21,6 +21,8 @@ _AUTOSTASH_NAME_PREFIX = "hermes-update-autostash-"
|
||||
#: (recent --keep-stash park); older ones are almost always forgotten.
|
||||
_AUTOSTASH_WARN_AGE_DAYS = 7
|
||||
|
||||
_STASH_LEFT_IN_PLACE = " The stash was left in place. You can remove it manually after checking the result."
|
||||
|
||||
|
||||
def _git_quiet(git_cmd: list[str], args: list[str], cwd: Path, **kwargs):
|
||||
"""``subprocess.run`` of a git command with captured output; None when git cannot run."""
|
||||
@@ -30,17 +32,33 @@ def _git_quiet(git_cmd: list[str], args: list[str], cwd: Path, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def _git_paths_z(git_cmd: list[str], args: list[str], cwd: Path):
|
||||
"""NUL-separated path listing as a set, or None when git failed (surrogateescape keeps odd filenames)."""
|
||||
result = _git_quiet(git_cmd, args, cwd, text=True, encoding="utf-8", errors="surrogateescape")
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
return {path for path in result.stdout.split("\0") if path}
|
||||
|
||||
|
||||
def _reset_hard(git_cmd: list[str], cwd: Path) -> None:
|
||||
subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True)
|
||||
|
||||
|
||||
def _print_nonempty(text: str, prefix: str = "") -> None:
|
||||
if text.strip():
|
||||
print(f"{prefix}{text.strip()}")
|
||||
|
||||
|
||||
def _print_first_line(text: str) -> None:
|
||||
if text.strip():
|
||||
print(f" {text.strip().splitlines()[0]}")
|
||||
|
||||
|
||||
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
|
||||
from hermes_cli.update_cmd import _git_run
|
||||
status = _git_run(git_cmd, ["status", "--porcelain"], cwd, check=True)
|
||||
if not status.stdout.strip():
|
||||
return None
|
||||
|
||||
# Unmerged index entries (interrupted merge/rebase) make `git stash` fail with
|
||||
# "needs merge"; `git reset` drops only the index conflict state, not the tree.
|
||||
if _git_run(git_cmd, ["ls-files", "--unmerged"], cwd).stdout.strip():
|
||||
@@ -55,25 +73,21 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
|
||||
stash_probe = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd)
|
||||
stash_ref = stash_probe.stdout.strip()
|
||||
stash_created = stash_probe.returncode == 0 and bool(stash_ref) and stash_ref != prev_stash
|
||||
|
||||
if push.returncode != 0:
|
||||
if stash_created:
|
||||
# Non-zero but entry created: push saved everything yet couldn't delete some
|
||||
# untracked files (e.g. root-owned dir). Not a failure — continue.
|
||||
_print_nonempty(push.stderr)
|
||||
print(" ⚠ Some untracked files could not be removed from the working tree (permission denied).")
|
||||
print(" They were still saved to the stash and were left in place — the update will continue.")
|
||||
# A partially-failed push also skips cleanup of TRACKED modifications;
|
||||
# they'd break the following pull. Safe to reset: all is in the stash.
|
||||
subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True)
|
||||
else:
|
||||
if not stash_created:
|
||||
# No entry created: changes NOT saved — bail before touching HEAD.
|
||||
print("✗ Could not stash local changes — update aborted.")
|
||||
if push.stderr.strip():
|
||||
print(f" {push.stderr.strip().splitlines()[0]}")
|
||||
_print_first_line(push.stderr)
|
||||
print(" Commit, stash, or clean up your local changes manually, then re-run `hermes update`.")
|
||||
raise subprocess.CalledProcessError(push.returncode, push.args, output=push.stdout, stderr=push.stderr)
|
||||
|
||||
# Non-zero but entry created: push saved everything yet couldn't delete some untracked files
|
||||
# (e.g. root-owned dir). Not a failure — continue.
|
||||
_print_nonempty(push.stderr)
|
||||
print(" ⚠ Some untracked files could not be removed from the working tree (permission denied).")
|
||||
print(" They were still saved to the stash and were left in place — the update will continue.")
|
||||
# A partially-failed push also skips cleanup of TRACKED modifications; they'd break the following
|
||||
# pull. Safe to reset: all is in the stash.
|
||||
_reset_hard(git_cmd, cwd)
|
||||
return stash_ref
|
||||
|
||||
|
||||
@@ -99,14 +113,13 @@ def _warn_orphaned_update_autostashes(git_cmd: list[str], cwd: Path) -> int:
|
||||
if stash_list.returncode != 0:
|
||||
return 0
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=_AUTOSTASH_WARN_AGE_DAYS)
|
||||
marker = _AUTOSTASH_NAME_PREFIX
|
||||
stale: list[tuple[str, str]] = []
|
||||
for line in stash_list.stdout.splitlines():
|
||||
selector, _, subject = line.strip().partition(" ")
|
||||
pos = subject.find(marker)
|
||||
pos = subject.find(_AUTOSTASH_NAME_PREFIX)
|
||||
if pos < 0:
|
||||
continue
|
||||
stamp = subject[pos + len(marker):][:15] # "YYYYMMDD-HHMMSS"
|
||||
stamp = subject[pos + len(_AUTOSTASH_NAME_PREFIX):][:15] # "YYYYMMDD-HHMMSS"
|
||||
try:
|
||||
stash_time = datetime.strptime(stamp, "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
@@ -148,9 +161,8 @@ def _stash_apply_failed_only_on_existing_untracked(stderr: str) -> bool:
|
||||
apply restores tracked changes, then refuses to overwrite those files and exits non-zero though nothing was lost.
|
||||
Any other error line (e.g. ``would be overwritten by merge``) means the tracked apply failed -> False.
|
||||
"""
|
||||
lines = [ln.strip() for ln in (stderr or "").splitlines() if ln.strip()]
|
||||
saw_untracked_error = False
|
||||
for ln in lines:
|
||||
for ln in (ln.strip() for ln in (stderr or "").splitlines() if ln.strip()):
|
||||
if "already exists, no checkout" in ln or "could not restore untracked files from stash" in ln:
|
||||
saw_untracked_error = True
|
||||
elif not ln.startswith(("warning:", "hint:")):
|
||||
@@ -159,10 +171,8 @@ def _stash_apply_failed_only_on_existing_untracked(stderr: str) -> bool:
|
||||
|
||||
|
||||
def _park_stashed_changes(stash_ref: str) -> None:
|
||||
"""Leave a pre-update autostash parked (``--keep-stash``, the desktop updater's mode).
|
||||
|
||||
Local source edits must never be silently re-applied onto updated code; the entry stays in ``git stash``.
|
||||
"""
|
||||
"""Leave a pre-update autostash parked (``--keep-stash``, the desktop updater's mode): local source
|
||||
edits must never be silently re-applied onto updated code; the entry stays in ``git stash``."""
|
||||
print()
|
||||
print("ℹ️ Local changes were stashed before updating and were NOT re-applied (--keep-stash).")
|
||||
print(f" Stash ref: {stash_ref}")
|
||||
@@ -171,32 +181,23 @@ def _park_stashed_changes(stash_ref: str) -> None:
|
||||
|
||||
def _git_untracked_paths(git_cmd: list[str], cwd: Path) -> set[str] | None:
|
||||
"""Return untracked paths, or ``None`` when Git cannot enumerate them."""
|
||||
result = _git_quiet(
|
||||
git_cmd, ["ls-files", "--others", "--exclude-standard", "-z"], cwd,
|
||||
text=True, encoding="utf-8", errors="surrogateescape",
|
||||
)
|
||||
if result is None or result.returncode != 0:
|
||||
paths = _git_paths_z(git_cmd, ["ls-files", "--others", "--exclude-standard", "-z"], cwd)
|
||||
if paths is None:
|
||||
print(" ⚠ Could not enumerate untracked files while validating the restored stash.")
|
||||
return None
|
||||
return {path for path in result.stdout.split("\0") if path}
|
||||
return paths
|
||||
|
||||
|
||||
def _restored_python_paths(git_cmd: list[str], cwd: Path) -> tuple[str, ...] | None:
|
||||
"""Restored ``.py`` paths changed from ``HEAD``; deliberately Python-only (entry scripts stay outside the health check)."""
|
||||
from hermes_cli.update_cmd import _git_untracked_paths
|
||||
changed = _git_quiet(
|
||||
git_cmd, ["diff", "--name-only", "-z", "HEAD", "--", "*.py"], cwd,
|
||||
text=True, encoding="utf-8", errors="surrogateescape",
|
||||
)
|
||||
if changed is None or changed.returncode != 0:
|
||||
paths = _git_paths_z(git_cmd, ["diff", "--name-only", "-z", "HEAD", "--", "*.py"], cwd)
|
||||
if paths is None:
|
||||
print(" ⚠ Could not enumerate tracked Python files restored from the stash.")
|
||||
return None
|
||||
paths = set(changed.stdout.split("\0"))
|
||||
untracked = _git_untracked_paths(git_cmd, cwd)
|
||||
if untracked is None:
|
||||
return None
|
||||
paths.update(path for path in untracked if path.endswith(".py"))
|
||||
paths.discard("")
|
||||
return tuple(sorted(paths))
|
||||
|
||||
|
||||
@@ -213,22 +214,16 @@ def _reject_unsafe_stash_restore(
|
||||
for line in str(detail).splitlines()[:6]:
|
||||
print(f" {line}")
|
||||
|
||||
current_untracked = _git_untracked_paths(git_cmd, cwd)
|
||||
restored_untracked = (current_untracked - preexisting_untracked if current_untracked is not None else set())
|
||||
reset = _git_quiet(git_cmd, ["reset", "--hard", "HEAD"], cwd)
|
||||
clean = None
|
||||
if restored_untracked:
|
||||
clean = _git_quiet(git_cmd, ["clean", "-fd", "--", *sorted(restored_untracked)], cwd)
|
||||
cleanup_ok = (
|
||||
current_untracked is not None
|
||||
and reset is not None
|
||||
and reset.returncode == 0
|
||||
and (not restored_untracked or (clean is not None and clean.returncode == 0))
|
||||
)
|
||||
if cleanup_ok:
|
||||
verify = _git_quiet(git_cmd, ["diff", "--quiet", "HEAD", "--"], cwd)
|
||||
cleanup_ok = verify is not None and verify.returncode == 0
|
||||
def _ok(result) -> bool:
|
||||
return result is not None and result.returncode == 0
|
||||
|
||||
current_untracked = _git_untracked_paths(git_cmd, cwd)
|
||||
restored_untracked = current_untracked - preexisting_untracked if current_untracked is not None else set()
|
||||
reset = _git_quiet(git_cmd, ["reset", "--hard", "HEAD"], cwd)
|
||||
clean = _git_quiet(git_cmd, ["clean", "-fd", "--", *sorted(restored_untracked)], cwd) if restored_untracked else None
|
||||
cleanup_ok = current_untracked is not None and _ok(reset) and (not restored_untracked or _ok(clean))
|
||||
if cleanup_ok:
|
||||
cleanup_ok = _ok(_git_quiet(git_cmd, ["diff", "--quiet", "HEAD", "--"], cwd))
|
||||
if cleanup_ok:
|
||||
print(" The clean updated tree has been restored; the gateway was not restarted.")
|
||||
else:
|
||||
@@ -241,72 +236,92 @@ def _reject_unsafe_stash_restore(
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _confirm_restore(stash_ref: str, input_fn) -> bool:
|
||||
"""Interactive gate; a remote ``input_fn`` defaults to No (``[y/N]``), the local prompt to Yes."""
|
||||
remote_prompt = input_fn is not None
|
||||
prompt_suffix = "[y/N]" if remote_prompt else "[Y/n]"
|
||||
print()
|
||||
print("⚠ Local changes were stashed before updating.")
|
||||
print(" Restoring them may reapply local customizations onto the updated codebase.")
|
||||
print(" Review the result afterward if Hermes behaves unexpectedly.")
|
||||
print(f"Restore local changes now? {prompt_suffix}")
|
||||
if remote_prompt:
|
||||
response = input_fn(f"Restore local changes now? {prompt_suffix}", "n")
|
||||
else:
|
||||
try:
|
||||
response = input().strip().lower()
|
||||
except (EOFError, UnicodeDecodeError):
|
||||
response = "n" # closed stdin/encoding error must not crash mid-restore
|
||||
if response in {"y", "yes"} or (not remote_prompt and response == ""):
|
||||
return True
|
||||
print("Skipped restoring local changes.")
|
||||
print("Your changes are still preserved in git stash.")
|
||||
print(f"Restore manually with: git stash apply {stash_ref}")
|
||||
return False
|
||||
|
||||
|
||||
def _apply_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool:
|
||||
"""``git stash apply``; False (tree reset, stash kept) on conflicts or any failure other than the
|
||||
undeletable-untracked class."""
|
||||
from hermes_cli.update_cmd import _git_run
|
||||
print("→ Restoring local changes...")
|
||||
restore = _git_run(git_cmd, ["stash", "apply", stash_ref], cwd)
|
||||
unmerged = _git_run(git_cmd, ["diff", "--name-only", "--diff-filter=U"], cwd) # conflicts can exist even on rc 0
|
||||
conflicted_files = unmerged.stdout.strip()
|
||||
if restore.returncode == 0 and not conflicted_files:
|
||||
return True
|
||||
if not conflicted_files and _stash_apply_failed_only_on_existing_untracked(restore.stderr):
|
||||
# Tracked changes applied; only undeletable-at-stash-time untracked files were refused. Their
|
||||
# content is untouched — treat as restored.
|
||||
print(" ⚠ Some stashed untracked files already exist in the working tree and were kept as-is.")
|
||||
return True
|
||||
print("✗ Update pulled new code, but restoring local changes hit conflicts.")
|
||||
_print_nonempty(restore.stdout)
|
||||
_print_nonempty(restore.stderr)
|
||||
if conflicted_files:
|
||||
print("\nConflicted files:")
|
||||
for f in conflicted_files.splitlines():
|
||||
print(f" • {f}")
|
||||
print("\nYour stashed changes are preserved — nothing is lost.")
|
||||
print(f" Stash ref: {stash_ref}")
|
||||
_reset_hard(git_cmd, cwd) # conflict markers make hermes unrunnable; changes stay in the stash
|
||||
print("Working tree reset to clean state.")
|
||||
print(f"Restore your changes later with: git stash apply {stash_ref}")
|
||||
return False # code update succeeded; cmd_update continues (deps, skills, gateway)
|
||||
|
||||
|
||||
def _drop_restored_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> None:
|
||||
from hermes_cli.update_cmd import _git_run
|
||||
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
||||
if stash_selector is None:
|
||||
print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.")
|
||||
print(_STASH_LEFT_IN_PLACE)
|
||||
_print_stash_cleanup_guidance(stash_ref)
|
||||
return
|
||||
drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd)
|
||||
if drop.returncode != 0:
|
||||
print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.")
|
||||
_print_nonempty(drop.stdout)
|
||||
_print_nonempty(drop.stderr)
|
||||
print(_STASH_LEFT_IN_PLACE)
|
||||
_print_stash_cleanup_guidance(stash_ref, stash_selector)
|
||||
|
||||
|
||||
def _restore_stashed_changes(
|
||||
git_cmd: list[str], cwd: Path, stash_ref: str, prompt_user: bool = False, input_fn=None,
|
||||
) -> bool:
|
||||
from hermes_cli.update_cmd import (
|
||||
_critical_module_import_failures, _git_run, _git_untracked_paths, _restored_python_paths,
|
||||
_validate_python_files_syntax,
|
||||
_critical_module_import_failures, _git_untracked_paths, _restored_python_paths, _validate_python_files_syntax,
|
||||
)
|
||||
if prompt_user:
|
||||
remote_prompt = input_fn is not None
|
||||
prompt_suffix = "[y/N]" if remote_prompt else "[Y/n]"
|
||||
print()
|
||||
print("⚠ Local changes were stashed before updating.")
|
||||
print(" Restoring them may reapply local customizations onto the updated codebase.")
|
||||
print(" Review the result afterward if Hermes behaves unexpectedly.")
|
||||
print(f"Restore local changes now? {prompt_suffix}")
|
||||
if input_fn is not None:
|
||||
response = input_fn(f"Restore local changes now? {prompt_suffix}", "n")
|
||||
else:
|
||||
try:
|
||||
response = input().strip().lower()
|
||||
except (EOFError, UnicodeDecodeError):
|
||||
response = "n" # closed stdin/encoding error must not crash mid-restore
|
||||
if not (response in {"y", "yes"} or (not remote_prompt and response == "")):
|
||||
print("Skipped restoring local changes.")
|
||||
print("Your changes are still preserved in git stash.")
|
||||
print(f"Restore manually with: git stash apply {stash_ref}")
|
||||
return False
|
||||
|
||||
if prompt_user and not _confirm_restore(stash_ref, input_fn):
|
||||
return False
|
||||
preexisting_untracked = _git_untracked_paths(git_cmd, cwd)
|
||||
if preexisting_untracked is None:
|
||||
print(" The stash was not restored because its cleanup baseline is unknown.")
|
||||
print(f" Restore manually with: git stash apply {stash_ref}")
|
||||
return False
|
||||
clean_import_failures = _critical_module_import_failures(cwd, report_runtime_errors=True)
|
||||
print("→ Restoring local changes...")
|
||||
restore = _git_run(git_cmd, ["stash", "apply", stash_ref], cwd)
|
||||
|
||||
# Conflicts can exist even when returncode is 0
|
||||
unmerged = _git_run(git_cmd, ["diff", "--name-only", "--diff-filter=U"], cwd)
|
||||
has_conflicts = bool(unmerged.stdout.strip())
|
||||
|
||||
if restore.returncode != 0 and not has_conflicts and (
|
||||
_stash_apply_failed_only_on_existing_untracked(restore.stderr)
|
||||
):
|
||||
# Tracked changes applied; only undeletable-at-stash-time untracked files
|
||||
# were refused. Their content is untouched — treat as restored.
|
||||
print(" ⚠ Some stashed untracked files already exist in the working tree and were kept as-is.")
|
||||
elif restore.returncode != 0 or has_conflicts:
|
||||
print("✗ Update pulled new code, but restoring local changes hit conflicts.")
|
||||
_print_nonempty(restore.stdout)
|
||||
_print_nonempty(restore.stderr)
|
||||
|
||||
conflicted_files = unmerged.stdout.strip()
|
||||
if conflicted_files:
|
||||
print("\nConflicted files:")
|
||||
for f in conflicted_files.splitlines():
|
||||
print(f" • {f}")
|
||||
|
||||
print("\nYour stashed changes are preserved — nothing is lost.")
|
||||
print(f" Stash ref: {stash_ref}")
|
||||
|
||||
# Always reset: conflict markers make hermes unrunnable; changes stay in the stash.
|
||||
subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True)
|
||||
print("Working tree reset to clean state.")
|
||||
print(f"Restore your changes later with: git stash apply {stash_ref}")
|
||||
# Don't exit: code update succeeded; cmd_update continues (deps, skills, gateway).
|
||||
if not _apply_stash(git_cmd, cwd, stash_ref):
|
||||
return False
|
||||
|
||||
def reject(failing_target: str, detail) -> None:
|
||||
@@ -318,27 +333,11 @@ def _restore_stashed_changes(
|
||||
syntax_ok, failing_path, syntax_error = _validate_python_files_syntax(cwd, restored_python)
|
||||
if not syntax_ok:
|
||||
reject(failing_path or "restored Python source", syntax_error)
|
||||
|
||||
restored_import_failures = _critical_module_import_failures(cwd, report_runtime_errors=True)
|
||||
for module, error in restored_import_failures.items():
|
||||
for module, error in _critical_module_import_failures(cwd, report_runtime_errors=True).items():
|
||||
if clean_import_failures.get(module) != error:
|
||||
reject(f"agent import {module or 'unknown'}", error[1])
|
||||
break
|
||||
|
||||
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
||||
if stash_selector is None:
|
||||
print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.")
|
||||
print(" The stash was left in place. You can remove it manually after checking the result.")
|
||||
_print_stash_cleanup_guidance(stash_ref)
|
||||
else:
|
||||
drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd)
|
||||
if drop.returncode != 0:
|
||||
print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.")
|
||||
_print_nonempty(drop.stdout)
|
||||
_print_nonempty(drop.stderr)
|
||||
print(" The stash was left in place. You can remove it manually after checking the result.")
|
||||
_print_stash_cleanup_guidance(stash_ref, stash_selector)
|
||||
|
||||
_drop_restored_stash(git_cmd, cwd, stash_ref)
|
||||
print("⚠ Local changes were restored on top of the updated codebase.")
|
||||
print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.")
|
||||
return True
|
||||
@@ -359,14 +358,11 @@ def _discard_stashed_changes(git_cmd: list[str], cwd: Path, stash_ref: str) -> b
|
||||
)
|
||||
_print_stash_cleanup_guidance(stash_ref)
|
||||
return False
|
||||
|
||||
drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd)
|
||||
if drop.returncode != 0:
|
||||
print("⚠ Configured to discard local changes, but Hermes couldn't drop the saved stash entry.")
|
||||
if drop.stderr.strip():
|
||||
print(f" {drop.stderr.strip().splitlines()[0]}")
|
||||
_print_first_line(drop.stderr)
|
||||
_print_stash_cleanup_guidance(stash_ref, stash_selector)
|
||||
return False
|
||||
|
||||
print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).")
|
||||
return True
|
||||
|
||||
+90
-134
@@ -23,6 +23,8 @@ _ZIP_STAGING_ARTIFACT_SUFFIXES = ".hermes-update-staging", ".hermes-update-old"
|
||||
# Single source of truth for entries the ZIP swap preserves — used by the dirty-tree filter and the swap loop.
|
||||
_ZIP_PRESERVED_TOP_LEVEL = {"venv", "node_modules", ".git", ".env"}
|
||||
|
||||
_STASH_HINT = " Stash or commit your changes, then rerun `hermes update`."
|
||||
|
||||
|
||||
def _remove_path(path: str, *, ignore_errors: bool = False) -> None:
|
||||
"""Remove a dir or file; missing paths are a no-op."""
|
||||
@@ -37,17 +39,15 @@ def _remove_path(path: str, *, ignore_errors: bool = False) -> None:
|
||||
|
||||
|
||||
def _atomic_replace_dir(src: str, dst: str) -> None:
|
||||
"""Replace *dst* with *src* without a half-deleted window: naive ``rmtree; copytree`` loses the old
|
||||
tree when the copy fails partway (likely here, since the ZIP path only runs when file I/O is flaky).
|
||||
Thin alias over the two-phase helpers; retained for the ``hermes_cli.main`` re-export surface.
|
||||
"""
|
||||
"""Replace *dst* with *src* without a half-deleted window (naive ``rmtree; copytree`` loses the old
|
||||
tree when the copy fails partway — likely here, since the ZIP path only runs when file I/O is flaky).
|
||||
Thin alias over the two-phase helpers; retained for the ``hermes_cli.main`` re-export surface."""
|
||||
_commit_staged_replacements([(_stage_replacement(src, dst), dst)])
|
||||
|
||||
|
||||
def _stage_replacement(src: str, dst: str) -> str:
|
||||
"""Phase 1: copy *src* (dir or file) to a sibling staging path for *dst*; return it.
|
||||
Touches nothing live, so a failure here leaves the install untouched.
|
||||
"""
|
||||
Touches nothing live, so a failure here leaves the install untouched."""
|
||||
staging = f"{dst}.hermes-update-staging"
|
||||
backup = f"{dst}.hermes-update-old"
|
||||
# A prior run may have died mid-swap leaving the backup as the ONLY copy. Restore it BEFORE
|
||||
@@ -56,17 +56,13 @@ def _stage_replacement(src: str, dst: str) -> str:
|
||||
os.rename(backup, dst)
|
||||
for leftover in (staging, backup):
|
||||
_remove_path(leftover)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, staging)
|
||||
else:
|
||||
shutil.copy2(src, staging)
|
||||
(shutil.copytree if os.path.isdir(src) else shutil.copy2)(src, staging)
|
||||
return staging
|
||||
|
||||
|
||||
def _discard_staged(staged) -> None:
|
||||
"""Remove staging paths for never-committed entries; otherwise a phase-1 failure (disk exhaustion)
|
||||
orphans up to a full second tree and the advised retry fails harder with less free space.
|
||||
"""
|
||||
orphans up to a full second tree and the advised retry fails harder with less free space."""
|
||||
for staging, _dst in staged:
|
||||
try:
|
||||
_remove_path(staging)
|
||||
@@ -94,8 +90,7 @@ def _commit_staged_replacements(staged) -> None:
|
||||
swapped.append((dst, ""))
|
||||
os.rename(staging, dst)
|
||||
except OSError:
|
||||
# Undo every swap already made so the install stays self-consistent.
|
||||
for dst, backup in reversed(swapped):
|
||||
for dst, backup in reversed(swapped): # undo every swap already made so the install stays self-consistent
|
||||
try:
|
||||
_remove_path(dst)
|
||||
if backup and os.path.exists(backup):
|
||||
@@ -104,8 +99,7 @@ def _commit_staged_replacements(staged) -> None:
|
||||
# Keep restoring the rest; a silent failure here turns a recoverable rollback into a mixed tree.
|
||||
logger.warning("rollback failed for %s: %s", dst, exc)
|
||||
raise
|
||||
# All swaps succeeded — drop the backups (best-effort, never fatal).
|
||||
for _dst, backup in swapped:
|
||||
for _dst, backup in swapped: # all swaps succeeded — drop the backups (best-effort, never fatal)
|
||||
if backup:
|
||||
_remove_path(backup, ignore_errors=True)
|
||||
|
||||
@@ -120,35 +114,26 @@ def _zip_overlay_block_reason(root: Path, *, ignore_staging_artifacts: bool = Fa
|
||||
"""
|
||||
if not (root / ".git").exists():
|
||||
return None
|
||||
git_cmd = ["git"]
|
||||
if sys.platform == "win32":
|
||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"] if sys.platform == "win32" else ["git"]
|
||||
result = subprocess.run(
|
||||
# -uall: a user-level ``status.showUntrackedFiles = no`` must not blind this guard. --ignored=matching:
|
||||
# gitignored files are still USER DATA the overlay would delete; ``matching`` reports an ignored dir
|
||||
# as one ``dir/`` line. ``--ignored=all`` is NOT a valid git mode (exits 128, would fail-close every update).
|
||||
git_cmd + ["status", "--porcelain", "--untracked-files=all", "--ignored=matching"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=root, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip().splitlines()
|
||||
suffix = f" ({detail[0]})" if detail else ""
|
||||
return f"could not check the working tree{suffix}"
|
||||
return f"could not check the working tree{f' ({detail[0]})' if detail else ''}"
|
||||
# Preserved entries (venv, node_modules are gitignored on every normal install) are never touched by the
|
||||
# swap, so they must not cause a false refusal. Everything else — including ignored files — blocks.
|
||||
lines = [
|
||||
line for line in (result.stdout or "").splitlines()
|
||||
if line.strip()
|
||||
dirty = any(
|
||||
line.strip()
|
||||
and not _is_zip_preserved_entry_status_line(line)
|
||||
and not (ignore_staging_artifacts and _is_zip_staging_artifact_status_line(line))
|
||||
]
|
||||
if lines:
|
||||
return "the working tree has uncommitted changes or untracked files"
|
||||
return None
|
||||
for line in (result.stdout or "").splitlines()
|
||||
)
|
||||
return "the working tree has uncommitted changes or untracked files" if dirty else None
|
||||
|
||||
|
||||
def _status_top_level(path: str) -> str:
|
||||
@@ -163,15 +148,13 @@ def _is_zip_preserved_entry_status_line(line: str) -> bool:
|
||||
EVERY path preserved keeps renames out of a preserved dir (``R venv/x -> src/x``) blocking.
|
||||
"""
|
||||
status, payload = (line[:2], line[3:]) if len(line) >= 3 else ("", line)
|
||||
is_rename = any(code in "RC" for code in status)
|
||||
paths = payload.split(" -> ") if is_rename else [payload]
|
||||
paths = payload.split(" -> ") if any(code in "RC" for code in status) else [payload]
|
||||
return all(_status_top_level(path) in _ZIP_PRESERVED_TOP_LEVEL for path in paths)
|
||||
|
||||
|
||||
def _is_zip_staging_artifact_status_line(line: str) -> bool:
|
||||
"""True when a porcelain status line is our own two-phase-swap artifact."""
|
||||
payload = line[3:] if len(line) >= 3 else line
|
||||
return _status_top_level(payload).endswith(_ZIP_STAGING_ARTIFACT_SUFFIXES)
|
||||
return _status_top_level(line[3:] if len(line) >= 3 else line).endswith(_ZIP_STAGING_ARTIFACT_SUFFIXES)
|
||||
|
||||
|
||||
def _abort_zip_update_if_dirty_tree() -> None:
|
||||
@@ -182,7 +165,7 @@ def _abort_zip_update_if_dirty_tree() -> None:
|
||||
return
|
||||
print(f"✗ ZIP fallback refused: {reason}.")
|
||||
print(" Overlaying the ZIP would overwrite uncommitted edits and permanently delete untracked files.")
|
||||
print(" Stash or commit your changes, then rerun `hermes update`.")
|
||||
print(_STASH_HINT)
|
||||
print(" To inspect: git status --porcelain")
|
||||
_m().sys.exit(1)
|
||||
|
||||
@@ -204,10 +187,63 @@ def _extract_zip_safely(zip_path: str, tmp_dir: str) -> None:
|
||||
zf.extractall(tmp_dir)
|
||||
|
||||
|
||||
def _extracted_root(tmp_dir: str, branch: str) -> str:
|
||||
"""GitHub ZIPs extract to ``hermes-agent-<branch>/``; fall back to the first non-``__MACOSX`` dir."""
|
||||
extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}")
|
||||
if not os.path.isdir(extracted):
|
||||
for d in os.listdir(tmp_dir):
|
||||
candidate = os.path.join(tmp_dir, d)
|
||||
if os.path.isdir(candidate) and d != "__MACOSX":
|
||||
return candidate
|
||||
return extracted
|
||||
|
||||
|
||||
def _require_staging_space(extracted: str, entries: list[str], project_root: str) -> None:
|
||||
"""Staging costs one extra tree copy; swaps are renames, so require the copy plus 20% headroom — not 2x,
|
||||
which would block updates on exactly the space-constrained machines that hit this path."""
|
||||
need = sum(
|
||||
os.path.getsize(os.path.join(dirpath, f))
|
||||
for entry in entries
|
||||
for dirpath, _dirs, files in os.walk(os.path.join(extracted, entry))
|
||||
for f in files
|
||||
) + sum(os.path.getsize(os.path.join(extracted, e)) for e in entries if os.path.isfile(os.path.join(extracted, e)))
|
||||
required = int(need * 1.2)
|
||||
free = shutil.disk_usage(project_root).free
|
||||
if free < required:
|
||||
raise RuntimeError(
|
||||
f"not enough free disk space to stage the update safely "
|
||||
f"(need ~{required // (1024 * 1024)} MB, have {free // (1024 * 1024)} MB)"
|
||||
)
|
||||
|
||||
|
||||
def _stage_entries(extracted: str, entries: list[str], project_root: str) -> list[tuple[str, str]]:
|
||||
"""Phase 1 for every entry; on failure nothing is live yet, so drop partial staging copies so a retry
|
||||
starts from the same free space."""
|
||||
staged: list[tuple[str, str]] = []
|
||||
try:
|
||||
for item in entries:
|
||||
dst = os.path.join(project_root, item)
|
||||
staged.append((_stage_replacement(os.path.join(extracted, item), dst), dst))
|
||||
# The source ZIP lacks apps/desktop/release/ (the BUILT desktop app); swapping `apps` without
|
||||
# it deletes the build and breaks the shortcut. Graft the live release dir in BEFORE the swap.
|
||||
if item == "apps":
|
||||
live_release = os.path.join(dst, "desktop", "release")
|
||||
staged_release = os.path.join(staged[-1][0], "desktop", "release")
|
||||
if os.path.isdir(live_release) and not os.path.exists(staged_release):
|
||||
os.makedirs(os.path.dirname(staged_release), exist_ok=True)
|
||||
shutil.copytree(live_release, staged_release)
|
||||
except Exception:
|
||||
_discard_staged(staged)
|
||||
raise
|
||||
return staged
|
||||
|
||||
|
||||
def _download_and_swap_zip(branch: str, zip_url: str) -> None:
|
||||
"""Download the source ZIP for *branch* and two-phase swap it into the checkout.
|
||||
``sys.exit(1)`` on any failure; the install ends fully updated or fully rolled back.
|
||||
"""
|
||||
Two-phase: stage every entry (dirs AND top-level files) beside its target, then swap all in with
|
||||
same-filesystem renames, rolling back on failure — one-at-a-time replacement left a mixed, unbootable
|
||||
tree on interruption."""
|
||||
from hermes_cli.update_cmd import _m
|
||||
|
||||
import tempfile
|
||||
@@ -217,64 +253,13 @@ def _download_and_swap_zip(branch: str, zip_url: str) -> None:
|
||||
try:
|
||||
zip_path = os.path.join(tmp_dir, f"hermes-agent-{branch}.zip")
|
||||
urlretrieve(zip_url, zip_path)
|
||||
|
||||
print("→ Extracting...")
|
||||
_extract_zip_safely(zip_path, tmp_dir)
|
||||
|
||||
# GitHub ZIPs extract to hermes-agent-<branch>/
|
||||
extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}")
|
||||
if not os.path.isdir(extracted):
|
||||
for d in os.listdir(tmp_dir):
|
||||
candidate = os.path.join(tmp_dir, d)
|
||||
if os.path.isdir(candidate) and d != "__MACOSX":
|
||||
extracted = candidate
|
||||
break
|
||||
|
||||
extracted = _extracted_root(tmp_dir, branch)
|
||||
entries = [i for i in os.listdir(extracted) if i not in _ZIP_PRESERVED_TOP_LEVEL]
|
||||
|
||||
# Two-phase replace: stage every entry (dirs AND top-level files) beside its target, then swap all
|
||||
# in with same-filesystem renames, rolling back on failure — one-at-a-time replacement left a
|
||||
# mixed, unbootable tree on interruption. Staging costs one extra tree copy: check space up front.
|
||||
need = sum(
|
||||
os.path.getsize(os.path.join(dirpath, f))
|
||||
for entry in entries
|
||||
for dirpath, _dirs, files in os.walk(os.path.join(extracted, entry))
|
||||
for f in files
|
||||
) + sum(
|
||||
os.path.getsize(os.path.join(extracted, e))
|
||||
for e in entries
|
||||
if os.path.isfile(os.path.join(extracted, e))
|
||||
)
|
||||
# Swaps are renames, so only the staging copy is new: require it plus 20% headroom, not 2x,
|
||||
# which would block updates on exactly the space-constrained machines that hit this path.
|
||||
required = int(need * 1.2)
|
||||
free = shutil.disk_usage(str(_m().PROJECT_ROOT)).free
|
||||
if free < required:
|
||||
raise RuntimeError(
|
||||
f"not enough free disk space to stage the update safely "
|
||||
f"(need ~{required // (1024 * 1024)} MB, have "
|
||||
f"{free // (1024 * 1024)} MB)"
|
||||
)
|
||||
|
||||
staged: list[tuple[str, str]] = []
|
||||
try:
|
||||
for item in entries:
|
||||
src = os.path.join(extracted, item)
|
||||
dst = os.path.join(str(_m().PROJECT_ROOT), item)
|
||||
staged.append((_stage_replacement(src, dst), dst))
|
||||
# The source ZIP lacks apps/desktop/release/ (the BUILT desktop app); swapping `apps` without
|
||||
# it deletes the build and breaks the shortcut. Graft the live release dir in BEFORE the swap.
|
||||
if item == "apps":
|
||||
live_release = os.path.join(dst, "desktop", "release")
|
||||
staged_release = os.path.join(staged[-1][0], "desktop", "release")
|
||||
if os.path.isdir(live_release) and not os.path.exists(staged_release):
|
||||
os.makedirs(os.path.dirname(staged_release), exist_ok=True)
|
||||
shutil.copytree(live_release, staged_release)
|
||||
except Exception:
|
||||
# Nothing is live yet; drop partial staging copies so a retry starts from the same free space.
|
||||
_discard_staged(staged)
|
||||
raise
|
||||
|
||||
project_root = str(_m().PROJECT_ROOT)
|
||||
_require_staging_space(extracted, entries, project_root)
|
||||
staged = _stage_entries(extracted, entries, project_root)
|
||||
try:
|
||||
# TOCTOU re-check right before the swap: download + extract + staging can take minutes and
|
||||
# work created meanwhile would be destroyed. Our own staging siblings are filtered out.
|
||||
@@ -282,11 +267,8 @@ def _download_and_swap_zip(branch: str, zip_url: str) -> None:
|
||||
if recheck_reason is not None:
|
||||
_discard_staged(staged)
|
||||
print(f"✗ ZIP fallback aborted before the swap: {recheck_reason}.")
|
||||
print(
|
||||
" Files appeared in the checkout while the update was "
|
||||
"downloading; committing the swap would delete them."
|
||||
)
|
||||
print(" Stash or commit your changes, then rerun `hermes update`.")
|
||||
print(" Files appeared in the checkout while the update was downloading; committing the swap would delete them.")
|
||||
print(_STASH_HINT)
|
||||
_m().sys.exit(1)
|
||||
_commit_staged_replacements(staged)
|
||||
except Exception:
|
||||
@@ -295,17 +277,12 @@ def _download_and_swap_zip(branch: str, zip_url: str) -> None:
|
||||
# Safe post-rollback: _discard_staged skips paths that no longer exist.
|
||||
_discard_staged(staged)
|
||||
raise
|
||||
|
||||
print(f"✓ Updated {len(staged)} items from ZIP")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ ZIP update failed: {e}")
|
||||
# Two-phase replace commits all or rolls all back, so no mixed tree here — don't push a needless reinstall.
|
||||
print(" Your existing install was left in place.")
|
||||
print(
|
||||
" Re-run `hermes update` to retry; if the agent won't start, "
|
||||
"reinstall from https://hermes-agent.nousresearch.com"
|
||||
)
|
||||
print(" Re-run `hermes update` to retry; if the agent won't start, reinstall from https://hermes-agent.nousresearch.com")
|
||||
_m().sys.exit(1)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
@@ -318,11 +295,8 @@ def _reinstall_python_deps_after_zip(active_tool_dependencies) -> None:
|
||||
)
|
||||
|
||||
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
||||
# Keep managed uv current — runs `uv self update` if we already have one.
|
||||
update_managed_uv()
|
||||
|
||||
update_managed_uv() # keep managed uv current — runs `uv self update` if we already have one
|
||||
uv_bin = ensure_uv()
|
||||
|
||||
pip_cmd = [_m().sys.executable, "-m", "pip"]
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
@@ -341,23 +315,20 @@ def _reinstall_python_deps_after_zip(active_tool_dependencies) -> None:
|
||||
# Runs inside the ZIP-fallback error handler, so cmd_update's boundary except cannot catch
|
||||
# it — refuse here with the same defer-via-marker contract.
|
||||
_refuse_update_for_contended_shims(_sqe)
|
||||
install_prefix, install_env = [uv_bin, "pip"], uv_env
|
||||
else:
|
||||
# sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors.
|
||||
_ensure_venv_pip(pip_cmd, _m().sys.executable)
|
||||
_m()._install_python_dependencies_with_optional_fallback(pip_cmd)
|
||||
|
||||
install_prefix = [uv_bin, "pip"] if uv_bin else pip_cmd
|
||||
install_env = uv_env if uv_bin else None
|
||||
install_prefix, install_env = pip_cmd, None
|
||||
_m()._restore_active_tool_dependencies(active_tool_dependencies, install_prefix, env=install_env)
|
||||
|
||||
# Parity with git-pull path: heal the active memory provider's bridge packages after the reinstall.
|
||||
_m()._refresh_active_memory_provider_dependencies()
|
||||
|
||||
|
||||
def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> bool:
|
||||
"""Update via ZIP archive; used on Windows when git file I/O is broken (antivirus / NTFS filter
|
||||
drivers causing 'Invalid argument'). Returns ``False`` when a Desktop rebuild ran and failed.
|
||||
"""
|
||||
drivers causing 'Invalid argument'). Returns ``False`` when a Desktop rebuild ran and failed."""
|
||||
from hermes_cli.update_cmd import (
|
||||
_finish_dashboard_update_cleanup, _m, _print_bundled_skills_sync_report, _print_curator_first_run_notice,
|
||||
_print_curator_recent_run_notice, _print_update_summary, _read_project_version, _rebuild_desktop_after_update,
|
||||
@@ -365,10 +336,7 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> boo
|
||||
_verify_and_restore_state_dbs_post_update,
|
||||
)
|
||||
active_tool_dependencies = _m()._capture_active_tool_dependencies()
|
||||
|
||||
# Snapshot the pre-update version before files are replaced so the completion line can report it.
|
||||
pre_update_version = _read_project_version()
|
||||
|
||||
pre_update_version = _read_project_version() # snapshot before files are replaced, for the completion line
|
||||
# The static archive would silently ignore --branch — the exact silent-divergence bug it exists to
|
||||
# prevent. Refuse rather than lie.
|
||||
branch = _m()._resolve_update_branch(args)
|
||||
@@ -382,20 +350,13 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> boo
|
||||
)
|
||||
_m().sys.exit(1)
|
||||
_abort_zip_update_if_dirty_tree()
|
||||
zip_url = f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip"
|
||||
|
||||
_download_and_swap_zip(branch, zip_url)
|
||||
|
||||
_download_and_swap_zip(branch, f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip")
|
||||
_sweep_bytecode_after_update(branch)
|
||||
|
||||
# Prefer .[all]; if one extra breaks, keep base deps and retry remaining extras individually so
|
||||
# capabilities aren't silently stripped. Self-lock deferral: the code swap is committed; defer only
|
||||
# the dependency sync when this process holds a native extension the sync must rewrite.
|
||||
# Self-lock deferral: the code swap is committed; defer only the dependency sync when this process
|
||||
# holds a native extension the sync must rewrite.
|
||||
_m()._abort_dependency_sync_if_self_locked()
|
||||
print("→ Updating Python dependencies...")
|
||||
|
||||
_reinstall_python_deps_after_zip(active_tool_dependencies)
|
||||
|
||||
# Verify the tree imports (catches the parse-OK-but-skewed tree an interrupted copy leaves). Runs
|
||||
# *after* the dep reinstall so a genuinely-new third-party requirement isn't misreported as a partial
|
||||
# copy. No SHA to roll back to — surface a concrete recovery step instead of success over a bricked install.
|
||||
@@ -408,27 +369,22 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> boo
|
||||
print(" This usually means the copy was interrupted partway through.")
|
||||
print(" Re-run `hermes update` to complete it.")
|
||||
_m().sys.exit(1)
|
||||
|
||||
node_failures = _update_node_dependencies()
|
||||
_m()._build_web_ui(_m().PROJECT_ROOT / "web")
|
||||
desktop_build_ok = _rebuild_desktop_after_update(
|
||||
_m().PROJECT_ROOT / "apps" / "desktop", had_desktop_app_before_update=had_desktop_app_before_update,
|
||||
)
|
||||
|
||||
with suppress(Exception):
|
||||
print("→ Syncing bundled skills...")
|
||||
_print_bundled_skills_sync_report()
|
||||
|
||||
# Seed the model-catalog disk cache from the fresh checkout (same rationale as _cmd_update_impl). Non-fatal.
|
||||
with _best_effort('Model catalog seed during zip update failed: %s'):
|
||||
from hermes_cli.model_catalog import seed_cache_from_checkout
|
||||
if seed_cache_from_checkout(_m().PROJECT_ROOT):
|
||||
print(" ✓ Model catalog cache refreshed from checkout")
|
||||
|
||||
# state.db integrity guard: root home AND every sibling profile, each auto-restored from its own snapshot.
|
||||
with _best_effort('Post-update state.db integrity check (zip path) failed: %s'):
|
||||
_verify_and_restore_state_dbs_post_update()
|
||||
|
||||
update_complete = _print_update_summary(
|
||||
node_failures=node_failures, desktop_build_ok=desktop_build_ok, pre_update_version=pre_update_version,
|
||||
)
|
||||
|
||||
+101
-114
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,17 +20,14 @@ class RuntimeRecord:
|
||||
"""One running (or expected) Hermes runtime on this machine."""
|
||||
|
||||
kind: str # gateway | dashboard | serve
|
||||
profile: str # profile name ("default", ...)
|
||||
pid: Optional[int] = None # live PID when known
|
||||
supervisor: str = "manual" # systemd | launchd | desktop | manual
|
||||
profile: str
|
||||
pid: Optional[int] = None
|
||||
supervisor: str = "manual" # systemd | launchd | desktop | windows-service | service | manual | manual-serve
|
||||
code_sha: Optional[str] = None # stamped running-code sha
|
||||
code_version: Optional[str] = None
|
||||
restart_via: str = "" # human-readable restart mechanism
|
||||
restart_via: str = "" # mechanism id, see _RESTART_MECHANISMS
|
||||
detail: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdatePlan:
|
||||
@@ -51,9 +48,8 @@ class UpdatePlan:
|
||||
def _detect_supervisor_for_pid(pid: int, service_pids: set, windows_service_pids: set | None = None) -> str:
|
||||
"""Classify how a live gateway PID is supervised."""
|
||||
if windows_service_pids and pid in windows_service_pids:
|
||||
# SCM-supervised Windows gateway (WinSW/NSSM/sc.exe create): the update pause machinery
|
||||
# stops the SERVICE via sc.exe instead of killing the child, so reconciliation must plan
|
||||
# it under its own mechanism id, not "manual".
|
||||
# SCM-supervised Windows gateway: the update pause machinery stops the SERVICE via sc.exe
|
||||
# instead of killing the child, so reconciliation must plan it under its own mechanism id.
|
||||
return "windows-service"
|
||||
if pid not in service_pids:
|
||||
return "manual"
|
||||
@@ -119,26 +115,17 @@ def _probe(label: str):
|
||||
logger.debug("%s failed: %s", label, exc)
|
||||
|
||||
|
||||
def collect_runtime_inventory() -> UpdatePlan:
|
||||
"""Build the pre-update plan. Read-only; never raises.
|
||||
|
||||
Every collector degrades independently — a probe failure yields fewer rows, not an exception.
|
||||
The result is embeddable in the update receipt and printable via :func:`print_update_plan`.
|
||||
"""
|
||||
plan = UpdatePlan()
|
||||
def _collect_install_shape(plan: UpdatePlan) -> None:
|
||||
with _probe("Install-method probe"):
|
||||
from hermes_cli.config import detect_install_method, get_managed_system, recommended_update_command_for_method
|
||||
|
||||
method = detect_install_method()
|
||||
plan.install_method = method
|
||||
managed = get_managed_system()
|
||||
if managed:
|
||||
plan.install_method = managed
|
||||
plan.install_method = managed or method
|
||||
plan.updatable_in_place = method in ("git", "unknown") and not managed
|
||||
# Baked image provenance: when the image marker is present it is authoritative — a
|
||||
# bind-mounted checkout inside a container can look like `git` to the heuristics while
|
||||
# the running filesystem is actually an immutable image. Fail-closed: an invalid marker
|
||||
# still flips the plan to not-updatable.
|
||||
# Baked image provenance is authoritative when present: a bind-mounted checkout inside a
|
||||
# container can look like `git` while the running filesystem is an immutable image.
|
||||
# Fail-closed: an invalid marker still flips the plan to not-updatable.
|
||||
with _probe("Image provenance probe"):
|
||||
from hermes_cli.image_provenance import read_image_provenance
|
||||
|
||||
@@ -148,24 +135,15 @@ def collect_runtime_inventory() -> UpdatePlan:
|
||||
if provenance.valid and provenance.manager:
|
||||
plan.install_method = provenance.manager
|
||||
plan.update_mechanism = recommended_update_command_for_method(method)
|
||||
with _probe("Code-identity probe"):
|
||||
from hermes_cli.build_info import get_code_identity
|
||||
|
||||
identity = get_code_identity(refresh=True)
|
||||
plan.expected_sha = identity.get("sha")
|
||||
plan.expected_version = identity.get("version")
|
||||
profile_homes = []
|
||||
with _probe("Profile enumeration"):
|
||||
from hermes_cli.update_receipt import _profile_homes
|
||||
|
||||
profile_homes = _profile_homes()
|
||||
plan.profiles = [name for name, _ in profile_homes]
|
||||
def _supervisor_classifier() -> Callable[[int], str]:
|
||||
"""``pid -> supervisor`` over the service-PID sets; each probe degrades to an empty set."""
|
||||
service_pids: set = set()
|
||||
with _probe("Service-PID probe"):
|
||||
from hermes_cli.gateway import _get_service_pids
|
||||
|
||||
service_pids = _get_service_pids(all_profiles=True) or set()
|
||||
|
||||
# Windows SCM services (no-op off Windows): the update's pause phase stops these via `sc.exe
|
||||
# stop` / restarts via `sc.exe start`, so the plan must carry the matching mechanism id.
|
||||
windows_service_pids: set = set()
|
||||
@@ -173,12 +151,14 @@ def collect_runtime_inventory() -> UpdatePlan:
|
||||
from hermes_cli.gateway import find_windows_gateway_services
|
||||
|
||||
windows_service_pids = {int(service.gateway_pid) for service in find_windows_gateway_services()}
|
||||
return lambda pid: _detect_supervisor_for_pid(pid, service_pids, windows_service_pids)
|
||||
|
||||
def _supervisor(pid: int) -> str:
|
||||
return _detect_supervisor_for_pid(pid, service_pids, windows_service_pids)
|
||||
# Per-profile gateways: control-socket identity first (declared by the process itself,
|
||||
# including supervisor provenance — no argv/PID inference), gateway_state.json fallback.
|
||||
seen_pids: set[int] = set()
|
||||
|
||||
def _collect_gateway_runtimes(plan: UpdatePlan, profile_homes: list, seen: set[int]) -> None:
|
||||
"""Per-profile gateways: control-socket identity first (declared by the process itself, including
|
||||
supervisor provenance — no argv/PID inference), ``gateway_state.json`` fallback, then PID-file
|
||||
mapped gateways no status record covers."""
|
||||
supervisor = _supervisor_classifier()
|
||||
with _probe("Gateway-state inventory"):
|
||||
from gateway.status import _pid_exists, read_runtime_status
|
||||
from hermes_cli.update_receipt import _socket_identity
|
||||
@@ -186,53 +166,45 @@ def collect_runtime_inventory() -> UpdatePlan:
|
||||
for profile, home in profile_homes:
|
||||
sock = _socket_identity(home)
|
||||
if sock is not None:
|
||||
sock_pid, identity = sock
|
||||
if sock_pid in seen_pids:
|
||||
# One multiplex gateway answers identify for several profile homes — one record per process.
|
||||
continue
|
||||
seen_pids.add(sock_pid)
|
||||
declared = identity.get("supervisor")
|
||||
supervisor = str(declared) if declared else _supervisor(sock_pid)
|
||||
record: dict = identity
|
||||
pid, record = sock
|
||||
if pid in seen:
|
||||
continue # one multiplex gateway answers identify for several homes — one record per process
|
||||
seen.add(pid)
|
||||
declared = record.get("supervisor")
|
||||
sup = str(declared) if declared else supervisor(pid)
|
||||
else:
|
||||
record = read_runtime_status(home / "gateway_state.json") or {}
|
||||
try:
|
||||
sock_pid = int(record.get("pid"))
|
||||
pid = int(record.get("pid"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not _pid_exists(sock_pid):
|
||||
if not _pid_exists(pid):
|
||||
continue
|
||||
seen_pids.add(sock_pid)
|
||||
supervisor = _supervisor(sock_pid)
|
||||
plan.runtimes.append(
|
||||
_runtime("gateway", profile, sock_pid, supervisor, record.get("code_sha"), record.get("code_version"))
|
||||
)
|
||||
|
||||
# PID-file mapped gateways not covered by a runtime-status record
|
||||
seen.add(pid)
|
||||
sup = supervisor(pid)
|
||||
plan.runtimes.append(_runtime("gateway", profile, pid, sup, record.get("code_sha"), record.get("code_version")))
|
||||
with _probe("PID-file gateway inventory"):
|
||||
from hermes_cli.gateway import find_profile_gateway_processes
|
||||
|
||||
for proc in find_profile_gateway_processes():
|
||||
if proc.pid in seen_pids:
|
||||
continue
|
||||
seen_pids.add(proc.pid)
|
||||
plan.runtimes.append(_runtime("gateway", proc.profile, proc.pid, _supervisor(proc.pid)))
|
||||
if proc.pid not in seen:
|
||||
seen.add(proc.pid)
|
||||
plan.runtimes.append(_runtime("gateway", proc.profile, proc.pid, supervisor(proc.pid)))
|
||||
|
||||
# Serve/dashboard backends from the spawn ledger — runtimes the gateway collectors can never
|
||||
# see (a manual `hermes serve --host <ip>` for a remote Desktop, a long-lived `hermes dashboard`).
|
||||
# ledger_entries() live-verifies (pid, create_time) so PID reuse never fabricates a row. Desktop-
|
||||
# supervised backends (spawner still alive) restart via the Desktop's own respawn, not ours.
|
||||
|
||||
def _collect_ledger_runtimes(plan: UpdatePlan, seen: set[int]) -> None:
|
||||
"""Serve/dashboard backends from the spawn ledger — runtimes the gateway collectors can never see
|
||||
(a manual `hermes serve --host <ip>` for a remote Desktop, a long-lived `hermes dashboard`).
|
||||
ledger_entries() live-verifies (pid, create_time) so PID reuse never fabricates a row. Desktop-
|
||||
supervised backends (spawner still alive) restart via the Desktop's own respawn, not ours."""
|
||||
with _probe("Serve/dashboard ledger inventory"):
|
||||
from hermes_cli.process_identity import ledger_entries, spawner_is_dead
|
||||
|
||||
for entry in ledger_entries():
|
||||
purpose = entry.get("purpose")
|
||||
if purpose not in _SERVE_KINDS:
|
||||
purpose, pid = entry.get("purpose"), entry.get("pid")
|
||||
if purpose not in _SERVE_KINDS or not isinstance(pid, int) or pid in seen:
|
||||
continue
|
||||
pid = entry.get("pid")
|
||||
if not isinstance(pid, int) or pid in seen_pids:
|
||||
continue
|
||||
seen_pids.add(pid)
|
||||
seen.add(pid)
|
||||
# detail.create_time: process incarnation, not just the numeric PID — a post-update
|
||||
# survivor probe comparing PIDs alone calls a NEW serve that reused the number a survivor.
|
||||
plan.runtimes.append(_runtime(
|
||||
@@ -243,6 +215,30 @@ def collect_runtime_inventory() -> UpdatePlan:
|
||||
"port": entry.get("port"), "create_time": entry.get("create_time"),
|
||||
},
|
||||
))
|
||||
|
||||
|
||||
def collect_runtime_inventory() -> UpdatePlan:
|
||||
"""Build the pre-update plan. Read-only; never raises — every collector degrades independently.
|
||||
|
||||
The result is embeddable in the update receipt and printable via :func:`print_update_plan`.
|
||||
"""
|
||||
plan = UpdatePlan()
|
||||
_collect_install_shape(plan)
|
||||
with _probe("Code-identity probe"):
|
||||
from hermes_cli.build_info import get_code_identity
|
||||
|
||||
identity = get_code_identity(refresh=True)
|
||||
plan.expected_sha = identity.get("sha")
|
||||
plan.expected_version = identity.get("version")
|
||||
profile_homes: list = []
|
||||
with _probe("Profile enumeration"):
|
||||
from hermes_cli.update_receipt import _profile_homes
|
||||
|
||||
profile_homes = _profile_homes()
|
||||
plan.profiles = [name for name, _ in profile_homes]
|
||||
seen: set[int] = set()
|
||||
_collect_gateway_runtimes(plan, profile_homes, seen)
|
||||
_collect_ledger_runtimes(plan, seen)
|
||||
return plan
|
||||
|
||||
|
||||
@@ -275,6 +271,15 @@ def _serve_unit_matches_profile(profile: str, unit: object) -> bool:
|
||||
return name in {f"hermes-serve{suffix}", f"hermes-dashboard{suffix}"}
|
||||
|
||||
|
||||
def _gateway_named_in(r: RuntimeRecord, names: set) -> bool:
|
||||
# The bare "hermes-gateway" unit name is gateway-specific: a serve/dashboard runtime that merely
|
||||
# shares the default profile is a different process the gateway restart never touched.
|
||||
return any(
|
||||
r.profile in name or (r.kind == "gateway" and r.profile == "default" and "hermes-gateway" in name)
|
||||
for name in names
|
||||
)
|
||||
|
||||
|
||||
def match_runtime_outcomes(
|
||||
plan: "UpdatePlan", *, restarted_services: list, relaunched_profiles: list,
|
||||
externally_supervised_profiles: list, killed_pids: set, failed_units: list,
|
||||
@@ -294,52 +299,35 @@ def match_runtime_outcomes(
|
||||
try:
|
||||
failed_set = {str(u) for u in (failed_units or [])}
|
||||
restarted_set = {str(s) for s in (restarted_services or [])}
|
||||
relaunched = set(relaunched_profiles or [])
|
||||
external = set(externally_supervised_profiles or [])
|
||||
relaunched = set(relaunched_profiles or []) | set(externally_supervised_profiles or [])
|
||||
killed = {int(p) for p in (killed_pids or set())}
|
||||
stale_serves = {int(p) for p in stale_serve_pids} if stale_serve_pids is not None else None
|
||||
|
||||
def _gateway_names(r: RuntimeRecord, names: set) -> bool:
|
||||
# The bare "hermes-gateway" unit name is gateway-specific: a serve/dashboard runtime
|
||||
# that merely shares the default profile is a different process the gateway restart
|
||||
# never touched, and must not borrow its outcome.
|
||||
return any(
|
||||
r.profile in name or (r.kind == "gateway" and r.profile == "default" and "hermes-gateway" in name)
|
||||
for name in names
|
||||
)
|
||||
|
||||
def _serve_outcome(r: RuntimeRecord) -> str:
|
||||
# Serve/dashboard outcome in its OWN vocabulary — never the gateway's.
|
||||
if r.pid is not None and r.pid in killed:
|
||||
return "stopped"
|
||||
if any(_serve_unit_matches_profile(r.profile, u) for u in failed_set):
|
||||
return "failed"
|
||||
if stale_serves is not None:
|
||||
# Incarnation-verified: the pre-update process is gone (replaced by its unit / the
|
||||
# dashboard cleanup respawn / the Desktop app) or it is still alive on pre-update code.
|
||||
return "unaccounted" if r.pid in stale_serves else "restarted"
|
||||
if any(_serve_unit_matches_profile(r.profile, s) for s in restarted_set):
|
||||
def _outcome(r: RuntimeRecord) -> str:
|
||||
killed_here = r.pid is not None and r.pid in killed
|
||||
if r.kind in _SERVE_KINDS:
|
||||
if killed_here:
|
||||
return "stopped"
|
||||
if any(_serve_unit_matches_profile(r.profile, u) for u in failed_set):
|
||||
return "failed"
|
||||
if stale_serves is not None:
|
||||
# Incarnation-verified: the pre-update process is gone (replaced by its unit / the
|
||||
# dashboard cleanup respawn / the Desktop app) or it is still alive on pre-update code.
|
||||
return "unaccounted" if r.pid in stale_serves else "restarted"
|
||||
return "restarted" if any(_serve_unit_matches_profile(r.profile, s) for s in restarted_set) else "unaccounted"
|
||||
if r.profile in relaunched:
|
||||
return "restarted"
|
||||
return "unaccounted"
|
||||
if killed_here:
|
||||
return "stopped"
|
||||
if _gateway_named_in(r, failed_set):
|
||||
return "failed"
|
||||
return "restarted" if _gateway_named_in(r, restarted_set) else "unaccounted"
|
||||
|
||||
for r in plan.runtimes:
|
||||
if not isinstance(r, RuntimeRecord):
|
||||
continue
|
||||
if r.kind in _SERVE_KINDS:
|
||||
outcome = _serve_outcome(r)
|
||||
elif r.profile in relaunched or r.profile in external:
|
||||
outcome = "restarted"
|
||||
elif r.pid is not None and r.pid in killed:
|
||||
outcome = "stopped"
|
||||
elif _gateway_names(r, failed_set):
|
||||
outcome = "failed"
|
||||
elif _gateway_names(r, restarted_set):
|
||||
outcome = "restarted"
|
||||
else:
|
||||
outcome = "unaccounted"
|
||||
outcomes.append(
|
||||
{"kind": r.kind, "profile": r.profile, "pid": r.pid, "mechanism": r.restart_via, "outcome": outcome}
|
||||
)
|
||||
if isinstance(r, RuntimeRecord):
|
||||
outcomes.append(
|
||||
{"kind": r.kind, "profile": r.profile, "pid": r.pid, "mechanism": r.restart_via, "outcome": _outcome(r)}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Runtime-outcome reconciliation failed: %s", exc)
|
||||
return outcomes
|
||||
@@ -364,8 +352,7 @@ def report_unaccounted_runtimes(outcomes: list[dict[str, Any]]) -> bool:
|
||||
print(" hermes gateway restart # active profile")
|
||||
print(" hermes -p <profile> gateway restart # named profile")
|
||||
if any(o.get("kind") in _SERVE_KINDS for o in missed):
|
||||
# A serve/dashboard is not reachable by any `gateway restart` command: name the process,
|
||||
# not the wrong verb.
|
||||
# A serve/dashboard is not reachable by any `gateway restart` command: name the process, not the wrong verb.
|
||||
print(" systemctl --user restart hermes-serve.service # unit-managed serve")
|
||||
print(" relaunch `hermes serve` / `hermes dashboard` / the Desktop app")
|
||||
return True
|
||||
|
||||
@@ -29,6 +29,15 @@ def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _code_identity(refresh: bool = False) -> dict[str, Any]:
|
||||
"""Running-code identity, or ``{}`` when the probe fails."""
|
||||
with suppress(Exception):
|
||||
from hermes_cli.build_info import get_code_identity
|
||||
|
||||
return get_code_identity(refresh=refresh) or {}
|
||||
return {}
|
||||
|
||||
|
||||
def _str_records(entries: Any, keys: tuple[str, ...], *, pid: bool = False) -> list[dict[str, Any]]:
|
||||
"""Dict entries reduced to stringified ``keys`` (plus an int ``pid`` first when requested)."""
|
||||
records = []
|
||||
@@ -49,13 +58,9 @@ class UpdateReceipt:
|
||||
"schema": 1, "started_at": _utc_now_iso(), "finished_at": None,
|
||||
"argv": list(sys.argv), "pid": os.getpid(),
|
||||
"outcome": "running", # running | success | partial | failed
|
||||
"pre_update": {}, "post_update": {},
|
||||
"pre_update": _code_identity(), "post_update": {},
|
||||
"steps": [], "skips": [], "gateway_restart": {}, "fleet": [],
|
||||
}
|
||||
with suppress(Exception):
|
||||
from hermes_cli.build_info import get_code_identity
|
||||
|
||||
self.data["pre_update"] = get_code_identity()
|
||||
|
||||
def step(self, name: str, ok: bool, detail: str = "") -> None:
|
||||
self.data["steps"].append({"name": name, "ok": bool(ok), "detail": detail, "at": _utc_now_iso()})
|
||||
@@ -107,10 +112,7 @@ class UpdateReceipt:
|
||||
def finalize(self, outcome: str) -> None:
|
||||
self.data["outcome"] = outcome
|
||||
self.data["finished_at"] = _utc_now_iso()
|
||||
with suppress(Exception):
|
||||
from hermes_cli.build_info import get_code_identity
|
||||
|
||||
self.data["post_update"] = get_code_identity(refresh=True)
|
||||
self.data["post_update"] = _code_identity(refresh=True)
|
||||
|
||||
|
||||
def _receipt_dir() -> Path:
|
||||
@@ -175,8 +177,7 @@ def finalize_update_receipt(outcome: str, fleet: list | None = None, stop_reason
|
||||
path = directory / f"update_{time.strftime('%Y%m%d_%H%M%S')}_{os.getpid()}.json"
|
||||
body = json.dumps(receipt.data, indent=2, default=str)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
# Stable pointer for the dashboard/desktop: latest receipt.
|
||||
with suppress(OSError):
|
||||
with suppress(OSError): # stable pointer for the dashboard/desktop
|
||||
(directory / "latest.json").write_text(body, encoding="utf-8")
|
||||
_prune_old_receipts(directory)
|
||||
return path
|
||||
@@ -265,6 +266,10 @@ def _fleet_row(
|
||||
}
|
||||
|
||||
|
||||
# Runtime-status states that do not describe a gateway that should be running now — no down row.
|
||||
_NOT_EXPECTED_STATES = {"stopped", "startup_failed"}
|
||||
|
||||
|
||||
def collect_fleet_versions(*, pre_restart_pids: Optional[list[int]] = None) -> list[dict[str, Any]]:
|
||||
"""Snapshot every profile's gateway code identity vs. the current tree.
|
||||
|
||||
@@ -272,15 +277,9 @@ def collect_fleet_versions(*, pre_restart_pids: Optional[list[int]] = None) -> l
|
||||
long-dead gateway (machine reboot, manual kill weeks ago) must NOT fail every future update.
|
||||
Without a pre-restart snapshot (``None``/empty) dead PIDs are skipped (historical behavior).
|
||||
"""
|
||||
# Runtime-status states that do not describe a gateway that should be running now — no down row.
|
||||
_NOT_EXPECTED_STATES = {"stopped", "startup_failed"}
|
||||
_pre_restart = {int(p) for p in (pre_restart_pids or []) if isinstance(p, int)}
|
||||
results: list[dict[str, Any]] = []
|
||||
expected_sha = None
|
||||
with suppress(Exception):
|
||||
from hermes_cli.build_info import get_code_identity
|
||||
|
||||
expected_sha = (get_code_identity(refresh=True) or {}).get("sha")
|
||||
expected_sha = _code_identity(refresh=True).get("sha")
|
||||
try:
|
||||
from gateway.status import read_runtime_status, runtime_status_pid_is_live
|
||||
|
||||
@@ -318,6 +317,14 @@ def collect_fleet_versions(*, pre_restart_pids: Optional[list[int]] = None) -> l
|
||||
return results
|
||||
|
||||
|
||||
_FLEET_ROW_LINES = {
|
||||
"current": " ✓ {profile} (pid {pid}) @ {short} — up to date",
|
||||
"stale": " ✗ {profile} (pid {pid}) @ {short} — STALE (pre-update code)",
|
||||
"down": " ✗ {profile} — DOWN (gateway was running before the update; pid {pid} is gone and nothing replaced it)",
|
||||
}
|
||||
_FLEET_ROW_UNKNOWN = " ? {profile} (pid {pid}) — version unknown (gateway predates version stamping; restart to enable)"
|
||||
|
||||
|
||||
def print_fleet_version_matrix(fleet: list[dict[str, Any]]) -> bool:
|
||||
"""Print the post-update fleet version matrix.
|
||||
|
||||
@@ -329,29 +336,16 @@ def print_fleet_version_matrix(fleet: list[dict[str, Any]]) -> bool:
|
||||
"""
|
||||
if not fleet:
|
||||
return False
|
||||
any_stale = any_down = False
|
||||
print()
|
||||
print("Fleet version check:")
|
||||
states = set()
|
||||
for entry in fleet:
|
||||
sha = entry.get("code_sha")
|
||||
short = sha[:8] if isinstance(sha, str) and sha else "?"
|
||||
state, profile, pid = entry.get("state"), entry.get("profile"), entry.get("pid")
|
||||
if state == "current":
|
||||
print(f" ✓ {profile} (pid {pid}) @ {short} — up to date")
|
||||
elif state == "stale":
|
||||
any_stale = True
|
||||
print(f" ✗ {profile} (pid {pid}) @ {short} — STALE (pre-update code)")
|
||||
elif state == "down":
|
||||
any_down = True
|
||||
print(
|
||||
f" ✗ {profile} — DOWN (gateway was running before the "
|
||||
f"update; pid {pid} is gone and nothing replaced it)"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f" ? {profile} (pid {pid}) — version unknown "
|
||||
"(gateway predates version stamping; restart to enable)"
|
||||
)
|
||||
states.add(entry.get("state"))
|
||||
print(_FLEET_ROW_LINES.get(entry.get("state"), _FLEET_ROW_UNKNOWN).format(
|
||||
profile=entry.get("profile"), pid=entry.get("pid"), short=sha[:8] if isinstance(sha, str) and sha else "?",
|
||||
))
|
||||
any_stale, any_down = "stale" in states, "down" in states
|
||||
if any_stale or any_down:
|
||||
print()
|
||||
if any_stale:
|
||||
|
||||
Reference in New Issue
Block a user