- m9: shared_metrics.startup_latency had no server-side latch, so any repeat call added a row. The TUI and Desktop now send an opaque per-launch id (TUI: one per process; Desktop: minted when the once-per-launch main-process claim succeeds) and the backend claims (surface, launch_id) once per process: a reconnect re-sending the same launch counts once, a new Desktop launch against a long-lived backend still counts. Legacy clients without an id count once per backend process and surface. The id is only a local latch key (bounded set, length-capped), never recorded. Only a usable measurement spends the claim. Contract + apps/shared regenerated. - m10: relaunch() execs in place, keeping the PID, so `sessions browse` -> resume counted the picker time as CLI startup. relaunch() stamps its PID in HERMES_RELAUNCHED_PID right before exec; process-start surfaces skip when it matches, while children (other PIDs) inheriting the env still count. - m8 (psutil half): record_process_ready checks the opt-in gate before reading the process start time (psutil) or starting a thread. The claim is taken first so a later opt-in never records a stale "startup" mid-session. Test changes to existing assertions: the TUI/Desktop param assertions now expect launch_id (a new wire param); the RPC surface test sends distinct launch ids so its three calls stay three launches under the new latch.
145 lines
5.8 KiB
Python
145 lines
5.8 KiB
Python
"""Unified self-relaunch for Hermes CLI: preserves inherited flags (--tui, --dev, --profile, --model…)
|
|
across process replacement so ``hermes sessions browse`` / post-setup relaunch keep the user's mode."""
|
|
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import sys
|
|
from typing import Optional, Sequence
|
|
|
|
from hermes_cli._parser import (
|
|
PRE_ARGPARSE_INHERITED_FLAGS, build_top_level_parser
|
|
)
|
|
|
|
|
|
def _build_inherited_flag_table() -> list[tuple[str, bool]]:
|
|
"""``(option_string, takes_value)`` for every parser Action carrying ``inherit_on_relaunch``
|
|
(set by ``_parser._inherited_flag``), plus the pre-argparse flags."""
|
|
parser, _subparsers, chat_parser = build_top_level_parser()
|
|
table: list[tuple[str, bool]] = []
|
|
seen: set[tuple[str, bool]] = set()
|
|
for p in (parser, chat_parser):
|
|
for action in p._actions:
|
|
if not action.option_strings:
|
|
continue # positional / no flag form
|
|
if not getattr(action, "inherit_on_relaunch", False):
|
|
continue
|
|
takes_value = action.nargs != 0 # store_true/false set nargs=0
|
|
for opt in action.option_strings:
|
|
key = (opt, takes_value)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
table.append(key)
|
|
table.extend(PRE_ARGPARSE_INHERITED_FLAGS)
|
|
return table
|
|
|
|
|
|
_INHERITED_FLAGS_TABLE = _build_inherited_flag_table()
|
|
|
|
|
|
def _extract_inherited_flags(argv: Sequence[str]) -> list[str]:
|
|
"""Pull out flags that should carry over into a self-relaunched hermes."""
|
|
flags: list[str] = []
|
|
i = 0
|
|
while i < len(argv):
|
|
arg = argv[i]
|
|
if "=" in arg:
|
|
if any(arg.split("=", 1)[0] == flag for flag, _ in _INHERITED_FLAGS_TABLE):
|
|
flags.append(arg)
|
|
i += 1
|
|
continue
|
|
for flag, takes_value in _INHERITED_FLAGS_TABLE:
|
|
if arg == flag:
|
|
flags.append(arg)
|
|
if takes_value and i + 1 < len(argv) and not argv[i + 1].startswith("-"):
|
|
flags.append(argv[i + 1])
|
|
i += 1
|
|
break
|
|
i += 1
|
|
return flags
|
|
|
|
|
|
def resolve_hermes_bin() -> Optional[str]:
|
|
"""Hermes entry point: ``sys.argv[0]`` if a real executable, else ``which hermes``, else ``None``
|
|
(caller falls back to ``python -m hermes_cli.main``).
|
|
|
|
Python launchers are never returned: on Windows a ``.py`` can't be exec'd directly, and on
|
|
POSIX the git installer runs the extensionless source launcher under the managed venv
|
|
interpreter while its ``#!/usr/bin/env python3`` shebang would pick the system Python and
|
|
lose the venv. Falling through to ``sys.executable -m hermes_cli.main`` keeps the venv.
|
|
"""
|
|
argv0 = sys.argv[0]
|
|
_is_windows = sys.platform == "win32"
|
|
|
|
def _is_python_script(p: str) -> bool:
|
|
return p.lower().endswith((".py", ".pyc"))
|
|
|
|
def _is_unsafe_python_launcher(p: str) -> bool:
|
|
if _is_windows:
|
|
return _is_python_script(p)
|
|
# A console script pinned to the running venv (``#!<venv>/bin/python``) keeps the venv and
|
|
# stays exec-able; a shebang that resolves elsewhere (``env python3``) loses it.
|
|
from hermes_cli.linux_desktop_entry import _needs_interpreter
|
|
|
|
return _needs_interpreter(pathlib.Path(p))
|
|
|
|
# Absolute executable (nix store, venv wrappers, …), then relative-to-CWD, then PATH.
|
|
if (
|
|
os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK)
|
|
and not _is_unsafe_python_launcher(argv0)
|
|
):
|
|
return argv0
|
|
if not argv0.startswith("-") and os.path.isfile(argv0):
|
|
abs_path = os.path.abspath(argv0)
|
|
if os.access(abs_path, os.X_OK) and not _is_unsafe_python_launcher(abs_path):
|
|
return abs_path
|
|
path_bin = shutil.which("hermes")
|
|
if path_bin and not _is_unsafe_python_launcher(path_bin):
|
|
return path_bin
|
|
return None
|
|
|
|
|
|
def build_relaunch_argv(
|
|
extra_args: Sequence[str], *, preserve_inherited: bool = True, original_argv: Optional[Sequence[str]] = None
|
|
) -> list[str]:
|
|
"""Construct an argv list for replacing the current process with hermes."""
|
|
bin_path = resolve_hermes_bin()
|
|
argv = [bin_path] if bin_path else [sys.executable, "-m", "hermes_cli.main"]
|
|
src = list(original_argv) if original_argv is not None else list(sys.argv[1:])
|
|
if preserve_inherited:
|
|
argv.extend(_extract_inherited_flags(src))
|
|
argv.extend(extra_args)
|
|
return argv
|
|
|
|
|
|
def relaunch(
|
|
extra_args: Sequence[str], *, preserve_inherited: bool = True, original_argv: Optional[Sequence[str]] = None
|
|
) -> None:
|
|
"""Replace the current process with a fresh hermes invocation.
|
|
|
|
POSIX: ``os.execvp`` in place (same PID, no double-fork). Windows has no real exec — its
|
|
``execvp`` emulation only works for a real Win32 executable, so spawn + exit instead.
|
|
"""
|
|
new_argv = build_relaunch_argv(extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv)
|
|
if sys.platform == "win32":
|
|
import subprocess
|
|
try:
|
|
result = subprocess.run(new_argv)
|
|
sys.exit(result.returncode)
|
|
except KeyboardInterrupt:
|
|
sys.exit(130)
|
|
except OSError as exc:
|
|
# Raw ``[Errno 8] Exec format error`` is cryptic; usual causes are ``hermes`` not on
|
|
# PATH yet (install hasn't propagated User PATH into this shell) or a stale shim.
|
|
print(
|
|
f"\nHermes relaunch failed: {exc}\n"
|
|
f"Command: {' '.join(new_argv)}\n"
|
|
f"Fix: open a new terminal so PATH picks up, then re-run hermes.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
else:
|
|
from hermes_cli.observability.shared_metrics_startup import mark_in_place_relaunch
|
|
|
|
mark_in_place_relaunch()
|
|
os.execvp(new_argv[0], new_argv) |