mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-08-07 10:04:02 +00:00
feat(runtime): heal outdated managed Node trees up to the target major
Existing users who only ever launch Hermes (never re-run an installer) kept their managed Node 22 tree forever: the heal path only fired for *broken* trees, and a healthy 22 passes the --version probe. Now "outdated" heals the same way "broken" does, on both sides of the mirror: - hermes_constants.py: find_hermes_node_executable() checks _managed_node_tree_outdated() (managed node major < _HERMES_NODE_TARGET_MAJOR) and routes through the existing once-per-process heal_hermes_managed_node(), which redownloads latest-v26.x. When the heal fails (offline, download error) the outdated-but-runnable tree is still returned — old Node beats no Node. - scripts/lib/node-bootstrap.sh: _nb_managed_node_needs_heal() gains the matching _nb_managed_node_outdated() rung, so heal_managed_node agrees with the Python side. This is the same shape as the managed-uv flow: resolve the managed runtime, notice it can't satisfy the requirement, provision the right one in place, fall back gracefully. Tests (tests/test_hermes_constants.py): outdated tree triggers heal and returns the upgraded binary; failed heal still serves the old tree; an at-target tree never heals (heal stub raises).
This commit is contained in:
@@ -551,21 +551,54 @@ def heal_hermes_managed_node() -> bool:
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def find_hermes_node_executable(command: str) -> str | None:
|
||||
"""Return a Hermes-managed Node/npm executable path, healing broken trees."""
|
||||
names = _candidate_node_command_names(command)
|
||||
broken_present = False
|
||||
for directory in iter_hermes_node_dirs():
|
||||
for name in names:
|
||||
def _managed_node_tree_outdated(home: Path | None = None) -> bool:
|
||||
"""Return True when the managed tree's node runs but is below the target major.
|
||||
|
||||
An outdated managed Node (e.g. a 22 tree from an older install) heals the
|
||||
same way a broken one does: :func:`find_hermes_node_executable` triggers
|
||||
the once-per-process heal, which redownloads
|
||||
``latest-v{_HERMES_NODE_TARGET_MAJOR}.x`` — so existing users are upgraded
|
||||
on next launch, not just on the next installer re-run. Mirrors
|
||||
``_nb_managed_node_outdated`` in ``scripts/lib/node-bootstrap.sh``.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
for directory in iter_hermes_node_dirs(home):
|
||||
for name in _candidate_node_command_names("node"):
|
||||
candidate = directory / name
|
||||
if candidate.is_file() and (
|
||||
sys.platform == "win32" or os.access(candidate, os.X_OK)
|
||||
if not candidate.is_file() or (
|
||||
sys.platform != "win32" and not os.access(candidate, os.X_OK)
|
||||
):
|
||||
resolved = str(candidate)
|
||||
if node_tool_runnable(resolved):
|
||||
return resolved
|
||||
broken_present = True
|
||||
if broken_present and heal_hermes_managed_node():
|
||||
continue
|
||||
try:
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
|
||||
result = subprocess.run(
|
||||
[str(candidate), "--version"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
major = int(result.stdout.decode().strip().lstrip("v").split(".")[0])
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError, IndexError):
|
||||
return False # broken, not outdated — the runnable probe handles it
|
||||
return major < _HERMES_NODE_TARGET_MAJOR
|
||||
return False
|
||||
|
||||
|
||||
def find_hermes_node_executable(command: str) -> str | None:
|
||||
"""Return a Hermes-managed Node/npm executable path, healing broken trees.
|
||||
|
||||
Outdated trees (node major below ``_HERMES_NODE_TARGET_MAJOR``) heal the
|
||||
same way broken ones do — the once-per-process heal redownloads the target
|
||||
major, upgrading existing users on next launch rather than next reinstall.
|
||||
When the heal fails (offline, download error), an outdated-but-runnable
|
||||
tree is still returned: old Node beats no Node.
|
||||
"""
|
||||
names = _candidate_node_command_names(command)
|
||||
|
||||
def _first_runnable() -> tuple[str | None, bool]:
|
||||
broken = False
|
||||
for directory in iter_hermes_node_dirs():
|
||||
for name in names:
|
||||
candidate = directory / name
|
||||
@@ -574,8 +607,19 @@ def find_hermes_node_executable(command: str) -> str | None:
|
||||
):
|
||||
resolved = str(candidate)
|
||||
if node_tool_runnable(resolved):
|
||||
return resolved
|
||||
return None
|
||||
return resolved, broken
|
||||
broken = True
|
||||
return None, broken
|
||||
|
||||
resolved, broken_present = _first_runnable()
|
||||
needs_heal = broken_present or (
|
||||
resolved is not None and _managed_node_tree_outdated()
|
||||
)
|
||||
if needs_heal and heal_hermes_managed_node():
|
||||
healed, _ = _first_runnable()
|
||||
if healed:
|
||||
return healed
|
||||
return resolved
|
||||
|
||||
|
||||
def find_node_executable_on_path(command: str) -> str | None:
|
||||
|
||||
@@ -255,6 +255,24 @@ _nb_managed_tool_broken() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# The managed node runs but is below HERMES_NODE_TARGET_MAJOR — an old tree
|
||||
# from a previous install (e.g. 22). Outdated heals the same way broken does,
|
||||
# so existing users get upgraded on the next heal probe, not just on a full
|
||||
# installer re-run. Mirrors _managed_node_tree_outdated() in
|
||||
# hermes_constants.py.
|
||||
_nb_managed_node_outdated() {
|
||||
local probe ver major
|
||||
for probe in "$HERMES_HOME/node/bin/node" "$HERMES_HOME/node/node"; do
|
||||
[ -x "$probe" ] || continue
|
||||
ver="$("$probe" --version 2>/dev/null)" || return 1
|
||||
major="${ver#v}"; major="${major%%.*}"
|
||||
case "$major" in ''|*[!0-9]*) return 1 ;; esac
|
||||
[ "$major" -lt "$HERMES_NODE_TARGET_MAJOR" ] && return 0
|
||||
return 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_nb_managed_node_needs_heal() {
|
||||
local tool
|
||||
for tool in node npm npx; do
|
||||
@@ -262,7 +280,7 @@ _nb_managed_node_needs_heal() {
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
_nb_managed_node_outdated
|
||||
}
|
||||
|
||||
# Redownload the pinned nodejs.org tarball when a managed tree exists but
|
||||
|
||||
@@ -213,6 +213,62 @@ class TestNodeToolRunnable:
|
||||
|
||||
assert find_node_executable("npm") is None
|
||||
|
||||
def test_outdated_managed_node_heals_to_target_major(self, tmp_path, monkeypatch):
|
||||
"""A healthy managed tree below the target major upgrades on next resolve."""
|
||||
profile_home = tmp_path / "profiles" / "assistant"
|
||||
managed_bin = profile_home / "node" / "bin"
|
||||
managed_bin.mkdir(parents=True)
|
||||
old_node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v22.20.0'\nexit 0\n")
|
||||
heal_called = {"value": False}
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
monkeypatch.setenv("PATH", "")
|
||||
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
|
||||
|
||||
def _heal():
|
||||
heal_called["value"] = True
|
||||
old_node.write_text("#!/bin/sh\necho 'v26.5.1'\nexit 0\n")
|
||||
old_node.chmod(0o755)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal)
|
||||
|
||||
resolved = hermes_constants.find_hermes_node_executable("node")
|
||||
assert heal_called["value"] is True
|
||||
assert resolved == str(old_node)
|
||||
|
||||
def test_outdated_managed_node_survives_failed_heal(self, tmp_path, monkeypatch):
|
||||
"""Offline heal failure keeps serving the old tree — old Node beats no Node."""
|
||||
profile_home = tmp_path / "profiles" / "assistant"
|
||||
managed_bin = profile_home / "node" / "bin"
|
||||
managed_bin.mkdir(parents=True)
|
||||
old_node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v22.20.0'\nexit 0\n")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
monkeypatch.setenv("PATH", "")
|
||||
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
|
||||
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", lambda: False)
|
||||
|
||||
assert hermes_constants.find_hermes_node_executable("node") == str(old_node)
|
||||
|
||||
def test_target_major_managed_node_does_not_heal(self, tmp_path, monkeypatch):
|
||||
"""A tree already at the target major never triggers the heal."""
|
||||
profile_home = tmp_path / "profiles" / "assistant"
|
||||
managed_bin = profile_home / "node" / "bin"
|
||||
managed_bin.mkdir(parents=True)
|
||||
node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v26.5.1'\nexit 0\n")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
monkeypatch.setenv("PATH", "")
|
||||
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
|
||||
|
||||
def _heal():
|
||||
raise AssertionError("heal must not run for an up-to-date tree")
|
||||
|
||||
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal)
|
||||
|
||||
assert hermes_constants.find_hermes_node_executable("node") == str(node)
|
||||
|
||||
|
||||
|
||||
class TestIsContainer:
|
||||
|
||||
Reference in New Issue
Block a user