refactor(update): single defer flag for the deferred catch-up; trim tests; document the flag

Salvage of #93649 (@TurgutKural). _apply_pending_fleet_restart_catchup took two
booleans (respect_no_gateway_restart + no_gateway_restart) that were only ever
true together; one `defer` keyword says the same thing. The 13 tests are cut to
the two invariants (pulled path skips restart + verify and keeps the marker;
already-current path defers the catch-up). The user guide gains a section on
running `hermes update` from inside the gateway.
This commit is contained in:
teknium1
2026-09-15 18:59:25 -07:00
committed by Teknium
parent 7b27ea3639
commit 2246c245f5
4 changed files with 29 additions and 237 deletions
+1 -2
View File
@@ -1226,8 +1226,7 @@ def _finish_already_up_to_date(
# demotes the outcome to partial, but must not strand the fleet on stale code (#91277 fleet contract —
# the pending-restart check always executes). Under --no-gateway-restart the
# catch-up is deferred instead (executing it would kill the cron's own gateway).
_apply_pending_fleet_restart_catchup(
respect_no_gateway_restart=True, no_gateway_restart=no_gateway_restart)
_apply_pending_fleet_restart_catchup(defer=no_gateway_restart)
if not current_checkout_complete:
if gateway_mode:
_write_gateway_update_exit_code(False)
+5 -8
View File
@@ -464,21 +464,18 @@ def _defer_fleet_restart_after_update(*, update_complete: bool, resume_incomplet
sys.exit(1)
def _apply_pending_fleet_restart_catchup(*, respect_no_gateway_restart: bool = False, no_gateway_restart: bool = False) -> None:
def _apply_pending_fleet_restart_catchup(*, defer: bool = False) -> 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.
When called from a cron's ``--no-gateway-restart`` flow (both flags true),
the pending restart is deferred instead of executed: running it here would
kill the cron's own gateway mid-flight. The marker is kept for a later
out-of-cron update.
does not treat the fleet as healthy. ``defer`` (``--no-gateway-restart``) keeps
the marker and warns instead: running the restart from inside the gateway's own
cgroup would kill the caller.
"""
from hermes_cli.update_cmd import _run_pending_fleet_restart
if not _pending_fleet_restart_needed():
return
if respect_no_gateway_restart and no_gateway_restart:
if defer:
print()
_warn_pending_fleet_restart()
print(" (fleet restart deferred — --no-gateway-restart; marker kept)")
@@ -1,35 +1,17 @@
"""Focused coverage for `hermes update --no-gateway-restart`.
"""`hermes update --no-gateway-restart` (#93649).
A cron running inside the gateway's own cgroup cannot survive the fleet
restart phase (SIGUSR1 drain + systemd KillMode=mixed kills the updater
itself). The flag runs the full update pipeline but defers the restart;
the pending-restart marker is kept so a later normal update catches up.
"""
import argparse
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli.subcommands.update import build_update_parser
from hermes_cli import update_cmd as uc
from hermes_cli import update_cmd_fleet as fleet
def _h(name):
def handler(args): # pragma: no cover - identity only
return name
handler.__name__ = f"cmd_{name}"
return handler
def _parse(argv):
parser = argparse.ArgumentParser(prog="hermes")
sub = parser.add_subparsers(dest="command")
build_update_parser(sub, cmd_update=_h("update"))
return parser.parse_args(argv)
def _opts(**overrides):
base = dict(
assume_yes=True, gw_input_fn=None, active_lazy_features=[],
@@ -41,123 +23,7 @@ def _opts(**overrides):
return SimpleNamespace(**base)
# ── flag parsing ──────────────────────────────────────────────────────
def test_parser_defaults_to_restart():
ns = _parse(["update"])
assert ns.no_gateway_restart is False
def test_parser_accepts_no_gateway_restart():
ns = _parse(["update", "--no-gateway-restart"])
assert ns.no_gateway_restart is True
def test_resolve_options_threads_flag():
with (
patch.object(uc, "_m") as mock_m,
patch.object(uc, "_read_project_version", return_value="1.0"),
patch.object(uc, "_updates_config", return_value={}),
):
mock_m.return_value._capture_active_lazy_features.return_value = []
mock_m.return_value._capture_active_tool_dependencies.return_value = []
assert uc._resolve_update_options(
SimpleNamespace(no_gateway_restart=True), False).no_gateway_restart is True
assert uc._resolve_update_options(
SimpleNamespace(), False).no_gateway_restart is False
# ── pending catch-up deferral ─────────────────────────────────────────
def test_catchup_deferred_under_flag_when_pending():
"""Already-up-to-date + pending marker + flag: no restart, no exit, marker kept."""
with (
patch.object(fleet, "_pending_fleet_restart_needed", return_value=True),
patch.object(fleet, "_warn_pending_fleet_restart"),
patch.object(uc, "_run_pending_fleet_restart") as mock_run,
patch.object(fleet, "_clear_fleet_restart_pending_marker") as mock_clear,
):
fleet._apply_pending_fleet_restart_catchup(
respect_no_gateway_restart=True, no_gateway_restart=True)
mock_run.assert_not_called()
mock_clear.assert_not_called()
def test_catchup_default_path_unchanged():
"""Without the flag the pending restart still runs and clears the marker."""
with (
patch.object(fleet, "_pending_fleet_restart_needed", return_value=True),
patch.object(fleet, "_warn_pending_fleet_restart"),
patch.object(uc, "_run_pending_fleet_restart", return_value=True) as mock_run,
patch.object(fleet, "_clear_fleet_restart_pending_marker") as mock_clear,
):
fleet._apply_pending_fleet_restart_catchup()
mock_run.assert_called_once()
mock_clear.assert_called_once()
def test_catchup_noop_when_nothing_pending():
with (
patch.object(fleet, "_pending_fleet_restart_needed", return_value=False),
patch.object(uc, "_run_pending_fleet_restart") as mock_run,
):
fleet._apply_pending_fleet_restart_catchup(
respect_no_gateway_restart=True, no_gateway_restart=True)
mock_run.assert_not_called()
# ── defer helper: outcome contract ────────────────────────────────────
def test_defer_success_when_update_complete_and_resume_clean():
"""Successful update + deliberate defer => success, exit 0, marker kept."""
with (
patch("hermes_cli.update_receipt.record_skip") as mock_skip,
patch("hermes_cli.update_receipt.finalize_update_receipt") as mock_final,
patch.object(fleet, "_clear_fleet_restart_pending_marker") as mock_clear,
):
fleet._defer_fleet_restart_after_update(update_complete=True)
mock_skip.assert_called_once()
assert mock_skip.call_args[0][0] == "gateway_restart"
mock_final.assert_called_once_with("success")
mock_clear.assert_not_called()
def test_defer_partial_when_update_incomplete():
"""update_complete=False + defer => partial + exit 1, marker kept."""
with (
patch("hermes_cli.update_receipt.record_skip"),
patch("hermes_cli.update_receipt.finalize_update_receipt") as mock_final,
patch.object(fleet, "_clear_fleet_restart_pending_marker") as mock_clear,
):
with pytest.raises(SystemExit) as exc:
fleet._defer_fleet_restart_after_update(update_complete=False)
assert exc.value.code == 1
mock_final.assert_called_once_with("partial")
mock_clear.assert_not_called()
def test_defer_partial_when_resume_incomplete():
"""Clean update but failed Windows resume + defer => partial + exit 1."""
with (
patch("hermes_cli.update_receipt.record_skip"),
patch("hermes_cli.update_receipt.finalize_update_receipt") as mock_final,
patch.object(fleet, "_clear_fleet_restart_pending_marker") as mock_clear,
):
with pytest.raises(SystemExit) as exc:
fleet._defer_fleet_restart_after_update(
update_complete=True, resume_incomplete=True)
assert exc.value.code == 1
mock_final.assert_called_once_with("partial")
mock_clear.assert_not_called()
# ── pulled-update orchestration ───────────────────────────────────────
def test_pulled_update_skips_restart_phase_under_flag():
def test_pulled_update_defers_restart_and_keeps_marker_under_flag():
with (
patch.object(uc, "_invalidate_update_cache"),
patch.object(uc, "_verify_head_after_pull", return_value="newsha"),
@@ -190,96 +56,14 @@ def test_pulled_update_skips_restart_phase_under_flag():
mock_clear.assert_not_called() # deferred stale fleet alone is not partial
def test_pulled_update_defer_reports_incomplete_maintenance():
"""update_complete=False flows into the deferral (=> partial downstream)."""
def test_already_current_catchup_is_deferred_under_flag():
"""Already-up-to-date + pending marker + flag: no restart, no exit, marker kept."""
with (
patch.object(uc, "_invalidate_update_cache"),
patch.object(uc, "_verify_head_after_pull", return_value="newsha"),
patch.object(uc, "_write_fleet_restart_pending_marker"),
patch.object(uc, "_sweep_bytecode_after_update"),
patch.object(uc, "_sync_python_dependencies_after_pull"),
patch.object(uc, "_update_node_dependencies", return_value=[]),
patch.object(uc, "_rebuild_desktop_after_update", return_value=True),
patch.object(uc, "_run_post_update_maintenance", return_value=False),
patch.object(uc, "_branch_head_suffix", return_value=""),
patch.object(uc, "_m", return_value=MagicMock()),
patch.object(uc, "_restart_gateway_fleet_after_update") as mock_restart,
patch.object(uc, "_resume_windows_gateways_and_merge_outcome"),
patch.object(uc, "_verify_fleet_after_update") as mock_verify,
patch.object(uc, "_defer_fleet_restart_after_update") as mock_defer,
patch.object(fleet, "_pending_fleet_restart_needed", return_value=True),
patch.object(fleet, "_warn_pending_fleet_restart"),
patch.object(uc, "_run_pending_fleet_restart") as mock_run,
patch.object(fleet, "_clear_fleet_restart_pending_marker") as mock_clear,
):
uc._apply_pulled_update(
"git", "main", "oldsha", SimpleNamespace(in_place_update=False),
_opts(no_gateway_restart=True), gateway_mode=False,
is_fork=False, desktop_dir="/tmp", had_desktop_app_before_update=False,
pre_update_snapshot_id=None, _pre_update_plan=None,
_windows_gateway_resume=None,
)
mock_restart.assert_not_called()
mock_verify.assert_not_called()
mock_defer.assert_called_once_with(update_complete=False, resume_incomplete=False)
def test_pulled_update_defer_retains_failed_resume_outcome():
"""A resume phase that marks its outcome incomplete reaches the deferral."""
def _fail_resume(outcome, token, gw_mode):
outcome.incomplete = True
with (
patch.object(uc, "_invalidate_update_cache"),
patch.object(uc, "_verify_head_after_pull", return_value="newsha"),
patch.object(uc, "_write_fleet_restart_pending_marker"),
patch.object(uc, "_sweep_bytecode_after_update"),
patch.object(uc, "_sync_python_dependencies_after_pull"),
patch.object(uc, "_update_node_dependencies", return_value=[]),
patch.object(uc, "_rebuild_desktop_after_update", return_value=True),
patch.object(uc, "_run_post_update_maintenance", return_value=True),
patch.object(uc, "_branch_head_suffix", return_value=""),
patch.object(uc, "_m", return_value=MagicMock()),
patch.object(uc, "_restart_gateway_fleet_after_update") as mock_restart,
patch.object(
uc, "_resume_windows_gateways_and_merge_outcome",
side_effect=_fail_resume,
),
patch.object(uc, "_verify_fleet_after_update") as mock_verify,
patch.object(uc, "_defer_fleet_restart_after_update") as mock_defer,
):
uc._apply_pulled_update(
"git", "main", "oldsha", SimpleNamespace(in_place_update=False),
_opts(no_gateway_restart=True), gateway_mode=False,
is_fork=False, desktop_dir="/tmp", had_desktop_app_before_update=False,
pre_update_snapshot_id=None, _pre_update_plan=None,
_windows_gateway_resume=None,
)
mock_restart.assert_not_called()
mock_verify.assert_not_called()
mock_defer.assert_called_once_with(update_complete=True, resume_incomplete=True)
def test_pulled_update_default_path_unchanged():
with (
patch.object(uc, "_invalidate_update_cache"),
patch.object(uc, "_verify_head_after_pull", return_value="newsha"),
patch.object(uc, "_write_fleet_restart_pending_marker"),
patch.object(uc, "_sweep_bytecode_after_update"),
patch.object(uc, "_sync_python_dependencies_after_pull"),
patch.object(uc, "_update_node_dependencies", return_value=[]),
patch.object(uc, "_rebuild_desktop_after_update", return_value=True),
patch.object(uc, "_run_post_update_maintenance", return_value=True),
patch.object(uc, "_branch_head_suffix", return_value=""),
patch.object(uc, "_m", return_value=MagicMock()),
patch.object(uc, "_restart_gateway_fleet_after_update") as mock_restart,
patch.object(uc, "_resume_windows_gateways_and_merge_outcome"),
patch.object(uc, "_verify_fleet_after_update") as mock_verify,
patch.object(uc, "_defer_fleet_restart_after_update") as mock_defer,
):
uc._apply_pulled_update(
"git", "main", "oldsha", SimpleNamespace(in_place_update=False),
_opts(no_gateway_restart=False), gateway_mode=False,
is_fork=False, desktop_dir="/tmp", had_desktop_app_before_update=False,
pre_update_snapshot_id=None, _pre_update_plan=None,
_windows_gateway_resume=None,
)
mock_restart.assert_called_once()
mock_verify.assert_called_once()
mock_defer.assert_not_called()
fleet._apply_pending_fleet_restart_catchup(defer=True)
mock_run.assert_not_called()
mock_clear.assert_not_called()
+12
View File
@@ -129,6 +129,18 @@ The same inventory is embedded in every real update's receipt (`~/.hermes/logs/u
Every `hermes update` run writes a machine-readable receipt to `~/.hermes/logs/update_receipts/` (last 20 kept, `latest.json` always points at the most recent): the pre-update fleet plan, each step taken, anything skipped and why, the gateway restart outcome, and the final fleet version matrix. The SQLite runtime repair is one of those steps (`sqlite_runtime_repair`): a failed repair records the actual reason (for example the `uv sync` error) and the SQLite version pair, a deferred or not-applicable repair lands in the skips with its reason. After the restart phase the updater compares each live gateway's running code against the freshly updated checkout and prints a per-profile matrix — a gateway still serving pre-update code is reported loudly with the exact restart command, and the update exits non-zero so automation never treats a mixed-version fleet as healthy. Both `--plan` and the fleet check ask each running gateway directly over its local control socket (`gateway.sock` in the profile's data directory, a named pipe on Windows) when available, so version and supervisor information comes from the gateway itself; gateways from older versions are still discovered through their state files as before.
### Automated updates from inside the gateway: `--no-gateway-restart`
An update launched *by* the gateway (a cron job, the Desktop updater, any automation that is a
child of the gateway process) cannot survive its own fleet restart: the gateway drains on
`SIGUSR1` and systemd's `KillMode=mixed` then kills everything left in the cgroup, updater
included. `hermes update --no-gateway-restart` runs the full pipeline (pull, dependencies,
Node workspaces, web UI, maintenance) and skips only the restart and fleet verification. The
pending-restart marker is kept, so the next CLI start warns and the next normal `hermes update`
(or `hermes gateway restart`) catches the fleet up. Pair it with a separate restart step, for
example a timer 10–15 minutes after the update job. The receipt records the deferral; a stale
fleet caused only by the deferral does not make the update `partial`.
### Interrupted gateway restarts
If an earlier update pulled code but did not finish restarting the fleet, the next