Files
hermes-agent/hermes_cli/source_build.py
Teknium ab8a00d2be fix(update): the Node build stays in the caller's process group and leaves no descendant behind
What: the POSIX build launcher (_OWN_GROUP -> _REAP_TREE) no longer starts node in a session of
its own. Node stays in the caller's process group; the launcher holds the checkout lock fd and,
once node exits, kills every remaining descendant: on Linux it is a child subreaper
(PR_SET_CHILD_SUBREAPER) and SIGKILLs + reaps its own children until none is left; elsewhere
(macOS) it records node's descendant tree from ps while node runs and kills the recorded
processes whose start time still matches. SIGINT/SIGTERM/SIGHUP are forwarded to node (killed
10 s later if still running), then the descendants are killed the same way.

Why: start_new_session=True moved node out of the caller's group, so every existing group kill
(update_completion's killpg on Ctrl-C, Desktop's kill(-pid), killHandoffTree) reached only the
launcher; node was orphaned and its npm/esbuild descendants kept writing after the checkout lock
was free (review L1, probe review/lock/custody/p1: grandchild wrote after abort, lock free).
The old killpg after p.wait() also signalled a reaped leader's group (pid-reuse window, review
L6): the launcher now signals only its own unreaped children (Linux) or start-time-verified
pids (ps walk), never a group.

Tests (RED on bbfa23b137d, GREEN here): a group kill of the caller-visible group stops node and
its child; a detached (setsid) grandchild outliving node is killed before the build returns, on
the subreaper and the ps-walk paths; SIGTERM to the launcher exits 143 and leaves no writer.
2026-10-06 04:36:02 -07:00

244 lines
12 KiB
Python

