From b5e54a2be606cbb2e60fb4ac057779b8588f94ba Mon Sep 17 00:00:00 2001 From: mika <211269698+mikamikasuki@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:45:30 -0700 Subject: [PATCH] fix(runtime): honor Node prerelease version precedence Signed-off-by: mika <211269698+mikamikasuki@users.noreply.github.com> --- loopx/control_plane/runtime/node_probe.py | 15 +++++++++---- .../test_effect_runtime_node_probe.py | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/loopx/control_plane/runtime/node_probe.py b/loopx/control_plane/runtime/node_probe.py index 527fbdd1ca..ae57291b9c 100644 --- a/loopx/control_plane/runtime/node_probe.py +++ b/loopx/control_plane/runtime/node_probe.py @@ -20,7 +20,7 @@ "local runtime access; do not enable optional capabilities, replace " "authority or spend until the guard succeeds" ) -_VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$") +_VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:-(.*?))?(?:\+.*)?$") class NodeProbeOutcome(StrEnum): @@ -125,6 +125,13 @@ def probe_node(*, timeout: float = STARTUP_READY_TIMEOUT_SECONDS) -> NodeProbe: match = _VERSION_RE.fullmatch(completed.stdout.strip()) if match is None: return NodeProbe(NodeProbeOutcome.INVALID_VERSION, executable) - version = tuple(int(part) for part in match.groups()) - outcome = NodeProbeOutcome.UNSUPPORTED if version < MINIMUM_NODE_VERSION else NodeProbeOutcome.READY - return NodeProbe(outcome, executable, ".".join(str(part) for part in version)) + version = tuple(int(part) for part in match.groups()[:3]) + prerelease = match.group(4) + below_minimum = version < MINIMUM_NODE_VERSION or ( + version == MINIMUM_NODE_VERSION and prerelease is not None + ) + outcome = NodeProbeOutcome.UNSUPPORTED if below_minimum else NodeProbeOutcome.READY + version_text = ".".join(str(part) for part in version) + if prerelease is not None: + version_text += f"-{prerelease}" + return NodeProbe(outcome, executable, version_text) diff --git a/tests/control_plane/test_effect_runtime_node_probe.py b/tests/control_plane/test_effect_runtime_node_probe.py index 87884f1473..9f500b2d90 100644 --- a/tests/control_plane/test_effect_runtime_node_probe.py +++ b/tests/control_plane/test_effect_runtime_node_probe.py @@ -111,6 +111,28 @@ def run(*_args, **kwargs): assert observed == [effect_runtime.STARTUP_READY_TIMEOUT_SECONDS] +@pytest.mark.parametrize( + ("reported_version", "expected_outcome", "expected_version"), + [ + ("v22.22.3-rc.1", node_probe.NodeProbeOutcome.UNSUPPORTED, "22.22.3-rc.1"), + ("v22.22.4-rc.1", node_probe.NodeProbeOutcome.READY, "22.22.4-rc.1"), + ("v22.22.3+build.1", node_probe.NodeProbeOutcome.READY, "22.22.3"), + ], +) +def test_node_probe_uses_prerelease_precedence_at_minimum_version( + monkeypatch, reported_version, expected_outcome, expected_version, +): + monkeypatch.setattr(node_probe.shutil, "which", lambda _: "node") + monkeypatch.setattr(node_probe.subprocess, "run", lambda *_args, **_kwargs: SimpleNamespace( + returncode=0, stdout=f"{reported_version}\n", + )) + + probe = node_probe.probe_node() + + assert probe.outcome is expected_outcome + assert probe.version == expected_version + + @pytest.mark.parametrize("failure", [ subprocess.TimeoutExpired("node", 15), SimpleNamespace(returncode=1, stdout=""),