refactor(update): finish source updates in fresh selected Python
This commit is contained in:
80
docs/source-update-completion.md
Normal file
80
docs/source-update-completion.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Source update completion ownership
|
||||
|
||||
## Phase seam
|
||||
|
||||
The command process owns admission, the update lock and output lifetime, pre-update
|
||||
inventory, all-profile snapshots, gateway pause, Git selection/stash/restore and
|
||||
syntax/HEAD guards, and the ZIP download/stage/dirty recheck/release graft/swap.
|
||||
It imports the completion transport before swapping code. Once the final tree is
|
||||
selected (including upstream merge), Git, already-current retry and ZIP all send
|
||||
one versioned JSON request to `update_completion.py` **from that tree**. No cached
|
||||
application module is evicted or reloaded in the command process.
|
||||
|
||||
The request carries canonical source/home, desktop product selection, interactive
|
||||
and gateway mode, pre-update version, active and sibling snapshot identifiers,
|
||||
serialized runtime plan, open receipt identity/data and paused-Windows token. It
|
||||
contains data, never callables or pickles. stdin stays inherited for interactive
|
||||
configuration prompts; gateway mode retains its non-interactive behavior. Child
|
||||
output stays visible and is mirrored by the parent's update output stream.
|
||||
|
||||
## New-code owner
|
||||
|
||||
A stdlib-only entrypoint starts using the available Python with `-I -S`, so no
|
||||
old site-packages or executable `.pth` files initialize. A private bytecode-cache
|
||||
prefix fences stale cache files before any new-checkout imports. Its explicit
|
||||
import path points at the new checkout. It calls the new PM interface to prepare the
|
||||
recorded dependency union, then starts the selected Python with the new activation
|
||||
environment. That interpreter also starts with site initialization disabled,
|
||||
then the runtime owner leases and activates its selected generation before any
|
||||
application imports. Only that interpreter imports application completion code. The same
|
||||
receipt/correlation identity crosses this preparation boundary (including PM
|
||||
results). Selected-Python completion owns launcher publication, builders, cache
|
||||
invalidation, all-profile configuration/state/skills maintenance, process scans,
|
||||
fleet restart, Windows resume, dashboard deduplication and verification.
|
||||
|
||||
The existing per-kind restart and abort-recovery algorithms remain; transient
|
||||
supervisor/process failures are real even without mixed-generation imports. Only
|
||||
the purge/reload workaround and independent retry/ZIP tail compositions disappear.
|
||||
Gateway exit status is written before a restart can terminate the updater's cgroup,
|
||||
and is demoted on later failure. Verification publishes the final receipt.
|
||||
|
||||
## Parent lifecycle and failures
|
||||
|
||||
The parent waits and propagates the child's exact nonzero result (a signal is
|
||||
mapped to shell-style 128+signal). A child cannot succeed by merely exiting zero:
|
||||
a terminal response with the matching receipt identity is required. The response
|
||||
returns the mutated Windows token so the parent's registered emergency resume does
|
||||
not repeat completed work. Normal parent completion performs no maintenance.
|
||||
|
||||
The parent retains its original receipt until acknowledged child finalization;
|
||||
missing/failed child output leaves it available to the existing command-boundary
|
||||
failure finalizer. The stdlib bootstrap returns correlated PM failure data even
|
||||
when application imports are unavailable, and normalizes negative signal exits
|
||||
at each process boundary. POSIX completion owns a new session/process group;
|
||||
cancellation kills that group before releasing the lock (Windows uses the retained
|
||||
child's `taskkill /T` tree). The parent records the pending fleet obligation before
|
||||
starting the completion process, including when preparation cannot begin. The parent's emergency Windows resume remains a last-resort
|
||||
lifecycle obligation when the child cannot execute or is killed. A failed child
|
||||
never clears the pending fleet obligation. No automatic code rollback after
|
||||
maintenance has begun (SQLite snapshots remain file-loss recovery, not rollback).
|
||||
|
||||
## Historical surface
|
||||
|
||||
All names frozen from the complete reachable shipped updater history stay
|
||||
resolvable. Retired preparation and module-reload hooks become narrow nonzero
|
||||
relaunch stops, not alternate completion paths or false successful receipts.
|
||||
Unfrozen branch-only retry compositions are deleted, not shimmed. ACP convenience
|
||||
publication uses the launcher owner's `expose_cli`; the historical ACP entry is
|
||||
only an adapter, never a second writer. The frozen set is never trimmed or replaced
|
||||
with tag-only coverage. New current-path imports are unioned with that history.
|
||||
|
||||
## Verification
|
||||
|
||||
Use isolated homes, disposable Git repositories and fake dependency/build/service
|
||||
adapters only. Exercise an old process with cached incompatible modules across a
|
||||
real Git transition to new code, selected-Python execution, receipt identity and
|
||||
snapshot transfer, nonzero/abrupt child exit, lock release and Windows-token
|
||||
return. Focused existing tests cover dirty ZIP checks/grafts, snapshots, fleet
|
||||
reconciliation, supervisor timing and historical imports. Native service restart
|
||||
and Windows/macOS acceptance remain separate required lanes; no live user service
|
||||
or user state is touched by this implementation's test runs.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
265
hermes_cli/update_completion.py
Normal file
265
hermes_cli/update_completion.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Fresh-checkout source update completion and its stdlib-only parent transport.
|
||||
|
||||
Imported before a swap; executed by path from the selected tree afterward. The
|
||||
parent never imports application helpers from the replacement checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict) -> None:
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(data), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _exit_status(code: int) -> int:
|
||||
return code if code >= 0 else 128 - code
|
||||
|
||||
|
||||
def _failed_result(request: dict, result_path: Path, code: int) -> int:
|
||||
code = _exit_status(code) or 1
|
||||
_write_json(result_path, {
|
||||
"schema": 1, "update_id": request["receipt"]["update_id"], "exit_code": code,
|
||||
"receipt": None, "windows_resume": None, "pm_receipt": request.get("pm_receipt"),
|
||||
})
|
||||
return code
|
||||
|
||||
|
||||
def run_completion(request: dict) -> dict:
|
||||
"""Wait for new code; zero exit without a correlated terminal result fails closed."""
|
||||
root = Path(request["source"])
|
||||
env = dict(os.environ, HERMES_HOME=request["home"], PYTHONUNBUFFERED="1")
|
||||
for key in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"):
|
||||
env.pop(key, None)
|
||||
with tempfile.TemporaryDirectory(prefix="hermes-completion-") as directory:
|
||||
request_path = Path(directory) / "request.json"
|
||||
result_path = Path(directory) / "result.json"
|
||||
request = {**request, "stdout_isatty": sys.stdout.isatty()}
|
||||
request["bytecode_cache"] = str(Path(directory) / "bytecode")
|
||||
_write_json(request_path, request)
|
||||
command = [sys.executable, "-I", "-S", "-X", f"pycache_prefix={request['bytecode_cache']}",
|
||||
str(root / "hermes_cli/update_completion.py"),
|
||||
str(request_path), str(result_path)]
|
||||
proc = subprocess.Popen(
|
||||
command, cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
**({"start_new_session": True} if os.name == "posix" else
|
||||
{"creationflags": subprocess.CREATE_NO_WINDOW}))
|
||||
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
try:
|
||||
while True:
|
||||
chunk = proc.stdout.read1(8192)
|
||||
sys.stdout.write(decoder.decode(chunk, final=not chunk))
|
||||
sys.stdout.flush()
|
||||
if not chunk:
|
||||
break
|
||||
code = proc.wait()
|
||||
except BaseException:
|
||||
# This group/retained process handle belongs exclusively to us.
|
||||
# Stop descendants BEFORE releasing the command's update lock.
|
||||
if os.name == "posix":
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
else:
|
||||
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=10,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
raise
|
||||
finally:
|
||||
proc.stdout.close()
|
||||
code = _exit_status(code)
|
||||
try:
|
||||
result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||
if result["schema"] != 1 or result["update_id"] != request["receipt"]["update_id"]:
|
||||
raise ValueError("completion response identity mismatch")
|
||||
if result["exit_code"] != code:
|
||||
raise ValueError("completion response disagrees with process exit")
|
||||
receipt = result.get("receipt")
|
||||
if receipt is not None and (
|
||||
receipt.get("update_id") != request["receipt"]["update_id"]
|
||||
or not receipt.get("finished_at")
|
||||
or (code == 0) != (receipt.get("outcome") == "success")
|
||||
):
|
||||
raise ValueError("completion receipt does not attest this outcome")
|
||||
if code == 0 and receipt is None:
|
||||
raise ValueError("completion did not publish a terminal receipt")
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
print(f"✗ Source update completion did not finish: {exc}")
|
||||
return {"exit_code": code or 1, "receipt": None, "windows_resume": None}
|
||||
return result
|
||||
|
||||
|
||||
def _resume_receipt(data: dict) -> None:
|
||||
from hermes_cli import update_receipt
|
||||
|
||||
# Hydrate the existing run, not a new receipt with a new identity/pre-update probe.
|
||||
receipt = object.__new__(update_receipt.UpdateReceipt)
|
||||
receipt.data = data
|
||||
receipt.correlation_id = data["update_id"]
|
||||
receipt.current_token = update_receipt._current.set(receipt)
|
||||
|
||||
|
||||
def _read_terminal_receipt(request: dict) -> dict | None:
|
||||
directory = Path(request["home"]) / "logs/update_receipts"
|
||||
# Never latest.json: another profile/context may have finalized more recently.
|
||||
for path in directory.glob(f"update_*_{request['receipt']['update_id']}.json"):
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if data.get("update_id") == request["receipt"]["update_id"] and data.get("finished_at"):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def _prepare(request: dict, request_path: Path, result_path: Path) -> int:
|
||||
import pm
|
||||
from pm import receipt
|
||||
from hermes_cli.runtime_paths import activation_environment, selected_venv
|
||||
from hermes_constants import venv_python_path
|
||||
|
||||
root = Path(request["source"])
|
||||
update_id = request["receipt"]["update_id"]
|
||||
with receipt.worker_context(update_id):
|
||||
try:
|
||||
pm.sync_venv(explicit=True, project_root=root)
|
||||
finally:
|
||||
request["pm_receipt"] = receipt.last_for_update(update_id)
|
||||
_write_json(request_path, request)
|
||||
command = [str(venv_python_path(selected_venv(root))),
|
||||
"-I", "-S", "-X", f"pycache_prefix={request['bytecode_cache']}",
|
||||
str(root / "hermes_cli/update_completion.py"),
|
||||
str(request_path), str(result_path), "--prepared"]
|
||||
# A second interpreter is mandatory: PM may have selected a different Python
|
||||
# and dependency graph. No application maintenance runs in this bootstrap.
|
||||
code = _exit_status(subprocess.call(command, cwd=root, env=activation_environment(root)))
|
||||
if not result_path.exists():
|
||||
return _failed_result(request, result_path, code)
|
||||
return code
|
||||
|
||||
|
||||
def _complete_selected(request: dict) -> None:
|
||||
from hermes_cli import main, update_cmd, update_cmd_config
|
||||
from hermes_cli.update_inventory import RuntimeRecord, UpdatePlan
|
||||
from hermes_cli.update_cmd_maint import _run_post_update_maintenance
|
||||
from hermes_cli.source_build import build_update_products
|
||||
from hermes_cli.venv_sync import publish_launchers
|
||||
|
||||
root = Path(request["source"])
|
||||
main.PROJECT_ROOT = root
|
||||
update_cmd_config._LAST_SIBLING_SNAPSHOTS = request["sibling_snapshots"]
|
||||
plan_data = request["plan"]
|
||||
plan = None if plan_data is None else UpdatePlan(**{
|
||||
**plan_data, "runtimes": [RuntimeRecord(**row) for row in plan_data.get("runtimes", [])]})
|
||||
update_cmd._sweep_bytecode_after_update(request["branch"])
|
||||
publish_launchers(root)
|
||||
build_update_products(root, desktop=request["desktop"])
|
||||
if not request.get("completion_message"):
|
||||
print("\n✓ Code updated!")
|
||||
complete = _run_post_update_maintenance(
|
||||
assume_yes=request["assume_yes"], gateway_mode=request["gateway_mode"],
|
||||
pre_update_snapshot_id=request["snapshot_id"],
|
||||
had_desktop_app_before_update=request["desktop"], pre_update_version=request["pre_update_version"],
|
||||
completion_message=request.get("completion_message"))
|
||||
# systemctl's KillMode=mixed fallback can kill this whole cgroup. Publish the
|
||||
# gateway watcher's status BEFORE that operation, and demote on later failure.
|
||||
if request["gateway_mode"]:
|
||||
update_cmd._write_gateway_update_exit_code(complete)
|
||||
restart = update_cmd._restart_gateway_fleet_after_update(plan, request["gateway_mode"])
|
||||
update_cmd._resume_windows_gateways_and_merge_outcome(restart, request["windows_resume"], request["gateway_mode"])
|
||||
update_cmd._verify_fleet_after_update(
|
||||
restart, _pre_update_plan=plan, _windows_gateway_resume=request["windows_resume"], update_complete=complete)
|
||||
|
||||
|
||||
class _ForwardedOutput:
|
||||
"""The parent's pipe preserves its terminal's prompt policy and log mirror."""
|
||||
|
||||
def __init__(self, stream, isatty: bool):
|
||||
self.stream, self.terminal = stream, isatty
|
||||
|
||||
def isatty(self):
|
||||
return self.terminal
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.stream, name)
|
||||
|
||||
|
||||
def _finish(request: dict, result_path: Path) -> int:
|
||||
from hermes_cli import update_receipt
|
||||
from pm.receipt import accept_worker_receipt
|
||||
|
||||
_resume_receipt(request["receipt"])
|
||||
accept_worker_receipt(request.get("pm_receipt"), request["receipt"]["update_id"])
|
||||
code, reason = 0, "source update completion"
|
||||
try:
|
||||
_complete_selected(request)
|
||||
except SystemExit as exc:
|
||||
code = _exit_status(exc.code) if isinstance(exc.code, int) else 1
|
||||
reason = f"completion exited {code}"
|
||||
except BaseException as exc:
|
||||
code = _exit_status(exc.returncode) if isinstance(exc, subprocess.CalledProcessError) else 1
|
||||
reason = f"{type(exc).__name__}: {exc}"
|
||||
print(f"✗ Source update completion failed: {reason}")
|
||||
finally:
|
||||
if code and request["gateway_mode"]:
|
||||
from hermes_cli.update_cmd import _write_gateway_update_exit_code
|
||||
_write_gateway_update_exit_code(False)
|
||||
# The new interpreter owns recovery too. The original parent's atexit
|
||||
# token is updated from the response; it acts only if this process dies.
|
||||
try:
|
||||
from hermes_cli.update_cmd import _resume_windows_gateways_after_update
|
||||
_resume_windows_gateways_after_update(request["windows_resume"])
|
||||
except Exception as exc:
|
||||
code, reason = 1, f"Windows gateway recovery failed: {exc}"
|
||||
print(f"✗ {reason}")
|
||||
update_receipt.finalize_pending_update_receipt(code, reason)
|
||||
terminal_receipt = _read_terminal_receipt(request)
|
||||
if not terminal_receipt:
|
||||
code = code or 1
|
||||
_write_json(result_path, {
|
||||
"schema": 1, "update_id": request["receipt"]["update_id"], "exit_code": code,
|
||||
"receipt": terminal_receipt, "windows_resume": request["windows_resume"],
|
||||
})
|
||||
return code
|
||||
|
||||
|
||||
def main() -> int:
|
||||
request_path, result_path = map(Path, sys.argv[1:3])
|
||||
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
if request["schema"] != 1:
|
||||
raise ValueError("unsupported source completion request")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
if root != Path(request["source"]).resolve():
|
||||
raise ValueError("completion checkout does not match request")
|
||||
# -I deliberately ignores inherited PYTHONPATH during PM preparation.
|
||||
sys.path.insert(0, str(root))
|
||||
sys.stdout = _ForwardedOutput(sys.stdout, request.get("stdout_isatty", False))
|
||||
if "--prepared" in sys.argv[3:]:
|
||||
# Claim the selected generation's lease and process its .pth files only
|
||||
# after PM selection, before importing any application dependencies.
|
||||
from hermes_cli.runtime_paths import activate_dependencies
|
||||
activate_dependencies(root)
|
||||
return _finish(request, result_path)
|
||||
try:
|
||||
return _prepare(request, request_path, result_path)
|
||||
except BaseException as exc:
|
||||
# PM failed before application dependencies were ready. Leave the parent
|
||||
# receipt and paused-gateway obligation intact for boundary recovery.
|
||||
print(f"✗ Source update preparation failed: {exc}")
|
||||
code = exc.returncode if isinstance(exc, subprocess.CalledProcessError) else 1
|
||||
return _failed_result(request, result_path, code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
391
tests/hermes_cli/test_update_completion_process.py
Normal file
391
tests/hermes_cli/test_update_completion_process.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""A checkout transition must not finish in the old interpreter's module graph."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import venv
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transition(tmp_path):
|
||||
root = tmp_path / "checkout"
|
||||
root.mkdir()
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
package = root / "hermes_cli"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text("")
|
||||
(root / "pm").mkdir()
|
||||
(root / "pm/__init__.py").write_text("OLD_API = True\n")
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(["git", *args], cwd=root, text=True, capture_output=True, check=True).stdout.strip()
|
||||
|
||||
git("init", "-b", "main")
|
||||
git("config", "user.name", "Completion test")
|
||||
git("config", "user.email", "completion@example.invalid")
|
||||
git("add", ".")
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "old incompatible runtime")
|
||||
old = git("rev-parse", "HEAD")
|
||||
# Deliberately incompatible: a cached OLD_API-only PM cannot prepare this tree.
|
||||
(root / "pm/__init__.py").write_text(
|
||||
"from hermes_cli.probe import event\n"
|
||||
"def sync_venv(*, explicit, project_root):\n"
|
||||
" assert explicit\n"
|
||||
" event('prepare')\n"
|
||||
)
|
||||
(root / "pm/receipt.py").write_text(
|
||||
"from contextlib import nullcontext\n"
|
||||
"worker_context = lambda update_id: nullcontext()\n"
|
||||
"last_for_update = lambda update_id: {'update_id': update_id, 'outcome': 'success'}\n"
|
||||
"def accept_worker_receipt(data, update_id):\n"
|
||||
" assert data['update_id'] == update_id\n"
|
||||
)
|
||||
(package / "probe.py").write_text(
|
||||
"import json, os, pathlib, sys\n"
|
||||
"def event(name, **values):\n"
|
||||
" with pathlib.Path('events.jsonl').open('a') as f:\n"
|
||||
" f.write(json.dumps(dict(name=name, pid=os.getpid(), python=sys.executable, **values)) + '\\n')\n"
|
||||
)
|
||||
(package / "runtime_paths.py").write_text(
|
||||
"import os, sys\n"
|
||||
"from pathlib import Path\n"
|
||||
"selected_venv = lambda root: Path(sys.executable).parent.parent\n"
|
||||
"activation_environment = lambda root: {**os.environ, 'PYTHONPATH': str(root)}\n"
|
||||
"def activate_dependencies(root):\n"
|
||||
" from hermes_cli.probe import event\n"
|
||||
" event('activate')\n"
|
||||
)
|
||||
selected = tmp_path / "selected-python"
|
||||
venv.EnvBuilder(with_pip=False).create(selected)
|
||||
selected_python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
||||
(root / "hermes_constants.py").write_text(
|
||||
f"venv_python_path = lambda root: {str(selected_python)!r}\n"
|
||||
)
|
||||
(package / "venv_sync.py").write_text(
|
||||
"from hermes_cli.probe import event\n"
|
||||
"publish_launchers = lambda root: event('launchers')\n"
|
||||
)
|
||||
(package / "source_build.py").write_text(
|
||||
"from hermes_cli.probe import event\n"
|
||||
"def build_update_products(root, *, desktop): event('build', desktop=desktop)\n"
|
||||
)
|
||||
(package / "main.py").write_text("")
|
||||
(package / "update_cmd_config.py").write_text("_LAST_SIBLING_SNAPSHOTS = {}\n")
|
||||
(package / "update_inventory.py").write_text(
|
||||
"from types import SimpleNamespace\nRuntimeRecord = UpdatePlan = SimpleNamespace\n"
|
||||
)
|
||||
(package / "update_cmd_maint.py").write_text(
|
||||
"from hermes_cli.probe import event\n"
|
||||
"def _run_post_update_maintenance(**kwargs):\n"
|
||||
" from hermes_cli.update_cmd_config import _LAST_SIBLING_SNAPSHOTS\n"
|
||||
" event('maintenance', snapshots=_LAST_SIBLING_SNAPSHOTS, **kwargs)\n"
|
||||
" return True\n"
|
||||
)
|
||||
(package / "update_cmd.py").write_text(
|
||||
"from hermes_cli.probe import event\n"
|
||||
"_invalidate_update_cache = lambda: event('cache')\n"
|
||||
"_sweep_bytecode_after_update = lambda branch: event('bytecode')\n"
|
||||
"_write_fleet_restart_pending_marker = lambda **kw: event('pending')\n"
|
||||
"_write_gateway_update_exit_code = lambda ok: event('exit_marker', ok=ok)\n"
|
||||
"def _restart_gateway_fleet_after_update(plan, gateway_mode):\n"
|
||||
" event('restart', profiles=[r.profile for r in plan.runtimes])\n"
|
||||
" return object()\n"
|
||||
"def _resume_windows_gateways_and_merge_outcome(out, token, gateway_mode):\n"
|
||||
" token['resume_needed'] = False\n"
|
||||
" event('resume')\n"
|
||||
"def _resume_windows_gateways_after_update(token):\n"
|
||||
" if token and token.get('resume_needed'):\n"
|
||||
" token['resume_needed'] = False\n"
|
||||
" event('emergency_resume')\n"
|
||||
"def _verify_fleet_after_update(out, **kw):\n"
|
||||
" from hermes_cli.update_receipt import finalize_pending_update_receipt\n"
|
||||
" event('verify')\n"
|
||||
" finalize_pending_update_receipt(0, 'verified')\n"
|
||||
)
|
||||
(package / "update_receipt.py").write_text(
|
||||
"import contextvars, json, os, pathlib\n"
|
||||
"_current = contextvars.ContextVar('receipt', default=None)\n"
|
||||
"class UpdateReceipt: pass\n"
|
||||
"def finalize_pending_update_receipt(code, reason):\n"
|
||||
" r = _current.get()\n"
|
||||
" if r is None: return\n"
|
||||
" r.data.update(exit_code=code, outcome='success' if code == 0 else 'failed', finished_at='now')\n"
|
||||
" path = pathlib.Path(os.environ['HERMES_HOME']) / 'logs/update_receipts'\n"
|
||||
" path.mkdir(parents=True, exist_ok=True)\n"
|
||||
" path = path / ('update_test_' + r.correlation_id + '.json')\n"
|
||||
" path.write_text(json.dumps(r.data))\n"
|
||||
" _current.set(None)\n"
|
||||
" return path\n"
|
||||
)
|
||||
git("add", ".")
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "new incompatible runtime")
|
||||
new = git("rev-parse", "HEAD")
|
||||
request = {
|
||||
"schema": 1, "source": str(root), "home": str(home), "branch": "main",
|
||||
"desktop": True, "assume_yes": True, "gateway_mode": True,
|
||||
"pre_update_version": "old", "snapshot_id": "active-before",
|
||||
"sibling_snapshots": {"work": "work-before"},
|
||||
"plan": {"runtimes": [{"kind": "gateway", "profile": "work"}]},
|
||||
"receipt": {"update_id": "b" * 32, "outcome": "running", "steps": []},
|
||||
"windows_resume": {"resume_needed": True, "profiles": {"work": [123]}},
|
||||
}
|
||||
return root, git, old, new, request
|
||||
|
||||
|
||||
def test_old_process_new_git_tree_completes_in_fresh_python(transition, tmp_path):
|
||||
from hermes_cli import update_completion
|
||||
|
||||
root, git, old, new, request = transition
|
||||
# Copy executable code, not its text shape: the process exercises the real transport.
|
||||
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
|
||||
git("add", ".")
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "completion entrypoint")
|
||||
new = git("rev-parse", "HEAD")
|
||||
git("checkout", old)
|
||||
driver = (
|
||||
"import importlib.util, json, os, subprocess, sys\n"
|
||||
"spec = importlib.util.spec_from_file_location('transport', sys.argv[1])\n"
|
||||
"transport = importlib.util.module_from_spec(spec); spec.loader.exec_module(transport)\n"
|
||||
"import pm\nassert pm.OLD_API\n"
|
||||
"subprocess.run(['git', 'checkout', sys.argv[2]], check=True)\n"
|
||||
"result = transport.run_completion(json.loads(sys.argv[3]))\n"
|
||||
"assert pm.OLD_API, 'transport mutated old module graph'\n"
|
||||
"print('RESULT=' + json.dumps(result))\n"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", driver, update_completion.__file__, new, json.dumps(request)],
|
||||
cwd=root, env={**os.environ, "PYTHONPATH": str(root), "HERMES_HOME": request["home"]},
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
response = json.loads(result.stdout.split("RESULT=")[1])
|
||||
assert response["exit_code"] == 0
|
||||
assert response["receipt"]["update_id"] == request["receipt"]["update_id"]
|
||||
assert response["windows_resume"]["resume_needed"] is False
|
||||
events = [json.loads(line) for line in (root / "events.jsonl").read_text().splitlines()]
|
||||
by_name = {event["name"]: event for event in events}
|
||||
assert by_name["activate"]["pid"] == by_name["build"]["pid"]
|
||||
assert by_name["prepare"]["pid"] != by_name["build"]["pid"]
|
||||
assert Path(by_name["build"]["python"]).is_relative_to(root.parent / "selected-python")
|
||||
assert by_name["build"]["pid"] == by_name["maintenance"]["pid"] == by_name["restart"]["pid"]
|
||||
assert by_name["maintenance"]["snapshots"] == {"work": "work-before"}
|
||||
assert by_name["maintenance"]["pre_update_snapshot_id"] == "active-before"
|
||||
assert by_name["restart"]["profiles"] == ["work"]
|
||||
assert [e["name"] for e in events].index("exit_marker") < [e["name"] for e in events].index("restart")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [0, 23])
|
||||
def test_missing_child_result_fails_boundary_receipt_and_releases_lock(transition, monkeypatch, code):
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli import main, update_cmd, update_receipt, update_lock
|
||||
|
||||
root, git, old, new, request = transition
|
||||
(root / "hermes_cli/update_completion.py").write_text(f"import os\nos._exit({code})\n")
|
||||
monkeypatch.setenv("HERMES_HOME", request["home"])
|
||||
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
|
||||
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None)
|
||||
monkeypatch.setattr(main, "_finalize_update_output", lambda state: None)
|
||||
|
||||
def complete(args, gateway_mode):
|
||||
update_receipt.begin_update_receipt()
|
||||
request["receipt"] = update_receipt._current.get().data
|
||||
update_cmd._complete_source_update(request)
|
||||
|
||||
monkeypatch.setattr(update_cmd, "_cmd_update_impl", complete)
|
||||
with pytest.raises(SystemExit) as error:
|
||||
main.cmd_update(SimpleNamespace(gateway=True))
|
||||
assert error.value.code == (code or 1)
|
||||
receipt = update_receipt.read_latest_receipt()
|
||||
assert receipt["outcome"] == "failed"
|
||||
assert receipt["exit_code"] == (code or 1)
|
||||
assert receipt["update_id"] == request["receipt"]["update_id"]
|
||||
assert request["windows_resume"]["resume_needed"] is True
|
||||
assert (Path(request["home"]) / ".update_exit_code").read_text().strip() == "1"
|
||||
lock = update_lock.UpdateLock()
|
||||
assert lock.acquire()
|
||||
lock.release()
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_killed_selected_python_returns_signal_exit_status(transition):
|
||||
from hermes_cli import update_completion
|
||||
|
||||
root, git, old, new, request = transition
|
||||
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
|
||||
(root / "hermes_cli/source_build.py").write_text(
|
||||
"import os, signal\n"
|
||||
"def build_update_products(*a, **kw): os.kill(os.getpid(), signal.SIGKILL)\n"
|
||||
)
|
||||
result = update_completion.run_completion(request)
|
||||
assert result["exit_code"] == 137
|
||||
assert result["pm_receipt"]["update_id"] == request["receipt"]["update_id"]
|
||||
|
||||
|
||||
def test_failed_build_preserves_exit_status_without_maintenance(transition):
|
||||
from hermes_cli import update_completion
|
||||
|
||||
root, git, old, new, request = transition
|
||||
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
|
||||
(root / "hermes_cli/source_build.py").write_text(
|
||||
"import subprocess\n"
|
||||
"def build_update_products(*a, **kw): raise subprocess.CalledProcessError(23, ['builder'])\n"
|
||||
)
|
||||
result = update_completion.run_completion(request)
|
||||
assert result["exit_code"] == 23
|
||||
assert result["receipt"]["outcome"] == "failed"
|
||||
events = [json.loads(line)["name"] for line in (root / "events.jsonl").read_text().splitlines()]
|
||||
assert "maintenance" not in events
|
||||
assert "restart" not in events
|
||||
assert "emergency_resume" in events
|
||||
|
||||
|
||||
def test_prepare_failure_preserves_correlated_pm_receipt(transition, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli import main, update_cmd, update_completion, update_receipt
|
||||
|
||||
root, git, old, new, request = transition
|
||||
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
|
||||
(root / "pm/__init__.py").write_text(
|
||||
"def sync_venv(**kw): raise RuntimeError('dependency refused')\n"
|
||||
)
|
||||
with (root / "pm/receipt.py").open("a") as stream:
|
||||
stream.write("last_for_update = lambda update_id: {'update_id': update_id, 'outcome': 'refused', 'refusal': {'reason': 'dependency refused'}}\n")
|
||||
monkeypatch.setenv("HERMES_HOME", request["home"])
|
||||
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
|
||||
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None)
|
||||
monkeypatch.setattr(main, "_finalize_update_output", lambda state: None)
|
||||
|
||||
def complete(args, gateway_mode):
|
||||
update_receipt.begin_update_receipt()
|
||||
request["receipt"] = update_receipt._current.get().data
|
||||
update_cmd._complete_source_update(request)
|
||||
|
||||
monkeypatch.setattr(update_cmd, "_cmd_update_impl", complete)
|
||||
with pytest.raises(SystemExit) as error:
|
||||
main.cmd_update(SimpleNamespace(gateway=True))
|
||||
assert error.value.code == 1
|
||||
receipt = update_receipt.read_latest_receipt()
|
||||
assert receipt["update_id"] == request["receipt"]["update_id"]
|
||||
assert receipt["pm_sync_outcome"] == "refused"
|
||||
assert receipt["pm_refusal"] == {"reason": "dependency refused"}
|
||||
|
||||
|
||||
def test_bootstrap_does_not_initialize_old_site_packages(transition, tmp_path, monkeypatch):
|
||||
from hermes_cli import update_completion
|
||||
|
||||
root, git, old, new, request = transition
|
||||
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
|
||||
obsolete = tmp_path / "obsolete-python"
|
||||
venv.EnvBuilder(with_pip=False).create(obsolete)
|
||||
site = obsolete / ("Lib/site-packages" if os.name == "nt" else
|
||||
f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages")
|
||||
trap = tmp_path / "old-site-loaded"
|
||||
(site / "application.pth").write_text(f"import pathlib; pathlib.Path({str(trap)!r}).touch()\n")
|
||||
monkeypatch.setattr(sys, "executable", str(obsolete / ("Scripts/python.exe" if os.name == "nt" else "bin/python")))
|
||||
result = update_completion.run_completion(request)
|
||||
assert result["exit_code"] == 0
|
||||
assert not trap.exists(), "preparation initialized the old application's .pth graph"
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_interactive_configuration_keeps_terminal_input(transition):
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import time
|
||||
from hermes_cli import update_completion
|
||||
|
||||
root, git, old, new, request = transition
|
||||
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
|
||||
(root / "hermes_cli/update_cmd_maint.py").write_text(
|
||||
"import sys\nfrom hermes_cli.probe import event\n"
|
||||
"def _run_post_update_maintenance(**kw):\n"
|
||||
" assert sys.stdin.isatty() and sys.stdout.isatty()\n"
|
||||
" event('answer', value=input('CONFIG? '))\n"
|
||||
" return True\n"
|
||||
)
|
||||
master, slave = pty.openpty()
|
||||
driver = "import json,runpy,sys; m=runpy.run_path(sys.argv[1]); raise SystemExit(m['run_completion'](json.loads(sys.argv[2]))['exit_code'])"
|
||||
proc = subprocess.Popen([sys.executable, "-c", driver, update_completion.__file__, json.dumps(request)],
|
||||
cwd=root, stdin=slave, stdout=slave, stderr=slave)
|
||||
os.close(slave)
|
||||
output = b""
|
||||
try:
|
||||
deadline = time.monotonic() + 20
|
||||
while b"CONFIG?" not in output:
|
||||
assert time.monotonic() < deadline, output.decode(errors="replace")
|
||||
if select.select([master], [], [], 0.1)[0]:
|
||||
output += os.read(master, 8192)
|
||||
os.write(master, b"yes\n")
|
||||
assert proc.wait(timeout=20) == 0
|
||||
events = [json.loads(line) for line in (root / "events.jsonl").read_text().splitlines()]
|
||||
assert next(e for e in events if e["name"] == "answer")["value"] == "yes"
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.send_signal(signal.SIGINT)
|
||||
proc.wait(timeout=5)
|
||||
os.close(master)
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
@pytest.mark.live_system_guard_bypass
|
||||
def test_interrupt_reaps_completion_descendants_before_return(transition, monkeypatch):
|
||||
import io
|
||||
import psutil
|
||||
import time
|
||||
from hermes_cli import update_completion
|
||||
|
||||
root, git, old, new, request = transition
|
||||
(root / "hermes_cli/update_completion.py").write_text(
|
||||
"import subprocess, sys, time\n"
|
||||
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(600)'])\n"
|
||||
"print('READY ' + str(child.pid), flush=True)\n"
|
||||
"time.sleep(600)\n"
|
||||
)
|
||||
pids = []
|
||||
|
||||
class Interrupt(io.StringIO):
|
||||
def write(self, value):
|
||||
if 'READY ' in value:
|
||||
pids.append(int(value.split('READY ')[1].strip()))
|
||||
raise KeyboardInterrupt()
|
||||
return super().write(value)
|
||||
|
||||
monkeypatch.setattr(sys, "stdout", Interrupt())
|
||||
try:
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
update_completion.run_completion(request)
|
||||
assert pids
|
||||
deadline = time.monotonic() + 3
|
||||
while time.monotonic() < deadline:
|
||||
if not psutil.pid_exists(pids[0]) or psutil.Process(pids[0]).status() == psutil.STATUS_ZOMBIE:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
else:
|
||||
pytest.fail("completion descendant survived parent cancellation")
|
||||
finally:
|
||||
for pid in pids:
|
||||
if psutil.pid_exists(pid):
|
||||
psutil.Process(pid).kill()
|
||||
|
||||
|
||||
def test_forged_terminal_receipt_cannot_acknowledge_success(transition):
|
||||
from hermes_cli.update_completion import run_completion
|
||||
|
||||
root, git, old, new, request = transition
|
||||
(root / "hermes_cli/update_completion.py").write_text(
|
||||
"import json, pathlib, sys\n"
|
||||
"request = json.loads(pathlib.Path(sys.argv[1]).read_text())\n"
|
||||
"pathlib.Path(sys.argv[2]).write_text(json.dumps(dict(\n"
|
||||
" schema=1, update_id=request['receipt']['update_id'], exit_code=0,\n"
|
||||
" windows_resume={}, receipt={'update_id': 'wrong', 'outcome': 'success'})))\n"
|
||||
)
|
||||
response = run_completion(request)
|
||||
assert response["exit_code"] != 0
|
||||
assert response["receipt"] is None
|
||||
50
tests/hermes_cli/test_update_completion_routing.py
Normal file
50
tests/hermes_cli/test_update_completion_routing.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""All source selection routes hand off once, without old-process maintenance."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import update_cmd, update_cmd_zip
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook,args,kwargs", [
|
||||
(update_cmd._prepare_updated_checkout, ("unused",), {"desktop": False}),
|
||||
(update_cmd._reload_config_modules, (), {}),
|
||||
(update_cmd._reload_process_scan_modules, (), {}),
|
||||
(update_cmd._run_pending_fleet_restart, (), {}),
|
||||
])
|
||||
def test_historical_completion_hook_never_reports_success(hook, args, kwargs, capsys):
|
||||
with pytest.raises(SystemExit) as error:
|
||||
hook(*args, **kwargs)
|
||||
assert error.value.code != 0
|
||||
assert "update" in capsys.readouterr().err.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["pulled", "current", "zip"])
|
||||
def test_every_route_hands_off_once(route, tmp_path, monkeypatch):
|
||||
request = {"branch": "main", "receipt": {"update_id": "c" * 32}}
|
||||
handed_off = []
|
||||
monkeypatch.setattr(update_cmd, "_complete_source_update", handed_off.append, raising=False)
|
||||
monkeypatch.setattr(update_cmd, "_m", lambda: SimpleNamespace(
|
||||
PROJECT_ROOT=tmp_path, _resolve_update_branch=lambda args: "main"))
|
||||
monkeypatch.setattr(update_cmd, "_verify_head_after_pull", lambda *a, **kw: "new-sha")
|
||||
monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **kw: pytest.fail("old-process preparation"))
|
||||
monkeypatch.setattr(update_cmd, "_write_fleet_restart_pending_marker", lambda **kw: None)
|
||||
monkeypatch.setattr(update_cmd, "_sweep_bytecode_after_update", lambda *a: None)
|
||||
monkeypatch.setattr(update_cmd_zip, "_abort_zip_update_if_dirty_tree", lambda: None)
|
||||
swap = Mock()
|
||||
monkeypatch.setattr(update_cmd_zip, "_download_and_swap_zip", swap)
|
||||
plan = SimpleNamespace(in_place_update=False, auto_stash_ref=None, parked_branch_switched=False,
|
||||
upstream_checked=True)
|
||||
opts = SimpleNamespace(assume_yes=True, gw_input_fn=None, pre_update_version="old")
|
||||
if route == "pulled":
|
||||
update_cmd._apply_pulled_update(
|
||||
["git"], "main", "old-sha", plan, opts, is_fork=False, _windows_gateway_resume=None,
|
||||
completion_request=request)
|
||||
elif route == "current":
|
||||
update_cmd._finish_already_up_to_date(
|
||||
["git"], "main", "main", plan, gw_input_fn=None, completion_request=request)
|
||||
else:
|
||||
assert update_cmd_zip._update_via_zip(SimpleNamespace(), completion_request=request) is True
|
||||
swap.assert_called_once()
|
||||
assert handed_off == [request]
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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", [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user