fix(gateway): prevent Windows Telegram /restart leaving gateway stopped

This commit is contained in:
Martin 2026-05-14 22:22:29 +02:00 committed by Teknium
parent 1d378605dd
commit 417a653d9e
4 changed files with 198 additions and 14 deletions

View file

@ -5275,10 +5275,13 @@ def _gateway_command_inner(args):
launchd_start()
elif is_windows():
from hermes_cli import gateway_windows
if gateway_windows.is_installed():
gateway_windows.start()
else:
run_gateway(verbose=0)
# On Windows, even without a registered Scheduled Task / Startup
# entry, gateway_windows.start() uses the safe detached
# pythonw.exe launcher. Do not fall back to run_gateway() here:
# when invoked from a gateway-hosted agent/tool call, foreground
# run_gateway() is tied to the very gateway process we just
# stopped and can die before the replacement is stable.
gateway_windows.start()
else:
run_gateway(verbose=0)
return
@ -5299,13 +5302,19 @@ def _gateway_command_inner(args):
pass
elif is_windows():
from hermes_cli import gateway_windows
if gateway_windows.is_installed():
service_configured = True
try:
gateway_windows.restart()
service_available = True
except (subprocess.CalledProcessError, RuntimeError):
pass
# Prefer the Windows-specific restart path: it supports both
# registered Scheduled Task / Startup installs and no-service
# detached restarts. In the normal successful Telegram-triggered
# restart flow, this avoids the generic foreground run_gateway()
# path that can be reaped with the old gateway process. If the
# Windows backend raises, intentionally preserve the existing
# generic failure fallback below.
service_configured = gateway_windows.is_installed()
try:
gateway_windows.restart()
return
except (subprocess.CalledProcessError, RuntimeError, OSError):
pass
if not service_available:
# systemd/launchd restart failed — check if linger is the issue

View file

@ -42,7 +42,7 @@ _SCHTASKS_TIMEOUT_S = 15
_SCHTASKS_NO_OUTPUT_TIMEOUT_S = 30
# Patterns in schtasks stderr that mean "fall back to the Startup folder".
_FALLBACK_PATTERNS = re.compile(
r"(access is denied|acceso denegado|schtasks timed out|schtasks produced no output)",
r"(access is denied|acceso denegado|přístup byl odepřen|schtasks timed out|schtasks produced no output)",
re.IGNORECASE,
)
@ -344,6 +344,56 @@ def _derive_venv_pythonw(python_exe: str) -> str:
return python_exe
def _read_pyvenv_cfg(venv_dir: Path) -> dict[str, str]:
cfg_path = venv_dir / "pyvenv.cfg"
try:
lines = cfg_path.read_text(encoding="utf-8").splitlines()
except OSError:
return {}
parsed: dict[str, str] = {}
for raw in lines:
if "=" not in raw:
continue
key, value = raw.split("=", 1)
parsed[key.strip().lower()] = value.strip()
return parsed
def _resolve_detached_python(python_exe: str) -> tuple[str, Path, list[str]]:
"""Return (windowed_python, venv_dir, extra_pythonpath) for detached runs.
uv-created Windows venv launchers are special: ``venv\\Scripts\\pythonw.exe``
starts hidden, but then respawns the base interpreter as console
``python.exe``. That child opens a visible Windows Terminal tab. For uv
venvs, use the base ``pythonw.exe`` directly and put the repo + venv
site-packages on ``PYTHONPATH`` so imports still resolve without the venv
launcher.
"""
p = Path(python_exe)
venv_dir = p.parent.parent
windowed = _derive_venv_pythonw(python_exe)
cfg = _read_pyvenv_cfg(venv_dir)
home = cfg.get("home", "")
if "uv" in cfg and home:
base_pythonw = Path(home) / "pythonw.exe"
site_packages = venv_dir / "Lib" / "site-packages"
if base_pythonw.exists() and site_packages.exists():
return (str(base_pythonw), venv_dir, [str(site_packages)])
return (windowed, venv_dir, [])
def _prepend_pythonpath(env_overlay: dict[str, str], entries: list[str]) -> None:
clean_entries = [entry for entry in entries if entry]
if not clean_entries:
return
existing = os.environ.get("PYTHONPATH", "")
if existing:
clean_entries.append(existing)
env_overlay["PYTHONPATH"] = os.pathsep.join(clean_entries)
def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:
"""Build (argv, working_dir, env_overlay) for the gateway subprocess.
@ -359,7 +409,7 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:
get_python_path,
)
python_exe = _derive_venv_pythonw(get_python_path())
python_exe, venv_dir, extra_pythonpath = _resolve_detached_python(get_python_path())
working_dir = str(PROJECT_ROOT)
hermes_home = str(Path(get_hermes_home()).resolve())
profile_arg = _profile_arg(hermes_home)
@ -373,8 +423,9 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:
"HERMES_HOME": hermes_home,
"PYTHONIOENCODING": "utf-8",
"HERMES_GATEWAY_DETACHED": "1",
"VIRTUAL_ENV": str(Path(python_exe).resolve().parent.parent),
"VIRTUAL_ENV": str(venv_dir),
}
_prepend_pythonpath(env_overlay, [working_dir, *extra_pythonpath] if extra_pythonpath else [])
return argv, working_dir, env_overlay