refactor(update): finish source updates in fresh selected Python

This commit is contained in:
ethernet
2026-09-12 19:09:29 -04:00
parent f5c84ee90a
commit 93cffdbd3a
28 changed files with 1333 additions and 1125 deletions

View File

@@ -0,0 +1,80 @@
# Source update completion ownership
## Phase seam
The command process owns admission, the update lock and output lifetime, pre-update
inventory, all-profile snapshots, gateway pause, Git selection/stash/restore and
syntax/HEAD guards, and the ZIP download/stage/dirty recheck/release graft/swap.
It imports the completion transport before swapping code. Once the final tree is
selected (including upstream merge), Git, already-current retry and ZIP all send
one versioned JSON request to `update_completion.py` **from that tree**. No cached
application module is evicted or reloaded in the command process.
The request carries canonical source/home, desktop product selection, interactive
and gateway mode, pre-update version, active and sibling snapshot identifiers,
serialized runtime plan, open receipt identity/data and paused-Windows token. It
contains data, never callables or pickles. stdin stays inherited for interactive
configuration prompts; gateway mode retains its non-interactive behavior. Child
output stays visible and is mirrored by the parent's update output stream.
## New-code owner
A stdlib-only entrypoint starts using the available Python with `-I -S`, so no
old site-packages or executable `.pth` files initialize. A private bytecode-cache
prefix fences stale cache files before any new-checkout imports. Its explicit
import path points at the new checkout. It calls the new PM interface to prepare the
recorded dependency union, then starts the selected Python with the new activation
environment. That interpreter also starts with site initialization disabled,
then the runtime owner leases and activates its selected generation before any
application imports. Only that interpreter imports application completion code. The same
receipt/correlation identity crosses this preparation boundary (including PM
results). Selected-Python completion owns launcher publication, builders, cache
invalidation, all-profile configuration/state/skills maintenance, process scans,
fleet restart, Windows resume, dashboard deduplication and verification.
The existing per-kind restart and abort-recovery algorithms remain; transient
supervisor/process failures are real even without mixed-generation imports. Only
the purge/reload workaround and independent retry/ZIP tail compositions disappear.
Gateway exit status is written before a restart can terminate the updater's cgroup,
and is demoted on later failure. Verification publishes the final receipt.
## Parent lifecycle and failures
The parent waits and propagates the child's exact nonzero result (a signal is
mapped to shell-style 128+signal). A child cannot succeed by merely exiting zero:
a terminal response with the matching receipt identity is required. The response
returns the mutated Windows token so the parent's registered emergency resume does
not repeat completed work. Normal parent completion performs no maintenance.
The parent retains its original receipt until acknowledged child finalization;
missing/failed child output leaves it available to the existing command-boundary
failure finalizer. The stdlib bootstrap returns correlated PM failure data even
when application imports are unavailable, and normalizes negative signal exits
at each process boundary. POSIX completion owns a new session/process group;
cancellation kills that group before releasing the lock (Windows uses the retained
child's `taskkill /T` tree). The parent records the pending fleet obligation before
starting the completion process, including when preparation cannot begin. The parent's emergency Windows resume remains a last-resort
lifecycle obligation when the child cannot execute or is killed. A failed child
never clears the pending fleet obligation. No automatic code rollback after
maintenance has begun (SQLite snapshots remain file-loss recovery, not rollback).
## Historical surface
All names frozen from the complete reachable shipped updater history stay
resolvable. Retired preparation and module-reload hooks become narrow nonzero
relaunch stops, not alternate completion paths or false successful receipts.
Unfrozen branch-only retry compositions are deleted, not shimmed. ACP convenience
publication uses the launcher owner's `expose_cli`; the historical ACP entry is
only an adapter, never a second writer. The frozen set is never trimmed or replaced
with tag-only coverage. New current-path imports are unioned with that history.
## Verification
Use isolated homes, disposable Git repositories and fake dependency/build/service
adapters only. Exercise an old process with cached incompatible modules across a
real Git transition to new code, selected-Python execution, receipt identity and
snapshot transfer, nonzero/abrupt child exit, lock release and Windows-token
return. Focused existing tests cover dirty ZIP checks/grafts, snapshots, fleet
reconciliation, supervisor timing and historical imports. Native service restart
and Windows/macOS acceptance remain separate required lanes; no live user service
or user state is touched by this implementation's test runs.

View File

@@ -114,9 +114,13 @@ it guards. `plan → snapshot → apply → restart-per-kind → verify → repo
(exit 1) — automation must never treat a mixed-version fleet as healthy.
- **Report**: every run writes a machine-readable receipt to `~/.hermes/logs/update_receipts/`
(`latest.json` pointer; steps, skips WITH reasons, restart outcome, plan, fleet snapshot).
Finalization is owned by the `cmd_update` command boundary — early `sys.exit` paths (preflight
refusals, fetch failures) still persist a receipt with the real exit code. A begun-but-unwritten
receipt is a bug: refused/failed runs are the ones receipts exist for.
Before a source swap, the parent captures plan/snapshots/receipt and its Windows pause token.
`update_completion.py` runs new-code PM preparation with site initialization disabled, then
selected-Python builds, maintenance, scans/restarts and verification. Git/current/ZIP share
this owner; never reload or purge modules to continue in the old interpreter. The parent keeps
the lock, waits, and accepts only a correlated terminal result. `cmd_update` still finalizes
early failures and missing/killed-child outcomes; PM refusal data survives the handoff.
See `docs/source-update-completion.md`. A begun-but-unwritten receipt is a bug.
Process-scan coordination between updater, serve/dashboard, and gateway is being replaced by a
gateway-owned control socket (#92091); scans are the fallback layer for old/crashed processes — read

View File

@@ -4,11 +4,12 @@ import sys
from typing import NoReturn
def stop_for_relaunch() -> NoReturn:
def stop_for_relaunch(*, incomplete: bool = False) -> NoReturn:
"""Do not return: old callers would fall back to pip or claim completion."""
command = "hermes update" if incomplete else "hermes"
print(
"You're updating from an older version of Hermes Agent. "
"To complete this update, run `hermes` again.",
f"To complete this update, run `{command}` again.",
file=sys.stderr,
)
raise SystemExit(0)
raise SystemExit(1 if incomplete else 0)

View File

@@ -20,6 +20,10 @@ from typing import NoReturn
from hermes_cli.config import get_hermes_home # noqa: F401 (re-exported; patched via update_cmd)
from hermes_cli.update_cmd_common import _best_effort
# Captured BEFORE a checkout swap: parent transport/lifecycle never imports new code.
from hermes_cli.update_completion import run_completion
from pm.receipt import accept_worker_receipt as _accept_completion_pm_receipt
from hermes_cli import update_receipt as _completion_receipt, update_cmd_config as _completion_config
from hermes_cli._old_updater import stop_for_relaunch
from hermes_constants import venv_python_path
@@ -43,14 +47,14 @@ from hermes_cli.update_cmd_windows import ( # noqa: F401
_wait_for_windows_update_gateway_exit, _write_update_planned_stop_marker)
from hermes_cli.update_cmd_fleet import ( # noqa: F401
_FLEET_RESTART_PENDING_NAME, _FRESH_RESTART_SUPERVISORS, _GatewayRestartOutcome,
_apply_pending_fleet_restart_catchup, _clear_fleet_restart_pending_marker,
_clear_fleet_restart_pending_marker,
_current_checkout_sha, _drain_or_signal_gateway_for_update, _fleet_probe_expected_runtimes,
_fleet_restart_pending_marker_path, _for_each_systemd_gateway_unit,
_gateway_recovery_partition, _gateway_service_matches_profile, _pending_fleet_restart_needed,
_receipt_looks_unfinished, _receipt_reports_stale_runtime, _resolve_manage_cmd,
_restart_gateway_fleet_after_update, _restart_launchd_gateway_after_update,
_restart_macos_launchd_gateways, _restart_phase_failure_is_incomplete,
_restart_systemd_gateway_units, _restart_systemd_gateway_units_best_effort,
_restart_systemd_gateway_units,
_run_pending_fleet_restart, _service_restart_sec,
_service_unit_supports_graceful_sigusr1_restart, _surviving_gateway_pids_after_failed_restart,
_systemctl, _systemctl_reset_and_restart, _verify_fleet_after_update,
@@ -93,8 +97,7 @@ from hermes_cli.update_cmd_git import ( # noqa: F401
_prune_orphan_rescue_refs, _should_skip_upstream_prompt, _sync_fork_with_upstream,
_sync_with_upstream_if_needed)
from hermes_cli.update_cmd_maint import ( # noqa: F401
_PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, _STALE_PURGE_PREFIXES,
_STALE_PURGE_PROTECTED, _clear_stale_sqlite_sidecars,
_PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, _clear_stale_sqlite_sidecars,
_ensure_acp_launcher, _ensure_fhs_path_guard, _finish_dashboard_update_cleanup,
_format_time_ago, _post_update_sqlite_runtime_status, _print_bundled_skills_sync_report,
_print_curator_first_run_notice, _print_curator_recent_run_notice,
@@ -788,18 +791,45 @@ def _print_update_check_result(behind: int | None, compare_branch: str) -> None:
print(f" Run '{recommended_update_command()}' to install.")
def _repair_current_checkout(
*, assume_yes, gateway_mode, pre_update_snapshot_id,
had_desktop_app_before_update, upstream_checked) -> bool:
"""A retry completes the same products as a newly pulled checkout."""
_prepare_updated_checkout(
_m().PROJECT_ROOT, desktop=had_desktop_app_before_update)
_check_and_apply_config_migration(
assume_yes=assume_yes, gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id)
return _print_verified_update_completion(
"✓ Already up to date!" if upstream_checked
else "✓ Up to date with your fork (official repo not checked).")
def _source_completion_request(opts, plan, snapshot_id, windows_resume, desktop, gateway_mode) -> dict:
"""Freeze data before mutation; no pre-swap module objects cross the seam."""
from copy import deepcopy
current = _completion_receipt._current.get()
if current is None:
_completion_receipt.begin_update_receipt()
current = _completion_receipt._current.get()
return {
"schema": 1, "source": str(_m().PROJECT_ROOT.resolve()),
"home": str(get_hermes_home()), "branch": "main", "desktop": desktop,
"assume_yes": opts.assume_yes, "gateway_mode": gateway_mode,
"pre_update_version": opts.pre_update_version, "snapshot_id": snapshot_id,
"sibling_snapshots": deepcopy(_completion_config._LAST_SIBLING_SNAPSHOTS),
"plan": plan.to_dict() if plan is not None else None,
"receipt": deepcopy(current.data), "windows_resume": windows_resume,
}
def _complete_source_update(request: dict | None) -> None:
if request is None:
stop_for_relaunch(incomplete=True)
from copy import deepcopy
current = _completion_receipt._current.get()
if current is not None:
request["receipt"] = deepcopy(current.data)
_write_fleet_restart_pending_marker(expected_sha=request.get("expected_sha") or "")
result = run_completion(request)
_accept_completion_pm_receipt(result.get("pm_receipt"), request["receipt"]["update_id"])
token = request["windows_resume"]
if token is not None and result.get("windows_resume") is not None:
resumed = dict(result["windows_resume"])
token.clear()
token.update(resumed)
if result.get("receipt") is not None:
current = _completion_receipt._current.get()
if current is not None:
_completion_receipt._current.reset(current.current_token)
if result["exit_code"]:
raise SystemExit(result["exit_code"])
def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha, *, target_ref=None) -> None:
@@ -1230,7 +1260,7 @@ def _current_branch_name(git_cmd, *, check: bool = False) -> str:
def _handle_update_called_process_error(
e, args, gateway_mode: bool, had_desktop_app_before_update: bool,
*, target_sha: str | None = None, target_repository: str | None = None) -> None:
*, target_sha: str | None = None, target_repository: str | None = None, completion_request=None) -> None:
"""Git/installer failure: ZIP-fallback when safe, else report and ``sys.exit(1)``."""
stage = _format_update_failure_stage(e)
if _should_zip_fallback_on_update_error(e):
@@ -1239,12 +1269,9 @@ def _handle_update_called_process_error(
print()
update_complete = _update_via_zip(
args, had_desktop_app_before_update=had_desktop_app_before_update,
target_sha=target_sha,
target_sha=target_sha, completion_request=completion_request,
**({"target_repository": target_repository} if target_repository else {}))
if gateway_mode:
_write_gateway_update_exit_code(update_complete)
if not update_complete:
sys.exit(1)
else:
print(f"✗ {stage}: {e}")
_print_called_process_error_tail(e)
@@ -1269,12 +1296,9 @@ def _finalize_receipt(status: str, debug_message: str) -> None:
def _finish_already_up_to_date(
git_cmd, branch: str, current_branch: str, _plan, *, assume_yes: bool, gateway_mode: bool,
gw_input_fn, pre_update_snapshot_id, had_desktop_app_before_update: bool,
_windows_gateway_resume) -> None:
git_cmd, branch: str, current_branch: str, _plan, *, gw_input_fn, completion_request: dict) -> None:
""""Already up to date" path: restore stash/branch, repair the checkout, catch up the fleet.
``sys.exit(1)`` when the repair is incomplete (after gateway exit code + partial receipt)."""
# Restore stash and switch back if we moved. EXCEPTION: a parked branch verified clean +
# fully merged stays on the target — re-parking on the stale branch recreates the incident.
if _plan.auto_stash_ref is not None:
@@ -1292,69 +1316,27 @@ def _finish_already_up_to_date(
elif current_branch not in {branch, "HEAD"}:
_git_run(git_cmd, ["checkout", current_branch])
current_checkout_complete = _repair_current_checkout(
assume_yes=assume_yes, gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id,
had_desktop_app_before_update=had_desktop_app_before_update,
upstream_checked=_plan.upstream_checked)
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
# A prior pull may still owe the fleet a restart; catch up here too, BEFORE the exit
# gate so a partial outcome can't strand the fleet on stale code.
# Catch up even on the "Already up to date" path — that early return is what left the gateway on stale
# code for two days. Runs BEFORE the runtime-verification exit gate below: a vulnerable SQLite runtime
# demotes the outcome to partial, but must not strand the fleet on stale code (#91277 fleet contract —
# the pending-restart check always executes).
_apply_pending_fleet_restart_catchup()
if not current_checkout_complete:
if gateway_mode:
_write_gateway_update_exit_code(False)
_finalize_receipt("partial", 'Update receipt finalize (current checkout) failed: %s')
sys.exit(1)
if completion_request is not None:
completion_request["completion_message"] = (
"✓ Already up to date!" if _plan.upstream_checked
else "✓ Up to date with your fork (official repo not checked).")
_complete_source_update(completion_request)
def _apply_pulled_update(
git_cmd, branch, pre_pull_sha, _plan, opts, *, gateway_mode, is_fork, desktop_dir,
had_desktop_app_before_update, pre_update_snapshot_id, _pre_update_plan,
_windows_gateway_resume) -> None:
git_cmd, branch, pre_pull_sha, _plan, opts, *, is_fork,
_windows_gateway_resume, completion_request: dict) -> None:
"""Post-pull phase: verify HEAD, sync Python/Node/web/Desktop, maintenance, fleet restart."""
post_pull_sha = _verify_head_after_pull(
git_cmd, branch, pre_pull_sha, in_place_update=_plan.in_place_update,
_windows_gateway_resume=_windows_gateway_resume)
# Gateways still serve pre-pull modules until the restart phase; an interrupt before a
# completed restart leaves this marker so the next update catches up even when git is
# current. Distinct from ``.update-incomplete`` (venv/install repair).
# See #95294.
_write_fleet_restart_pending_marker(expected_sha=post_pull_sha or "")
# Stale .pyc would ImportError on gateway restart when new source references new names.
_sweep_bytecode_after_update(branch)
if is_fork and branch == "main":
_m()._sync_with_upstream_if_needed(
git_cmd, _m().PROJECT_ROOT, assume_yes=opts.assume_yes, input_fn=opts.gw_input_fn)
_prepare_updated_checkout(_m().PROJECT_ROOT, desktop=had_desktop_app_before_update)
print()
print(f"✓ Code updated!{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}")
update_complete = _run_post_update_maintenance(
assume_yes=opts.assume_yes, gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id,
had_desktop_app_before_update=had_desktop_app_before_update,
pre_update_version=opts.pre_update_version)
# Exit code *before* the restart: under --gateway this process lives in the gateway's
# systemd cgroup and the systemctl-restart fallback SIGKILLs it (KillMode=mixed), so
# the marker would never land and the new gateway's watcher would time out spuriously.
if gateway_mode:
_write_gateway_update_exit_code(update_complete)
_restart = _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode)
_resume_windows_gateways_and_merge_outcome(_restart, _windows_gateway_resume, gateway_mode)
_verify_fleet_after_update(
_restart, _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume,
update_complete=update_complete)
if completion_request is not None:
completion_request["expected_sha"] = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) or post_pull_sha
_complete_source_update(completion_request)
def _cmd_update_impl(args, gateway_mode: bool):
@@ -1369,6 +1351,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Backup before any git/file mutation; the snapshot id (None if disabled/failed) feeds
# the post-update cron-jobs safety net.
_completion_config._LAST_SIBLING_SNAPSHOTS = {}
pre_update_snapshot_id = _m()._run_pre_update_backup(args)
_record_update_step(
"pre_update_backup", pre_update_snapshot_id is not None,
@@ -1387,7 +1370,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
use_zip_update, git_cmd, is_fork = _prepare_git_command()
completion_request = _source_completion_request(
opts, _pre_update_plan, pre_update_snapshot_id, _windows_gateway_resume,
had_desktop_app_before_update, gateway_mode)
branch = _m()._resolve_update_branch(args)
completion_request["branch"] = branch
target_ref = f"origin/{branch}"
release_tag, release_sha = None, None
target_repository = None
@@ -1419,14 +1406,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
try:
update_complete = _update_via_zip(
args, had_desktop_app_before_update=had_desktop_app_before_update,
target_sha=release_sha,
target_sha=release_sha, completion_request=completion_request,
**({"target_repository": target_repository} if target_repository else {}))
finally:
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
if gateway_mode:
_write_gateway_update_exit_code(update_complete)
if not update_complete:
sys.exit(1)
return
try:
@@ -1482,11 +1466,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
if commit_count == 0:
_finish_already_up_to_date(
git_cmd, branch, current_branch, _plan, assume_yes=assume_yes,
gateway_mode=gateway_mode, gw_input_fn=gw_input_fn,
pre_update_snapshot_id=pre_update_snapshot_id,
had_desktop_app_before_update=had_desktop_app_before_update,
_windows_gateway_resume=_windows_gateway_resume)
git_cmd, branch, current_branch, _plan, gw_input_fn=gw_input_fn,
completion_request=completion_request)
return
if release_tag:
@@ -1503,16 +1484,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
gw_input_fn=gw_input_fn, discard_local_changes=opts.discard_local_changes,
keep_stash=opts.keep_stash, target_ref=target_ref)
_apply_pulled_update(
git_cmd, branch, pre_pull_sha, _plan, opts, gateway_mode=gateway_mode,
is_fork=is_fork and not release_tag, desktop_dir=desktop_dir,
had_desktop_app_before_update=had_desktop_app_before_update,
pre_update_snapshot_id=pre_update_snapshot_id, _pre_update_plan=_pre_update_plan,
_windows_gateway_resume=_windows_gateway_resume)
git_cmd, branch, pre_pull_sha, _plan, opts, is_fork=is_fork and not release_tag,
_windows_gateway_resume=_windows_gateway_resume, completion_request=completion_request)
except subprocess.CalledProcessError as e:
try:
_handle_update_called_process_error(
e, args, gateway_mode, had_desktop_app_before_update, target_sha=release_sha,
target_repository=target_repository)
target_repository=target_repository, completion_request=completion_request)
finally:
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)

View File

@@ -13,37 +13,19 @@ logger = logging.getLogger("hermes_cli.update_cmd")
def _reload_config_modules() -> None:
"""Force-reload config modules after git pull: the updater is the PRE-pull process, so the
cached modules hold OLD code and ``check_config_version()`` would report "up to date" despite a
pulled migration. ``_subprocess_compat`` / ``dashboard_procs`` reload too so the later dashboard
cleanup sees symbols the update added."""
import importlib
importlib.invalidate_caches()
for mod_name in (
"hermes_cli.config_defaults", "hermes_cli.config", "hermes_cli.config_migrations",
"hermes_cli._subprocess_compat", "hermes_cli.dashboard_procs"):
mod = sys.modules.get(mod_name)
if mod is not None:
try:
importlib.reload(mod)
except Exception as exc:
logger.debug("Could not reload %s for fresh post-update code: %s", mod_name, exc)
"""Historical updater hook; migration now belongs to fresh completion Python."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch(incomplete=True)
def _run_config_check_fresh() -> tuple:
"""``(current_ver, latest_ver)`` from freshly-reloaded modules (see ``_reload_config_modules``)."""
from hermes_cli.update_cmd import _reload_config_modules
_reload_config_modules()
from hermes_cli.config import check_config_version
return check_config_version(raise_on_parse_error=True)
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch(incomplete=True)
def _run_migrate_config_fresh(*, interactive: bool = False, quiet: bool = False) -> dict:
"""Run config migration with freshly-reloaded modules; returns the results dict."""
from hermes_cli.update_cmd import _reload_config_modules
_reload_config_modules()
from hermes_cli.config import migrate_config
return migrate_config(interactive=interactive, quiet=quiet)
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch(incomplete=True)
def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]:
@@ -56,7 +38,7 @@ def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]:
profile, but ``hermes update`` historically migrated only the active profile's config — siblings drifted
versions until their gateway hit a config the new code couldn't read.
"""
from hermes_cli.update_cmd import _run_config_check_fresh, _run_migrate_config_fresh
from hermes_cli.config import check_config_version, migrate_config
migrated: list[tuple[str, int, int]] = []
with _best_effort('Sibling profile enumeration failed: %s'):
from hermes_constants import (
@@ -78,11 +60,11 @@ def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]:
continue # profile never configured — nothing to migrate
token = set_hermes_home_override(entry)
try:
current_ver, latest_ver = _run_config_check_fresh()
current_ver, latest_ver = check_config_version(raise_on_parse_error=True)
if current_ver >= latest_ver:
continue
_run_migrate_config_fresh(interactive=False, quiet=True)
after_ver, _ = _run_config_check_fresh()
migrate_config(interactive=False, quiet=True)
after_ver, _ = check_config_version(raise_on_parse_error=True)
if after_ver > current_ver:
migrated.append((entry.name, current_ver, after_ver))
except Exception as exc:
@@ -168,20 +150,17 @@ def _check_and_apply_config_migration(
See #91360.
"""
from hermes_cli.update_cmd import (
_migrate_sibling_profile_configs, _reload_config_modules, _run_config_check_fresh,
_run_migrate_config_fresh)
from hermes_cli.update_cmd import _migrate_sibling_profile_configs
from hermes_cli.config import check_config_version, migrate_config
print()
print("→ Checking configuration for new options...")
# Reload BEFORE any config reads so all checks use the updated code.
_reload_config_modules()
from hermes_cli.config import get_missing_env_vars, get_missing_config_fields
# A config-check failure must not break an otherwise-successful update.
try:
# Log, point at the manual command, and return. See #91360.
missing_env = get_missing_env_vars(required_only=True)
missing_config = get_missing_config_fields()
current_ver, latest_ver = _run_config_check_fresh()
current_ver, latest_ver = check_config_version(raise_on_parse_error=True)
except Exception as exc:
logger.debug("Config check during update failed: %s", exc)
print(" ⚠️ Could not check config version.")
@@ -198,7 +177,7 @@ def _check_and_apply_config_migration(
print()
print(f" ℹ Updating config format (v{current_ver} → v{latest_ver})…")
try:
_mig_results = _run_migrate_config_fresh(interactive=False, quiet=True)
_mig_results = migrate_config(interactive=False, quiet=True)
print(" ✓ Config format updated (no new settings to configure)")
# quiet=True also mutes steps that RESET/REMOVE a setting; re-surface them so an
# unattended update never silently changes config (config_added holds only mutations here).
@@ -228,7 +207,7 @@ def _check_and_apply_config_migration(
# Gateway/--yes/non-interactive can't prompt for API keys; still run the
# non-interactive pass so defaults and version bumps land before the gateway restarts.
unattended = gateway_mode or assume_yes or response == "auto"
results = _run_migrate_config_fresh(interactive=not unattended, quiet=False)
results = migrate_config(interactive=not unattended, quiet=False)
if results["env_added"] or results["config_added"]:
print()
print("✓ Configuration updated!")

View File

@@ -239,128 +239,13 @@ def _needs_sudo(scope: str) -> bool:
)
def _restart_systemd_gateway_units_best_effort(failed: list, listings) -> None:
"""Best-effort ``systemctl restart`` of every hermes-gateway/serve unit."""
answered = set()
for scope, scope_cmd, result in listings:
answered.add(scope)
if result.returncode != 0:
failed.append(f"systemd-{scope} (listing failed)")
continue
def process_unit(svc_name: str, _scope=scope, _cmd=scope_cmd) -> None:
manage_cmd = list(_cmd) + ["--no-ask-password"]
if _needs_sudo(_scope):
manage_cmd = ["sudo", "-n"] + manage_cmd
result = _systemctl_reset_and_restart(manage_cmd, svc_name, scope_cmd=_cmd)
if result.returncode != 0 or not _wait_for_service_active(_cmd, svc_name):
failed.append(svc_name)
_for_each_systemd_gateway_unit(
result.stdout,
process_unit=process_unit,
on_unit_timeout=lambda svc_name, exc: failed.append(svc_name),
)
# A timeout or missing executable is not an empty scope.
failed.extend(f"systemd-{scope} (listing unavailable)" for scope, _ in _SYSTEMD_SCOPES if scope not in answered)
def _run_pending_fleet_restart() -> bool:
"""Catch-up restart for gateways left on pre-update code. Never raises.
True when all discovered targets recovered (or none exist); False if incomplete.
See #95294.
"""
from hermes_cli.update_cmd import _m
print("→ Restarting gateways left on pre-update code...")
with suppress(Exception):
_m()._purge_stale_hermes_modules()
# Warn if legacy Hermes gateway unit files are still installed. When both hermes.service (from a
# pre-rename install) and the current hermes-gateway.service are enabled, they SIGTERM-fight for the
# same bot token (see PR #11909). Flagging here means every `hermes update` surfaces the issue until the
# user migrates.
try:
from hermes_cli.gateway import (
find_gateway_pids, is_macos, is_windows, kill_gateway_processes, supports_systemd_services,
_wait_for_gateway_exit,
)
except Exception as exc:
_warn_gateway_restart_phase_aborted(exc, None)
return False
try:
pids = list(find_gateway_pids(all_profiles=True))
except Exception as exc:
logger.debug("Pending fleet restart: gateway probe failed: %s", exc)
pids = None
failed: list = []
try:
# Snapshot before stopping: Restart=no units can disappear from list-units on a clean exit.
systemd_listings = list(_systemd_gateway_unit_listings()) if supports_systemd_services() else None
# Stop old processes before supervisor recovery, never its freshly verified workers.
if pids != []:
try:
leftover = list(find_gateway_pids(all_profiles=True))
except Exception:
leftover = list(pids or [])
if leftover:
with _best_effort('Pending fleet restart: PID stop failed: %s'):
kill_gateway_processes(all_profiles=True)
_wait_for_gateway_exit(timeout=5.0, force_after=None)
# --- Systemd services (Linux) --- Discover all hermes-gateway* units (default + profiles) plus
# hermes-serve* units (the Desktop app's backend, #83438).
if systemd_listings is not None:
_restart_systemd_gateway_units_best_effort(failed, systemd_listings)
# --- Launchd services (macOS) --- Restart EVERY ai.hermes.gateway* LaunchAgent, not only the
# invoking profile's — parity with the systemd branch above (#41403). Per-label TimeoutExpired
# isolation happens inside.
if is_macos():
try:
_restart_macos_launchd_gateways([], failed, 45.0, require_supervision=True)
except Exception as exc:
logger.debug("Pending fleet restart: launchd failed: %s", exc)
failed.append("launchd")
if is_windows():
try:
from hermes_cli import gateway_windows
if gateway_windows.is_installed():
gateway_windows.restart()
except Exception as exc:
logger.debug("Pending fleet restart: Windows failed: %s", exc)
failed.append("windows-gateway")
if failed:
_warn_incomplete_gateway_fleet_restart(failed)
return False
print(" ✓ Pending fleet restart completed.")
return True
except Exception as exc:
try:
surviving = list(find_gateway_pids(all_profiles=True))
except Exception:
surviving = pids
_warn_gateway_restart_phase_aborted(exc, surviving)
return False
def _apply_pending_fleet_restart_catchup() -> None:
"""On an already-up-to-date ``hermes update``, finish a skipped restart.
No-op when nothing is pending; exits 1 on incomplete catch-up so automation
does not treat the fleet as healthy.
"""
from hermes_cli.update_cmd import _run_pending_fleet_restart
if not _pending_fleet_restart_needed():
return
print()
_warn_pending_fleet_restart()
print("→ Running the pending fleet restart...")
if _run_pending_fleet_restart():
_clear_fleet_restart_pending_marker()
return
print(" ⚠ Fleet restart incomplete. Recover with: hermes gateway restart")
sys.exit(1)
"""Historical retry hook; new retries use the ordinary completion owner."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch(incomplete=True)
def _systemctl(cmd: list, *, timeout: float):
@@ -1182,10 +1067,6 @@ def _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode: bool):
# already-restarted units to ``_refresh_dashboard_after_update`` (review on #83595).
restarted_scoped_units: set = set()
# Purge stale cached Hermes modules FIRST: the import below loads new gateway
# source into this pre-update interpreter, and a cached sibling missing a
# symbol the new source expects would ImportError and abort the whole phase.
_m()._purge_stale_hermes_modules()
try:
# Every gateway helper the phase needs is imported up front so a broken gateway
# module aborts into recovery BEFORE any unit is touched.

View File

@@ -5,7 +5,6 @@ still resolves/monkeypatches. Origin helpers are imported lazily per function (n
test patches on ``update_cmd`` stay effective).
"""
import importlib
import logging
from contextlib import suppress
import os
@@ -24,37 +23,10 @@ logger = logging.getLogger("hermes_cli.update_cmd")
def _prepare_updated_checkout(project_root: Path, *, desktop: bool) -> None:
"""PM publishes dependencies before the shared builders consume the checkout."""
import pm
"""Historical updater hook: never complete inside the pre-swap interpreter."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch(incomplete=True)
pm.sync_venv(explicit=True, project_root=project_root)
from hermes_cli.venv_sync import publish_launchers
publish_launchers(project_root)
from hermes_cli.runtime_paths import activation_environment, selected_venv
# The updater still holds pre-pull imports. Build only in the newly selected Python.
command = [str(venv_python_path(selected_venv(project_root))),
"-m", "hermes_cli.source_build", "--source", str(project_root)]
if desktop:
command.append("--desktop")
subprocess.run(command, cwd=project_root, env=activation_environment(project_root), check=True)
#: Package prefixes whose cached modules go stale when the checkout changes under this
#: process; purged (not reloaded) so any LATER import chain resolves against fresh source.
_STALE_PURGE_PREFIXES = "hermes_cli", "gateway", "tools", "tui_gateway", "agent"
#: Modules EXECUTING the update survive the purge: evicting them buys nothing (running frames
#: keep them alive) and reloading them mid-flight is the one genuinely unsafe move.
_STALE_PURGE_PROTECTED = frozenset({"hermes_cli", "hermes_cli.main", "hermes_cli.hermes_logging"})
#: The updater's own module family (``update_cmd*``, ``update_receipt``, ``update_inventory``,
#: ``update_lock``, ...) is protected as a prefix: these hold per-run state — the open receipt
#: singleton, the pre-update plan's ``RuntimeRecord`` class identity, the lock — and evicting
#: one swaps in a fresh module whose ``_current`` is None (receipt silently never written) or
#: whose dataclass fails every ``isinstance`` against the plan built before the purge.
_STALE_PURGE_PROTECTED_PREFIX = "hermes_cli.update_"
_PRE_UPDATE_SNAPSHOT_KEEP = 1
@@ -73,41 +45,10 @@ def _load_updates_cfg() -> dict:
return updates if isinstance(updates, dict) else {}
def _reload_modules(names, *, modules, log) -> None:
"""``importlib.reload`` each module of *names* cached in *modules*; failures go to *log*."""
importlib.invalidate_caches()
for module_name in names:
module = modules.get(module_name)
if module is None:
continue
try:
importlib.reload(module)
except Exception as exc:
log(module_name, exc)
def _purge_stale_hermes_modules() -> None:
"""Evict every cached Hermes module after the checkout changed in-place. Never raises.
The update runs in the pre-pull process; later phases lazily import NEW source into an OLD
``sys.modules`` world and die when new code references a symbol missing from a cached
module. Purging (unlike reload) only drops the ``sys.modules`` entry — running frames keep
their module objects — so later imports rebuild a self-consistent graph from the new tree.
"""
from hermes_cli.update_cmd import _m
with _best_effort('Could not purge stale Hermes modules: %s'):
importlib.invalidate_caches()
modules = _m().sys.modules
purged = [
name for name in list(modules)
if name not in _STALE_PURGE_PROTECTED
and not name.startswith(_STALE_PURGE_PROTECTED_PREFIX)
# Root-package check: startswith() alone also matches unrelated ``gateway_foo``.
and name.split(".", 1)[0] in _STALE_PURGE_PREFIXES
and modules.pop(name, None) is not None
]
if purged:
logger.debug("Purged %d stale Hermes module(s) after checkout update", len(purged))
"""Historical updater hook; module-graph surgery cannot complete an update."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch(incomplete=True)
def _reload_updated_runtime_modules() -> None:
@@ -296,25 +237,9 @@ def _format_time_ago(iso_ts: str) -> str:
def _reload_process_scan_modules() -> None:
"""Reload the process-scan modules, dependency-first, so ``dashboard_procs`` binds against a
fresh ``_subprocess_compat``: cleanup runs in the PRE-update process and a symbol the update
added would otherwise ImportError after the code update succeeded. Called from the cleanup
entry point so every caller (git path, ZIP fallback) is covered.
``_refresh_dashboard_after_update`` runs in the PRE-update Python process, but
``_scan_dashboard_processes`` does a function-level ``from hermes_cli._subprocess_compat import
bounded_probe_run``. If the update added a new symbol to ``_subprocess_compat`` (as #87134 did with
``bounded_probe_run``), the cached OLD module object doesn't have it and the cleanup step crashes with
ImportError — after the code update itself already succeeded.
"""
_reload_modules(
("hermes_cli._subprocess_compat", "hermes_cli.dashboard_procs"),
modules=sys.modules,
# warning, not debug: a failed reload surfaces as ImportError seconds later.
log=lambda name, exc: logger.warning(
"Could not reload %s for post-update cleanup: %s", name, exc
),
)
"""Historical updater hook; scans now run only in fresh completion Python."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch(incomplete=True)
def _finish_dashboard_update_cleanup(
@@ -334,8 +259,7 @@ def _refresh_dashboard_after_update(*, already_restarted_units: set[str] | None
See #83595.
"""
from hermes_cli.update_cmd import _m, _reload_process_scan_modules
_reload_process_scan_modules()
from hermes_cli.update_cmd import _m
stop_result = _m()._kill_stale_dashboard_processes(
restart_managed=True, already_restarted_units=already_restarted_units
@@ -393,12 +317,9 @@ def _update_complete_message(pre_version: str | None) -> str:
def _post_update_sqlite_runtime_status():
"""Return whether the interpreter used after update has safe SQLite."""
from hermes_cli.update_cmd import _m
from hermes_constants import project_venv_dir
from hermes_cli.sqlite_runtime import probe_sqlite_runtime
venv_dir = project_venv_dir(_m().PROJECT_ROOT)
python = (venv_python_path(venv_dir, windows=_m()._is_windows()) if venv_dir is not None else Path(sys.executable))
info = probe_sqlite_runtime(python)
# Completion already runs on PM's selected Python, not the obsolete repo venv.
info = probe_sqlite_runtime(Path(sys.executable))
return info is not None and not info.wal_reset_vulnerable, info
@@ -628,43 +549,10 @@ def _ensure_fhs_path_guard() -> None:
def _ensure_acp_launcher() -> None:
r"""Self-heal a ``hermes-acp`` launcher next to ``hermes`` (mirrors install.sh): ACP hosts
resolve it on the login-shell PATH but the console script lives in the venv. The shim
delegates to the sibling ``hermes acp``, correct for every layout.
No-op on Windows (install.ps1 stages launchers into ``$HermesHome\bin``, never
``venv\Scripts`` which would shadow the user's python; launcher repair lives in
_install_repair) and where it already exists. Unwritable dirs are skipped. Idempotent.
``/usr/local/bin`` as non-root) are skipped silently. See #83797.
"""
"""Historical export; launcher policy belongs to the launcher owner."""
from hermes_cli import _launchers
from hermes_cli.update_cmd import _m
if _m().sys.platform == "win32":
return
for bin_dir in (Path.home() / ".local" / "bin", Path("/usr/local/bin")):
hermes_cmd = bin_dir / "hermes"
acp_cmd = bin_dir / "hermes-acp"
try:
if not (hermes_cmd.is_file() or hermes_cmd.is_symlink()):
continue
# is_symlink() catches broken symlinks exists() misses; never follow-and-overwrite.
# Already present — a console script (pip/pipx install), an earlier shim, or a symlink.
# is_symlink() catches broken symlinks that exists() would miss; never follow-and-overwrite (the
# #21454 failure mode).
if acp_cmd.exists() or acp_cmd.is_symlink():
continue
shim = (
"#!/usr/bin/env bash\n"
"# Hermes Agent — ACP launcher (written by `hermes update`).\n"
"# ACP hosts (Zed, JetBrains, Buzz) resolve the agent by this\n"
"# command name on the login-shell PATH.\n"
f'exec "{hermes_cmd}" acp "$@"\n'
)
acp_cmd.write_text(shim, encoding="utf-8")
acp_cmd.chmod(acp_cmd.stat().st_mode | 0o755)
except OSError:
continue
print(f" ✓ Installed hermes-acp launcher → {acp_cmd}")
_launchers.expose_cli(_m().PROJECT_ROOT)
_BACKUP_MODE_ALIASES = {
@@ -934,6 +822,7 @@ def _print_post_update_notices_and_self_heals() -> None:
"""Best-effort notices (FTS optimize, curator) and self-heals (FHS PATH, ACP launcher,
Windows bin launchers, cua-driver refresh) that run after the summary."""
from hermes_cli.update_cmd import _m, _print_curator_first_run_notice, _print_curator_recent_run_notice
from hermes_cli import _launchers
def _migrate_windows_bin_path() -> None:
# Windows launchers into the managed bin dir: in-checkout launchers were swept by the
@@ -947,7 +836,7 @@ def _print_post_update_notices_and_self_heals() -> None:
('Curator first-run notice failed: %s', _print_curator_first_run_notice),
('Curator recent-run notice failed: %s', _print_curator_recent_run_notice),
('FHS PATH guard check failed: %s', _ensure_fhs_path_guard),
('hermes-acp launcher self-heal failed: %s', _ensure_acp_launcher),
('CLI launcher exposure failed: %s', lambda: _launchers.expose_cli(_m().PROJECT_ROOT)),
('Windows bin launcher migration failed: %s', _migrate_windows_bin_path),
('cua-driver refresh failed: %s', _refresh_cua_driver_after_update),
('Plugin compat notice failed: %s', _print_plugin_compat_notice),
@@ -958,7 +847,7 @@ def _print_post_update_notices_and_self_heals() -> None:
def _run_post_update_maintenance(
*, assume_yes, gateway_mode, pre_update_snapshot_id, had_desktop_app_before_update,
pre_update_version,
pre_update_version, completion_message=None,
) -> bool:
"""Post-build housekeeping and completion, returning the SQLite runtime verdict.
@@ -1011,7 +900,7 @@ def _run_post_update_maintenance(
)
print()
update_complete = _print_verified_update_completion(_update_complete_message(pre_update_version))
update_complete = _print_verified_update_completion(completion_message or _update_complete_message(pre_update_version))
_print_post_update_notices_and_self_heals()
return update_complete

View File

@@ -319,24 +319,13 @@ def _download_and_swap_zip(branch: str, zip_url: str) -> None:
def _update_via_zip(args, *, had_desktop_app_before_update: bool = False,
target_sha: str | None = None, target_repository: str | None = None) -> bool:
target_sha: str | None = None, target_repository: str | None = None,
completion_request=None) -> bool:
"""Update via ZIP when Windows git file I/O fails; dependency/build failures propagate.
A supplied commit keeps the archive on the target selected before Git failed.
"""
from hermes_cli.update_cmd import (
_m,
_print_curator_first_run_notice,
_print_curator_recent_run_notice,
_read_project_version,
_verify_and_restore_state_dbs_post_update,
)
from hermes_cli.update_cmd_maint import (
_prepare_updated_checkout, _refresh_dashboard_after_update,
_print_verified_update_completion, _update_complete_message)
from hermes_cli.update_cmd_maint import _print_bundled_skills_sync_report
from hermes_cli.update_cmd_maint import _sweep_bytecode_after_update
pre_update_version = _read_project_version() # snapshot before files are replaced, for the completion line
from hermes_cli.update_cmd import _m, _complete_source_update
# The static archive would silently ignore --branch — the exact silent-divergence bug it exists to
# prevent. Refuse rather than lie.
branch = _m()._resolve_update_branch(args)
@@ -350,6 +339,10 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False,
)
_m().sys.exit(1)
_abort_zip_update_if_dirty_tree()
# Older callers lack the snapshot/receipt/lifecycle handoff. Refuse before swap.
if completion_request is None:
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch(incomplete=True)
if target_sha is not None and not re.fullmatch(r"[0-9a-f]{40}", target_sha):
raise ValueError("ZIP update requires an exact full commit SHA")
ref = target_sha if target_sha is not None else f"refs/heads/{branch}"
@@ -358,27 +351,6 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False,
or any(part in (".", "..") for part in repository.split("/"))):
raise ValueError("ZIP update requires a GitHub owner/repository")
_download_and_swap_zip(branch, f"https://github.com/{repository}/archive/{ref}.zip")
_sweep_bytecode_after_update(branch)
_prepare_updated_checkout(_m().PROJECT_ROOT, desktop=had_desktop_app_before_update)
with suppress(Exception):
print("→ Syncing bundled skills...")
_print_bundled_skills_sync_report()
# Seed the model-catalog disk cache from the fresh checkout (same rationale as _cmd_update_impl). Non-fatal.
with _best_effort('Model catalog seed during zip update failed: %s'):
from hermes_cli.model_catalog import seed_cache_from_checkout
if seed_cache_from_checkout(_m().PROJECT_ROOT):
print(" ✓ Model catalog cache refreshed from checkout")
# state.db integrity guard: root home AND every sibling profile, each auto-restored from its own snapshot.
with _best_effort('Post-update state.db integrity check (zip path) failed: %s'):
# See #97994.
_verify_and_restore_state_dbs_post_update()
update_complete = _print_verified_update_completion(_update_complete_message(pre_update_version))
with _best_effort('Curator first-run notice failed: %s'):
_print_curator_first_run_notice()
with _best_effort('Curator recent-run notice failed: %s'):
_print_curator_recent_run_notice()
_refresh_dashboard_after_update()
with _best_effort('Update receipt finalize (zip path) failed: %s'):
from hermes_cli.update_receipt import finalize_update_receipt
finalize_update_receipt("success" if update_complete else "partial")
return update_complete
completion_request["expected_sha"] = target_sha
_complete_source_update(completion_request)
return True

View File

@@ -0,0 +1,265 @@
"""Fresh-checkout source update completion and its stdlib-only parent transport.
Imported before a swap; executed by path from the selected tree afterward. The
parent never imports application helpers from the replacement checkout.
"""
from __future__ import annotations
import codecs
import json
import os
import signal
from pathlib import Path
import subprocess
import sys
import tempfile
def _write_json(path: Path, data: dict) -> None:
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(data), encoding="utf-8")
temporary.replace(path)
def _exit_status(code: int) -> int:
return code if code >= 0 else 128 - code
def _failed_result(request: dict, result_path: Path, code: int) -> int:
code = _exit_status(code) or 1
_write_json(result_path, {
"schema": 1, "update_id": request["receipt"]["update_id"], "exit_code": code,
"receipt": None, "windows_resume": None, "pm_receipt": request.get("pm_receipt"),
})
return code
def run_completion(request: dict) -> dict:
"""Wait for new code; zero exit without a correlated terminal result fails closed."""
root = Path(request["source"])
env = dict(os.environ, HERMES_HOME=request["home"], PYTHONUNBUFFERED="1")
for key in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"):
env.pop(key, None)
with tempfile.TemporaryDirectory(prefix="hermes-completion-") as directory:
request_path = Path(directory) / "request.json"
result_path = Path(directory) / "result.json"
request = {**request, "stdout_isatty": sys.stdout.isatty()}
request["bytecode_cache"] = str(Path(directory) / "bytecode")
_write_json(request_path, request)
command = [sys.executable, "-I", "-S", "-X", f"pycache_prefix={request['bytecode_cache']}",
str(root / "hermes_cli/update_completion.py"),
str(request_path), str(result_path)]
proc = subprocess.Popen(
command, cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
**({"start_new_session": True} if os.name == "posix" else
{"creationflags": subprocess.CREATE_NO_WINDOW}))
decoder = codecs.getincrementaldecoder("utf-8")("replace")
try:
while True:
chunk = proc.stdout.read1(8192)
sys.stdout.write(decoder.decode(chunk, final=not chunk))
sys.stdout.flush()
if not chunk:
break
code = proc.wait()
except BaseException:
# This group/retained process handle belongs exclusively to us.
# Stop descendants BEFORE releasing the command's update lock.
if os.name == "posix":
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
else:
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=10,
creationflags=subprocess.CREATE_NO_WINDOW)
proc.kill()
proc.wait()
raise
finally:
proc.stdout.close()
code = _exit_status(code)
try:
result = json.loads(result_path.read_text(encoding="utf-8"))
if result["schema"] != 1 or result["update_id"] != request["receipt"]["update_id"]:
raise ValueError("completion response identity mismatch")
if result["exit_code"] != code:
raise ValueError("completion response disagrees with process exit")
receipt = result.get("receipt")
if receipt is not None and (
receipt.get("update_id") != request["receipt"]["update_id"]
or not receipt.get("finished_at")
or (code == 0) != (receipt.get("outcome") == "success")
):
raise ValueError("completion receipt does not attest this outcome")
if code == 0 and receipt is None:
raise ValueError("completion did not publish a terminal receipt")
except (OSError, ValueError, KeyError, TypeError) as exc:
print(f"✗ Source update completion did not finish: {exc}")
return {"exit_code": code or 1, "receipt": None, "windows_resume": None}
return result
def _resume_receipt(data: dict) -> None:
from hermes_cli import update_receipt
# Hydrate the existing run, not a new receipt with a new identity/pre-update probe.
receipt = object.__new__(update_receipt.UpdateReceipt)
receipt.data = data
receipt.correlation_id = data["update_id"]
receipt.current_token = update_receipt._current.set(receipt)
def _read_terminal_receipt(request: dict) -> dict | None:
directory = Path(request["home"]) / "logs/update_receipts"
# Never latest.json: another profile/context may have finalized more recently.
for path in directory.glob(f"update_*_{request['receipt']['update_id']}.json"):
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("update_id") == request["receipt"]["update_id"] and data.get("finished_at"):
return data
return None
def _prepare(request: dict, request_path: Path, result_path: Path) -> int:
import pm
from pm import receipt
from hermes_cli.runtime_paths import activation_environment, selected_venv
from hermes_constants import venv_python_path
root = Path(request["source"])
update_id = request["receipt"]["update_id"]
with receipt.worker_context(update_id):
try:
pm.sync_venv(explicit=True, project_root=root)
finally:
request["pm_receipt"] = receipt.last_for_update(update_id)
_write_json(request_path, request)
command = [str(venv_python_path(selected_venv(root))),
"-I", "-S", "-X", f"pycache_prefix={request['bytecode_cache']}",
str(root / "hermes_cli/update_completion.py"),
str(request_path), str(result_path), "--prepared"]
# A second interpreter is mandatory: PM may have selected a different Python
# and dependency graph. No application maintenance runs in this bootstrap.
code = _exit_status(subprocess.call(command, cwd=root, env=activation_environment(root)))
if not result_path.exists():
return _failed_result(request, result_path, code)
return code
def _complete_selected(request: dict) -> None:
from hermes_cli import main, update_cmd, update_cmd_config
from hermes_cli.update_inventory import RuntimeRecord, UpdatePlan
from hermes_cli.update_cmd_maint import _run_post_update_maintenance
from hermes_cli.source_build import build_update_products
from hermes_cli.venv_sync import publish_launchers
root = Path(request["source"])
main.PROJECT_ROOT = root
update_cmd_config._LAST_SIBLING_SNAPSHOTS = request["sibling_snapshots"]
plan_data = request["plan"]
plan = None if plan_data is None else UpdatePlan(**{
**plan_data, "runtimes": [RuntimeRecord(**row) for row in plan_data.get("runtimes", [])]})
update_cmd._sweep_bytecode_after_update(request["branch"])
publish_launchers(root)
build_update_products(root, desktop=request["desktop"])
if not request.get("completion_message"):
print("\n✓ Code updated!")
complete = _run_post_update_maintenance(
assume_yes=request["assume_yes"], gateway_mode=request["gateway_mode"],
pre_update_snapshot_id=request["snapshot_id"],
had_desktop_app_before_update=request["desktop"], pre_update_version=request["pre_update_version"],
completion_message=request.get("completion_message"))
# systemctl's KillMode=mixed fallback can kill this whole cgroup. Publish the
# gateway watcher's status BEFORE that operation, and demote on later failure.
if request["gateway_mode"]:
update_cmd._write_gateway_update_exit_code(complete)
restart = update_cmd._restart_gateway_fleet_after_update(plan, request["gateway_mode"])
update_cmd._resume_windows_gateways_and_merge_outcome(restart, request["windows_resume"], request["gateway_mode"])
update_cmd._verify_fleet_after_update(
restart, _pre_update_plan=plan, _windows_gateway_resume=request["windows_resume"], update_complete=complete)
class _ForwardedOutput:
"""The parent's pipe preserves its terminal's prompt policy and log mirror."""
def __init__(self, stream, isatty: bool):
self.stream, self.terminal = stream, isatty
def isatty(self):
return self.terminal
def __getattr__(self, name):
return getattr(self.stream, name)
def _finish(request: dict, result_path: Path) -> int:
from hermes_cli import update_receipt
from pm.receipt import accept_worker_receipt
_resume_receipt(request["receipt"])
accept_worker_receipt(request.get("pm_receipt"), request["receipt"]["update_id"])
code, reason = 0, "source update completion"
try:
_complete_selected(request)
except SystemExit as exc:
code = _exit_status(exc.code) if isinstance(exc.code, int) else 1
reason = f"completion exited {code}"
except BaseException as exc:
code = _exit_status(exc.returncode) if isinstance(exc, subprocess.CalledProcessError) else 1
reason = f"{type(exc).__name__}: {exc}"
print(f"✗ Source update completion failed: {reason}")
finally:
if code and request["gateway_mode"]:
from hermes_cli.update_cmd import _write_gateway_update_exit_code
_write_gateway_update_exit_code(False)
# The new interpreter owns recovery too. The original parent's atexit
# token is updated from the response; it acts only if this process dies.
try:
from hermes_cli.update_cmd import _resume_windows_gateways_after_update
_resume_windows_gateways_after_update(request["windows_resume"])
except Exception as exc:
code, reason = 1, f"Windows gateway recovery failed: {exc}"
print(f"✗ {reason}")
update_receipt.finalize_pending_update_receipt(code, reason)
terminal_receipt = _read_terminal_receipt(request)
if not terminal_receipt:
code = code or 1
_write_json(result_path, {
"schema": 1, "update_id": request["receipt"]["update_id"], "exit_code": code,
"receipt": terminal_receipt, "windows_resume": request["windows_resume"],
})
return code
def main() -> int:
request_path, result_path = map(Path, sys.argv[1:3])
request = json.loads(request_path.read_text(encoding="utf-8"))
if request["schema"] != 1:
raise ValueError("unsupported source completion request")
root = Path(__file__).resolve().parents[1]
if root != Path(request["source"]).resolve():
raise ValueError("completion checkout does not match request")
# -I deliberately ignores inherited PYTHONPATH during PM preparation.
sys.path.insert(0, str(root))
sys.stdout = _ForwardedOutput(sys.stdout, request.get("stdout_isatty", False))
if "--prepared" in sys.argv[3:]:
# Claim the selected generation's lease and process its .pth files only
# after PM selection, before importing any application dependencies.
from hermes_cli.runtime_paths import activate_dependencies
activate_dependencies(root)
return _finish(request, result_path)
try:
return _prepare(request, request_path, result_path)
except BaseException as exc:
# PM failed before application dependencies were ready. Leave the parent
# receipt and paused-gateway obligation intact for boundary recovery.
print(f"✗ Source update preparation failed: {exc}")
code = exc.returncode if isinstance(exc, subprocess.CalledProcessError) else 1
return _failed_result(request, result_path, code)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,5 +1,5 @@
{
"_comment": "Generated by scripts/audit-old-updater-imports.py --freeze. Names an already-running `hermes update` loads from the NEW tree after the checkout swap. Deleting a bare name bricks every release that loads it, mid-update, on a half-new tree. Regenerate after changing the update flow; never hand-trim. History enumeration is complete; static call-graph limits and unresolved_dynamic still require manual review. Current-tree requirements refreshed with audit_tree and unioned without removing any frozen historical requirement; history_ref is unchanged.",
"_comment": "Baseline generated by scripts/audit-old-updater-imports.py --freeze; additively extended with the same audit engine over complete trees of every newly reachable shipped commit and the current tree. No baseline requirement was removed. Names an already-running `hermes update` loads from the NEW tree after the checkout swap. Deleting a bare name bricks every release that loads it, mid-update, on a half-new tree. Regenerate after changing the update flow; never hand-trim. History enumeration is complete; static call-graph limits and unresolved_dynamic still require manual review.",
"stats": {
"mode": "union",
"history": {
@@ -307,12 +307,297 @@
"blobs_mentioning_entrypoints": 2443
},
"coverage": "All reachable commits inventoried; distinct selected path/blob versions analyzed. Commit evidence lists version witnesses, not every unchanged descendant.",
"history_ref": "1021a0325696e9070e6659f95fcd84c3e7e114df",
"history_ref": "d595e636c83aa0b9606d4e914e1140ae9c796897",
"complete_history": true,
"commits": 33720,
"commits": 34026,
"roots": [
"21d80ca68346dfdb8d3556015a723a9217f8566f"
]
],
"incremental_extension": {
"base_ref": "1021a0325696e9070e6659f95fcd84c3e7e114df",
"tip": "d595e636c83aa0b9606d4e914e1140ae9c796897",
"newly_reachable_commits": 306,
"coverage": "Complete trees for every newly reachable commit plus the baseline tree; union with the complete frozen baseline.",
"audit": {
"files_analyzed": [
"agent/curator.py",
"agent/deadline.py",
"agent/delegation_context.py",
"agent/memory_provider.py",
"agent/redact.py",
"agent/retry_utils.py",
"agent/secret_scope.py",
"agent/secret_sources/base.py",
"agent/secret_sources/registry.py",
"agent/skill_utils.py",
"agent/terminal_env_registry.py",
"gateway/config.py",
"gateway/config_env.py",
"gateway/config_loader.py",
"gateway/control_socket.py",
"gateway/cwd_placeholder.py",
"gateway/lifecycle_ledger.py",
"gateway/platform_registry.py",
"gateway/platforms/_shared.py",
"gateway/profile_routing.py",
"gateway/restart.py",
"gateway/run.py",
"gateway/session_context.py",
"gateway/shutdown_forensics.py",
"gateway/shutdown_watchdog.py",
"gateway/status.py",
"hermes_cli/_early_recovery.py",
"hermes_cli/_install_repair.py",
"hermes_cli/_parser.py",
"hermes_cli/_scan_venv_blockers.py",
"hermes_cli/_subprocess_compat.py",
"hermes_cli/agent_plugins.py",
"hermes_cli/auth.py",
"hermes_cli/backup.py",
"hermes_cli/banner.py",
"hermes_cli/build_info.py",
"hermes_cli/cli_output.py",
"hermes_cli/colors.py",
"hermes_cli/config.py",
"hermes_cli/config_backups.py",
"hermes_cli/config_defaults.py",
"hermes_cli/config_home.py",
"hermes_cli/config_migrations.py",
"hermes_cli/curses_ui.py",
"hermes_cli/default_soul.py",
"hermes_cli/env_loader.py",
"hermes_cli/gateway.py",
"hermes_cli/gateway_migrate.py",
"hermes_cli/gateway_multiplex_served.py",
"hermes_cli/gateway_windows.py",
"hermes_cli/git_credentials.py",
"hermes_cli/gitlock.py",
"hermes_cli/image_provenance.py",
"hermes_cli/macos_tcc_anchor.py",
"hermes_cli/main.py",
"hermes_cli/main_dashboard.py",
"hermes_cli/main_install_repair.py",
"hermes_cli/main_tui_launch.py",
"hermes_cli/main_web_build.py",
"hermes_cli/managed_scope.py",
"hermes_cli/managed_uv.py",
"hermes_cli/mcp_security.py",
"hermes_cli/memory_setup.py",
"hermes_cli/model_catalog.py",
"hermes_cli/npm_engine.py",
"hermes_cli/plugin_capabilities.py",
"hermes_cli/plugin_catalog.py",
"hermes_cli/plugin_compat.py",
"hermes_cli/plugins.py",
"hermes_cli/plugins_cmd.py",
"hermes_cli/plugins_cmd_catalog.py",
"hermes_cli/plugins_discovery.py",
"hermes_cli/plugins_ledger.py",
"hermes_cli/plugins_loader.py",
"hermes_cli/plugins_manifest.py",
"hermes_cli/process_identity.py",
"hermes_cli/profiles.py",
"hermes_cli/psutil_android.py",
"hermes_cli/pt_input_extras.py",
"hermes_cli/relay_plugin_cutover.py",
"hermes_cli/resource_limits.py",
"hermes_cli/secret_prompt.py",
"hermes_cli/service_manager.py",
"hermes_cli/setup.py",
"hermes_cli/sizefmt.py",
"hermes_cli/sqlite_runtime.py",
"hermes_cli/sqlite_safe_read.py",
"hermes_cli/subcommands/update.py",
"hermes_cli/tools_config.py",
"hermes_cli/tools_config_cua.py",
"hermes_cli/tools_config_post_setup.py",
"hermes_cli/toolset_scope.py",
"hermes_cli/toolset_validation.py",
"hermes_cli/update_abort_recovery.py",
"hermes_cli/update_cmd.py",
"hermes_cli/update_cmd_common.py",
"hermes_cli/update_cmd_config.py",
"hermes_cli/update_cmd_deps.py",
"hermes_cli/update_cmd_fleet.py",
"hermes_cli/update_cmd_git.py",
"hermes_cli/update_cmd_maint.py",
"hermes_cli/update_cmd_stash.py",
"hermes_cli/update_cmd_windows.py",
"hermes_cli/update_cmd_zip.py",
"hermes_cli/update_contract.py",
"hermes_cli/update_inventory.py",
"hermes_cli/update_lock.py",
"hermes_cli/update_receipt.py",
"hermes_constants.py",
"hermes_state.py",
"plugins/memory/__init__.py",
"plugins/memory/honcho/cli.py",
"plugins/memory/honcho/client.py",
"plugins/memory/honcho/client_cache.py",
"plugins/memory/honcho/oauth.py",
"plugins/plugin_loader.py",
"plugins/plugin_utils.py",
"tools/browser_tool.py",
"tools/browser_tool_install.py",
"tools/browser_tool_lifecycle.py",
"tools/browser_tool_origin.py",
"tools/computer_use/cua_backend.py",
"tools/computer_use/cua_backend_driver.py",
"tools/env_passthrough.py",
"tools/environments/local.py",
"tools/environments/local_env_policy.py",
"tools/environments/local_pythonpath.py",
"tools/lazy_deps.py",
"tools/plugin_guard.py",
"tools/skill_usage.py",
"tools/skills_guard.py",
"tools/skills_sync.py",
"tools/skills_sync_optional.py",
"tools/terminal_scope.py",
"utils.py"
],
"entrypoint_paths": [
"hermes_cli/main.py",
"hermes_cli/plugins_cmd.py",
"hermes_cli/update_cmd.py",
"hermes_cli/update_cmd_zip.py"
],
"files_with_reachable_functions": [
"agent/curator.py",
"agent/deadline.py",
"agent/delegation_context.py",
"agent/redact.py",
"agent/retry_utils.py",
"agent/secret_scope.py",
"agent/secret_sources/base.py",
"agent/secret_sources/registry.py",
"agent/skill_utils.py",
"agent/terminal_env_registry.py",
"gateway/config.py",
"gateway/config_env.py",
"gateway/config_loader.py",
"gateway/control_socket.py",
"gateway/cwd_placeholder.py",
"gateway/lifecycle_ledger.py",
"gateway/platforms/_shared.py",
"gateway/profile_routing.py",
"gateway/restart.py",
"gateway/run.py",
"gateway/session_context.py",
"gateway/shutdown_forensics.py",
"gateway/shutdown_watchdog.py",
"gateway/status.py",
"hermes_cli/_early_recovery.py",
"hermes_cli/_install_repair.py",
"hermes_cli/_parser.py",
"hermes_cli/_scan_venv_blockers.py",
"hermes_cli/_subprocess_compat.py",
"hermes_cli/agent_plugins.py",
"hermes_cli/auth.py",
"hermes_cli/backup.py",
"hermes_cli/banner.py",
"hermes_cli/build_info.py",
"hermes_cli/cli_output.py",
"hermes_cli/colors.py",
"hermes_cli/config.py",
"hermes_cli/config_backups.py",
"hermes_cli/config_home.py",
"hermes_cli/config_migrations.py",
"hermes_cli/curses_ui.py",
"hermes_cli/default_soul.py",
"hermes_cli/env_loader.py",
"hermes_cli/gateway.py",
"hermes_cli/gateway_migrate.py",
"hermes_cli/gateway_multiplex_served.py",
"hermes_cli/gateway_windows.py",
"hermes_cli/git_credentials.py",
"hermes_cli/gitlock.py",
"hermes_cli/image_provenance.py",
"hermes_cli/macos_tcc_anchor.py",
"hermes_cli/main.py",
"hermes_cli/main_dashboard.py",
"hermes_cli/main_install_repair.py",
"hermes_cli/main_tui_launch.py",
"hermes_cli/main_web_build.py",
"hermes_cli/managed_scope.py",
"hermes_cli/managed_uv.py",
"hermes_cli/mcp_security.py",
"hermes_cli/memory_setup.py",
"hermes_cli/model_catalog.py",
"hermes_cli/npm_engine.py",
"hermes_cli/plugin_capabilities.py",
"hermes_cli/plugin_catalog.py",
"hermes_cli/plugin_compat.py",
"hermes_cli/plugins.py",
"hermes_cli/plugins_cmd.py",
"hermes_cli/plugins_cmd_catalog.py",
"hermes_cli/plugins_discovery.py",
"hermes_cli/plugins_ledger.py",
"hermes_cli/plugins_loader.py",
"hermes_cli/plugins_manifest.py",
"hermes_cli/process_identity.py",
"hermes_cli/profiles.py",
"hermes_cli/psutil_android.py",
"hermes_cli/pt_input_extras.py",
"hermes_cli/relay_plugin_cutover.py",
"hermes_cli/resource_limits.py",
"hermes_cli/secret_prompt.py",
"hermes_cli/service_manager.py",
"hermes_cli/setup.py",
"hermes_cli/sizefmt.py",
"hermes_cli/sqlite_runtime.py",
"hermes_cli/sqlite_safe_read.py",
"hermes_cli/tools_config_cua.py",
"hermes_cli/tools_config_post_setup.py",
"hermes_cli/toolset_scope.py",
"hermes_cli/toolset_validation.py",
"hermes_cli/update_abort_recovery.py",
"hermes_cli/update_cmd.py",
"hermes_cli/update_cmd_common.py",
"hermes_cli/update_cmd_config.py",
"hermes_cli/update_cmd_deps.py",
"hermes_cli/update_cmd_fleet.py",
"hermes_cli/update_cmd_git.py",
"hermes_cli/update_cmd_maint.py",
"hermes_cli/update_cmd_stash.py",
"hermes_cli/update_cmd_windows.py",
"hermes_cli/update_cmd_zip.py",
"hermes_cli/update_contract.py",
"hermes_cli/update_inventory.py",
"hermes_cli/update_lock.py",
"hermes_cli/update_receipt.py",
"hermes_constants.py",
"plugins/memory/__init__.py",
"plugins/memory/honcho/cli.py",
"plugins/memory/honcho/client.py",
"plugins/memory/honcho/client_cache.py",
"plugins/memory/honcho/oauth.py",
"plugins/plugin_loader.py",
"tools/browser_tool_install.py",
"tools/browser_tool_lifecycle.py",
"tools/browser_tool_origin.py",
"tools/computer_use/cua_backend.py",
"tools/computer_use/cua_backend_driver.py",
"tools/env_passthrough.py",
"tools/environments/local.py",
"tools/environments/local_env_policy.py",
"tools/environments/local_pythonpath.py",
"tools/lazy_deps.py",
"tools/plugin_guard.py",
"tools/skill_usage.py",
"tools/skills_guard.py",
"tools/skills_sync.py",
"tools/skills_sync_optional.py",
"tools/terminal_scope.py",
"utils.py"
],
"commits_with_audited_changes": 307,
"revisions_read": 41361,
"distinct_file_versions": 231,
"analysis_passes": 331,
"versions_prepared": 231
}
}
},
"tree": {
"files_analyzed": [
@@ -430,6 +715,7 @@
"hermes_cli/update_cmd_validation.py",
"hermes_cli/update_cmd_windows.py",
"hermes_cli/update_cmd_zip.py",
"hermes_cli/update_completion.py",
"hermes_cli/update_contract.py",
"hermes_cli/update_inventory.py",
"hermes_cli/update_lock.py",
@@ -606,6 +892,7 @@
"hermes_cli/update_cmd_validation.py",
"hermes_cli/update_cmd_windows.py",
"hermes_cli/update_cmd_zip.py",
"hermes_cli/update_completion.py",
"hermes_cli/update_contract.py",
"hermes_cli/update_inventory.py",
"hermes_cli/update_lock.py",
@@ -662,12 +949,13 @@
"utils.py"
],
"commits_with_audited_changes": 1,
"revisions_read": 172,
"distinct_file_versions": 172,
"analysis_passes": 214,
"versions_prepared": 172,
"revisions_read": 173,
"distinct_file_versions": 173,
"analysis_passes": 215,
"versions_prepared": 173,
"mode": "tree"
}
},
"completion_union": "Preserved complete checked-in history and prior tree edges; unioned fresh current-tree audit."
},
"unresolved_dynamic": [
"hermes_cli/config.py: module object hermes_cli.managed_scope requires manual call-graph review",
@@ -1005,6 +1293,7 @@
"hermes_cli.update_cmd::_check_and_apply_config_migration",
"hermes_cli.update_cmd::_cmd_update_check",
"hermes_cli.update_cmd::_cmd_update_impl",
"hermes_cli.update_cmd::_complete_source_update",
"hermes_cli.update_cmd::_count_commits_between",
"hermes_cli.update_cmd::_critical_module_import_failures",
"hermes_cli.update_cmd::_current_checkout_sha",
@@ -1097,6 +1386,7 @@
"hermes_cli.version_info::get_code_identity",
"hermes_cli::__version__",
"hermes_cli::_early_recovery",
"hermes_cli::_launchers",
"hermes_cli::_subprocess_compat",
"hermes_cli::gateway",
"hermes_cli::gateway_windows",

View File

@@ -61,7 +61,23 @@ def _suppress_concurrent_hermes_gate(request, monkeypatch):
@pytest.fixture
def isolated_update_processes():
def isolated_source_completion(monkeypatch):
"""Unit-test the tail in-process; real transport is tested separately."""
from hermes_cli import update_cmd, update_completion
monkeypatch.setattr("hermes_cli.source_build.build_update_products", lambda *a, **kw: None)
monkeypatch.setattr("hermes_cli.venv_sync.publish_launchers", lambda *a: None)
def complete(request):
update_completion._complete_selected(request)
return {"exit_code": 0, "receipt": update_completion._read_terminal_receipt(request),
"windows_resume": request["windows_resume"]}
monkeypatch.setattr(update_cmd, "run_completion", complete)
@pytest.fixture
def isolated_update_processes(isolated_source_completion):
"""Keep cmd_update's gateway auto-restart phase off this machine's gateways.
The restart phase used to swallow every exception at debug level, so these

View File

@@ -18,10 +18,6 @@ def _isolate_venv_holders(monkeypatch):
monkeypatch.setattr("hermes_cli.update_cmd_windows._detect_venv_python_processes", lambda: [])
@pytest.fixture(autouse=True)
def _isolate_product_preparation(monkeypatch):
"""These tests exercise update orchestration, not PM installs or npm builds."""
monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **k: None)
def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"):
@@ -182,7 +178,7 @@ class TestCmdUpdateBranchFallback:
assert exit_info.value.code == 1
runtime_check.assert_called_once_with()
write_gateway_exit.assert_called_once_with(False)
finalize_receipt.assert_called_once_with("partial")
assert finalize_receipt.call_args.args[0] == "partial"
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
@@ -216,7 +212,7 @@ class TestCmdUpdateBranchFallback:
hm, "_sync_with_upstream_if_needed"
), patch.object(
update_cmd,
"_run_post_update_maintenance",
"_complete_source_update",
# Unlike product preparation, this phase only runs after a pull.
# Stop before skills sync and fleet restart; the regression took
# the current-checkout path instead and never reached this phase.
@@ -242,9 +238,9 @@ class TestCmdUpdateBranchFallback:
), patch(
"hermes_cli.update_cmd._reload_config_modules"
), patch(
"hermes_cli.update_cmd._run_config_check_fresh", return_value=(1, 2)
"hermes_cli.config.check_config_version", return_value=(1, 2)
), patch(
"hermes_cli.update_cmd._run_migrate_config_fresh",
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": ["new.option"]},
) as migrate_config, patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = False
@@ -285,9 +281,9 @@ class TestCmdUpdateMigrationPrompt:
), patch(
"hermes_cli.update_cmd._reload_config_modules"
), patch(
"hermes_cli.update_cmd._run_config_check_fresh", return_value=(5, 24)
"hermes_cli.config.check_config_version", return_value=(5, 24)
), patch(
"hermes_cli.update_cmd._run_migrate_config_fresh",
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": [], "warnings": []},
) as mock_migrate:
mock_run.side_effect = _make_run_side_effect(
@@ -324,9 +320,9 @@ class TestCmdUpdateMigrationPrompt:
), patch(
"hermes_cli.update_cmd._reload_config_modules"
), patch(
"hermes_cli.update_cmd._run_config_check_fresh", return_value=(33, 34)
"hermes_cli.config.check_config_version", return_value=(33, 34)
), patch(
"hermes_cli.update_cmd._run_migrate_config_fresh",
"hermes_cli.config.migrate_config",
return_value={
"env_added": [],
"config_added": ["display.personality=none (one-time reset)"],
@@ -366,9 +362,9 @@ class TestCmdUpdateMigrationPrompt:
), patch(
"hermes_cli.update_cmd._reload_config_modules"
), patch(
"hermes_cli.update_cmd._run_config_check_fresh", return_value=(1, 24)
"hermes_cli.config.check_config_version", return_value=(1, 24)
), patch(
"hermes_cli.update_cmd._run_migrate_config_fresh",
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": [], "warnings": []},
), patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = True
@@ -386,35 +382,6 @@ class TestCmdUpdateMigrationPrompt:
assert "display.new_widget" in out
class TestConfigVersionCheckUsesFreshModules:
"""Regression: config migration must use freshly-reloaded modules, not the
sys.modules cache from before git pull.
Before the fix, ``hermes update`` ran in the PRE-pull Python process.
After ``git pull`` updated the source on disk, function-level imports
returned the OLD cached ``hermes_cli.config`` module — so
``DEFAULT_CONFIG["_config_version"]`` was stale and
``check_config_version()`` reported ``(33, 33)`` "up to date" even though
the freshly-pulled code had v34 with a migration to run. The personality
reset migration (#81946) was silently skipped this way.
"""
def test_run_config_check_fresh_reloads_modules(self):
"""_run_config_check_fresh must call _reload_config_modules which
force-reloads the config modules from disk.
Regression: config migration was silently skipped because
sys.modules held the OLD hermes_cli.config with the OLD
DEFAULT_CONFIG["_config_version"] after git pull.
"""
from unittest.mock import patch
import hermes_cli.update_cmd as update_cmd
with patch.object(update_cmd, "_reload_config_modules") as mock_reload:
update_cmd._run_config_check_fresh()
mock_reload.assert_called_once()
class TestCmdUpdateProfileSkillSync:
@@ -720,7 +687,7 @@ class TestCmdUpdateZipBranchRefusal:
args = SimpleNamespace(branch="bb/gui")
with pytest.raises(SystemExit) as exc_info:
_update_via_zip(args)
_update_via_zip(args, completion_request={})
assert exc_info.value.code == 1
out = capsys.readouterr().out
@@ -738,6 +705,7 @@ class TestZipDesktopPreservation:
pre-update desktop selection (#70337/#87331).
"""
import zipfile
from pathlib import Path
from hermes_cli import main as hm
from hermes_cli import update_cmd
@@ -761,8 +729,8 @@ class TestZipDesktopPreservation:
preparations = []
def prepare_checkout(root, *, desktop):
preparations.append((root, desktop, packaged_exe.read_bytes()))
def prepare_checkout(request):
preparations.append((Path(request["source"]), request["desktop"], packaged_exe.read_bytes()))
monkeypatch.setattr(hm, "PROJECT_ROOT", project_root)
monkeypatch.setattr(hm, "_is_windows", lambda: True)
@@ -775,7 +743,7 @@ class TestZipDesktopPreservation:
lambda _desktop_dir: packaged_exe if packaged_exe.exists() else None,
)
monkeypatch.setattr(hm, "_desktop_dist_exists", lambda _desktop_dir: False)
monkeypatch.setattr(update_cmd_maint, "_prepare_updated_checkout", prepare_checkout)
monkeypatch.setattr(update_cmd, "_complete_source_update", prepare_checkout)
monkeypatch.setattr(hm, "_clear_bytecode_cache", lambda *_args: 0)
monkeypatch.setattr(hm, "_record_bytecode_fingerprint", lambda: None)
monkeypatch.setattr(hm, "_refresh_bootstrap_cache_scripts", lambda _branch: None)

View File

@@ -7,52 +7,7 @@ import pytest
from hermes_cli import gateway, main, update_cmd_fleet as fleet
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("failure", ["listing", "timeout", "missing", "restart", "inactive", "running", None])
def test_pending_marker_requires_complete_systemd_recovery(monkeypatch, tmp_path, failure):
monkeypatch.setattr(main, "_purge_stale_hermes_modules", lambda: None)
stopped = []
monkeypatch.setattr(gateway, "find_gateway_pids", lambda **kw: [123] if failure == "running" and not stopped else [])
monkeypatch.setattr(gateway, "kill_gateway_processes", lambda **kw: stopped.append(True))
monkeypatch.setattr(gateway, "_wait_for_gateway_exit", lambda **kw: None)
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
monkeypatch.setattr(fleet, "_SYSTEMD_SCOPES", (("user", ["systemctl", "--user"]),))
monkeypatch.setattr(fleet._time, "sleep", lambda _: None)
ticks = iter(range(1000))
monkeypatch.setattr(fleet._time, "monotonic", lambda: next(ticks))
recovered = []
def systemctl(cmd, **kw):
if "list-units" in cmd:
if stopped:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if failure == "timeout":
raise subprocess.TimeoutExpired(cmd, 10)
if failure == "missing":
raise FileNotFoundError("systemctl")
return SimpleNamespace(returncode=int(failure == "listing"), stdout=(
"hermes-gateway-one.service loaded active running\n"
"hermes-gateway-two.service loaded failed failed\n"), stderr="")
bad = cmd[-1] == "hermes-gateway-two"
if "restart" in cmd:
recovered.append(cmd[-1])
return SimpleNamespace(returncode=int(bad and failure == "restart"), stdout="")
if "is-active" in cmd:
active = not (bad and failure == "inactive")
return SimpleNamespace(returncode=0 if active else 3, stdout="active" if active else "inactive")
return SimpleNamespace(returncode=0, stdout="0s")
monkeypatch.setattr(fleet, "_systemctl", systemctl)
marker = fleet._fleet_restart_pending_marker_path()
marker.write_text("expected_sha=pending\n")
if failure not in (None, "running"):
with pytest.raises(SystemExit, match="1"):
fleet._apply_pending_fleet_restart_catchup()
assert marker.exists()
else:
fleet._apply_pending_fleet_restart_catchup()
assert not marker.exists()
assert set(recovered) == {"hermes-gateway-one", "hermes-gateway-two"}
@pytest.mark.parametrize("failure", ["listing", "restart", "inactive", "unloaded", None])

View File

@@ -125,20 +125,22 @@ def test_source_check_and_apply_land_on_selected_release(releases, monkeypatch,
# Exercise the real selection/fetch/checkout path, not dependency installation
# or live service management. No host OS is simulated.
opts = update_cmd._UpdateOptions(
active_lazy_features=[], pre_update_version=None, gw_input_fn=None,
pre_update_version=None, gw_input_fn=None,
assume_yes=True, keep_stash=False, switch_branch=False, discard_local_changes=False,
)
monkeypatch.setattr(update_cmd, "_resolve_update_options", lambda *_: opts)
monkeypatch.setattr(update_cmd, "_begin_update_receipt_and_plan", lambda *_: None)
monkeypatch.setattr(main, "_run_pre_update_backup", lambda *_: None)
monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: [])
monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: None)
monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (False, ["git"], False))
applied = []
monkeypatch.setattr(update_cmd, "_apply_pulled_update", lambda *a, **k: applied.append(git(releases.root, "rev-parse", "HEAD")))
monkeypatch.setattr(update_cmd, "_complete_source_update", lambda request: applied.append(request))
args = SimpleNamespace(branch=None, channel=None, force_venv=True)
update_cmd._cmd_update_impl(args, False)
expected = releases.commits[1 if channel == "stable" else 2]
assert applied == [expected]
assert len(applied) == 1
assert applied[0]["expected_sha"] == expected
assert applied[0]["source"] == str(releases.root.resolve())
assert git(releases.root, "rev-parse", "HEAD") == expected
if start != "old":
assert git(releases.root, "rev-parse", "my-work") == branch_sha
@@ -185,7 +187,7 @@ def test_zip_fallback_keeps_selected_repository_and_commit(releases, monkeypatch
with pytest.raises(DownloadBoundary):
update_cmd_zip._update_via_zip(
SimpleNamespace(branch=None), target_sha=releases.commits[2],
target_repository="Fixture/hermes-agent")
target_repository="Fixture/hermes-agent", completion_request={})
assert seen == [f"https://github.com/Fixture/hermes-agent/archive/{releases.commits[2]}.zip"]

View File

@@ -0,0 +1,391 @@
"""A checkout transition must not finish in the old interpreter's module graph."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import venv
import pytest
@pytest.fixture
def transition(tmp_path):
root = tmp_path / "checkout"
root.mkdir()
home = tmp_path / "home"
home.mkdir()
package = root / "hermes_cli"
package.mkdir()
(package / "__init__.py").write_text("")
(root / "pm").mkdir()
(root / "pm/__init__.py").write_text("OLD_API = True\n")
def git(*args):
return subprocess.run(["git", *args], cwd=root, text=True, capture_output=True, check=True).stdout.strip()
git("init", "-b", "main")
git("config", "user.name", "Completion test")
git("config", "user.email", "completion@example.invalid")
git("add", ".")
git("-c", "commit.gpgsign=false", "commit", "-m", "old incompatible runtime")
old = git("rev-parse", "HEAD")
# Deliberately incompatible: a cached OLD_API-only PM cannot prepare this tree.
(root / "pm/__init__.py").write_text(
"from hermes_cli.probe import event\n"
"def sync_venv(*, explicit, project_root):\n"
" assert explicit\n"
" event('prepare')\n"
)
(root / "pm/receipt.py").write_text(
"from contextlib import nullcontext\n"
"worker_context = lambda update_id: nullcontext()\n"
"last_for_update = lambda update_id: {'update_id': update_id, 'outcome': 'success'}\n"
"def accept_worker_receipt(data, update_id):\n"
" assert data['update_id'] == update_id\n"
)
(package / "probe.py").write_text(
"import json, os, pathlib, sys\n"
"def event(name, **values):\n"
" with pathlib.Path('events.jsonl').open('a') as f:\n"
" f.write(json.dumps(dict(name=name, pid=os.getpid(), python=sys.executable, **values)) + '\\n')\n"
)
(package / "runtime_paths.py").write_text(
"import os, sys\n"
"from pathlib import Path\n"
"selected_venv = lambda root: Path(sys.executable).parent.parent\n"
"activation_environment = lambda root: {**os.environ, 'PYTHONPATH': str(root)}\n"
"def activate_dependencies(root):\n"
" from hermes_cli.probe import event\n"
" event('activate')\n"
)
selected = tmp_path / "selected-python"
venv.EnvBuilder(with_pip=False).create(selected)
selected_python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
(root / "hermes_constants.py").write_text(
f"venv_python_path = lambda root: {str(selected_python)!r}\n"
)
(package / "venv_sync.py").write_text(
"from hermes_cli.probe import event\n"
"publish_launchers = lambda root: event('launchers')\n"
)
(package / "source_build.py").write_text(
"from hermes_cli.probe import event\n"
"def build_update_products(root, *, desktop): event('build', desktop=desktop)\n"
)
(package / "main.py").write_text("")
(package / "update_cmd_config.py").write_text("_LAST_SIBLING_SNAPSHOTS = {}\n")
(package / "update_inventory.py").write_text(
"from types import SimpleNamespace\nRuntimeRecord = UpdatePlan = SimpleNamespace\n"
)
(package / "update_cmd_maint.py").write_text(
"from hermes_cli.probe import event\n"
"def _run_post_update_maintenance(**kwargs):\n"
" from hermes_cli.update_cmd_config import _LAST_SIBLING_SNAPSHOTS\n"
" event('maintenance', snapshots=_LAST_SIBLING_SNAPSHOTS, **kwargs)\n"
" return True\n"
)
(package / "update_cmd.py").write_text(
"from hermes_cli.probe import event\n"
"_invalidate_update_cache = lambda: event('cache')\n"
"_sweep_bytecode_after_update = lambda branch: event('bytecode')\n"
"_write_fleet_restart_pending_marker = lambda **kw: event('pending')\n"
"_write_gateway_update_exit_code = lambda ok: event('exit_marker', ok=ok)\n"
"def _restart_gateway_fleet_after_update(plan, gateway_mode):\n"
" event('restart', profiles=[r.profile for r in plan.runtimes])\n"
" return object()\n"
"def _resume_windows_gateways_and_merge_outcome(out, token, gateway_mode):\n"
" token['resume_needed'] = False\n"
" event('resume')\n"
"def _resume_windows_gateways_after_update(token):\n"
" if token and token.get('resume_needed'):\n"
" token['resume_needed'] = False\n"
" event('emergency_resume')\n"
"def _verify_fleet_after_update(out, **kw):\n"
" from hermes_cli.update_receipt import finalize_pending_update_receipt\n"
" event('verify')\n"
" finalize_pending_update_receipt(0, 'verified')\n"
)
(package / "update_receipt.py").write_text(
"import contextvars, json, os, pathlib\n"
"_current = contextvars.ContextVar('receipt', default=None)\n"
"class UpdateReceipt: pass\n"
"def finalize_pending_update_receipt(code, reason):\n"
" r = _current.get()\n"
" if r is None: return\n"
" r.data.update(exit_code=code, outcome='success' if code == 0 else 'failed', finished_at='now')\n"
" path = pathlib.Path(os.environ['HERMES_HOME']) / 'logs/update_receipts'\n"
" path.mkdir(parents=True, exist_ok=True)\n"
" path = path / ('update_test_' + r.correlation_id + '.json')\n"
" path.write_text(json.dumps(r.data))\n"
" _current.set(None)\n"
" return path\n"
)
git("add", ".")
git("-c", "commit.gpgsign=false", "commit", "-m", "new incompatible runtime")
new = git("rev-parse", "HEAD")
request = {
"schema": 1, "source": str(root), "home": str(home), "branch": "main",
"desktop": True, "assume_yes": True, "gateway_mode": True,
"pre_update_version": "old", "snapshot_id": "active-before",
"sibling_snapshots": {"work": "work-before"},
"plan": {"runtimes": [{"kind": "gateway", "profile": "work"}]},
"receipt": {"update_id": "b" * 32, "outcome": "running", "steps": []},
"windows_resume": {"resume_needed": True, "profiles": {"work": [123]}},
}
return root, git, old, new, request
def test_old_process_new_git_tree_completes_in_fresh_python(transition, tmp_path):
from hermes_cli import update_completion
root, git, old, new, request = transition
# Copy executable code, not its text shape: the process exercises the real transport.
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
git("add", ".")
git("-c", "commit.gpgsign=false", "commit", "-m", "completion entrypoint")
new = git("rev-parse", "HEAD")
git("checkout", old)
driver = (
"import importlib.util, json, os, subprocess, sys\n"
"spec = importlib.util.spec_from_file_location('transport', sys.argv[1])\n"
"transport = importlib.util.module_from_spec(spec); spec.loader.exec_module(transport)\n"
"import pm\nassert pm.OLD_API\n"
"subprocess.run(['git', 'checkout', sys.argv[2]], check=True)\n"
"result = transport.run_completion(json.loads(sys.argv[3]))\n"
"assert pm.OLD_API, 'transport mutated old module graph'\n"
"print('RESULT=' + json.dumps(result))\n"
)
result = subprocess.run(
[sys.executable, "-c", driver, update_completion.__file__, new, json.dumps(request)],
cwd=root, env={**os.environ, "PYTHONPATH": str(root), "HERMES_HOME": request["home"]},
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
response = json.loads(result.stdout.split("RESULT=")[1])
assert response["exit_code"] == 0
assert response["receipt"]["update_id"] == request["receipt"]["update_id"]
assert response["windows_resume"]["resume_needed"] is False
events = [json.loads(line) for line in (root / "events.jsonl").read_text().splitlines()]
by_name = {event["name"]: event for event in events}
assert by_name["activate"]["pid"] == by_name["build"]["pid"]
assert by_name["prepare"]["pid"] != by_name["build"]["pid"]
assert Path(by_name["build"]["python"]).is_relative_to(root.parent / "selected-python")
assert by_name["build"]["pid"] == by_name["maintenance"]["pid"] == by_name["restart"]["pid"]
assert by_name["maintenance"]["snapshots"] == {"work": "work-before"}
assert by_name["maintenance"]["pre_update_snapshot_id"] == "active-before"
assert by_name["restart"]["profiles"] == ["work"]
assert [e["name"] for e in events].index("exit_marker") < [e["name"] for e in events].index("restart")
@pytest.mark.parametrize("code", [0, 23])
def test_missing_child_result_fails_boundary_receipt_and_releases_lock(transition, monkeypatch, code):
from types import SimpleNamespace
from hermes_cli import main, update_cmd, update_receipt, update_lock
root, git, old, new, request = transition
(root / "hermes_cli/update_completion.py").write_text(f"import os\nos._exit({code})\n")
monkeypatch.setenv("HERMES_HOME", request["home"])
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None)
monkeypatch.setattr(main, "_finalize_update_output", lambda state: None)
def complete(args, gateway_mode):
update_receipt.begin_update_receipt()
request["receipt"] = update_receipt._current.get().data
update_cmd._complete_source_update(request)
monkeypatch.setattr(update_cmd, "_cmd_update_impl", complete)
with pytest.raises(SystemExit) as error:
main.cmd_update(SimpleNamespace(gateway=True))
assert error.value.code == (code or 1)
receipt = update_receipt.read_latest_receipt()
assert receipt["outcome"] == "failed"
assert receipt["exit_code"] == (code or 1)
assert receipt["update_id"] == request["receipt"]["update_id"]
assert request["windows_resume"]["resume_needed"] is True
assert (Path(request["home"]) / ".update_exit_code").read_text().strip() == "1"
lock = update_lock.UpdateLock()
assert lock.acquire()
lock.release()
@pytest.mark.platforms("posix")
def test_killed_selected_python_returns_signal_exit_status(transition):
from hermes_cli import update_completion
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
(root / "hermes_cli/source_build.py").write_text(
"import os, signal\n"
"def build_update_products(*a, **kw): os.kill(os.getpid(), signal.SIGKILL)\n"
)
result = update_completion.run_completion(request)
assert result["exit_code"] == 137
assert result["pm_receipt"]["update_id"] == request["receipt"]["update_id"]
def test_failed_build_preserves_exit_status_without_maintenance(transition):
from hermes_cli import update_completion
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
(root / "hermes_cli/source_build.py").write_text(
"import subprocess\n"
"def build_update_products(*a, **kw): raise subprocess.CalledProcessError(23, ['builder'])\n"
)
result = update_completion.run_completion(request)
assert result["exit_code"] == 23
assert result["receipt"]["outcome"] == "failed"
events = [json.loads(line)["name"] for line in (root / "events.jsonl").read_text().splitlines()]
assert "maintenance" not in events
assert "restart" not in events
assert "emergency_resume" in events
def test_prepare_failure_preserves_correlated_pm_receipt(transition, monkeypatch):
from types import SimpleNamespace
from hermes_cli import main, update_cmd, update_completion, update_receipt
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
(root / "pm/__init__.py").write_text(
"def sync_venv(**kw): raise RuntimeError('dependency refused')\n"
)
with (root / "pm/receipt.py").open("a") as stream:
stream.write("last_for_update = lambda update_id: {'update_id': update_id, 'outcome': 'refused', 'refusal': {'reason': 'dependency refused'}}\n")
monkeypatch.setenv("HERMES_HOME", request["home"])
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None)
monkeypatch.setattr(main, "_finalize_update_output", lambda state: None)
def complete(args, gateway_mode):
update_receipt.begin_update_receipt()
request["receipt"] = update_receipt._current.get().data
update_cmd._complete_source_update(request)
monkeypatch.setattr(update_cmd, "_cmd_update_impl", complete)
with pytest.raises(SystemExit) as error:
main.cmd_update(SimpleNamespace(gateway=True))
assert error.value.code == 1
receipt = update_receipt.read_latest_receipt()
assert receipt["update_id"] == request["receipt"]["update_id"]
assert receipt["pm_sync_outcome"] == "refused"
assert receipt["pm_refusal"] == {"reason": "dependency refused"}
def test_bootstrap_does_not_initialize_old_site_packages(transition, tmp_path, monkeypatch):
from hermes_cli import update_completion
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
obsolete = tmp_path / "obsolete-python"
venv.EnvBuilder(with_pip=False).create(obsolete)
site = obsolete / ("Lib/site-packages" if os.name == "nt" else
f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages")
trap = tmp_path / "old-site-loaded"
(site / "application.pth").write_text(f"import pathlib; pathlib.Path({str(trap)!r}).touch()\n")
monkeypatch.setattr(sys, "executable", str(obsolete / ("Scripts/python.exe" if os.name == "nt" else "bin/python")))
result = update_completion.run_completion(request)
assert result["exit_code"] == 0
assert not trap.exists(), "preparation initialized the old application's .pth graph"
@pytest.mark.platforms("posix")
def test_interactive_configuration_keeps_terminal_input(transition):
import pty
import select
import signal
import time
from hermes_cli import update_completion
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
(root / "hermes_cli/update_cmd_maint.py").write_text(
"import sys\nfrom hermes_cli.probe import event\n"
"def _run_post_update_maintenance(**kw):\n"
" assert sys.stdin.isatty() and sys.stdout.isatty()\n"
" event('answer', value=input('CONFIG? '))\n"
" return True\n"
)
master, slave = pty.openpty()
driver = "import json,runpy,sys; m=runpy.run_path(sys.argv[1]); raise SystemExit(m['run_completion'](json.loads(sys.argv[2]))['exit_code'])"
proc = subprocess.Popen([sys.executable, "-c", driver, update_completion.__file__, json.dumps(request)],
cwd=root, stdin=slave, stdout=slave, stderr=slave)
os.close(slave)
output = b""
try:
deadline = time.monotonic() + 20
while b"CONFIG?" not in output:
assert time.monotonic() < deadline, output.decode(errors="replace")
if select.select([master], [], [], 0.1)[0]:
output += os.read(master, 8192)
os.write(master, b"yes\n")
assert proc.wait(timeout=20) == 0
events = [json.loads(line) for line in (root / "events.jsonl").read_text().splitlines()]
assert next(e for e in events if e["name"] == "answer")["value"] == "yes"
finally:
if proc.poll() is None:
proc.send_signal(signal.SIGINT)
proc.wait(timeout=5)
os.close(master)
@pytest.mark.platforms("posix")
@pytest.mark.live_system_guard_bypass
def test_interrupt_reaps_completion_descendants_before_return(transition, monkeypatch):
import io
import psutil
import time
from hermes_cli import update_completion
root, git, old, new, request = transition
(root / "hermes_cli/update_completion.py").write_text(
"import subprocess, sys, time\n"
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(600)'])\n"
"print('READY ' + str(child.pid), flush=True)\n"
"time.sleep(600)\n"
)
pids = []
class Interrupt(io.StringIO):
def write(self, value):
if 'READY ' in value:
pids.append(int(value.split('READY ')[1].strip()))
raise KeyboardInterrupt()
return super().write(value)
monkeypatch.setattr(sys, "stdout", Interrupt())
try:
with pytest.raises(KeyboardInterrupt):
update_completion.run_completion(request)
assert pids
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if not psutil.pid_exists(pids[0]) or psutil.Process(pids[0]).status() == psutil.STATUS_ZOMBIE:
break
time.sleep(0.02)
else:
pytest.fail("completion descendant survived parent cancellation")
finally:
for pid in pids:
if psutil.pid_exists(pid):
psutil.Process(pid).kill()
def test_forged_terminal_receipt_cannot_acknowledge_success(transition):
from hermes_cli.update_completion import run_completion
root, git, old, new, request = transition
(root / "hermes_cli/update_completion.py").write_text(
"import json, pathlib, sys\n"
"request = json.loads(pathlib.Path(sys.argv[1]).read_text())\n"
"pathlib.Path(sys.argv[2]).write_text(json.dumps(dict(\n"
" schema=1, update_id=request['receipt']['update_id'], exit_code=0,\n"
" windows_resume={}, receipt={'update_id': 'wrong', 'outcome': 'success'})))\n"
)
response = run_completion(request)
assert response["exit_code"] != 0
assert response["receipt"] is None

View File

@@ -0,0 +1,50 @@
"""All source selection routes hand off once, without old-process maintenance."""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from hermes_cli import update_cmd, update_cmd_zip
@pytest.mark.parametrize("hook,args,kwargs", [
(update_cmd._prepare_updated_checkout, ("unused",), {"desktop": False}),
(update_cmd._reload_config_modules, (), {}),
(update_cmd._reload_process_scan_modules, (), {}),
(update_cmd._run_pending_fleet_restart, (), {}),
])
def test_historical_completion_hook_never_reports_success(hook, args, kwargs, capsys):
with pytest.raises(SystemExit) as error:
hook(*args, **kwargs)
assert error.value.code != 0
assert "update" in capsys.readouterr().err.lower()
@pytest.mark.parametrize("route", ["pulled", "current", "zip"])
def test_every_route_hands_off_once(route, tmp_path, monkeypatch):
request = {"branch": "main", "receipt": {"update_id": "c" * 32}}
handed_off = []
monkeypatch.setattr(update_cmd, "_complete_source_update", handed_off.append, raising=False)
monkeypatch.setattr(update_cmd, "_m", lambda: SimpleNamespace(
PROJECT_ROOT=tmp_path, _resolve_update_branch=lambda args: "main"))
monkeypatch.setattr(update_cmd, "_verify_head_after_pull", lambda *a, **kw: "new-sha")
monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **kw: pytest.fail("old-process preparation"))
monkeypatch.setattr(update_cmd, "_write_fleet_restart_pending_marker", lambda **kw: None)
monkeypatch.setattr(update_cmd, "_sweep_bytecode_after_update", lambda *a: None)
monkeypatch.setattr(update_cmd_zip, "_abort_zip_update_if_dirty_tree", lambda: None)
swap = Mock()
monkeypatch.setattr(update_cmd_zip, "_download_and_swap_zip", swap)
plan = SimpleNamespace(in_place_update=False, auto_stash_ref=None, parked_branch_switched=False,
upstream_checked=True)
opts = SimpleNamespace(assume_yes=True, gw_input_fn=None, pre_update_version="old")
if route == "pulled":
update_cmd._apply_pulled_update(
["git"], "main", "old-sha", plan, opts, is_fork=False, _windows_gateway_resume=None,
completion_request=request)
elif route == "current":
update_cmd._finish_already_up_to_date(
["git"], "main", "main", plan, gw_input_fn=None, completion_request=request)
else:
assert update_cmd_zip._update_via_zip(SimpleNamespace(), completion_request=request) is True
swap.assert_called_once()
assert handed_off == [request]

View File

@@ -18,6 +18,7 @@ from __future__ import annotations
import contextlib
import io
from hermes_cli import config as update_config
from unittest.mock import patch
import hermes_cli.update_cmd as update_cmd
@@ -39,9 +40,9 @@ def _run(current: int, latest: int):
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=[]
), patch.object(
update_cmd, "_run_config_check_fresh", return_value=(current, latest)
update_config, "check_config_version", return_value=(current, latest)
), patch.object(
update_cmd, "_run_migrate_config_fresh", side_effect=_fake_migrate
update_config, "migrate_config", side_effect=_fake_migrate
), patch.object(
update_cmd, "_migrate_sibling_profile_configs", return_value=[]
):
@@ -88,9 +89,9 @@ def test_surfaces_migration_warnings():
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=[]
), patch.object(
update_cmd, "_run_config_check_fresh", return_value=(37, 38)
update_config, "check_config_version", return_value=(37, 38)
), patch.object(
update_cmd, "_run_migrate_config_fresh", side_effect=_fake_migrate
update_config, "migrate_config", side_effect=_fake_migrate
), patch.object(
update_cmd, "_migrate_sibling_profile_configs", return_value=[]
):
@@ -109,9 +110,9 @@ def test_check_failure_does_not_break_repair_path():
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=[]
), patch.object(
update_cmd, "_run_config_check_fresh", side_effect=RuntimeError("boom")
update_config, "check_config_version", side_effect=RuntimeError("boom")
), patch.object(
update_cmd, "_run_migrate_config_fresh", return_value={}
update_config, "migrate_config", return_value={}
) as mig:
buf = io.StringIO()
with contextlib.redirect_stdout(buf):

View File

@@ -8,83 +8,28 @@ install is not left in a non-bootable state with new code on old config version.
from __future__ import annotations
from hermes_cli import config as update_config
from unittest.mock import MagicMock, patch
from hermes_cli import update_cmd
def test_current_checkout_runs_config_migration_on_version_bump(capsys):
"""A retry migrates old config after preparing the updated checkout."""
completion = MagicMock(return_value=True)
with (
patch.object(update_cmd, "_prepare_updated_checkout") as prepare,
patch.object(update_cmd, "_m") as m,
patch.object(update_cmd, "_reload_config_modules"),
patch.object(update_cmd, "_run_config_check_fresh", return_value=(37, 38)),
patch("hermes_cli.config.get_missing_env_vars", return_value=[]),
patch("hermes_cli.config.get_missing_config_fields", return_value=[]),
patch.object(
update_cmd,
"_run_migrate_config_fresh",
return_value={"env_added": [], "config_added": ["migrated to v38"], "warnings": []},
) as mock_migrate,
patch.object(update_cmd, "_print_verified_update_completion", completion),
):
complete = update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=False, upstream_checked=True,
)
assert complete is True
prepare.assert_called_once_with(m.return_value.PROJECT_ROOT, desktop=False)
mock_migrate.assert_called_once_with(interactive=False, quiet=True)
completion.assert_called_once_with("✓ Already up to date!")
out = capsys.readouterr().out
assert "Checking configuration for new options..." in out
assert "Updating config format (v37 → v38)…" in out
assert "Config format updated" in out
def test_current_checkout_up_to_date_config(capsys):
"""When config is already up to date, it reports up to date without error."""
completion = MagicMock(return_value=True)
with (
patch.object(update_cmd, "_prepare_updated_checkout") as prepare,
patch.object(update_cmd, "_m") as m,
patch.object(update_cmd, "_reload_config_modules"),
patch.object(update_cmd, "_run_config_check_fresh", return_value=(38, 38)),
patch("hermes_cli.config.get_missing_env_vars", return_value=[]),
patch("hermes_cli.config.get_missing_config_fields", return_value=[]),
patch.object(update_cmd, "_run_migrate_config_fresh") as mock_migrate,
patch.object(update_cmd, "_print_verified_update_completion", completion),
):
complete = update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=False, upstream_checked=True,
)
assert complete is True
prepare.assert_called_once_with(m.return_value.PROJECT_ROOT, desktop=False)
mock_migrate.assert_not_called()
completion.assert_called_once_with("✓ Already up to date!")
out = capsys.readouterr().out
assert "Checking configuration for new options..." in out
assert "Configuration is up to date" in out
def test_check_and_apply_config_migration_interactive_prompt():
"""When new config options exist in an interactive session, it prompts the user."""
with (
patch.object(update_cmd, "_reload_config_modules"),
patch.object(update_cmd, "_run_config_check_fresh", return_value=(37, 38)),
patch.object(update_config, "check_config_version", return_value=(37, 38)),
patch("hermes_cli.config.get_missing_env_vars", return_value=[{"name": "NEW_KEY", "description": "desc"}]),
patch("hermes_cli.config.get_missing_config_fields", return_value=[]),
patch("sys.stdin.isatty", return_value=True),
patch("sys.stdout.isatty", return_value=True),
patch("builtins.input", return_value="y"),
patch.object(
update_cmd,
"_run_migrate_config_fresh",
update_config, "migrate_config",
return_value={"env_added": ["NEW_KEY"], "config_added": [], "warnings": []},
) as mock_migrate,
):
@@ -97,12 +42,11 @@ def test_check_and_apply_config_migration_assume_yes():
"""When assume_yes=True, it applies migrations non-interactively without prompting."""
with (
patch.object(update_cmd, "_reload_config_modules"),
patch.object(update_cmd, "_run_config_check_fresh", return_value=(37, 38)),
patch.object(update_config, "check_config_version", return_value=(37, 38)),
patch("hermes_cli.config.get_missing_env_vars", return_value=[{"name": "NEW_KEY"}]),
patch("hermes_cli.config.get_missing_config_fields", return_value=[]),
patch.object(
update_cmd,
"_run_migrate_config_fresh",
update_config, "migrate_config",
return_value={"env_added": [], "config_added": ["opt"], "warnings": []},
) as mock_migrate,
):
@@ -115,14 +59,13 @@ def test_check_and_apply_config_migration_non_interactive():
"""In a non-interactive session (e.g. CI/scripts), it applies safe migrations automatically."""
with (
patch.object(update_cmd, "_reload_config_modules"),
patch.object(update_cmd, "_run_config_check_fresh", return_value=(37, 38)),
patch.object(update_config, "check_config_version", return_value=(37, 38)),
patch("hermes_cli.config.get_missing_env_vars", return_value=[]),
patch("hermes_cli.config.get_missing_config_fields", return_value=[{"key": "new_setting"}]),
patch("sys.stdin.isatty", return_value=False),
patch("sys.stdout.isatty", return_value=False),
patch.object(
update_cmd,
"_run_migrate_config_fresh",
update_config, "migrate_config",
return_value={"env_added": [], "config_added": ["new_setting"], "warnings": []},
) as mock_migrate,
):

View File

@@ -123,7 +123,7 @@ def test_maintenance_returns_sqlite_verdict_without_frontend_flags(monkeypatch,
@pytest.mark.parametrize("already_restarted_units", [None, {"hermes-serve"}])
def test_dashboard_refresh_reloads_then_preserves_restart_bookkeeping(
def test_dashboard_refresh_preserves_restart_bookkeeping(
already_restarted_units, monkeypatch, capsys,
):
order = []
@@ -138,7 +138,7 @@ def test_dashboard_refresh_reloads_then_preserves_restart_bookkeeping(
)
update_cmd_maint._refresh_dashboard_after_update(already_restarted_units=already_restarted_units)
assert order == ["reload", {
assert order == [{
"restart_managed": True, "already_restarted_units": already_restarted_units,
}]
assert "could not be auto-restarted" in capsys.readouterr().out

View File

@@ -29,6 +29,8 @@ import hermes_cli.update_cmd_fleet as update_cmd_fleet
from hermes_cli.update_receipt import COMMAND_BOUNDARY_STOP_REASON
from hermes_constants import get_hermes_home
pytestmark = pytest.mark.usefixtures("isolated_source_completion")
def _make_head_moved_side_effect(pre_sha="abc123", post_sha="def456"):
"""Simulate git commands where HEAD advances from pre_sha to post_sha."""
@@ -374,19 +376,6 @@ def test_stale_fleet_matrix_on_latest_receipt_is_pending(monkeypatch):
assert update_cmd._pending_fleet_restart_needed() is True
def test_run_pending_restart_true_when_no_gateways(monkeypatch, capsys):
monkeypatch.setattr(
"hermes_cli.gateway.find_gateway_pids", lambda **k: []
)
monkeypatch.setattr(hermes_main, "_purge_stale_hermes_modules", lambda: None)
# An empty PID scan is insufficient; both supervisor scopes must answer empty.
monkeypatch.setattr(update_cmd_fleet, "_systemd_gateway_unit_listings", lambda: [
(scope, cmd, SimpleNamespace(returncode=0, stdout=""))
for scope, cmd in update_cmd_fleet._SYSTEMD_SCOPES
])
assert update_cmd._run_pending_fleet_restart() is True
assert "Pending fleet restart completed" in capsys.readouterr().out
# ---------------------------------------------------------------------------
@@ -536,19 +525,19 @@ def test_already_up_to_date_runs_pending_restart_when_marker_present(
seen = {"ran": False}
def _restart():
original = update_cmd._restart_gateway_fleet_after_update
def _restart(*args):
seen["ran"] = True
return True
return original(*args)
monkeypatch.setattr(update_cmd, "_run_pending_fleet_restart", _restart)
monkeypatch.setattr(update_cmd_fleet, "_run_pending_fleet_restart", _restart)
monkeypatch.setattr(update_cmd, "_restart_gateway_fleet_after_update", _restart)
hermes_main.cmd_update(args)
assert seen["ran"] is True
assert not update_cmd._fleet_restart_pending_marker_path().exists()
out = capsys.readouterr().out
assert "did not restart running gateways" in out
assert "Already up to date!" in out
def test_already_up_to_date_runs_pending_restart_when_receipt_skewed(
@@ -585,46 +574,19 @@ def test_already_up_to_date_runs_pending_restart_when_receipt_skewed(
)
seen = {"ran": False}
monkeypatch.setattr(
update_cmd,
"_run_pending_fleet_restart",
lambda: seen.__setitem__("ran", True) or True,
)
monkeypatch.setattr(
update_cmd_fleet,
"_run_pending_fleet_restart",
lambda: seen.__setitem__("ran", True) or True,
)
original = update_cmd._restart_gateway_fleet_after_update
def restart(*args):
seen["ran"] = True
return original(*args)
monkeypatch.setattr(update_cmd, "_restart_gateway_fleet_after_update", restart)
hermes_main.cmd_update(args)
assert seen["ran"] is True
out = capsys.readouterr().out
assert "did not restart running gateways" in out
assert "Already up to date!" in out
def test_already_up_to_date_skips_restart_when_nothing_pending(
monkeypatch, tmp_path, capsys
):
args = _update_args()
_patch_update_deps(monkeypatch, tmp_path, _make_up_to_date_side_effect())
seen = {"ran": False}
monkeypatch.setattr(
update_cmd,
"_run_pending_fleet_restart",
lambda: seen.__setitem__("ran", True) or True,
)
monkeypatch.setattr(
update_cmd_fleet,
"_run_pending_fleet_restart",
lambda: seen.__setitem__("ran", True) or True,
)
hermes_main.cmd_update(args)
assert seen["ran"] is False
assert "did not restart running gateways" not in capsys.readouterr().out
def test_startup_warn_prints_when_marker_present(capsys):

View File

@@ -1,53 +0,0 @@
"""The current-checkout repair path must rebuild the Desktop app (#97343).
A retry with no new commits must still prepare the Desktop product selected
before the update. Failure must stop before configuration and success reporting.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import update_cmd
def test_current_checkout_repair_rebuilds_desktop_under_project_root(tmp_path):
"""The retry preserves the pre-update desktop selection and checkout root."""
completion = MagicMock(return_value=True)
with (
patch.object(update_cmd, "_prepare_updated_checkout") as prepare,
patch.object(update_cmd, "_m") as m,
patch.object(update_cmd, "_check_and_apply_config_migration"),
patch.object(update_cmd, "_print_verified_update_completion", completion),
):
m.return_value.PROJECT_ROOT = tmp_path
complete = update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=True, upstream_checked=True,
)
assert complete is True
prepare.assert_called_once_with(tmp_path, desktop=True)
completion.assert_called_once_with("✓ Already up to date!")
def test_failed_desktop_rebuild_withholds_success_completion(tmp_path):
"""A failed build propagates before config migration or success reporting."""
completion = MagicMock(return_value=True)
with (
patch.object(update_cmd, "_m") as m,
patch.object(update_cmd, "_check_and_apply_config_migration") as migrate,
patch.object(update_cmd, "_prepare_updated_checkout", side_effect=RuntimeError("desktop build failed")),
patch.object(update_cmd, "_print_verified_update_completion", completion),
):
m.return_value.PROJECT_ROOT = tmp_path
with pytest.raises(RuntimeError, match="desktop build failed"):
update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=True, upstream_checked=True,
)
migrate.assert_not_called()
completion.assert_not_called()

View File

@@ -11,60 +11,8 @@ import pm
from hermes_cli import main, update_cmd
def test_current_checkout_dependency_failure_prevents_completion(tmp_path, monkeypatch):
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(update_cmd, "_print_verified_update_completion", lambda *a: pytest.fail("reported completion"))
def fail_sync(*args, **kwargs):
raise pm.InstallError("venv", "dependency conflict")
monkeypatch.setattr(pm, "sync_venv", fail_sync)
with pytest.raises(pm.InstallError, match="dependency conflict"):
update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=False, upstream_checked=True,
)
def test_build_runs_in_selected_python_and_propagates_failure(tmp_path, monkeypatch):
from hermes_cli.update_cmd_maint import _prepare_updated_checkout
from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path
from hermes_constants import venv_python_path
root = tmp_path / "checkout"
package = root / "hermes_cli"
package.mkdir(parents=True)
(package / "__init__.py").write_text("")
(package / "source_build.py").write_text(
"import json, os, pathlib, sys\n"
"pathlib.Path('build-process.json').write_text(json.dumps({"
"'python': sys.executable, 'argv': sys.argv[1:], 'path': sys.path}))\n"
"raise SystemExit(23)\n"
)
calls = []
selected = install_state_dir(root) / "environments/selected/venv"
# A stale repo-local venv must not win over PM's selected generation.
venv.EnvBuilder(with_pip=False).create(root / "venv")
def sync(*args, **kwargs):
calls.append((args, kwargs))
# Publication creates the interpreter the next process must use.
venv.EnvBuilder(with_pip=False).create(selected)
pm.Facts(runtime_facts_path(root)).record_state("venv", "prepared", [], environment=selected)
monkeypatch.setattr(pm, "sync_venv", sync)
monkeypatch.setenv("PYTHONPATH", str(tmp_path / "obsolete-deps"))
with pytest.raises(subprocess.CalledProcessError) as error:
_prepare_updated_checkout(root, desktop=True)
assert error.value.returncode == 23
assert calls == [((), {"explicit": True, "project_root": root})]
record = json.loads((root / "build-process.json").read_text())
assert record["python"] == str(venv_python_path(selected))
assert record["argv"] == ["--source", str(root), "--desktop"]
assert str(tmp_path / "obsolete-deps") not in record["path"]
assert not (root / ".update-incomplete").exists()
assert not (root / ".lazy-refresh-incomplete").exists()
@pytest.mark.parametrize("failure", [

View File

@@ -25,6 +25,17 @@ def test_runtime_status_probes_running_venv_outside_checkout(tmp_path, monkeypat
assert info is vulnerable
def test_runtime_status_uses_selected_python_not_legacy_repo_venv(tmp_path, monkeypatch):
selected = tmp_path / "selected/bin/python"
monkeypatch.setattr("hermes_constants.project_venv_dir", lambda root: tmp_path / "obsolete-venv")
monkeypatch.setattr(update_cmd.sys, "executable", str(selected))
observed = []
safe = SimpleNamespace(wal_reset_vulnerable=False)
monkeypatch.setattr("hermes_cli.sqlite_runtime.probe_sqlite_runtime", lambda python: observed.append(Path(python)) or safe)
assert update_cmd._post_update_sqlite_runtime_status() == (True, safe)
assert observed == [selected]
def test_summary_withholds_success_when_sqlite_remediation_failed(capsys, monkeypatch):
monkeypatch.setattr(
update_cmd,
@@ -80,22 +91,3 @@ def test_current_checkout_completion_is_verified_before_success(capsys, monkeypa
assert complete is False
assert "Already up to date" not in out
assert "SQLite 3.46.1" in out
def test_current_checkout_repair_returns_verified_completion_result(monkeypatch):
monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **k: None)
monkeypatch.setattr(update_cmd, "_check_and_apply_config_migration", lambda **k: None)
monkeypatch.setattr(
update_cmd,
"_print_verified_update_completion",
lambda _message: False,
)
complete = update_cmd._repair_current_checkout(
assume_yes=True,
gateway_mode=False,
pre_update_snapshot_id=None,
had_desktop_app_before_update=False,
upstream_checked=True,
)
assert complete is False

View File

@@ -843,75 +843,3 @@ class TestCmdlineCapture:
"""
live = self._live()
assert main_dashboard._dashboard_cmdline_for_pid(123) is None
class TestPostUpdateStaleModuleReload:
"""Regression tests for the post-update stale-module ImportError.
``hermes update`` runs in the PRE-pull Python process. When the update
adds a new symbol to ``hermes_cli._subprocess_compat`` (as #87134 added
``bounded_probe_run``), the post-update dashboard cleanup's lazy
``from hermes_cli._subprocess_compat import bounded_probe_run`` hits the
stale cached module and crashes with ImportError — after the code update
itself already succeeded. The cleanup entry point must force-reload the
process-scan modules first (PR #87757 + ZIP-path widening).
"""
def test_cleanup_reloads_before_scanning(self):
"""Dashboard refresh must reload the process-scan
modules BEFORE calling _kill_stale_dashboard_processes, on every
call path (git update and ZIP fallback both route here)."""
from hermes_cli import update_cmd
order: list[str] = []
with patch.object(
update_cmd, "_reload_process_scan_modules",
side_effect=lambda: order.append("reload"),
), patch(
"hermes_cli.main._kill_stale_dashboard_processes",
side_effect=lambda **kw: order.append("kill") or {"unrecovered": []},
):
update_cmd_maint._refresh_dashboard_after_update()
assert order == ["reload", "kill"]
def test_reload_restores_missing_symbol(self):
"""Simulate the stale-module state: strip ``bounded_probe_run`` off
the cached module object (what an old pre-#87134 module looks like)
and verify the reload restores it from disk — the exact state the
Windows update crash came from."""
import hermes_cli._subprocess_compat as compat
from hermes_cli import update_cmd
assert hasattr(compat, "bounded_probe_run")
try:
delattr(compat, "bounded_probe_run")
assert not hasattr(compat, "bounded_probe_run")
update_cmd._reload_process_scan_modules()
stale = sys.modules["hermes_cli._subprocess_compat"]
assert hasattr(stale, "bounded_probe_run")
finally:
importlib.reload(sys.modules["hermes_cli._subprocess_compat"])
importlib.reload(sys.modules["hermes_cli.dashboard_procs"])
def test_reload_failure_is_nonfatal(self):
"""A reload failure must log and continue, never raise — the cleanup
step runs after the update already succeeded."""
from hermes_cli import update_cmd
with patch("importlib.reload", side_effect=RuntimeError("boom")):
update_cmd._reload_process_scan_modules() # must not raise
def test_config_reload_list_includes_process_scan_modules(self):
"""PR #87757's half: the git-path pre-cleanup reload also refreshes
the process-scan modules (belt to the entry-point suspenders)."""
from hermes_cli import update_cmd
reloaded: list[str] = []
with patch("importlib.reload", side_effect=lambda m: reloaded.append(m.__name__)):
update_cmd._reload_config_modules()
assert "hermes_cli._subprocess_compat" in reloaded
assert "hermes_cli.dashboard_procs" in reloaded

View File

@@ -1,173 +0,0 @@
"""Tests for _purge_stale_hermes_modules — the class fix for stale
sys.modules breaking the gateway auto-restart after `hermes update`.
Field failure (2026-08-20, Teknium's Linux box): `hermes update` pulled a
checkout where hermes_cli/gateway.py newly imports `line_input` from
hermes_cli.cli_output, but the updater process had cli_output cached from
before that symbol existed. The function-level `from hermes_cli.gateway
import ...` in the restart phase raised ImportError, the whole phase
aborted, and the running gateway kept serving pre-update code.
The old mitigation (_UPDATE_RUNTIME_RELOAD_MODULES) reloaded 3 hardcoded
modules — re-fixed per symptom. The purge evicts EVERY cached module under
the Hermes package prefixes so later imports rebuild a self-consistent
module graph from the updated checkout.
"""
from __future__ import annotations
import importlib
import json
import sys
import types
import pytest
from hermes_cli import main as cli_main
from hermes_cli import update_cmd
@pytest.fixture(autouse=True)
def _restore_sys_modules():
"""Snapshot & restore sys.modules around each test.
The purge under test evicts real Hermes modules from the cache; later
tests in the same process may hold references to the evicted module
objects (e.g. `patch.object` targets), so put the originals back.
"""
snapshot = dict(sys.modules)
yield
for name, mod in snapshot.items():
sys.modules[name] = mod
for name in list(sys.modules):
if name not in snapshot:
del sys.modules[name]
def _fake_module(name: str) -> types.ModuleType:
mod = types.ModuleType(name)
mod.__stale_sentinel__ = True
return mod
def test_purge_evicts_hermes_prefixed_modules():
victims = [
"hermes_cli.cli_output",
"hermes_cli.gateway",
"gateway.status",
"tools.ansi_strip",
"tui_gateway.server",
"agent.memory_store",
]
added = []
for name in victims:
if name not in sys.modules:
sys.modules[name] = _fake_module(name)
added.append(name)
try:
cli_main._purge_stale_hermes_modules()
for name in victims:
mod = sys.modules.get(name)
assert mod is None or not getattr(mod, "__stale_sentinel__", False), (
f"{name} survived the purge"
)
finally:
for name in added:
sys.modules.pop(name, None)
def test_purge_protects_executing_modules():
# The updater's own modules must survive — they're running this code.
cli_main._purge_stale_hermes_modules()
assert sys.modules.get("hermes_cli.update_cmd") is update_cmd
assert sys.modules.get("hermes_cli.main") is cli_main
assert "hermes_cli" in sys.modules
def test_purge_preserves_active_update_receipt(tmp_path, monkeypatch):
"""A receipt begun before the post-pull purge must still be finalizable."""
import hermes_cli.update_receipt as receipt
receipt_dir = tmp_path / "update_receipts"
monkeypatch.setattr(receipt, "_receipt_dir", lambda: receipt_dir)
token = receipt._current.set(None)
post_purge_receipt = receipt
try:
receipt.begin_update_receipt()
receipt.record_step("git_pull", True, "updated checkout")
cli_main._purge_stale_hermes_modules()
post_purge_receipt = importlib.import_module("hermes_cli.update_receipt")
path = post_purge_receipt.finalize_update_receipt("success")
assert path is not None and path.is_file()
latest = json.loads((receipt_dir / "latest.json").read_text(encoding="utf-8"))
assert latest["outcome"] == "success"
assert latest["steps"][0]["name"] == "git_pull"
finally:
receipt._current.reset(token)
def test_purge_leaves_prefix_lookalikes_alone():
# `gateway_foo` starts with the string prefix "gateway" but is NOT the
# gateway package — the root-segment check must spare it.
lookalikes = ["gatewayd", "toolshed", "agents_external"]
added = []
for name in lookalikes:
if name not in sys.modules:
sys.modules[name] = _fake_module(name)
added.append(name)
try:
cli_main._purge_stale_hermes_modules()
for name in lookalikes:
assert name in sys.modules, f"{name} was wrongly purged"
finally:
for name in added:
sys.modules.pop(name, None)
def test_purge_never_raises_on_weird_sys_modules():
# Entries with None values (import machinery quirk) must not break it.
sys.modules["hermes_cli._purge_test_none"] = None # type: ignore[assignment]
try:
cli_main._purge_stale_hermes_modules()
finally:
sys.modules.pop("hermes_cli._purge_test_none", None)
def test_stale_symbol_scenario_end_to_end():
"""Reproduce the field failure shape: a cached module missing a symbol
that freshly-imported code needs — purge, then re-import resolves it."""
name = "hermes_cli.cli_output"
real = sys.modules.get(name)
# Install a stale stand-in WITHOUT line_input (pre-d0132b582 world).
stale = types.ModuleType(name)
sys.modules[name] = stale
try:
# The failure mode: importing the symbol from the stale cache dies.
try:
from hermes_cli.cli_output import line_input # noqa: F401
raised = False
except ImportError:
raised = True
assert raised, "precondition: stale module must lack line_input"
cli_main._purge_stale_hermes_modules()
# Post-purge, the import resolves against real on-disk source.
from hermes_cli.cli_output import line_input # noqa: F401
finally:
sys.modules.pop(name, None)
if real is not None:
sys.modules[name] = real
def test_purge_keeps_plan_record_class_identity():
# The pre-update plan is built BEFORE the purge; reconciliation after it filters with
# ``isinstance(r, RuntimeRecord)``. An evicted ``update_inventory`` yields a fresh class,
# every record fails the check, and the plan-vs-execution report goes silently empty.
from hermes_cli.update_inventory import RuntimeRecord as before
cli_main._purge_stale_hermes_modules()
from hermes_cli.update_inventory import RuntimeRecord as after
assert after is before

View File

@@ -98,6 +98,12 @@ def snapshot_db(tmp_path):
return snapshot
@pytest.fixture(autouse=True)
def _isolate_database_holders(monkeypatch):
# These fixtures own all DB connections. Do not scan other users' /proc FDs.
monkeypatch.setattr("hermes_cli.backup_restore._foreign_db_holder_pids", lambda path: [])
def _row_count(db_path: Path) -> int:
conn = sqlite3.connect(db_path)
try:

View File

@@ -1,102 +1,30 @@
"""A failed dependency transaction must stop the ZIP update, not report success."""
"""ZIP and Git-error fallback return the completion owner's exact failure."""
from types import SimpleNamespace
import subprocess
from unittest.mock import patch
from unittest.mock import Mock
import pytest
import pm
import hermes_cli.main as main
from hermes_cli import update_cmd, update_cmd_maint, update_cmd_zip
from hermes_cli import main, update_cmd, update_cmd_zip
def test_zip_dependency_failure_propagates_before_followup_mutations(tmp_path, monkeypatch):
@pytest.mark.parametrize("route", ["zip", "git-error"])
def test_zip_completion_failure_does_not_run_old_followup(tmp_path, monkeypatch, route):
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
with (
patch.object(update_cmd_zip, "_abort_zip_update_if_dirty_tree"),
patch.object(update_cmd_zip, "_download_and_swap_zip"),
patch.object(update_cmd_maint, "_sweep_bytecode_after_update"),
patch.object(
update_cmd_maint, "_prepare_updated_checkout",
side_effect=pm.InstallError("venv", "network unavailable"),
) as prepare,
patch.object(update_cmd_maint, "_print_bundled_skills_sync_report") as skills,
patch.object(update_cmd_maint, "_print_verified_update_completion") as summary,
):
with pytest.raises(pm.InstallError, match="network unavailable"):
update_cmd_zip._update_via_zip(SimpleNamespace(branch="main"))
prepare.assert_called_once_with(tmp_path, desktop=False)
skills.assert_not_called()
summary.assert_not_called()
def test_pull_dependency_failure_keeps_recovery_marker(tmp_path, monkeypatch):
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
post_pull_sha = "b" * 40
with (
patch.object(update_cmd, "_verify_head_after_pull", return_value=post_pull_sha),
patch.object(update_cmd, "_sweep_bytecode_after_update"),
patch.object(
update_cmd, "_prepare_updated_checkout",
side_effect=pm.InstallError("venv", "sync stopped"),
) as prepare,
patch.object(update_cmd, "_run_post_update_maintenance") as maintenance,
patch.object(update_cmd, "_restart_gateway_fleet_after_update") as restart,
):
with pytest.raises(pm.InstallError, match="sync stopped"):
update_cmd._apply_pulled_update(
["git"], "main", "a" * 40, SimpleNamespace(in_place_update=False),
SimpleNamespace(assume_yes=True, gw_input_fn=None),
gateway_mode=False, is_fork=False, desktop_dir=tmp_path / "apps" / "desktop",
had_desktop_app_before_update=False, pre_update_snapshot_id=None,
_pre_update_plan=None, _windows_gateway_resume=[],
)
prepare.assert_called_once_with(tmp_path, desktop=False)
marker = update_cmd._fleet_restart_pending_marker_path()
assert f"expected_sha={post_pull_sha}" in marker.read_text(encoding="utf-8")
maintenance.assert_not_called()
restart.assert_not_called()
@pytest.mark.parametrize("route", ["direct", "git-failure"])
@pytest.mark.parametrize("gateway_mode", [False, True])
@pytest.mark.parametrize("complete", [False, True])
def test_zip_callers_propagate_completion(tmp_path, monkeypatch, route, gateway_mode, complete):
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(update_cmd, "_update_via_zip", lambda *args, **kwargs: complete)
exit_markers = []
monkeypatch.setattr(update_cmd, "_write_gateway_update_exit_code", exit_markers.append)
resumed = []
args = SimpleNamespace(branch="main")
if route == "direct":
monkeypatch.setattr(update_cmd, "_resolve_update_options", lambda *args: SimpleNamespace(
gw_input_fn=None, assume_yes=True))
monkeypatch.setattr(update_cmd, "_begin_update_receipt_and_plan", lambda args: None)
monkeypatch.setattr(main, "_run_pre_update_backup", lambda args: None)
monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: None)
monkeypatch.setattr(main, "_resume_windows_gateways_after_update", resumed.append)
monkeypatch.setattr(main, "_desktop_packaged_executable", lambda root: None)
monkeypatch.setattr(main, "_desktop_dist_exists", lambda root: False)
monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (True, [], False))
monkeypatch.setattr(update_cmd, "_source_update_channel", lambda args: "main")
def invoke():
update_cmd._cmd_update_impl(args, gateway_mode=gateway_mode)
else:
monkeypatch.setattr(update_cmd, "_should_zip_fallback_on_update_error", lambda error: True)
def invoke():
monkeypatch.setattr(update_cmd_zip, "_abort_zip_update_if_dirty_tree", lambda: None)
monkeypatch.setattr(update_cmd_zip, "_download_and_swap_zip", lambda *a: None)
monkeypatch.setattr(update_cmd, "_should_zip_fallback_on_update_error", lambda exc: True)
request = {"expected_sha": None, "desktop": True}
completion = Mock(side_effect=SystemExit(23))
monkeypatch.setattr(update_cmd, "_complete_source_update", completion)
monkeypatch.setattr(update_cmd, "_run_post_update_maintenance", lambda **kw: pytest.fail("old maintenance"))
monkeypatch.setattr(update_cmd, "_update_via_zip", update_cmd_zip._update_via_zip)
with pytest.raises(SystemExit) as error:
if route == "zip":
update_cmd_zip._update_via_zip(SimpleNamespace(branch="main"), completion_request=request)
else:
update_cmd._handle_update_called_process_error(
subprocess.CalledProcessError(1, ["git", "fetch"]), args, gateway_mode, False)
if complete:
invoke()
else:
with pytest.raises(SystemExit) as failure:
invoke()
assert failure.value.code == 1
assert exit_markers == ([complete] if gateway_mode else [])
assert resumed == ([None] if route == "direct" else [])
subprocess.CalledProcessError(1, ["git", "fetch"]), SimpleNamespace(branch="main"),
False, True, completion_request=request)
assert error.value.code == 23
completion.assert_called_once_with(request)

View File

@@ -39,12 +39,17 @@ entry shims stop the old updater cleanly and ask for a relaunch instead of invok
PM or falling back to pip. Completion belongs to the new launcher, not that mixed
old-code/new-files process.
Current source updates use one PM sync for the recorded extras and enabled
plugins, then build frontend products in a fresh process on the selected
Python. A retry on an already-current checkout follows the same path.
Dependency or build failures stop completion; the updater does not retry
through pip, reinstall providers separately, or create incomplete markers.
Use `hermes pm repair` for damaged dependency files.
Current source updates hand the selected checkout to a fresh completion owner.
Its bootstrap Python disables site-package initialization before asking PM to
sync the recorded extras and enabled plugins. The selected Python then owns
frontend builds, profile/configuration maintenance, gateway restarts and runtime
verification. Git, already-current retries and ZIP fallback use this same path.
The original command keeps the update lock while waiting; a missing or failed
completion result cannot report success. Correlated PM failures remain in the
update receipt, and interrupted restarts retain their fleet obligation.
Dependency or build failures never retry through pip or a source re-download.
Use `hermes pm repair` for damaged dependency files. See the developer
[source completion ownership note](https://github.com/NousResearch/hermes-agent/blob/main/docs/source-update-completion.md).
## Source installs and packaged builds