fix(install): the shared completion tail runs from a source slice

- build_update_products builds only the frontends the checkout carries; a
  python-only slice (the installer's acceptance fixture) publishes commands
  and runs maintenance without asking PM for node.
- stderr_timestamp.py is a launcher boot file copied into published
  commands; it inlines the EX_CONFIG code instead of importing gateway.restart.
- Tests: the stamp-writer slice gains hermes_cli/release_channels.py and
  pm/paths.py (the modules update_channel now imports); the source-launch
  fixture records the source_completion hand-off (--finish-update) instead
  of building products; the stdlib recovery probe blocks PM's engine
  modules, not the pm.environments boot leaf; the memory-provider restart
  test selects a generation the running interpreter has not activated;
  the warm-path installer stage is `products`.
This commit is contained in:
ethernet
2026-09-19 02:53:58 -04:00
parent 30bf9ab47c
commit 2eec0d9b64
9 changed files with 44 additions and 15 deletions

View File

@@ -80,6 +80,13 @@ def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None
"--icons", str(icons), "--out", str(project_root / "hermes_cli/web_dist"), env=env)
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())
def build_update_products(project_root: Path, *, desktop: bool) -> None:
"""Prepare the selected union once; a failed product aborts the update."""
# Both current updates and historical takeover reach this in a fresh target
@@ -88,14 +95,19 @@ def build_update_products(project_root: Path, *, desktop: bool) -> None:
from hermes_cli.update_stage import publish_stage
_warn_configured_features_missing_deps()
frontends = source_frontends(project_root)
if not frontends:
return
env = source_build_env(explicit=True)
workspaces = ("ui-tui", "web") + (("apps/desktop",) if desktop else ())
workspaces = frontends + (("apps/desktop",) if desktop else ())
publish_stage("Updating Node dependencies")
prepare_source_dependencies(project_root, workspaces, env=env, explicit=True)
publish_stage("Building the TUI")
build_source_tui(project_root, env=env)
publish_stage("Building the web UI")
build_source_web(project_root, env=env, explicit=True)
if "ui-tui" in frontends:
publish_stage("Building the TUI")
build_source_tui(project_root, env=env)
if "web" in frontends:
publish_stage("Building the web UI")
build_source_web(project_root, env=env, explicit=True)
if desktop:
from hermes_cli.main_desktop import _install_rebuilt_desktop_app, build_prepared_desktop

View File