"""Source launch/update composition over the shared JavaScript builders."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
def source_product_current(project_root: Path, product: str, out: Path) -> bool:
"""Read the compiler's receipt without acquiring tools or dependencies."""
from pm import env_for, installed_package
installed = installed_package("node")
if installed is None or installed.binary is None:
return False # Only PM's Node may run the receipt reader; never the user's PATH copy.
node, env = str(installed.binary), env_for("node")
try:
result = subprocess.run(
[node, str(project_root / "scripts/build/freshness.mjs"),
"--source", str(project_root), "--product", product, "--out", str(out)],
cwd=project_root, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=True,
)
return result.stdout.strip() == "true"
except (OSError, subprocess.SubprocessError):
return False
def source_build_env(base_env: dict | None = None, *, explicit: bool = False) -> dict[str, str]:
from pm import ensure
from pm.environments import project_python, running_from_selected_environment
from pm.paths import repo_root
from hermes_constants import get_hermes_home
# The historical update runs on store Python with the selected environment
# activated in-process. Icon generation starts an isolated child, which needs
# the selected venv executable rather than the store interpreter.
root = repo_root()
python = str(project_python(root)) if running_from_selected_environment(root) else sys.executable
env = {**os.environ, **(base_env or {}), "CI": "1", "HERMES_PYTHON": python,
"PYTHON": python}
env.pop("ESBUILD_BINARY_PATH", None)
npmrc = get_hermes_home() / "npmrc"
if npmrc.is_file():
env.setdefault("NPM_CONFIG_USERCONFIG", str(npmrc))
return ensure("npm", base_env=env, explicit=explicit).env
def run_in_custody(project_root: Path, command: list, label: str, **kwargs):
"""``pm.progress.run_contained`` for a build command that writes the checkout (node, npm):
it and everything it starts stay in the update's custody (POSIX: the checkout lock fd, the
caller's process group, and every descendant killed when it exits; Windows: the owner's
kill-on-close job), so the checkout is never handed to a contender while one of them still
writes. Outside an update it is ``run_contained`` as is."""
from pm.progress import run_contained
from hermes_cli.update_custody import contained_command
with contained_command(command, root=project_root) as (argv, custody):
return run_contained(argv, label, **kwargs, **custody)
def run_source_script(project_root: Path, script: str, *args: str, env: dict, label: str) -> None:
command = [shutil.which("node", path=env["PATH"]), str(project_root / script), *args]
# npm's deprecation warnings are the loudest lines and never actionable
# here; they still land in the failure tail.
run_in_custody(project_root, command, label, hide=lambda line: line.lower().startswith("npm warn"),
indent=" ", cwd=project_root, env=env)
def prepare_source_dependencies(project_root: Path, workspaces: tuple[str, ...], *, env: dict,
explicit: bool = False) -> None:
from pm import lazy_installs_allowed
run_source_script(
project_root, "scripts/build/node-deps.mjs", "--source", str(project_root), "--reuse",
*(() if explicit or lazy_installs_allowed() else ("--no-install",)),
*(arg for workspace in workspaces for arg in ("--workspace", workspace)), env=env,
label="Preparing Node dependencies",
)
def prepare_launch_dependencies(project_root: Path, *, env: dict) -> None:
"""A launch rebuild must not prune another installed source frontend."""
from hermes_cli.main_desktop import _desktop_dist_exists, _desktop_packaged_executable
desktop_dir = project_root / "apps/desktop"
desktop = _desktop_dist_exists(desktop_dir) or _desktop_packaged_executable(desktop_dir) is not None
workspaces = ("ui-tui", "web") + (("apps/desktop",) if desktop else ())
prepare_source_dependencies(project_root, workspaces, env=env)
def build_source_tui(project_root: Path, *, env: dict) -> None:
run_source_script(project_root, "scripts/build/tui.mjs", env=env, label="Building the TUI")
def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None) -> None:
# Default-brand icons are committed; installs never render them.
icons = icons or project_root
run_source_script(project_root, "scripts/build/web.mjs", "--source", str(project_root),
"--icons", str(icons), "--out", str(project_root / "hermes_cli/web_dist"), env=env,
label="Building the web UI")
def source_frontends(project_root: Path) -> tuple[str, ...]:
"""The frontend workspaces this checkout carries. A source slice without them
(python-only installs, the installer's acceptance fixture) has no products
to build; it still publishes commands and runs the maintenance tail."""
return tuple(name for name in ("ui-tui", "web") if (project_root / name / "package.json").is_file())
class ProductBuildError(RuntimeError):
"""One or more products failed; every independent product was still attempted."""
def __init__(self, failures: list[tuple[str, BaseException]]):
self.failures = failures
super().__init__("; ".join(f"{name}: {_failure_text(exc)}" for name, exc in failures))
def _failure_text(exc: BaseException) -> str:
if isinstance(exc, subprocess.CalledProcessError):
return f"{' '.join(map(str, exc.cmd)) if isinstance(exc.cmd, (list, tuple)) else exc.cmd} exited {exc.returncode}"
return f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
def build_update_products(project_root: Path, *, desktop: bool) -> None:
"""Prepare the selected union once, attempting every independent product.
A failed product never skips the next one (a broken web build must not leave the TUI or the
desktop app stale); the failures are raised together at the end as ``ProductBuildError``.
"""
# Both current updates and historical takeover reach this in a fresh target
# interpreter, never in the updater's pre-sync import graph.
from hermes_cli.main_install_repair import _install_configured_features_missing_deps
from hermes_cli.update_stage import publish_stage
failures: list[tuple[str, BaseException]] = []
def attempt(name: str, step) -> bool:
try:
step()
return True
except Exception as exc: # health: allow BLE001 -- collected and raised after the independent steps
print(f" ⚠ {name} failed: {_failure_text(exc)}")
failures.append((name, exc))
return False
attempt("feature dependencies", lambda: _install_configured_features_missing_deps(project_root))
frontends = source_frontends(project_root)
if frontends:
desktop_built = False
env: dict = {}
workspaces = frontends + (("apps/desktop",) if desktop else ())
publish_stage("Updating Node dependencies")
def node_dependencies() -> None:
# Acquiring npm is part of this step: its failure must be reported like the install's.
env.update(source_build_env(explicit=True))
prepare_source_dependencies(project_root, workspaces, env=env, explicit=True)
# Every product compiles from these node_modules: without them there is nothing to build.
if attempt("Node dependencies", node_dependencies):
# An update that changed no TUI/web input reuses the receipted output, as the
# launch path already does; recompiling it produces the same bytes. Desktop
# additionally needs the packaged app to name HEAD (its baked stamp carries the
# commit), so it is reused only when HEAD did not move: "Already up to date",
# a retried tail, a takeover re-entry.
if "ui-tui" in frontends:
if source_product_current(project_root, "tui", project_root / "ui-tui/dist"):
print(" ✓ TUI is up to date")
else:
publish_stage("Building the TUI")
attempt("TUI build", lambda: build_source_tui(project_root, env=env))
if "web" in frontends:
if source_product_current(project_root, "web", project_root / "hermes_cli/web_dist"):
print(" ✓ Web UI is up to date")
else:
publish_stage("Building the web UI")
attempt("web UI build", lambda: build_source_web(project_root, env=env))
if desktop:
desktop_built = attempt("desktop app build", lambda: _build_desktop_product(project_root, env, publish_stage))
if desktop and not desktop_built:
# One whole line the Desktop hand-off scripts match: the follow-up text is truncated
# and names whichever products failed first, so it cannot say if THIS app was rebuilt.
print(f" Desktop app build owed: {failures[-1][0]} failed")
# A configured memory provider that no longer ships in core is installed from the
# catalog for every profile home sharing this venv (config, data and tool names
# unchanged). The update must finish even if the migration blows up.
try:
from hermes_cli.memory_provider_migration import migrate_all_homes
migrate_all_homes()
except Exception as exc: # health: allow BLE001 -- post-commit boundary: printed as ⚠, never fails the committed update; every later update/tail re-runs it
print(f" ⚠ Memory provider migration skipped: {exc}")
# Same for a gateway platform / toolset that left core (Home Assistant): every home that used
# it gets its catalog plugin (hermes_cli/left_core_migration.py).
try:
from hermes_cli.left_core_migration import migrate_all_homes as migrate_left_core
migrate_left_core()
except Exception as exc: # health: allow BLE001 -- post-commit boundary: printed as ⚠, never fails the committed update; every later update/tail re-runs it
print(f" ⚠ Plugin migration skipped: {exc}")
if failures:
raise ProductBuildError(failures)
def _build_desktop_product(project_root: Path, env: dict, publish_stage) -> None:
from hermes_cli.main_desktop import (
_packaged_desktop_current_for_head, _refresh_installed_desktop_apps, build_prepared_desktop)
desktop_dir = project_root / "apps/desktop"
if _packaged_desktop_current_for_head(desktop_dir, project_root):
print(" ✓ Desktop app is up to date")
else:
publish_stage("Building the desktop app")
# The desktop build mutates checkout-scoped node_modules and
# apps/desktop/release; serialize it against a concurrent manual
# `hermes desktop` (#93940). The update path waits rather than exits:
# the in-flight build it queues behind produces the same fresh tree
# this update needs.
from hermes_cli.desktop_build_lock import DesktopBuildLock
build_lock = DesktopBuildLock(project_root)
build_lock.acquire(wait=True)
try:
build_prepared_desktop(
desktop_dir, source_mode=False,
npm=shutil.which("npm", path=env["PATH"]), env=env, icons=project_root,
)
finally:
build_lock.release()
# A current release/ can still sit beside a stale installed copy (an earlier
# update rebuilt but never installed); healing must not wait for the next build.
_refresh_installed_desktop_apps(desktop_dir)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Build source-install frontends")
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--desktop", action="store_true")
args = parser.parse_args()
build_update_products(args.source.resolve(), desktop=args.desktop)