fix(gateway): log the converge hint on a standalone owner; put a refusal in the profile's own log

Two gaps left by the standalone-owner START (field report on #118097):

* decide() now logs ONE WARNING when it starts beside another profile's
  standalone gateway, naming `hermes gateway migrate --multiplex`. The
  legacy per-profile topology stays live, but the fleet should be able to
  find out it is still on it from its own logs.

* A REFUSE on `gateway run` prints to stdout, which under launchd is the
  unit's stdout file; the wrapper then maps 78 to 0 and the unit is
  parked with nothing in that profile's gateway/errors log. Log the
  verdict and the remedy at WARNING before exiting. The exit code is
  unchanged: a multiplexing owner that excludes the profile is a
  config-derived, permanent refusal, and 75 would make launchd relaunch
  it every ThrottleInterval forever (#89477) — the smaller correct
  change is to stop the refusal being silent, not to make it retry.
This commit is contained in:
teknium1
2026-09-21 07:47:27 -07:00
committed by Teknium
parent d39ce8f455
commit bb359ec5c1
4 changed files with 31 additions and 3 deletions

View File

@@ -312,6 +312,10 @@ def decide(our_home: Path, *, replace: bool = False) -> HostAttachDecision:
# here exits 78, which every supervisor treats as permanent — on a launchd fleet that parked
# every unit but the first to claim the host lock. Start beside it; the host-lock claim logs
# the topology and the `gateway migrate --multiplex` path stays the way to converge.
logger.warning(
"Another profile's standalone gateway owns this host (%s); starting profile '%s' beside it. "
"Fold every profile onto one gateway with: hermes gateway migrate --multiplex",
attached.describe(), profile)
return HostAttachDecision(START, "")
if not gateway.served_known:
# The owner never answered, so we know only that it exists. ATTACH here (on the record's

View File

@@ -3797,7 +3797,12 @@ def _attach_to_host_gateway_or_guard(force: bool = False, replace: bool = False)
if decision is not None and decision.outcome in (ATTACH, REFUSE):
print(decision.message)
if decision.outcome == REFUSE:
sys.exit(_host_decision_exit_code(decision))
code = _host_decision_exit_code(decision)
# stdout goes to the supervisor's unit log; under launchd a permanent refusal is then
# mapped to a clean exit and the unit is parked. The profile's own logs (errors.log,
# WARNING+) are where a parked fleet is diagnosed, so name the verdict and the remedy there.
logger.warning("gateway run refused (exit %d): %s", code, decision.message)
sys.exit(code)
if _running_under_gateway_supervisor():
sys.exit(_host_decision_exit_code(decision))
sys.exit(0)

View File

@@ -148,7 +148,7 @@ def test_host_gateway_refuses_when_it_will_not_serve_the_profile(tmp_path, monke
assert asyncio.run(gateway_run._host_attach_or_none(replace=False)) is False
def test_a_standalone_owner_is_the_per_profile_topology_not_a_refusal(tmp_path, monkeypatch, owner_pid):
def test_a_standalone_owner_is_the_per_profile_topology_not_a_refusal(tmp_path, monkeypatch, owner_pid, caplog):
"""The owner answers ``multiplex: False``: it is a per-profile gateway, not a multiplexer that
excluded us. Refusing here (exit 78 → launchd parks the unit) took every other profile's
supervised gateway down at boot on a one-process-per-profile fleet. Start as before."""
@@ -159,7 +159,9 @@ def test_a_standalone_owner_is_the_per_profile_topology_not_a_refusal(tmp_path,
monkeypatch.setattr("gateway.control_socket.rescan_gateway_profiles",
lambda home, timeout=8.0: {"multiplex": False, "served_profiles": ["tank"]})
assert host_attach.decide(tmp_path / "root" / "profiles" / "nous").outcome == host_attach.START
with caplog.at_level("WARNING", logger="gateway.host_attach"):
assert host_attach.decide(tmp_path / "root" / "profiles" / "nous").outcome == host_attach.START
assert any("migrate --multiplex" in r.getMessage() for r in caplog.records), "the converge hint is logged"
assert asyncio.run(gateway_run._host_attach_or_none(replace=False)) is None

View File

@@ -170,6 +170,23 @@ def test_a_supervised_attach_is_retried_not_parked(monkeypatch, capsys):
assert exc.value.code != gw.GATEWAY_FATAL_CONFIG_EXIT_CODE
def test_a_refusal_lands_in_the_profile_logs_not_only_on_stdout(monkeypatch, caplog):
"""The refusal's stdout goes to the supervisor's unit log; launchd then maps 78 to a clean exit and
parks the unit. Nothing in the profile's own logs said why (field report on #118097)."""
owner = host_attach.HostGateway(4321, Path("/somewhere"), ("default",))
monkeypatch.setattr(
"gateway.host_attach.decide",
lambda home, replace=False: host_attach.HostAttachDecision(
host_attach.REFUSE, host_attach._refuse_message(owner, "nous"), owner))
with caplog.at_level("WARNING", logger="hermes_cli.gateway"), pytest.raises(SystemExit) as exc:
gw._attach_to_host_gateway_or_guard(force=False)
assert exc.value.code == gw.GATEWAY_FATAL_CONFIG_EXIT_CODE
warned = [r for r in caplog.records if r.levelno >= 30 and "migrate --multiplex" in r.getMessage()]
assert warned, "a refusal must leave the remedy in the profile's own log"
def test_replace_is_not_eaten_by_the_cli_guard(monkeypatch):
"""The guard exited before ``start_gateway`` ever saw ``--replace``, so nothing was replaced."""
owner = host_attach.HostGateway(4321, Path("/somewhere"), ("default", "other"))