mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
9179fb72ea
commit
3a69e34702
6 changed files with 244 additions and 74 deletions
2
.lazy-refresh-incomplete
Normal file
2
.lazy-refresh-incomplete
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
started=1785343166.9711895
|
||||||
|
pid=2093896
|
||||||
|
|
@ -11030,15 +11030,33 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
||||||
depth_args = ["--depth", "1"] if is_shallow else []
|
depth_args = ["--depth", "1"] if is_shallow else []
|
||||||
|
|
||||||
if branch == "main":
|
if branch == "main":
|
||||||
print("→ Fetching from upstream...")
|
# Probe locally (~6 ms) whether an 'upstream' remote exists at all
|
||||||
fetch_result = subprocess.run(
|
# before spending a network fetch on it. Non-fork installs have no
|
||||||
git_cmd + ["fetch"] + depth_args + ["upstream", branch],
|
# 'upstream' remote, and the old flow burned a failed network attempt
|
||||||
cwd=PROJECT_ROOT,
|
# (~0.3-1 s) on every --check before falling back to origin.
|
||||||
capture_output=True,
|
has_upstream_remote = (
|
||||||
text=True, encoding="utf-8", errors="replace",
|
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:
|
fetch_result = None
|
||||||
# Fallback to origin if upstream doesn't exist
|
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...")
|
print("→ Fetching from origin...")
|
||||||
fetch_result = subprocess.run(
|
fetch_result = subprocess.run(
|
||||||
git_cmd + ["fetch"] + depth_args + ["origin", branch],
|
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
|
upstream_exists = False
|
||||||
compare_branch = f"origin/{branch}"
|
compare_branch = f"origin/{branch}"
|
||||||
else:
|
|
||||||
upstream_exists = True
|
|
||||||
compare_branch = f"upstream/{branch}"
|
|
||||||
else:
|
else:
|
||||||
# Non-default branch: compare against origin/<branch> directly.
|
# Non-default branch: compare against origin/<branch> directly.
|
||||||
print("→ Fetching from origin...")
|
print("→ Fetching from origin...")
|
||||||
|
|
@ -12551,8 +12566,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||||
# the bad commit and the fix landing).
|
# the bad commit and the fix landing).
|
||||||
pre_pull_sha = _capture_head_sha(git_cmd, PROJECT_ROOT)
|
pre_pull_sha = _capture_head_sha(git_cmd, PROJECT_ROOT)
|
||||||
try:
|
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(
|
pull_result = subprocess.run(
|
||||||
git_cmd + ["pull", "--ff-only", "origin", branch],
|
git_cmd + ["merge", "--ff-only", f"origin/{branch}"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True, encoding="utf-8", errors="replace",
|
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)
|
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:
|
if (desktop_dir / "package.json").exists() and _resolve_node_runtime_npm() and has_desktop_app:
|
||||||
print("→ Checking if desktop app needs rebuilding...")
|
print("→ Checking if desktop app needs rebuilding...")
|
||||||
_desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
|
# Consult the content-hash stamp IN-PROCESS first. The spawned
|
||||||
# Capture the (very loud) Electron/vite build output into
|
# `hermes desktop --build-only` subprocess re-imports the whole
|
||||||
# update.log instead of streaming it to the terminal. On the rare
|
# CLI stack (~1-3 s) just to reach the same _desktop_build_needed
|
||||||
# nonzero exit, retry once after waiting again for the venv — this
|
# check; when the stamp already says "up to date" we can skip the
|
||||||
# covers a still-settling rebuild window the first wait didn't fully
|
# spawn entirely. The update path never passes --source, so the
|
||||||
# catch — then surface the captured tail so the failure is
|
# subprocess would run with source_mode=False — mirror that here.
|
||||||
# debuggable.
|
# Any error in the pre-check falls through to the subprocess.
|
||||||
#
|
_skip_desktop_build = False
|
||||||
# Start the build subprocess with the Hermes-managed Node on PATH:
|
try:
|
||||||
# when `hermes update` runs inside the desktop updater chain
|
_skip_desktop_build = not _desktop_build_needed(
|
||||||
# (Desktop → hermes-setup → hermes update), the shell PATH
|
desktop_dir, PROJECT_ROOT, source_mode=False
|
||||||
# customizations are lost, so a bare-PATH child would fail with
|
)
|
||||||
# `node: not found` before cmd_gui can self-heal.
|
except Exception:
|
||||||
from hermes_constants import with_hermes_node_path
|
_skip_desktop_build = False
|
||||||
|
if _skip_desktop_build:
|
||||||
_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(" ✓ 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()
|
||||||
print("✓ Code updated!")
|
print("✓ Code updated!")
|
||||||
|
|
|
||||||
|
|
@ -274,9 +274,46 @@ def ensure_uv(
|
||||||
return _UvResult(result)
|
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(
|
def update_managed_uv(
|
||||||
*,
|
*,
|
||||||
repair_observer: Callable[[RuntimeRepairResult], None] | None = None,
|
repair_observer: Callable[[RuntimeRepairResult], None] | None = None,
|
||||||
|
force: bool = False,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Run ``uv self update`` on the managed uv binary.
|
"""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.
|
Returns the managed path when uv is available and ``None`` otherwise.
|
||||||
A self-update failure is non-fatal because the old version still works.
|
A self-update failure is non-fatal because the old version still works.
|
||||||
``repair_observer``, when provided, receives the runtime repair result.
|
``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()
|
existing = resolve_uv()
|
||||||
if not existing:
|
if not existing:
|
||||||
# Not installed yet — ensure_uv() will handle that elsewhere.
|
# Not installed yet — ensure_uv() will handle that elsewhere.
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result = subprocess.run(
|
if force or not _uv_self_update_is_fresh():
|
||||||
[existing, "self", "update"],
|
try:
|
||||||
capture_output=True,
|
result = subprocess.run(
|
||||||
text=True, encoding='utf-8', errors='replace',
|
[existing, "self", "update"],
|
||||||
check=False,
|
capture_output=True,
|
||||||
)
|
text=True, encoding='utf-8', errors='replace',
|
||||||
if result.returncode == 0:
|
check=False,
|
||||||
version = subprocess.run(
|
timeout=UV_SELF_UPDATE_TIMEOUT_SECONDS,
|
||||||
[existing, "--version"],
|
)
|
||||||
capture_output=True,
|
except subprocess.TimeoutExpired:
|
||||||
text=True, encoding='utf-8', errors='replace',
|
logger.debug("uv self update timed out after %ss", UV_SELF_UPDATE_TIMEOUT_SECONDS)
|
||||||
check=False,
|
result = None
|
||||||
).stdout.strip()
|
if result is not None and result.returncode == 0:
|
||||||
print(f" ✓ Managed uv updated ({version})")
|
_touch_uv_self_update_stamp()
|
||||||
else:
|
version = subprocess.run(
|
||||||
# Non-fatal — old uv still works fine.
|
[existing, "--version"],
|
||||||
logger.debug(
|
capture_output=True,
|
||||||
"uv self update failed (rc=%d): %s", result.returncode, result.stderr
|
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
|
# 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
|
# already imported from the old checkout, then ``git pull`` replaces this
|
||||||
|
|
|
||||||
|
|
@ -359,10 +359,11 @@ class TestCmdUpdateBranchFallback:
|
||||||
assert "origin/main" in rev_list_cmds[0]
|
assert "origin/main" in rev_list_cmds[0]
|
||||||
assert "origin/fix/stoicneko" not in rev_list_cmds[0]
|
assert "origin/fix/stoicneko" not in rev_list_cmds[0]
|
||||||
|
|
||||||
# pull should use main, not fix/stoicneko
|
# the ff-only merge should target origin/main, not the feature branch
|
||||||
pull_cmds = [c for c in commands if "pull" in c]
|
merge_cmds = [c for c in commands if "merge --ff-only" in c]
|
||||||
assert len(pull_cmds) == 1
|
assert len(merge_cmds) == 1
|
||||||
assert "main" in pull_cmds[0]
|
assert "origin/main" in merge_cmds[0]
|
||||||
|
assert "fix/stoicneko" not in merge_cmds[0]
|
||||||
|
|
||||||
@patch("shutil.which", return_value=None)
|
@patch("shutil.which", return_value=None)
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
|
|
@ -381,9 +382,9 @@ class TestCmdUpdateBranchFallback:
|
||||||
assert len(rev_list_cmds) == 1
|
assert len(rev_list_cmds) == 1
|
||||||
assert "origin/main" in rev_list_cmds[0]
|
assert "origin/main" in rev_list_cmds[0]
|
||||||
|
|
||||||
pull_cmds = [c for c in commands if "pull" in c]
|
merge_cmds = [c for c in commands if "merge --ff-only" in c]
|
||||||
assert len(pull_cmds) == 1
|
assert len(merge_cmds) == 1
|
||||||
assert "main" in pull_cmds[0]
|
assert "origin/main" in merge_cmds[0]
|
||||||
|
|
||||||
@patch("shutil.which", return_value=None)
|
@patch("shutil.which", return_value=None)
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
|
|
@ -408,9 +409,9 @@ class TestCmdUpdateBranchFallback:
|
||||||
assert update_observer.__self__ is ensure_observer.__self__
|
assert update_observer.__self__ is ensure_observer.__self__
|
||||||
assert update_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]
|
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
|
assert len(pull_cmds) == 0
|
||||||
|
|
||||||
@patch("shutil.which", return_value=None)
|
@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 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
|
assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
|
||||||
|
|
||||||
# pull must target bb/gui
|
# the ff-only merge must target origin/bb/gui
|
||||||
pull_cmds = [c for c in commands if "pull" in c and "ff-only" in c]
|
merge_cmds = [c for c in commands if "merge --ff-only" in c]
|
||||||
assert any("bb/gui" in c and "main" not in c.split() for c in pull_cmds), pull_cmds
|
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("shutil.which", return_value=None)
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,86 @@ class TestUpdateManagedUv:
|
||||||
# Still returns the path — failure is non-fatal
|
# Still returns the path — failure is non-fatal
|
||||||
assert result == str(tmp_path / "bin" / "uv")
|
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):
|
def test_old_updater_api_triggers_runtime_repair(self, tmp_path):
|
||||||
"""The pre-pull main.py call site must activate the fresh module hook."""
|
"""The pre-pull main.py call site must activate the fresh module hook."""
|
||||||
from hermes_cli.managed_uv import RuntimeRepairResult, update_managed_uv
|
from hermes_cli.managed_uv import RuntimeRepairResult, update_managed_uv
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,7 @@ def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypa
|
||||||
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
|
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
|
||||||
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
|
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
|
||||||
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
|
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(stdout="Updating\n", stderr="", returncode=0)
|
||||||
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[all]"]:
|
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[all]"]:
|
||||||
raise CalledProcessError(returncode=1, cmd=cmd)
|
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)
|
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
|
||||||
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
|
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
|
||||||
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
|
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(stdout="Updating\n", stderr="", returncode=0)
|
||||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
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)
|
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
|
||||||
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
|
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
|
||||||
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
|
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(stdout="Updating\n", stderr="", returncode=0)
|
||||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
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)
|
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
|
||||||
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
|
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
|
||||||
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
|
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")
|
events.append("pull")
|
||||||
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
|
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
|
||||||
if "pip" in cmd and "install" in cmd:
|
if "pip" in cmd and "install" in cmd:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue