Merge branch 'simp/r3-25-L' into simp/integration3

This commit is contained in:
Teknium
2026-09-03 00:09:19 -07:00
4 changed files with 348 additions and 415 deletions
+126 -130
View File
@@ -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
View File
@@ -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
View File
@@ -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
+31 -37
View File
@@ -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: