diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 062cf5bf19..70d15d4c0f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -230,6 +230,7 @@ except Exception: pass # best-effort — don't crash if config isn't available yet import logging +import threading import time as _time from datetime import datetime @@ -6445,6 +6446,45 @@ def _load_installable_optional_extras() -> list[str]: return referenced +def _run_install_with_heartbeat( + cmd: list[str], + *, + env: dict[str, str] | None = None, + heartbeat_interval_seconds: int = 30, +) -> None: + """Run dependency install command with periodic heartbeat output. + + Some resolvers/build backends (especially when compiling Rust/C extensions) + can stay quiet for minutes. Emit a simple elapsed-time heartbeat so users + know ``hermes update`` is still progressing even if pip/uv itself is silent. + """ + done = threading.Event() + start = _time.time() + + def _heartbeat() -> None: + # Wait first, then print, so short installs don't emit noise. + while not done.wait(heartbeat_interval_seconds): + elapsed = int(_time.time() - start) + print( + f" … still installing dependencies ({elapsed}s elapsed)" + " — compiling Rust/C extensions can take several minutes", + flush=True, + ) + + t = threading.Thread(target=_heartbeat, daemon=True) + t.start() + try: + subprocess.run( + cmd, + cwd=PROJECT_ROOT, + check=True, + env=env, + ) + finally: + done.set() + t.join(timeout=0.2) + + def _install_python_dependencies_with_optional_fallback( install_cmd_prefix: list[str], *, @@ -6461,12 +6501,13 @@ def _install_python_dependencies_with_optional_fallback( Collecting/Building/Installing step), so keeping it visible costs nothing on fast hardware and prevents the "hermes update hangs" reports on slow hardware. + + We also add periodic heartbeat lines in case the resolver/build backend is + itself silent for long stretches. """ try: - subprocess.run( + _run_install_with_heartbeat( install_cmd_prefix + ["install", "-e", ".[all]"], - cwd=PROJECT_ROOT, - check=True, env=env, ) return @@ -6475,10 +6516,8 @@ def _install_python_dependencies_with_optional_fallback( " ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..." ) - subprocess.run( + _run_install_with_heartbeat( install_cmd_prefix + ["install", "-e", "."], - cwd=PROJECT_ROOT, - check=True, env=env, ) @@ -6486,10 +6525,8 @@ def _install_python_dependencies_with_optional_fallback( installed_extras: list[str] = [] for extra in _load_installable_optional_extras(): try: - subprocess.run( + _run_install_with_heartbeat( install_cmd_prefix + ["install", "-e", f".[{extra}]"], - cwd=PROJECT_ROOT, - check=True, env=env, ) installed_extras.append(extra) diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index df8bccb209..bdc72681bb 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -323,15 +323,15 @@ 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", "origin", "main"]: + if cmd == ["git", "pull", "--ff-only", "origin", "main"]: return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) - if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[all]", "--quiet"]: + if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[all]"]: raise CalledProcessError(returncode=1, cmd=cmd) - if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".", "--quiet"]: + if cmd == ["/usr/bin/uv", "pip", "install", "-e", "."]: return SimpleNamespace(returncode=0) - if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[matrix]", "--quiet"]: + if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[matrix]"]: raise CalledProcessError(returncode=1, cmd=cmd) - if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[mcp]", "--quiet"]: + if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[mcp]"]: return SimpleNamespace(returncode=0) # Catch-all must include stdout/stderr so consumers that parse # output (e.g. the dashboard-restart `ps -A` scan added in the @@ -344,10 +344,10 @@ def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypa install_cmds = [c for c in recorded if "pip" in c and "install" in c] assert install_cmds == [ - ["/usr/bin/uv", "pip", "install", "-e", ".[all]", "--quiet"], - ["/usr/bin/uv", "pip", "install", "-e", ".", "--quiet"], - ["/usr/bin/uv", "pip", "install", "-e", ".[matrix]", "--quiet"], - ["/usr/bin/uv", "pip", "install", "-e", ".[mcp]", "--quiet"], + ["/usr/bin/uv", "pip", "install", "-e", ".[all]"], + ["/usr/bin/uv", "pip", "install", "-e", "."], + ["/usr/bin/uv", "pip", "install", "-e", ".[matrix]"], + ["/usr/bin/uv", "pip", "install", "-e", ".[mcp]"], ] out = capsys.readouterr().out @@ -371,7 +371,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", "origin", "main"]: + if cmd == ["git", "pull", "--ff-only", "origin", "main"]: return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) return SimpleNamespace(returncode=0, stdout="", stderr="") @@ -384,6 +384,24 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path): assert ".[all]" in install_cmds[0] +def test_install_heartbeat_prints_when_dependency_install_is_silent(monkeypatch, capsys): + """Long quiet installs should emit periodic heartbeat lines.""" + + def fake_run(cmd, **kwargs): + hermes_main._time.sleep(1.2) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + hermes_main._run_install_with_heartbeat( + ["uv", "pip", "install", "-e", "."], + heartbeat_interval_seconds=1, + ) + + out = capsys.readouterr().out + assert "still installing dependencies" in out + + # --------------------------------------------------------------------------- # ff-only fallback to reset --hard on diverged history # ---------------------------------------------------------------------------