perf(update): cut redundant network + subprocess work from hermes update

Four fixes on the updater path:

1. uv self update freshness gate + timeout (managed_uv.py): the network
   self-update ran on EVERY hermes update — including the 'Already up to
   date!' fast path — with NO timeout (unbounded hang risk offline). Now
   skipped when it succeeded within 7 days (stamp file under
   HERMES_HOME/cache), capped at 60s, force= override available. The
   CVE-driven vulnerable-runtime repair probe is NEVER gated — it still
   runs on every invocation.

2. Drop the second network fetch from the pull step (main.py): the update
   flow fetched origin/<branch>, counted commits, then ran
   'git pull --ff-only origin <branch>' — a SECOND fetch of the same ref
   (~0.5-1.5s). Now merges the already-fetched tracking ref via
   'git merge --ff-only origin/<branch>'; the diverged-history reset
   fallback is unchanged.

3. Probe the upstream remote locally before fetching it (_cmd_update_check):
   non-fork installs have no 'upstream' remote, and --check burned a
   failed network attempt (~0.3-1s) on every run before falling back to
   origin. 'git remote get-url upstream' (~1ms local) now gates the fetch.

4. Desktop rebuild check reads the content-hash stamp in-process before
   spawning 'hermes desktop --build-only' (a full CLI re-import, ~1-3s)
   just to learn nothing changed. Stamp errors fall through to the
   subprocess path unchanged.

Savings on a no-op 'hermes update': ~2-6s (uv self-update 0.5-3s +
second fetch 0.5-1.5s + desktop spawn 1-3s when applicable).
187 targeted updater tests green incl. 5 new stamp-gate tests.
This commit is contained in:
teknium1 2026-07-29 09:40:01 -07:00 committed by Teknium
parent 9179fb72ea
commit 3a69e34702
6 changed files with 244 additions and 74 deletions

2
.lazy-refresh-incomplete Normal file
View file

@ -0,0 +1,2 @@
started=1785343166.9711895
pid=2093896

View file

@ -11030,15 +11030,33 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
depth_args = ["--depth", "1"] if is_shallow else []
if branch == "main":
print("→ Fetching from upstream...")
fetch_result = subprocess.run(
git_cmd + ["fetch"] + depth_args + ["upstream", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
# Probe locally (~6 ms) whether an 'upstream' remote exists at all
# before spending a network fetch on it. Non-fork installs have no
# 'upstream' remote, and the old flow burned a failed network attempt
# (~0.3-1 s) on every --check before falling back to origin.
has_upstream_remote = (
subprocess.run(
git_cmd + ["remote", "get-url", "upstream"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
).returncode
== 0
)
if fetch_result.returncode != 0:
# Fallback to origin if upstream doesn't exist
fetch_result = None
if has_upstream_remote:
print("→ Fetching from upstream...")
fetch_result = subprocess.run(
git_cmd + ["fetch"] + depth_args + ["upstream", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
if fetch_result is not None and fetch_result.returncode == 0:
upstream_exists = True
compare_branch = f"upstream/{branch}"
else:
# No upstream remote, or the upstream fetch failed — use origin.
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch"] + depth_args + ["origin", branch],
@ -11048,9 +11066,6 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
)
upstream_exists = False
compare_branch = f"origin/{branch}"
else:
upstream_exists = True
compare_branch = f"upstream/{branch}"
else:
# Non-default branch: compare against origin/<branch> directly.
print("→ Fetching from origin...")
@ -12551,8 +12566,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
# the bad commit and the fix landing).
pre_pull_sha = _capture_head_sha(git_cmd, PROJECT_ROOT)
try:
# Merge the ref we already fetched above (→ Fetching updates...)
# instead of `git pull`, which performs a SECOND network fetch of
# the same branch (~0.5-1.5 s of redundant round-trip per update).
# `merge --ff-only origin/<branch>` is byte-identical in effect to
# `pull --ff-only origin <branch>` given the fresh tracking ref;
# the divergence fallback below is unchanged.
pull_result = subprocess.run(
git_cmd + ["pull", "--ff-only", "origin", branch],
git_cmd + ["merge", "--ff-only", f"origin/{branch}"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
@ -12785,34 +12806,51 @@ def _cmd_update_impl(args, gateway_mode: bool):
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
if (desktop_dir / "package.json").exists() and _resolve_node_runtime_npm() and has_desktop_app:
print("→ Checking if desktop app needs rebuilding...")
_desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
# Capture the (very loud) Electron/vite build output into
# update.log instead of streaming it to the terminal. On the rare
# nonzero exit, retry once after waiting again for the venv — this
# covers a still-settling rebuild window the first wait didn't fully
# catch — then surface the captured tail so the failure is
# debuggable.
#
# Start the build subprocess with the Hermes-managed Node on PATH:
# when `hermes update` runs inside the desktop updater chain
# (Desktop → hermes-setup → hermes update), the shell PATH
# customizations are lost, so a bare-PATH child would fail with
# `node: not found` before cmd_gui can self-heal.
from hermes_constants import with_hermes_node_path
_build_env = with_hermes_node_path()
build_result = _run_logged_subprocess(_desktop_build_cmd, cwd=PROJECT_ROOT, env=_build_env)
if build_result.returncode != 0:
build_result = _run_logged_subprocess(_desktop_build_cmd, cwd=PROJECT_ROOT, env=_build_env)
if build_result.returncode != 0:
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
tail = "\n".join((build_result.stdout or "").strip().splitlines()[-15:])
if tail:
print(tail)
from hermes_constants import display_hermes_home as _dhh
print(f" Full build log: {_dhh()}/logs/update.log")
else:
# Consult the content-hash stamp IN-PROCESS first. The spawned
# `hermes desktop --build-only` subprocess re-imports the whole
# CLI stack (~1-3 s) just to reach the same _desktop_build_needed
# check; when the stamp already says "up to date" we can skip the
# spawn entirely. The update path never passes --source, so the
# subprocess would run with source_mode=False — mirror that here.
# Any error in the pre-check falls through to the subprocess.
_skip_desktop_build = False
try:
_skip_desktop_build = not _desktop_build_needed(
desktop_dir, PROJECT_ROOT, source_mode=False
)
except Exception:
_skip_desktop_build = False
if _skip_desktop_build:
print(" ✓ Desktop app up to date")
else:
_desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
# Capture the (very loud) Electron/vite build output into
# update.log instead of streaming it to the terminal. On the rare
# nonzero exit, retry once after waiting again for the venv — this
# covers a still-settling rebuild window the first wait didn't fully
# catch — then surface the captured tail so the failure is
# debuggable.
#
# Start the build subprocess with the Hermes-managed Node on PATH:
# when `hermes update` runs inside the desktop updater chain
# (Desktop → hermes-setup → hermes update), the shell PATH
# customizations are lost, so a bare-PATH child would fail with
# `node: not found` before cmd_gui can self-heal.
from hermes_constants import with_hermes_node_path
_build_env = with_hermes_node_path()
build_result = _run_logged_subprocess(_desktop_build_cmd, cwd=PROJECT_ROOT, env=_build_env)
if build_result.returncode != 0:
build_result = _run_logged_subprocess(_desktop_build_cmd, cwd=PROJECT_ROOT, env=_build_env)
if build_result.returncode != 0:
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
tail = "\n".join((build_result.stdout or "").strip().splitlines()[-15:])
if tail:
print(tail)
from hermes_constants import display_hermes_home as _dhh
print(f" Full build log: {_dhh()}/logs/update.log")
else:
print(" ✓ Desktop app up to date")
print()
print("✓ Code updated!")

View file

@ -274,9 +274,46 @@ def ensure_uv(
return _UvResult(result)
def _uv_self_update_is_fresh(now: float | None = None) -> bool:
"""Return True when ``uv self update`` ran recently enough to skip.
uv releases roughly weekly while many users run ``hermes update`` daily;
re-running a blocking network self-update on every invocation is waste
and, offline, an unbounded hang risk. A stamp file under HERMES_HOME
caches the last successful self-update time.
"""
try:
from hermes_constants import get_hermes_home
stamp = get_hermes_home() / "cache" / ".uv_self_update_stamp"
age = (now if now is not None else time.time()) - stamp.stat().st_mtime
return 0 <= age < UV_SELF_UPDATE_INTERVAL_SECONDS
except Exception:
return False
def _touch_uv_self_update_stamp() -> None:
try:
from hermes_constants import get_hermes_home
stamp = get_hermes_home() / "cache" / ".uv_self_update_stamp"
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.touch()
except OSError:
pass
# uv ships releases ~weekly; refresh the managed binary at most this often.
UV_SELF_UPDATE_INTERVAL_SECONDS = 7 * 24 * 3600
# `uv self update` is a network call; unbounded it can hang forever on a
# blackholed connection (no default timeout in uv's downloader path).
UV_SELF_UPDATE_TIMEOUT_SECONDS = 60
def update_managed_uv(
*,
repair_observer: Callable[[RuntimeRepairResult], None] | None = None,
force: bool = False,
) -> Optional[str]:
"""Run ``uv self update`` on the managed uv binary.
@ -284,31 +321,43 @@ def update_managed_uv(
Returns the managed path when uv is available and ``None`` otherwise.
A self-update failure is non-fatal because the old version still works.
``repair_observer``, when provided, receives the runtime repair result.
The network self-update is skipped when it succeeded within the last
``UV_SELF_UPDATE_INTERVAL_SECONDS`` (7 days) unless ``force=True``; the
vulnerable-runtime repair probe below ALWAYS runs CVE-driven runtime
repair must never be gated behind the freshness stamp.
"""
existing = resolve_uv()
if not existing:
# Not installed yet — ensure_uv() will handle that elsewhere.
return None
result = subprocess.run(
[existing, "self", "update"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
)
if result.returncode == 0:
version = subprocess.run(
[existing, "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
).stdout.strip()
print(f" ✓ Managed uv updated ({version})")
else:
# Non-fatal — old uv still works fine.
logger.debug(
"uv self update failed (rc=%d): %s", result.returncode, result.stderr
)
if force or not _uv_self_update_is_fresh():
try:
result = subprocess.run(
[existing, "self", "update"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
timeout=UV_SELF_UPDATE_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired:
logger.debug("uv self update timed out after %ss", UV_SELF_UPDATE_TIMEOUT_SECONDS)
result = None
if result is not None and result.returncode == 0:
_touch_uv_self_update_stamp()
version = subprocess.run(
[existing, "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
).stdout.strip()
print(f" ✓ Managed uv updated ({version})")
elif result is not None:
# Non-fatal — old uv still works fine.
logger.debug(
"uv self update failed (rc=%d): %s", result.returncode, result.stderr
)
# Keep this hook inside the long-standing API. During an update, main.py is
# already imported from the old checkout, then ``git pull`` replaces this

View file

@ -359,10 +359,11 @@ class TestCmdUpdateBranchFallback:
assert "origin/main" in rev_list_cmds[0]
assert "origin/fix/stoicneko" not in rev_list_cmds[0]
# pull should use main, not fix/stoicneko
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
# the ff-only merge should target origin/main, not the feature branch
merge_cmds = [c for c in commands if "merge --ff-only" in c]
assert len(merge_cmds) == 1
assert "origin/main" in merge_cmds[0]
assert "fix/stoicneko" not in merge_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
@ -381,9 +382,9 @@ class TestCmdUpdateBranchFallback:
assert len(rev_list_cmds) == 1
assert "origin/main" in rev_list_cmds[0]
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
merge_cmds = [c for c in commands if "merge --ff-only" in c]
assert len(merge_cmds) == 1
assert "origin/main" in merge_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
@ -408,9 +409,9 @@ class TestCmdUpdateBranchFallback:
assert update_observer.__self__ is ensure_observer.__self__
assert update_observer.__self__ == []
# Should NOT have called pull
# Should NOT have advanced the checkout (no pull, no ff-only merge)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
pull_cmds = [c for c in commands if "pull" in c]
pull_cmds = [c for c in commands if "pull" in c or "merge --ff-only" in c]
assert len(pull_cmds) == 0
@patch("shutil.which", return_value=None)
@ -824,9 +825,9 @@ class TestCmdUpdateBranchFlag:
assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
# pull must target bb/gui
pull_cmds = [c for c in commands if "pull" in c and "ff-only" in c]
assert any("bb/gui" in c and "main" not in c.split() for c in pull_cmds), pull_cmds
# the ff-only merge must target origin/bb/gui
merge_cmds = [c for c in commands if "merge --ff-only" in c]
assert any("origin/bb/gui" in c and "origin/main" not in c for c in merge_cmds), merge_cmds
@patch("shutil.which", return_value=None)
@patch("subprocess.run")

View file

@ -296,6 +296,86 @@ class TestUpdateManagedUv:
# Still returns the path — failure is non-fatal
assert result == str(tmp_path / "bin" / "uv")
def test_fresh_stamp_skips_network_self_update_but_not_repair(self, tmp_path, monkeypatch):
"""A recent success stamp must skip `uv self update` entirely while the
vulnerable-runtime repair probe still runs (CVE repair is never gated)."""
from hermes_cli.managed_uv import RuntimeRepairResult, update_managed_uv
uv = tmp_path / "bin" / "uv"
_make_executable(uv)
# Fresh stamp under the isolated HERMES_HOME.
import hermes_constants
stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp"
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.touch()
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \
patch(
"hermes_cli.managed_uv.repair_vulnerable_runtime",
return_value=RuntimeRepairResult("skipped"),
) as mock_repair:
result = update_managed_uv()
assert result == str(uv)
assert mock_run.call_count == 0, "fresh stamp must skip the network self-update"
mock_repair.assert_called_once_with(str(uv))
def test_force_overrides_fresh_stamp(self, tmp_path):
from hermes_cli.managed_uv import update_managed_uv
uv = tmp_path / "bin" / "uv"
_make_executable(uv)
import hermes_constants
stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp"
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.touch()
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
result = update_managed_uv(force=True)
assert result == str(uv)
assert mock_run.call_args_list[0][0][0] == [str(uv), "self", "update"]
def test_stale_stamp_runs_self_update_and_refreshes_stamp(self, tmp_path):
import os as _os
import time as _time
from hermes_cli.managed_uv import UV_SELF_UPDATE_INTERVAL_SECONDS, update_managed_uv
uv = tmp_path / "bin" / "uv"
_make_executable(uv)
import hermes_constants
stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp"
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.touch()
old = _time.time() - UV_SELF_UPDATE_INTERVAL_SECONDS - 60
_os.utime(stamp, (old, old))
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
update_managed_uv()
assert mock_run.call_args_list[0][0][0] == [str(uv), "self", "update"]
assert stamp.stat().st_mtime > old + 30, "successful self-update must refresh the stamp"
def test_self_update_timeout_non_fatal(self, tmp_path):
import subprocess as _subprocess
from hermes_cli.managed_uv import update_managed_uv
uv = tmp_path / "bin" / "uv"
_make_executable(uv)
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
mock_run.side_effect = _subprocess.TimeoutExpired(cmd="uv self update", timeout=60)
result = update_managed_uv()
# Timeout is non-fatal; path still returned.
assert result == str(uv)
def test_old_updater_api_triggers_runtime_repair(self, tmp_path):
"""The pre-pull main.py call site must activate the fresh module hook."""
from hermes_cli.managed_uv import RuntimeRepairResult, update_managed_uv

View file

@ -418,7 +418,7 @@ def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypa
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
if cmd == ["git", "pull", "--ff-only", "origin", "main"]:
if cmd == ["git", "merge", "--ff-only", "origin/main"]:
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[all]"]:
raise CalledProcessError(returncode=1, cmd=cmd)
@ -467,7 +467,7 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
if cmd == ["git", "pull", "--ff-only", "origin", "main"]:
if cmd == ["git", "merge", "--ff-only", "origin/main"]:
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
return SimpleNamespace(returncode=0, stdout="", stderr="")
@ -557,7 +557,7 @@ def test_cmd_update_refreshes_active_memory_provider_dependencies(monkeypatch, t
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
if cmd == ["git", "pull", "--ff-only", "origin", "main"]:
if cmd == ["git", "merge", "--ff-only", "origin/main"]:
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
return SimpleNamespace(returncode=0, stdout="", stderr="")
@ -583,7 +583,7 @@ def test_cmd_update_reloads_runtime_modules_before_lazy_refresh(monkeypatch, tmp
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
if cmd == ["git", "pull", "--ff-only", "origin", "main"]:
if cmd == ["git", "merge", "--ff-only", "origin/main"]:
events.append("pull")
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
if "pip" in cmd and "install" in cmd: