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

This commit is contained in:
Teknium
2026-09-02 23:11:24 -07:00
2 changed files with 42 additions and 96 deletions
+11 -40
View File
@@ -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-<branch>-<ts>-<sha>``).
@@ -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)
+31 -56
View File
@@ -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)
@@ -799,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
@@ -917,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")
@@ -1011,15 +995,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")):
@@ -1276,24 +1258,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 <ip>` for a REMOTE Desktop):
# ledger identity only (spawner dead; Desktop-owned keep the refusal). Stop and