@@ -14,6 +14,9 @@ from pathlib import Path
from typing import BinaryIO, Sequence, TextIO
EXTERNAL_SUPERVISOR_FLAG = "--external-supervisor"
# gateway.restart.GATEWAY_FATAL_CONFIG_EXIT_CODE. This wrapper is a launcher boot
# file: it runs from a source slice and stays stdlib-only.
_GATEWAY_FATAL_CONFIG_EXIT_CODE = 78
_TIMESTAMP_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}(?:\s|$)")
@@ -97,10 +100,8 @@ def _child_returncode_for_supervisor(command: Sequence[str], returncode: int) ->
"""
if returncode < 0:
return 128 + abs(returncode)
from gateway.restart import GATEWAY_FATAL_CONFIG_EXIT_CODE, map_fatal_config_exit_for_launchd
if returncode == GATEWAY_FATAL_CONFIG_EXIT_CODE and _is_hermes_gateway_run_argv(command):
return map_fatal_config_exit_for_launchd(returncode)
if returncode == _GATEWAY_FATAL_CONFIG_EXIT_CODE and _is_hermes_gateway_run_argv(command):
return 0
return returncode

View File

@@ -97,8 +97,9 @@ import json
from pathlib import Path
import sys
sys.path.insert(0, sys.argv[1])
# No selected application environment, no PM, and no CLI config/UI reader.
for module in ('pm', 'hermes_cli.config', 'hermes_cli.plugins_cmd'):
# No selected application environment, no PM engine, and no CLI config/UI reader.
# pm.environments/pm.paths are stdlib boot leaves the recovery owner may use.
for module in ('pm.client', 'pm.install', 'pm.store', 'pm.workspace', 'hermes_cli.config', 'hermes_cli.plugins_cmd'):
sys.modules[module] = None
from hermes_cli import plugins_transaction
row = json.loads(sys.stdin.read())

View File

@@ -123,6 +123,8 @@ def test_setup_reports_restart_and_preserves_external_steps(tmp_path, monkeypatc
if python_failure:
raise RuntimeError("Python preparation refused")
monkeypatch.setattr("pm.sync_venv", sync)
# A sync selects a new generation the running interpreter has not activated.
monkeypatch.setattr("pm.environments.selected_venv", lambda root: tmp_path / "next-generation")
result = mp._install_memory_provider_setup("provider")
assert result["ok"] is not python_failure
assert result["results"][0]["status"] == ("failed" if python_failure else "restart_required")

View File

@@ -61,6 +61,15 @@ def source_launch(tmp_path, monkeypatch, isolated_python):
(root / "install-stamp.json").write_text(
json.dumps({"updateMechanism": "self"}), encoding="utf-8",
)
# The startup heal hands the shared completion tail (launchers, products,
# maintenance) to the checkout's own hermes_cli/source_completion.py. This
# source slice has no products; record the hand-off instead of running it.
(root / "hermes_cli").mkdir()
(root / "hermes_cli" / "source_completion.py").write_text(
"import json, sys\n"
f"open({str(tmp_path / 'completion-calls')!r}, 'a').write(json.dumps(sys.argv[1:]) + '\\n')\n",
encoding="utf-8",
)
(root / "pyproject.toml").write_text(
'[project]\nname = "launch-proof"\nversion = "1"\nrequires-python = ">=3.11"\n'
'[project.optional-dependencies]\nall = []\nlaunch-extra = []\n'
@@ -136,6 +145,9 @@ def test_source_python_pin_update_survives_real_gc(source_launch, tmp_path, monk
previous = selected_venv(root)
if update == "launch":
assert venv_sync.prepare_launch(root, []) == new_python
# The heal finished the whole tail, with update wording, through the checkout's own completion.
calls = [json.loads(line) for line in (tmp_path / "completion-calls").read_text().splitlines()]
assert calls == [["--source", str(root), "--finish-update"]]
elif update == "sync":
# This is the PM sync -> launcher publication sequence now split across
# update_completion._prepare and _complete_selected. The former checkout

View File

@@ -15,7 +15,7 @@ def test_stamp_uses_built_commit_even_with_dispatch_sha_and_refuses_mismatch(tmp
repo.mkdir()
for relative in ('scripts/write_install_stamp.py', 'scripts/releases/commit_build.py',
'hermes_cli/__init__.py', 'hermes_cli/update_channel.py', 'hermes_cli/release_channels.py',
'pm/environments.py', 'hermes_cli/steward.py', 'hermes_constants.py'):
'pm/paths.py', 'pm/environments.py', 'hermes_cli/steward.py', 'hermes_constants.py'):
dest = repo / relative
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / relative, dest)

View File

@@ -146,7 +146,7 @@ def test_current_installer_publishes_real_dependencies_and_warm_path(tmp_path, s
marker.chmod(0o444)
before = facts.read_bytes()
try:
run([*command, "--stage", "path"])
run([*command, "--stage", "products"])
finally:
marker.chmod(0o644)
assert facts.read_bytes() == before

View File

@@ -35,7 +35,7 @@ def fixture_repo(tmp_path):
'scripts/releases/r2.py', 'scripts/releases/r2_scope.py', 'scripts/release-content-types.json',
'hermes_cli/__init__.py', 'hermes_cli/update_channel.py',
'hermes_cli/release_channels.py',
'pm/environments.py', 'hermes_constants.py'):
'pm/paths.py', 'pm/environments.py', 'hermes_constants.py'):
dest = repo / relative
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / relative, dest)

View File

@@ -17,7 +17,8 @@ def _stamp_probe(tmp_path, shell):
repo = tmp_path / "installed source"
for relative in (
"scripts/write_install_stamp.py", "hermes_cli/__init__.py",
"hermes_cli/update_channel.py", "pm/environments.py",
"hermes_cli/update_channel.py", "hermes_cli/release_channels.py",
"pm/paths.py", "pm/environments.py",
"hermes_cli/steward.py", "hermes_constants.py",
):
dest = repo / relative