Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7) First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today). ### Added | PR | Author | Fix | |----|--------|-----| | #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** | ### Absorbed on the way in (fix-it-ourselves, reviewed fresh) - `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard. - Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports. - Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard. - Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit). ### Gate results - **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed** - **ruff forward gate**: CLEAN - **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision) - **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv) - **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs) Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified. Closes #1952. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
1 parent
aae584ba57
commit
6f68fdb6ff
5 files changed
+273
-12
No files matched your search
@@ -3,6 +3,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.236] — 2026-06-03 — Release HD (stage-q7 — native Windows support for bootstrap and terminal)
|
||||
|
||||
### Added
|
||||
- Native Windows support for `bootstrap.py` and the embedded terminal (#1952). Hermes WebUI already ran on Windows when invoked as `python server.py` directly; this unblocks the supported `python bootstrap.py` path. `api/terminal.py` no longer hard-imports the POSIX-only `fcntl`/`termios`/`select` at module load — they're guarded behind `_TERMINAL_SUPPORTED = sys.platform != "win32"`, and the embedded-terminal entry points raise `NotImplementedError` (or no-op) on Windows, following the existing optional-feature guard pattern (`api/turn_journal.py`, `api/providers.py`). The bootstrap native-Windows block becomes a warning instead of a hard `RuntimeError`; auto-install (which shells out to `/bin/bash`) still errors clearly on native Windows (WSL is unaffected), and the foreground launch path uses `subprocess.Popen` + exit on Windows (where `os.execv` spawns rather than replaces the process, orphaning it from a supervisor) instead of `os.execv`. POSIX behavior is unchanged on every path (#1952, @rodboev).
|
||||
|
||||
## [v0.51.235] — 2026-06-03 — Release HC (stage-q5 — no duplicate transcript replay on repeated questions after compression)
|
||||
|
||||
### Fixed
|
||||
|
||||
+24
-4
@@ -11,20 +11,29 @@ from __future__ import annotations
|
||||
import errno
|
||||
import atexit
|
||||
import codecs
|
||||
import fcntl
|
||||
import os
|
||||
import queue
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import termios
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_TERMINAL_SUPPORTED = sys.platform != "win32"
|
||||
|
||||
if _TERMINAL_SUPPORTED:
|
||||
import fcntl
|
||||
import select
|
||||
import termios
|
||||
else:
|
||||
fcntl = None # type: ignore[assignment]
|
||||
select = None # type: ignore[assignment]
|
||||
termios = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _set_nonblocking(fd: int) -> None:
|
||||
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
@@ -175,7 +184,8 @@ def _ensure_spawn_supervisor() -> None:
|
||||
_spawn_supervisor_started = True
|
||||
|
||||
|
||||
_ensure_spawn_supervisor()
|
||||
if _TERMINAL_SUPPORTED:
|
||||
_ensure_spawn_supervisor()
|
||||
|
||||
|
||||
# NOTE on parent-death-signal: a previous version of this module set
|
||||
@@ -257,6 +267,8 @@ def _set_size(term: TerminalSession, rows: int, cols: int) -> None:
|
||||
|
||||
def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = 80, restart: bool = False) -> TerminalSession:
|
||||
"""Start or return the embedded terminal for a WebUI session."""
|
||||
if not _TERMINAL_SUPPORTED:
|
||||
raise NotImplementedError("Embedded terminal is not supported on Windows")
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
raise ValueError("session_id is required")
|
||||
@@ -346,6 +358,8 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int =
|
||||
|
||||
|
||||
def get_terminal(session_id: str) -> TerminalSession | None:
|
||||
if not _TERMINAL_SUPPORTED:
|
||||
return None
|
||||
with _LOCK:
|
||||
term = _TERMINALS.get(str(session_id or ""))
|
||||
if term and term.is_alive():
|
||||
@@ -354,6 +368,8 @@ def get_terminal(session_id: str) -> TerminalSession | None:
|
||||
|
||||
|
||||
def write_terminal(session_id: str, data: str) -> None:
|
||||
if not _TERMINAL_SUPPORTED:
|
||||
raise NotImplementedError("Embedded terminal is not supported on Windows")
|
||||
term = get_terminal(session_id)
|
||||
if not term or not term.is_alive():
|
||||
raise KeyError("terminal not running")
|
||||
@@ -361,6 +377,8 @@ def write_terminal(session_id: str, data: str) -> None:
|
||||
|
||||
|
||||
def resize_terminal(session_id: str, rows: int, cols: int) -> None:
|
||||
if not _TERMINAL_SUPPORTED:
|
||||
raise NotImplementedError("Embedded terminal is not supported on Windows")
|
||||
term = get_terminal(session_id)
|
||||
if not term:
|
||||
raise KeyError("terminal not running")
|
||||
@@ -368,6 +386,8 @@ def resize_terminal(session_id: str, rows: int, cols: int) -> None:
|
||||
|
||||
|
||||
def close_terminal(session_id: str) -> bool:
|
||||
if not _TERMINAL_SUPPORTED:
|
||||
return False
|
||||
sid = str(session_id or "")
|
||||
with _LOCK:
|
||||
term = _TERMINALS.pop(sid, None)
|
||||
|
||||
+25
-7
@@ -92,9 +92,9 @@ def is_wsl() -> bool:
|
||||
|
||||
def ensure_supported_platform() -> None:
|
||||
if platform.system() == "Windows" and not is_wsl():
|
||||
raise RuntimeError(
|
||||
"Native Windows is not supported for this bootstrap yet. "
|
||||
"Please run it from Linux, macOS, or inside WSL2."
|
||||
info(
|
||||
"Warning: Native Windows bootstrap is experimental. "
|
||||
"Embedded terminal and auto-install are not supported."
|
||||
)
|
||||
|
||||
|
||||
@@ -270,6 +270,11 @@ def hermes_command_exists() -> bool:
|
||||
|
||||
|
||||
def install_hermes_agent() -> None:
|
||||
if platform.system() == "Windows" and not is_wsl():
|
||||
raise RuntimeError(
|
||||
"Auto-install is not supported on native Windows. "
|
||||
"Install hermes-agent manually first."
|
||||
)
|
||||
info(f"Hermes Agent not found. Attempting install via {INSTALLER_URL}")
|
||||
subprocess.run(
|
||||
["/bin/bash", "-lc", f"curl -fsSL {INSTALLER_URL} | bash"], check=True
|
||||
@@ -316,8 +321,10 @@ def parse_args() -> argparse.Namespace:
|
||||
"--foreground",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Run server.py in this process (via os.execv) instead of spawning a "
|
||||
"child. Use this under launchd / systemd / supervisord so the "
|
||||
"Run server.py in this process (via os.execv on POSIX; via a "
|
||||
"Popen child + exit on Windows, where execv can't replace the "
|
||||
"process image) instead of spawning a detached child. Use this "
|
||||
"under launchd / systemd / supervisord so the "
|
||||
"supervisor sees the long-lived server as the original child. "
|
||||
"Implies --no-browser. Skips the post-launch health probe — the "
|
||||
"supervisor's own KeepAlive / Restart=on-failure handles liveness."
|
||||
@@ -444,8 +451,19 @@ def main() -> int:
|
||||
f"Set HERMES_WEBUI_PYTHON to a working interpreter or fix "
|
||||
f"the agent venv at {agent_dir}."
|
||||
)
|
||||
# os.execv replaces the current process image. Anything after this line
|
||||
# only runs if execv itself fails (it raises OSError on failure).
|
||||
# os.execv replaces the current process image. On Windows, execv
|
||||
# spawns a new process instead of replacing (Python calls CreateProcess),
|
||||
# orphaning it from any supervisor. Use Popen + exit there instead.
|
||||
if sys.platform == "win32":
|
||||
# CREATE_NEW_PROCESS_GROUP only exists in the subprocess module on
|
||||
# Windows; resolve it defensively (0 = no extra flags) so this line
|
||||
# can't AttributeError if reached on a non-Windows interpreter
|
||||
# (e.g. a win32-simulating test) — mirrors the getattr() guard used
|
||||
# for SO_EXCLUSIVEADDRUSE.
|
||||
_CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
subprocess.Popen([python_exe, server_path],
|
||||
creationflags=_CREATE_NEW_PROCESS_GROUP)
|
||||
sys.exit(0)
|
||||
os.execv(python_exe, [python_exe, server_path])
|
||||
# Unreachable — execv either replaces the process or raises.
|
||||
raise RuntimeError("os.execv returned unexpectedly")
|
||||
|
||||
@@ -55,4 +55,8 @@ def test_bootstrap_script_contains_official_installer_and_windows_guard():
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
|
||||
in src
|
||||
)
|
||||
assert "Native Windows is not supported" in src
|
||||
# Native Windows is now experimental-supported (#1952), not hard-blocked:
|
||||
# ensure_supported_platform() warns instead of raising, but auto-install
|
||||
# (which shells out to /bin/bash) still guards native Windows explicitly.
|
||||
assert "Native Windows bootstrap is experimental" in src
|
||||
assert "Auto-install is not supported on native Windows" in src
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tests for native Windows support (terminal.py POSIX guards + bootstrap.py unblock).
|
||||
|
||||
terminal.py guards:
|
||||
- _TERMINAL_SUPPORTED is False on Windows, True on POSIX
|
||||
- Terminal functions raise NotImplementedError on Windows
|
||||
- close_terminal returns False (no-op) on Windows
|
||||
- get_terminal returns None on Windows
|
||||
- Module imports cleanly on Windows (no fcntl/termios ImportError)
|
||||
|
||||
bootstrap.py unblock:
|
||||
- ensure_supported_platform does not raise on Windows
|
||||
- install_hermes_agent raises RuntimeError on Windows (calls /bin/bash)
|
||||
- Foreground path uses Popen + sys.exit on Windows instead of os.execv
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
# ── terminal.py tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTerminalPosixGuard:
|
||||
"""Verify _TERMINAL_SUPPORTED flag and import guards."""
|
||||
|
||||
def test_terminal_supported_matches_platform(self):
|
||||
from api import terminal
|
||||
if sys.platform == "win32":
|
||||
assert not terminal._TERMINAL_SUPPORTED
|
||||
else:
|
||||
assert terminal._TERMINAL_SUPPORTED
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only check")
|
||||
def test_posix_modules_are_loaded(self):
|
||||
from api import terminal
|
||||
assert terminal.fcntl is not None
|
||||
assert terminal.select is not None
|
||||
assert terminal.termios is not None
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only check")
|
||||
def test_windows_modules_are_none(self):
|
||||
from api import terminal
|
||||
assert terminal.fcntl is None
|
||||
assert terminal.select is None
|
||||
assert terminal.termios is None
|
||||
|
||||
|
||||
class TestTerminalWindowsFunctions:
|
||||
"""Terminal functions should fail gracefully on Windows."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_unsupported(self, monkeypatch):
|
||||
"""Force _TERMINAL_SUPPORTED=False regardless of actual platform."""
|
||||
from api import terminal
|
||||
monkeypatch.setattr(terminal, "_TERMINAL_SUPPORTED", False)
|
||||
|
||||
def test_start_terminal_raises(self, tmp_path):
|
||||
from api.terminal import start_terminal
|
||||
with pytest.raises(NotImplementedError, match="not supported on Windows"):
|
||||
start_terminal("test-session", tmp_path)
|
||||
|
||||
def test_write_terminal_raises(self):
|
||||
from api.terminal import write_terminal
|
||||
with pytest.raises(NotImplementedError, match="not supported on Windows"):
|
||||
write_terminal("test-session", "hello")
|
||||
|
||||
def test_resize_terminal_raises(self):
|
||||
from api.terminal import resize_terminal
|
||||
with pytest.raises(NotImplementedError, match="not supported on Windows"):
|
||||
resize_terminal("test-session", 24, 80)
|
||||
|
||||
def test_close_terminal_returns_false(self):
|
||||
from api.terminal import close_terminal
|
||||
assert close_terminal("test-session") is False
|
||||
|
||||
def test_get_terminal_returns_none(self):
|
||||
from api.terminal import get_terminal
|
||||
assert get_terminal("test-session") is None
|
||||
|
||||
|
||||
# ── bootstrap.py tests ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def import_bootstrap():
|
||||
"""Import bootstrap freshly to avoid module-level state bleed."""
|
||||
if "bootstrap" in sys.modules:
|
||||
del sys.modules["bootstrap"]
|
||||
import bootstrap as bs
|
||||
return bs
|
||||
|
||||
|
||||
class TestBootstrapWindowsUnblock:
|
||||
|
||||
def test_ensure_supported_platform_does_not_raise_on_windows(self, import_bootstrap, monkeypatch):
|
||||
monkeypatch.setattr("platform.system", lambda: "Windows")
|
||||
monkeypatch.setattr(import_bootstrap, "is_wsl", lambda: False)
|
||||
# Should print a warning, not raise
|
||||
import_bootstrap.ensure_supported_platform()
|
||||
|
||||
def test_ensure_supported_platform_still_passes_on_posix(self, import_bootstrap, monkeypatch):
|
||||
monkeypatch.setattr("platform.system", lambda: "Linux")
|
||||
import_bootstrap.ensure_supported_platform()
|
||||
|
||||
def test_install_hermes_agent_raises_on_windows(self, import_bootstrap, monkeypatch):
|
||||
monkeypatch.setattr("platform.system", lambda: "Windows")
|
||||
monkeypatch.setattr(import_bootstrap, "is_wsl", lambda: False)
|
||||
with pytest.raises(RuntimeError, match="not supported on native Windows"):
|
||||
import_bootstrap.install_hermes_agent()
|
||||
|
||||
def test_install_hermes_agent_allowed_in_wsl(self, import_bootstrap, monkeypatch):
|
||||
"""WSL should still be able to auto-install (it has /bin/bash) — the
|
||||
Windows guard must NOT fire. Stub subprocess.run so the test proves the
|
||||
guard was passed without actually launching the real installer."""
|
||||
monkeypatch.setattr("platform.system", lambda: "Windows")
|
||||
monkeypatch.setattr(import_bootstrap, "is_wsl", lambda: True)
|
||||
run_calls = []
|
||||
monkeypatch.setattr(
|
||||
import_bootstrap.subprocess, "run",
|
||||
lambda *a, **kw: run_calls.append((a, kw)),
|
||||
)
|
||||
# Must NOT raise the native-Windows guard; instead it reaches the
|
||||
# (stubbed) install subprocess.
|
||||
import_bootstrap.install_hermes_agent()
|
||||
assert len(run_calls) == 1, "WSL install should reach the install subprocess"
|
||||
|
||||
|
||||
class TestBootstrapForegroundWindows:
|
||||
"""Foreground path uses Popen + sys.exit on Windows instead of os.execv."""
|
||||
|
||||
@pytest.fixture
|
||||
def stub_main_dependencies(self, monkeypatch, tmp_path):
|
||||
import bootstrap as bs
|
||||
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
||||
monkeypatch.setattr(bs, "discover_agent_dir", lambda: tmp_path / "agent")
|
||||
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
||||
python_exe = sys.executable
|
||||
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: python_exe)
|
||||
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
|
||||
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
|
||||
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
|
||||
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path / "state"))
|
||||
(tmp_path / "agent").mkdir(parents=True, exist_ok=True)
|
||||
return bs
|
||||
|
||||
def test_foreground_uses_popen_on_windows(self, stub_main_dependencies, monkeypatch):
|
||||
"""On win32, foreground mode should use Popen + sys.exit, not os.execv."""
|
||||
bs = stub_main_dependencies
|
||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||||
|
||||
# Strip supervisor env vars that would interfere
|
||||
for var in ("INVOCATION_ID", "JOURNAL_STREAM", "NOTIFY_SOCKET",
|
||||
"XPC_SERVICE_NAME", "SUPERVISOR_ENABLED", "HERMES_WEBUI_FOREGROUND"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
popen_calls = []
|
||||
execv_calls = []
|
||||
|
||||
class FakePopen:
|
||||
pid = 12345
|
||||
def __init__(self, *args, **kwargs):
|
||||
popen_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", FakePopen)
|
||||
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
bs.main()
|
||||
assert ei.value.code == 0
|
||||
assert len(popen_calls) == 1, "Windows foreground should use Popen"
|
||||
assert len(execv_calls) == 0, "Windows foreground must NOT use execv"
|
||||
|
||||
def test_foreground_uses_execv_on_posix(self, stub_main_dependencies, monkeypatch):
|
||||
"""On POSIX, foreground mode should still use os.execv."""
|
||||
bs = stub_main_dependencies
|
||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||||
|
||||
for var in ("INVOCATION_ID", "JOURNAL_STREAM", "NOTIFY_SOCKET",
|
||||
"XPC_SERVICE_NAME", "SUPERVISOR_ENABLED", "HERMES_WEBUI_FOREGROUND"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
execv_calls = []
|
||||
popen_calls = []
|
||||
|
||||
def fake_execv(path, argv):
|
||||
execv_calls.append((path, argv))
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(os, "execv", fake_execv)
|
||||
|
||||
class FakePopen:
|
||||
pid = 12345
|
||||
def __init__(self, *args, **kwargs):
|
||||
popen_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", FakePopen)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
bs.main()
|
||||
assert ei.value.code == 0
|
||||
assert len(execv_calls) == 1, "POSIX foreground should use execv"
|
||||
assert len(popen_calls) == 0, "POSIX foreground must NOT use Popen"
|
||||
Reference in new issue
Block a user