diff --git a/gateway/host_attach.py b/gateway/host_attach.py index 98083b0e7a..89b51943c4 100644 --- a/gateway/host_attach.py +++ b/gateway/host_attach.py @@ -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 diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 07304c2213..0a9af29e0c 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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) diff --git a/tests/gateway/test_host_attach_lifecycle.py b/tests/gateway/test_host_attach_lifecycle.py index 67c4fe38c3..7c526bdfdf 100644 --- a/tests/gateway/test_host_attach_lifecycle.py +++ b/tests/gateway/test_host_attach_lifecycle.py @@ -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 diff --git a/tests/hermes_cli/test_gateway_host_verbs.py b/tests/hermes_cli/test_gateway_host_verbs.py index 2ec0feb886..aba25a93dc 100644 --- a/tests/hermes_cli/test_gateway_host_verbs.py +++ b/tests/hermes_cli/test_gateway_host_verbs.py @@ -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"))