From 8145730f75508ff1413212f590b2207f5c8782c6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:43:25 -0700 Subject: [PATCH 1/4] refactor(hermes_cli/update): compact upstream-sync flow and git probe call sites in update_cmd_git --- hermes_cli/update_cmd_git.py | 51 ++++++++---------------------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/hermes_cli/update_cmd_git.py b/hermes_cli/update_cmd_git.py index 03c71ec74f..a5b0c4b640 100644 --- a/hermes_cli/update_cmd_git.py +++ b/hermes_cli/update_cmd_git.py @@ -33,11 +33,7 @@ def _git_ok(git_cmd, args, cwd, **kw) -> bool: def _prune_orphan_rescue_refs( - git_cmd, - cwd, - branch, - keep=_ORPHAN_RESCUE_REFS_TO_KEEP, - max_age_days=_ORPHAN_RESCUE_REF_MAX_AGE_DAYS, + git_cmd, cwd, branch, keep=_ORPHAN_RESCUE_REFS_TO_KEEP, max_age_days=_ORPHAN_RESCUE_REF_MAX_AGE_DAYS ) -> None: """Expire old orphan rescue refs (``refs/hermes-update-backups/orphan---``). @@ -49,9 +45,7 @@ def _prune_orphan_rescue_refs( from hermes_cli.update_cmd import _git_run with suppress(OSError): prefix = f"refs/hermes-update-backups/orphan-{branch}-" - list_result = _git_run( - git_cmd, ["for-each-ref", "--format=%(refname)", "--sort=refname", f"{prefix}*"], cwd, - ) + list_result = _git_run(git_cmd, ["for-each-ref", "--format=%(refname)", "--sort=refname", f"{prefix}*"], cwd) if list_result.returncode != 0: return refs = [line.strip() for line in list_result.stdout.splitlines() if line.strip()] @@ -277,27 +271,21 @@ def _sync_with_upstream_if_needed( the check never happened, so the caller never reports "up to date" on an origin-only compare. """ from hermes_cli.update_cmd import ( - _add_upstream_remote, - _count_commits_between, - _has_upstream_remote, - _mark_skip_upstream_prompt, - _no_prompt_git_kwargs, - _should_skip_upstream_prompt, + _add_upstream_remote, _count_commits_between, _has_upstream_remote, + _mark_skip_upstream_prompt, _no_prompt_git_kwargs, _should_skip_upstream_prompt, ) if not _has_upstream_remote(git_cmd, cwd): if _should_skip_upstream_prompt(): return False - print( "\nℹ Your fork is not tracking the official Hermes repository.\n" " This means you may miss updates from NousResearch/hermes-agent.\n" ) if assume_yes or (input_fn is None and not (sys.stdin.isatty() and sys.stdout.isatty())): # --yes means "don't block", not "mutate my remotes"; don't persist the decline. - print(" Skipping upstream setup (non-interactive run).") - print(" Add it later with: git remote add upstream https://github.com/NousResearch/hermes-agent.git") + print(" Skipping upstream setup (non-interactive run).\n" + " Add it later with: git remote add upstream https://github.com/NousResearch/hermes-agent.git") return False - if input_fn is not None: response = input_fn("Add official repo as 'upstream' remote? [y/N]", "n").strip().lower() else: @@ -306,7 +294,6 @@ def _sync_with_upstream_if_needed( except (EOFError, KeyboardInterrupt, UnicodeDecodeError): print() response = "n" - if response not in {"", "y", "yes"}: print(" Skipped. Run 'git remote add upstream https://github.com/NousResearch/hermes-agent.git' to add later.") _mark_skip_upstream_prompt() @@ -320,21 +307,16 @@ def _sync_with_upstream_if_needed( # Only upstream/main: a bare fetch drags in thousands of auto-generated branches. print("\n→ Fetching upstream...") try: - subprocess.run( - git_cmd + ["fetch", "upstream", "main", "--quiet"], - cwd=cwd, capture_output=True, check=True, **_no_prompt_git_kwargs(), - ) + subprocess.run(git_cmd + ["fetch", "upstream", "main", "--quiet"], cwd=cwd, capture_output=True, check=True, **_no_prompt_git_kwargs()) except subprocess.CalledProcessError: print(" ✗ Failed to fetch upstream. Skipping upstream sync.") return False origin_ahead = _count_commits_between(git_cmd, cwd, "upstream/main", "origin/main") upstream_ahead = _count_commits_between(git_cmd, cwd, "origin/main", "upstream/main") - if origin_ahead < 0 or upstream_ahead < 0: print(" ✗ Could not compare branches. Skipping upstream sync.") return False - if origin_ahead > 0: print( f"\nℹ Your fork has {origin_ahead} commit(s) not on upstream.\n" @@ -342,17 +324,13 @@ def _sync_with_upstream_if_needed( " If you want to merge upstream changes, run:\n git pull upstream main" ) return True - if upstream_ahead == 0: print(" ✓ Fork is up to date with upstream") return True print(f"\n→ Fork is {upstream_ahead} commit(s) behind upstream\n→ Pulling from upstream...") try: - subprocess.run( - git_cmd + ["pull", "--ff-only", "upstream", "main"], - cwd=cwd, check=True, **_no_prompt_git_kwargs(), - ) + subprocess.run(git_cmd + ["pull", "--ff-only", "upstream", "main"], cwd=cwd, check=True, **_no_prompt_git_kwargs()) except subprocess.CalledProcessError: print(" ✗ Failed to pull from upstream. You may need to resolve conflicts manually.") return False @@ -430,8 +408,7 @@ def _portable_git_candidates() -> list: from hermes_cli.update_cmd import get_default_hermes_root, get_hermes_home candidates = [] with suppress(Exception): - for root in (get_default_hermes_root(), Path(get_hermes_home())): - candidates.append(root / "git" / "mingw64" / "libexec" / "git-core" / "git.exe") + candidates += [root / "git" / "mingw64" / "libexec" / "git-core" / "git.exe" for root in (get_default_hermes_root(), Path(get_hermes_home()))] return candidates @@ -459,10 +436,7 @@ def _ensure_non_trampoline_git(git_cmd: list) -> list: return git_cmd real_git = _locate_real_git() if real_git is None: - print( - "⚠ Detected a broken git trampoline and could not locate a real " - "git binary — the update will fall back to the ZIP path." - ) + print("⚠ Detected a broken git trampoline and could not locate a real git binary — the update will fall back to the ZIP path.") return git_cmd print(f"⚠ Detected a broken git trampoline; switching to real git at {real_git}") return [str(real_git)] + list(git_cmd[1:]) @@ -479,10 +453,7 @@ def _discard_lockfile_churn(git_cmd, repo_root): return changed = [line.strip() for line in diff.stdout.splitlines()] dirty_package_dirs = {Path(p).parent for p in changed if p.endswith("package.json")} - dirty = [ - p for p in changed - if p.endswith("package-lock.json") and Path(p).parent not in dirty_package_dirs - ] + dirty = [p for p in changed if p.endswith("package-lock.json") and Path(p).parent not in dirty_package_dirs] if not dirty: return _git_run(git_cmd, ["checkout", "--", *dirty], repo_root) From d8a1ee3bfd90948a7de464d378497a4bb6089de1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:50:53 -0700 Subject: [PATCH 2/4] refactor(hermes_cli/update): table-drive the ledger/orphan tree-reap rungs --- hermes_cli/update_cmd_windows.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/hermes_cli/update_cmd_windows.py b/hermes_cli/update_cmd_windows.py index 3d076397c1..bc3890c1aa 100644 --- a/hermes_cli/update_cmd_windows.py +++ b/hermes_cli/update_cmd_windows.py @@ -1276,24 +1276,17 @@ def _clear_windows_venv_holders_or_exit(args, gateway_mode: bool, _windows_gatew f" ⚠ {len(_gateway_holders)} gateway process(es) still hold the venv after the pause; stopping them", _gateway_holders, stop=_terminate_leftover_gateways, ) - if _venv_holders: - # Positive-identity rung (any context): spawn ledger proves the holder is an - # orphaned backend (self-registered, spawner provably dead). No PPID archaeology. - _ledger_backends = _m()._ledger_reapable_backend_pids(_venv_holders) - if _ledger_backends: - _venv_holders = _reap_and_rescan( - f" ⚠ {len(_ledger_backends)} ledger-identified orphaned " - "Hermes backend process(es) hold the venv; stopping their trees", _ledger_backends, - ) - if _venv_holders: - # Desktop `serve` backends whose app is GONE: nothing respawns an orphan, so - # reap the tree. Live-Desktop backends return None and keep the refusal. - _orphan_backends = _m()._orphaned_desktop_backend_pids(_venv_holders) - if _orphan_backends: - _venv_holders = _reap_and_rescan( - f" ⚠ {len(_orphan_backends)} orphaned Desktop backend " - "process(es) still hold the venv; stopping their trees", _orphan_backends, - ) + # Tree-reap rungs: (classifier, message). Ledger rung = positive identity in any context (self-registered + # backend, spawner provably dead; no PPID archaeology). Orphan rung = Desktop `serve` whose app is GONE + # (nothing respawns an orphan); live-Desktop backends return None and keep the refusal. + for classifier, message in ( + (_m()._ledger_reapable_backend_pids, "ledger-identified orphaned Hermes backend process(es) hold the venv"), + (_m()._orphaned_desktop_backend_pids, "orphaned Desktop backend process(es) still hold the venv"), + ): + if _venv_holders: + backends = classifier(_venv_holders) + if backends: + _venv_holders = _reap_and_rescan(f" ⚠ {len(backends)} {message}; stopping their trees", backends) if _venv_holders: # Manual serve/dashboard rung (e.g. `hermes serve --host ` for a REMOTE Desktop): # ledger identity only (spawner dead; Desktop-owned keep the refusal). Stop and From 5607b7beeb5636cee230c9ebba60c14152ab4a0c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:55:58 -0700 Subject: [PATCH 3/4] refactor(hermes_cli/update): hoist stdlib imports, single-line remaining signatures in update_cmd_windows --- hermes_cli/update_cmd_windows.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/hermes_cli/update_cmd_windows.py b/hermes_cli/update_cmd_windows.py index bc3890c1aa..e50e977654 100644 --- a/hermes_cli/update_cmd_windows.py +++ b/hermes_cli/update_cmd_windows.py @@ -7,10 +7,12 @@ Origin helpers are imported lazily per function (no cycle; test patches on the o import logging from contextlib import contextmanager, suppress import os +import re +import shlex import subprocess import sys import time as _time -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from hermes_cli.update_cmd_common import _best_effort @@ -31,8 +33,6 @@ def _abort_on_error(prefix: str): def _write_update_planned_stop_marker(profile_path: Path, pid: int) -> bool: """Write a planned-stop marker into a specific profile home.""" try: - from datetime import timezone - from gateway.status import _get_process_start_time from utils import atomic_json_write @@ -218,8 +218,6 @@ def _hermes_holder_subcommand(cmdline: str) -> str | None: ``hermes(.exe)`` entry token, return the first following token that isn't a flag or a flag's value. """ try: - import shlex - tokens = shlex.split(cmdline, posix=False) except Exception: tokens = cmdline.split() @@ -352,9 +350,7 @@ def _refuse_gateway_ancestor_tree_kill(pids: list[int], *, gateway_mode: bool) - return True -def _ledger_manual_serve_holders( - matches: list[tuple[int, str, str]], -) -> list[dict]: +def _ledger_manual_serve_holders(matches: list[tuple[int, str, str]]) -> list[dict]: """Full ledger entries for venv holders that are MANUAL serve/dashboard backends. Positive identity only: self-registered purpose serve/dashboard, live (pid, create_time), recorded spawner @@ -559,9 +555,7 @@ def _handoff_reapable_backend_pids(matches: list[tuple[int, str, str]]) -> list[ return roots or None -def _stop_process_trees( - pids: list[int] | list[tuple[int, int]], -) -> None: +def _stop_process_trees(pids: list[int] | list[tuple[int, int]]) -> None: """Force-stop each PID with its full child tree (Windows); best effort, never raises. ``taskkill /T /F``: stopping only the parent can leave a ``.hermes-runtime`` child holding the install open. @@ -570,11 +564,7 @@ def _stop_process_trees( from hermes_cli._subprocess_compat import pid_is_hermes, windows_hide_flags for entry in pids: - if isinstance(entry, tuple): - pid, expected_start_time = entry - else: - pid = int(entry) - expected_start_time = get_process_start_time(pid) + pid, expected_start_time = entry if isinstance(entry, tuple) else (int(entry), get_process_start_time(int(entry))) try: if expected_start_time is None: logger.debug("Skipping taskkill of PID %s: process identity unavailable", pid) @@ -1011,15 +1001,13 @@ def _refresh_bootstrap_cache_scripts(branch: str = "main") -> None: """ from hermes_cli.update_cmd import _m with _best_effort('Could not refresh bootstrap-cache scripts after update: %s'): - import re as _re - cache_dir = Path(_m().get_hermes_home()) / "bootstrap-cache" if not cache_dir.is_dir(): return # Mirror install_script.rs::sanitize_ref(). - safe_ref = _re.sub(r"[^A-Za-z0-9._-]", "_", str(branch or "main")) + safe_ref = re.sub(r"[^A-Za-z0-9._-]", "_", str(branch or "main")) # Mirror install_script.rs::is_valid_commit(): immutable commit pin, never rewrite. - if _re.fullmatch(r"[0-9a-fA-F]{7,40}", safe_ref): + if re.fullmatch(r"[0-9a-fA-F]{7,40}", safe_ref): return refreshed = [] for kind, src_name in (("ps1", "install.ps1"), ("sh", "install.sh")): From 5bf59a2e3a248cb368bb5583f49f708fd16ab50b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:59:37 -0700 Subject: [PATCH 4/4] refactor(hermes_cli/update): fold argv snapshot + SCM token bookkeeping in the Windows pause path --- hermes_cli/update_cmd_windows.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/hermes_cli/update_cmd_windows.py b/hermes_cli/update_cmd_windows.py index e50e977654..6dc9585ff0 100644 --- a/hermes_cli/update_cmd_windows.py +++ b/hermes_cli/update_cmd_windows.py @@ -789,13 +789,9 @@ def _pause_windows_gateway_services(service_gateways, token: dict, profiles: dic paused_services.append(current_service_name) current_service_name = None if paused_services: - token["services"] = paused_services - token["expected_services"] = list(paused_services) - token["restarted_services"] = [] + token.update(services=paused_services, expected_services=list(paused_services), restarted_services=[]) token["service_profiles"] = { - str(service.name): str(service.profile) - for service in service_gateways - if str(service.name) in paused_services + str(s.name): str(s.profile) for s in service_gateways if str(s.name) in paused_services } print(" ✓ Paused Windows gateway service(s): " + ", ".join(paused_services)) return token @@ -907,34 +903,32 @@ def _pause_windows_gateways_for_update() -> dict | None: survivors = _m()._wait_for_windows_update_gateway_exit(mapped_pids, timeout=drain_timeout) unmapped_pids = [pid for pid in running_pids if pid not in profile_processes and pid not in service_gateway_pids] - # Snapshot unmapped gateways' argv *before* force-killing so resume can replay it. - # Unmapped = no profile->PID-file mapping (e.g. Scheduled Task ``pythonw.exe -m ...``). - unmapped: list[dict] = [] - for pid in unmapped_pids: - argv = None + def _argv_or_none(pid: int): try: - argv = _capture_gateway_argv(int(pid)) + return _capture_gateway_argv(pid) except Exception as exc: logger.debug("Could not capture argv for unmapped gateway %s: %s", pid, exc) - unmapped.append({"pid": int(pid), "argv": argv}) + return None + + # Snapshot unmapped gateways' argv *before* force-killing so resume can replay it. + # Unmapped = no profile->PID-file mapping (e.g. Scheduled Task ``pythonw.exe -m ...``). + unmapped = [{"pid": int(pid), "argv": _argv_or_none(int(pid))} for pid in unmapped_pids] # Tree-kill survivors, unmapped gateways, and pre-drain launchers; a launcher # already gone with its worker raises ProcessLookupError and is skipped. force_killed = [] for pid in sorted(set(survivors).union(unmapped_pids).union(launcher_pids)): with suppress(ProcessLookupError, PermissionError, OSError): - pid_int = int(pid) - terminate_pid(pid_int, force=True, expected_start_time=get_process_start_time(pid_int)) - force_killed.append(pid_int) + terminate_pid(int(pid), force=True, expected_start_time=get_process_start_time(int(pid))) + force_killed.append(int(pid)) if profiles: print(f" ✓ Paused gateway profile(s): {', '.join(sorted(profiles))}") if force_killed: print(f" → Force-stopped {len(force_killed)} gateway process(es)") - if unmapped_pids: print(f" → Stopped {len(unmapped_pids)} gateway process(es) without profile mapping") - if sum(1 for u in unmapped if u.get("argv")) < len(unmapped_pids): + if any(not u.get("argv") for u in unmapped): # No recoverable cmdline (psutil missing, access denied, gone): manual restart. print(" Restart manually after update: hermes gateway run")