diff --git a/hermes_cli/update_cmd_fleet.py b/hermes_cli/update_cmd_fleet.py index d637db9556..aeaacecba2 100644 --- a/hermes_cli/update_cmd_fleet.py +++ b/hermes_cli/update_cmd_fleet.py @@ -15,6 +15,7 @@ from dataclasses import dataclass, field from pathlib import Path from hermes_cli.update_cmd_common import _best_effort +from hermes_cli.update_inventory import _gateway_service_matches_profile # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.update_cmd") @@ -542,15 +543,8 @@ def _surviving_gateway_pids_after_failed_restart(): return None -def _gateway_service_matches_profile(profile: str, service: object) -> bool: - """Match an exact gateway service/label (systemd/launchd/s6 shapes) to a profile. - - Never substring-match: ``foo`` must not claim ``hermes-gateway-foobar.service``. - """ - name = str(service).removesuffix(".service") - if profile == "default": - return name in {"hermes-gateway", "ai.hermes.gateway", "gateway", "gateway-default"} - return name in {f"hermes-gateway-{profile}", f"ai.hermes.gateway-{profile}", f"gateway-{profile}"} +# `_gateway_service_matches_profile` is imported from update_inventory so plan +# reconciliation and abort-recovery share one systemd/launchd/s6 matcher. _MANUAL_GATEWAY_SKIP_REASON = ( diff --git a/hermes_cli/update_inventory.py b/hermes_cli/update_inventory.py index 912d66dcc3..bc9d6bdd6c 100644 --- a/hermes_cli/update_inventory.py +++ b/hermes_cli/update_inventory.py @@ -288,13 +288,25 @@ def _serve_unit_matches_profile(profile: str, unit: object) -> bool: return name in {f"hermes-serve{suffix}", f"hermes-dashboard{suffix}"} +def _gateway_service_matches_profile(profile: str, service: object) -> bool: + """Match an exact gateway service/label (systemd/launchd/s6 shapes) to a profile. + + Never substring-match: ``foo`` must not claim ``hermes-gateway-foobar.service``. + Launchd labels are ``ai.hermes.gateway`` / ``ai.hermes.gateway-`` — they do + not contain the substring ``hermes-gateway``, so a successful macOS kickstart must + still credit the planned default gateway. A scope prefix (``user/hermes-gateway``, + ``gui/501/ai.hermes.gateway``) is stripped the same way serve units are. + """ + name = str(service).removesuffix(".service").rsplit("/", 1)[-1] + if profile == "default": + return name in {"hermes-gateway", "ai.hermes.gateway", "gateway", "gateway-default"} + return name in {f"hermes-gateway-{profile}", f"ai.hermes.gateway-{profile}", f"gateway-{profile}"} + + def _gateway_named_in(r: RuntimeRecord, names: set) -> bool: - # The bare "hermes-gateway" unit name is gateway-specific: a serve/dashboard runtime that merely - # shares the default profile is a different process the gateway restart never touched. - return any( - r.profile in name or (r.kind == "gateway" and r.profile == "default" and "hermes-gateway" in name) - for name in names - ) + # Gateway-only vocabulary: a serve/dashboard that merely shares the profile is a + # different process. Exact label match (systemd + launchd + s6), not substring. + return any(_gateway_service_matches_profile(r.profile, name) for name in names) def match_runtime_outcomes( diff --git a/tests/hermes_cli/test_restart_plan_reconciliation.py b/tests/hermes_cli/test_restart_plan_reconciliation.py index b87cc98f7a..91209886ae 100644 --- a/tests/hermes_cli/test_restart_plan_reconciliation.py +++ b/tests/hermes_cli/test_restart_plan_reconciliation.py @@ -133,6 +133,55 @@ def test_restarted_service_unit_matches_profile(): assert outcomes[0]["outcome"] == "restarted" +def test_launchd_default_gateway_restarted_via_ai_hermes_label(): + """macOS restart bookkeeping records ``ai.hermes.gateway``, which does not + contain the substring ``hermes-gateway``. The default-profile gateway must + still count as restarted — otherwise every Desktop update on launchd + exits 1 after a successful kickstart (receipt outcome=partial, tripwire + 'never touched').""" + outcomes = match_runtime_outcomes( + _plan(_rt("default", 400, supervisor="launchd")), + restarted_services=["ai.hermes.gateway"], relaunched_profiles=[], + externally_supervised_profiles=[], killed_pids=set(), failed_units=[], + ) + assert outcomes[0]["outcome"] == "restarted" + assert report_unaccounted_runtimes(outcomes) is False + + +def test_launchd_named_profile_and_failed_label(): + restarted = match_runtime_outcomes( + _plan(_rt("work", 401, supervisor="launchd")), + restarted_services=["ai.hermes.gateway-work"], relaunched_profiles=[], + externally_supervised_profiles=[], killed_pids=set(), failed_units=[], + ) + assert restarted[0]["outcome"] == "restarted" + failed = match_runtime_outcomes( + _plan(_rt("default", 402, supervisor="launchd")), + restarted_services=[], relaunched_profiles=[], + externally_supervised_profiles=[], killed_pids=set(), + failed_units=["ai.hermes.gateway"], + ) + assert failed[0]["outcome"] == "failed" + # Sibling label must not credit the default profile (exact match, not prefix). + sibling = match_runtime_outcomes( + _plan(_rt("default", 403, supervisor="launchd")), + restarted_services=["ai.hermes.gateway-foo"], relaunched_profiles=[], + externally_supervised_profiles=[], killed_pids=set(), failed_units=[], + ) + assert sibling[0]["outcome"] == "unaccounted" + + +def test_launchd_gateway_restart_does_not_credit_serve(): + """#100479 sibling: a launchd gateway restart is still gateway vocabulary.""" + outcomes = match_runtime_outcomes( + _plan(_rt("default", 100, supervisor="launchd"), _serve("default", 900)), + restarted_services=["ai.hermes.gateway"], relaunched_profiles=[], + externally_supervised_profiles=[], killed_pids=set(), failed_units=[], + ) + by_pid = {o["pid"]: o["outcome"] for o in outcomes} + assert by_pid == {100: "restarted", 900: "unaccounted"} + + def test_untouched_runtime_is_unaccounted_and_escalates(capsys): """The tripwire: plan saw it, NO bookkeeping mentions it.""" outcomes = match_runtime_outcomes( diff --git a/tests/hermes_cli/test_update_restart_recovery.py b/tests/hermes_cli/test_update_restart_recovery.py index 780cef66c4..d7c870c1b4 100644 --- a/tests/hermes_cli/test_update_restart_recovery.py +++ b/tests/hermes_cli/test_update_restart_recovery.py @@ -252,6 +252,13 @@ def test_service_matching_is_exact_for_overlapping_profile_names(): assert not update_cmd._gateway_service_matches_profile( "default", "ai.hermes.gateway-foo" ) + # Scope-qualified identities the restart phase may record. + assert update_cmd._gateway_service_matches_profile( + "default", "gui/501/ai.hermes.gateway" + ) + assert update_cmd._gateway_service_matches_profile( + "foo", "user/hermes-gateway-foo.service" + ) def test_recovery_child_restarts_each_profile_with_a_fresh_main(monkeypatch):