diff --git a/docs/source-update-completion.md b/docs/source-update-completion.md new file mode 100644 index 0000000000..8aa34a437b --- /dev/null +++ b/docs/source-update-completion.md @@ -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. diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index abbaf5e942..9647120ffe 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -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 diff --git a/hermes_cli/_old_updater.py b/hermes_cli/_old_updater.py index 89a4bca1c8..d72c33965b 100644 --- a/hermes_cli/_old_updater.py +++ b/hermes_cli/_old_updater.py @@ -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) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 2513fe5951..b655d9ef63 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -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) diff --git a/hermes_cli/update_cmd_config.py b/hermes_cli/update_cmd_config.py index 53cc4b73d2..bebe000da7 100644 --- a/hermes_cli/update_cmd_config.py +++ b/hermes_cli/update_cmd_config.py @@ -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!") diff --git a/hermes_cli/update_cmd_fleet.py b/hermes_cli/update_cmd_fleet.py index 2b49ed4b16..e8e38e653e 100644 --- a/hermes_cli/update_cmd_fleet.py +++ b/hermes_cli/update_cmd_fleet.py @@ -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. diff --git a/hermes_cli/update_cmd_maint.py b/hermes_cli/update_cmd_maint.py index d5e5b2d11c..c14a3bf405 100644 --- a/hermes_cli/update_cmd_maint.py +++ b/hermes_cli/update_cmd_maint.py @@ -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 diff --git a/hermes_cli/update_cmd_zip.py b/hermes_cli/update_cmd_zip.py index 20ea9b056b..d61e2ff127 100644 --- a/hermes_cli/update_cmd_zip.py +++ b/hermes_cli/update_cmd_zip.py @@ -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 diff --git a/hermes_cli/update_completion.py b/hermes_cli/update_completion.py new file mode 100644 index 0000000000..65b44bfb3e --- /dev/null +++ b/hermes_cli/update_completion.py @@ -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()) diff --git a/tests/compat/old_updater_surface.json b/tests/compat/old_updater_surface.json index d84313a3d2..07a4a1857f 100644 --- a/tests/compat/old_updater_surface.json +++ b/tests/compat/old_updater_surface.json @@ -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", diff --git a/tests/hermes_cli/conftest.py b/tests/hermes_cli/conftest.py index 3d2f907462..04cb312604 100644 --- a/tests/hermes_cli/conftest.py +++ b/tests/hermes_cli/conftest.py @@ -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 diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 5bdc679fdc..62612d988f 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -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) diff --git a/tests/hermes_cli/test_pending_supervisor_recovery.py b/tests/hermes_cli/test_pending_supervisor_recovery.py index 2b62ff8ae7..a0a29618d7 100644 --- a/tests/hermes_cli/test_pending_supervisor_recovery.py +++ b/tests/hermes_cli/test_pending_supervisor_recovery.py @@ -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]) diff --git a/tests/hermes_cli/test_source_release_channels.py b/tests/hermes_cli/test_source_release_channels.py index 3009fa4b2f..430beaa7ae 100644 --- a/tests/hermes_cli/test_source_release_channels.py +++ b/tests/hermes_cli/test_source_release_channels.py @@ -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"] diff --git a/tests/hermes_cli/test_update_completion_process.py b/tests/hermes_cli/test_update_completion_process.py new file mode 100644 index 0000000000..c48cf66f16 --- /dev/null +++ b/tests/hermes_cli/test_update_completion_process.py @@ -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 diff --git a/tests/hermes_cli/test_update_completion_routing.py b/tests/hermes_cli/test_update_completion_routing.py new file mode 100644 index 0000000000..e9a81d548e --- /dev/null +++ b/tests/hermes_cli/test_update_completion_routing.py @@ -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] diff --git a/tests/hermes_cli/test_update_config_migration_on_current.py b/tests/hermes_cli/test_update_config_migration_on_current.py index 907818a3dd..12175deba6 100644 --- a/tests/hermes_cli/test_update_config_migration_on_current.py +++ b/tests/hermes_cli/test_update_config_migration_on_current.py @@ -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): diff --git a/tests/hermes_cli/test_update_config_migration_on_current_checkout.py b/tests/hermes_cli/test_update_config_migration_on_current_checkout.py index 91783fe5f7..ccdb29fdae 100644 --- a/tests/hermes_cli/test_update_config_migration_on_current_checkout.py +++ b/tests/hermes_cli/test_update_config_migration_on_current_checkout.py @@ -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, ): diff --git a/tests/hermes_cli/test_update_desktop_stale_warning.py b/tests/hermes_cli/test_update_desktop_stale_warning.py index 1a98f77253..7bef12082e 100644 --- a/tests/hermes_cli/test_update_desktop_stale_warning.py +++ b/tests/hermes_cli/test_update_desktop_stale_warning.py @@ -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 diff --git a/tests/hermes_cli/test_update_fleet_restart_pending.py b/tests/hermes_cli/test_update_fleet_restart_pending.py index 538a23511c..7359ea4fe6 100644 --- a/tests/hermes_cli/test_update_fleet_restart_pending.py +++ b/tests/hermes_cli/test_update_fleet_restart_pending.py @@ -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): diff --git a/tests/hermes_cli/test_update_handoff_desktop_rebuild.py b/tests/hermes_cli/test_update_handoff_desktop_rebuild.py deleted file mode 100644 index 1ea5211084..0000000000 --- a/tests/hermes_cli/test_update_handoff_desktop_rebuild.py +++ /dev/null @@ -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() diff --git a/tests/hermes_cli/test_update_products.py b/tests/hermes_cli/test_update_products.py index a5562a5021..a9981e5c3c 100644 --- a/tests/hermes_cli/test_update_products.py +++ b/tests/hermes_cli/test_update_products.py @@ -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", [ diff --git a/tests/hermes_cli/test_update_sqlite_remediation.py b/tests/hermes_cli/test_update_sqlite_remediation.py index 79b110eaf1..3843dbe0b9 100644 --- a/tests/hermes_cli/test_update_sqlite_remediation.py +++ b/tests/hermes_cli/test_update_sqlite_remediation.py @@ -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 diff --git a/tests/hermes_cli/test_update_stale_dashboard.py b/tests/hermes_cli/test_update_stale_dashboard.py index f76258f919..257568a019 100644 --- a/tests/hermes_cli/test_update_stale_dashboard.py +++ b/tests/hermes_cli/test_update_stale_dashboard.py @@ -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 diff --git a/tests/hermes_cli/test_update_stale_module_purge.py b/tests/hermes_cli/test_update_stale_module_purge.py deleted file mode 100644 index d722280235..0000000000 --- a/tests/hermes_cli/test_update_stale_module_purge.py +++ /dev/null @@ -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 diff --git a/tests/hermes_cli/test_update_state_autorestore.py b/tests/hermes_cli/test_update_state_autorestore.py index 7b54340835..7be1c75aad 100644 --- a/tests/hermes_cli/test_update_state_autorestore.py +++ b/tests/hermes_cli/test_update_state_autorestore.py @@ -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: diff --git a/tests/hermes_cli/test_update_zip_sync_failure.py b/tests/hermes_cli/test_update_zip_sync_failure.py index b8d9636920..e47661677b 100644 --- a/tests/hermes_cli/test_update_zip_sync_failure.py +++ b/tests/hermes_cli/test_update_zip_sync_failure.py @@ -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) diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 0304d3efe6..0dfd1fe5be 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -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