diff --git a/hermes_cli/update_cmd_stash.py b/hermes_cli/update_cmd_stash.py index 36d4d769b7..75e416429e 100644 --- a/hermes_cli/update_cmd_stash.py +++ b/hermes_cli/update_cmd_stash.py @@ -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 diff --git a/hermes_cli/update_cmd_zip.py b/hermes_cli/update_cmd_zip.py index ef8c656fda..b86c719f9f 100644 --- a/hermes_cli/update_cmd_zip.py +++ b/hermes_cli/update_cmd_zip.py @@ -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-/``; 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-/ - 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, ) diff --git a/hermes_cli/update_inventory.py b/hermes_cli/update_inventory.py index a91c40e587..5a43cdd762 100644 --- a/hermes_cli/update_inventory.py +++ b/hermes_cli/update_inventory.py @@ -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 ` 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 ` 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 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 diff --git a/hermes_cli/update_receipt.py b/hermes_cli/update_receipt.py index e2c5e47894..034128c921 100644 --- a/hermes_cli/update_receipt.py +++ b/hermes_cli/update_receipt.py @@ -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: