fix(update): credit launchd ai.hermes.gateway in fleet reconciliation (#103679)

The restart phase records macOS LaunchAgent labels (ai.hermes.gateway).
match_runtime_outcomes used a substring check for "hermes-gateway", so a
successful Desktop update on the default profile always tripped
"Planned runtimes the restart phase never touched" and exited 1.

Use the exact systemd/launchd/s6 matcher for both plan reconciliation
and abort-recovery so the two cannot drift.
This commit is contained in:
mengtanx
2026-09-05 21:35:14 +08:00
committed by kshitij
parent 1d039b47e0
commit b4b6235239
4 changed files with 77 additions and 15 deletions
+3 -9
View File
@@ -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 = (
+18 -6
View File
@@ -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-<profile>`` — 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(
@@ -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(
@@ -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):