mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
A child started in its own session or group (start_new_session, os.setsid /
setpgid / setpgrp, windows_detach_popen_kwargs) does not die with a SIGKILLed
Hermes: it reparents to init and runs forever. That is how every family this PR
migrates leaked. With the families on spawn_owned, this keeps the class closed.
scripts/check_detached_spawns.py (AST) flags those shapes in the production trees
unless the line, or the line above it, says `detached-spawn: ok — <why>`. It runs
in scripts/check ("detached-spawns") and as a blocking lint.yml step. The baseline
is empty: every remaining site is on spawn_owned / own_process_groups, or carries
its reason. The reasons this commit adds are the census verdicts:
- must outlive the starter on purpose: restart watchers, /update and the updater
hand-offs, dashboard/desktop/service launches, cron and kanban workers
(restart-safe), the update-custody watchdog itself;
- awaited with a group kill on timeout: cron scripts, rg search, the Browser Use
CLI, update completion (holds the checkout lock fd);
- covered by its own parent guard: the compute host (compute_host._parent_guard_loop
flushes sessions; a supervisor KILL could cut that short);
- not a spawn: the wake-word `start_new_session` config key and event field.
tests/scripts/test_check_detached_spawns.py: each shape flagged, non-detached and
commented/string shapes not, the marker opts out on its line or the line above
and nowhere else, tests and the helper are out of scope, main() fails on a new
unmarked spawn, and the repository scan is empty. Teeth: appending an unmarked
start_new_session Popen to tools/file_operations_search.py turns
`scripts/check --only detached-spawns` red (rc 1); restoring it turns it green.
181 lines
6.1 KiB
Python
181 lines
6.1 KiB
Python
"""Where the running BUNDLED desktop app lives, and how to start it.
|
|
|
|
A bundled artifact runs its agent out of ``<app>/resources/agent-payload``,
|
|
so a Python process inside that payload sits at a known offset from the app
|
|
the user double-clicks. This module turns that offset into the launcher path
|
|
and starts it — the two things every in-bundle caller needs and each one
|
|
used to work out for itself.
|
|
|
|
The shape authority is the build stamp (``payload: bundled``, read through
|
|
:func:`hermes_cli.steward.is_bundled_payload`), never a filesystem probe: a
|
|
probe answers "is this artifact intact?", not "which shape am I?". Callers
|
|
gate on the stamp, then ask here WHERE the app is. A stamp that says bundled
|
|
over a tree that is not one is damage, and :func:`resolve_bundle_layout`
|
|
says so instead of degrading to a checkout.
|
|
|
|
Two consumers today: ``hermes desktop`` (start the app this CLI ships
|
|
inside) and the sealed self-updater (stop the app, then relaunch it).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
__all__ = [
|
|
"PAYLOAD_DIR_NAME",
|
|
"BundleLayout",
|
|
"NotBundledApp",
|
|
"launch_detached",
|
|
"resolve_bundle_layout",
|
|
]
|
|
|
|
#: The resources-resident payload directory every bundled artifact ships.
|
|
PAYLOAD_DIR_NAME = "agent-payload"
|
|
|
|
#: Executables that sit beside a Linux Electron launcher without being one.
|
|
#: AppRun is the AppImage entry stub, which re-execs the launcher below.
|
|
_LINUX_NON_LAUNCHERS = frozenset({"AppRun", "chrome-sandbox", "chrome_crashpad_handler"})
|
|
|
|
|
|
class NotBundledApp(Exception):
|
|
"""Raised when a tree is not a bundled desktop payload."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BundleLayout:
|
|
"""The three directories and the launcher of a bundled desktop app.
|
|
|
|
``launcher`` is None when the app directory holds no single obvious
|
|
executable. That is a real state on a damaged or unusual install, and
|
|
both callers would rather report it than act on a guess.
|
|
"""
|
|
|
|
app_root: Path
|
|
resources: Path
|
|
payload: Path
|
|
launcher: Path | None
|
|
|
|
|
|
def resolve_bundle_layout(
|
|
project_root: Path | str, *, platform: str | None = None
|
|
) -> BundleLayout:
|
|
"""Locate the desktop app that contains the payload at *project_root*.
|
|
|
|
*project_root* is the payload's ``repo/`` tree — the install root a
|
|
bundled backend runs from. The app sits two directories above the
|
|
payload on Windows and Linux, and four above it on macOS, where the
|
|
payload lands in ``Hermes.app/Contents/Resources``.
|
|
|
|
The macOS offset is detected from the directory names themselves
|
|
rather than from *platform*, so a layout can be resolved for any host
|
|
from any host. *platform* (default :data:`sys.platform`) only decides
|
|
the launcher-naming rule.
|
|
|
|
Raises :class:`NotBundledApp` when the tree is not a bundled payload —
|
|
a source checkout, or the docker/nix sealed shape, which has no app.
|
|
"""
|
|
repo = Path(project_root).resolve()
|
|
payload = repo.parent
|
|
if payload.name != PAYLOAD_DIR_NAME:
|
|
raise NotBundledApp(
|
|
f"{repo} is not inside a {PAYLOAD_DIR_NAME}/ payload — no desktop app contains it"
|
|
)
|
|
|
|
resources = payload.parent
|
|
app_root = resources.parent
|
|
# macOS: .../Hermes.app/Contents/Resources/agent-payload/repo
|
|
if (
|
|
resources.name == "Resources"
|
|
and app_root.name == "Contents"
|
|
and app_root.parent.suffix == ".app"
|
|
):
|
|
app_root = app_root.parent
|
|
|
|
if not app_root.is_dir():
|
|
raise NotBundledApp(f"no app directory above the payload at {payload}")
|
|
|
|
return BundleLayout(
|
|
app_root=app_root,
|
|
resources=resources,
|
|
payload=payload,
|
|
launcher=_resolve_launcher(app_root, platform or sys.platform),
|
|
)
|
|
|
|
|
|
def _resolve_launcher(app_root: Path, platform: str) -> Path | None:
|
|
"""The executable that starts the app, or None when it is ambiguous.
|
|
|
|
One rule per platform, each one "the single launcher among files that
|
|
are not launchers":
|
|
|
|
* macOS — the one file in ``Contents/MacOS``.
|
|
* Windows — the one top-level ``.exe`` that is not the uninstaller.
|
|
* Linux — the one top-level executable that is not an Electron helper
|
|
or a shared library.
|
|
"""
|
|
if platform == "darwin":
|
|
return _only(_files(app_root / "Contents" / "MacOS"))
|
|
|
|
if platform == "win32":
|
|
return _only(
|
|
[
|
|
p
|
|
for p in _files(app_root)
|
|
if p.suffix.lower() == ".exe"
|
|
and not p.name.lower().startswith("uninstall")
|
|
]
|
|
)
|
|
|
|
return _only(
|
|
[
|
|
p
|
|
for p in _files(app_root)
|
|
if p.name not in _LINUX_NON_LAUNCHERS
|
|
and ".so" not in p.name
|
|
and os.access(p, os.X_OK)
|
|
]
|
|
)
|
|
|
|
|
|
def _files(directory: Path) -> list[Path]:
|
|
"""Regular files directly in *directory*; empty when it is unreadable."""
|
|
try:
|
|
return sorted(p for p in directory.iterdir() if p.is_file())
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def _only(candidates: list[Path]) -> Path | None:
|
|
return candidates[0] if len(candidates) == 1 else None
|
|
|
|
|
|
def launch_detached(
|
|
argv: list[str], *, env: dict[str, str] | None = None, cwd: Path | str | None = None
|
|
) -> int:
|
|
"""Start *argv* as its own process and return its pid.
|
|
|
|
The child outlives this process: it leads a new session on POSIX, and
|
|
on Windows it gets the detach creation flags (new process group, own
|
|
hidden console, breakaway from any job object). Its stdio goes to the
|
|
null device, because the terminal that ran ``hermes desktop`` is free
|
|
to close the moment this call returns.
|
|
"""
|
|
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
|
|
|
|
child = subprocess.Popen(
|
|
argv,
|
|
cwd=str(cwd) if cwd is not None else None,
|
|
env=env,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
close_fds=True,
|
|
# detached-spawn: ok — the desktop app launch, meant to outlive the CLI that opens it
|
|
**windows_detach_popen_kwargs(),
|
|
)
|
|
return child.pid
|