diff --git a/.lazy-refresh-incomplete b/.lazy-refresh-incomplete new file mode 100644 index 00000000000..b9745a74b12 --- /dev/null +++ b/.lazy-refresh-incomplete @@ -0,0 +1,2 @@ +started=1785343166.9711895 +pid=2093896 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2a74fe0bbad..8f13ba27d7d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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/ 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/` is byte-identical in effect to + # `pull --ff-only origin ` 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!") diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index b5bd99252d9..b23cd90f3b5 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -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 diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index d550a229723..fcb0bf4d61a 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -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") diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index 4ce791af6ce..1a0e5bf30ee 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -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 diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index bd1638fcf65..e06709e1a4b 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -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: