Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -7035,7 +7035,25 @@ async def start(self) -> bool:
enabled_platform_count = 0
startup_nonretryable_errors: list[str] = []
startup_retryable_errors: list[str] = []


# Reconcile explicitly-disabled platforms up front: mark each as
# "disabled" and clear any stale connected/paused/error metadata a
# previous run left in gateway_state.json. The connect loop below skips
# disabled platforms, so without this their old status would persist and
# mislead `hermes status` / readiness probes into reporting a turned-off
# platform as live. See gateway.status.write_runtime_status.
try:
disabled_platform_values = [
p.value
for p, pc in self.config.platforms.items()
if not getattr(pc, "enabled", False)
]
if disabled_platform_values:
from gateway.status import write_runtime_status
write_runtime_status(disabled_platforms=disabled_platform_values)
except Exception:
logger.debug("disabled-platform status reconcile failed", exc_info=True)

# Initialize and connect each configured platform
for platform, platform_config in self.config.platforms.items():
if await self._abort_startup_if_shutdown_requested():
Expand Down
30 changes: 29 additions & 1 deletion gateway/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,8 +805,19 @@ def write_runtime_status(
error_code: Any = _UNSET,
error_message: Any = _UNSET,
served_profiles: Any = _UNSET,
disabled_platforms: Any = _UNSET,
) -> None:
"""Persist gateway runtime health information for diagnostics/status."""
"""Persist gateway runtime health information for diagnostics/status.

``disabled_platforms`` reconciles the persisted ``platforms`` map against
the platforms explicitly disabled in the *current* config: each named
platform is written as ``state="disabled"`` with its connection/error
metadata cleared. Without this, a ``gateway_state.json`` left over from a
previous run keeps stale ``connected``/``paused``/``fatal`` entries for a
platform the operator has since turned off — the startup connect loop skips
disabled platforms, so nothing else ever overwrites those entries and
``hermes status`` / readiness probes report a dead platform as live.
"""
path = _get_runtime_status_path()
payload = _read_json_file(path) or _build_runtime_status_record()
current_record = _build_pid_record()
Expand Down Expand Up @@ -842,6 +853,23 @@ def write_runtime_status(
platform_payload["updated_at"] = _utc_now_iso()
payload["platforms"][platform] = platform_payload

if disabled_platforms is not _UNSET:
# Reconcile every explicitly-disabled platform to a clean "disabled"
# record for this run, clearing stale connection/error metadata a prior
# run may have left behind. Never touch a platform that was updated in
# THIS call (e.g. an enabled platform mid-connect) — the explicit
# `platform=` argument wins.
now = _utc_now_iso()
for name in disabled_platforms or []:
if name == platform:
continue
payload["platforms"][name] = {
"state": "disabled",
"error_code": None,
"error_message": None,
"updated_at": now,
}

Comment on lines +856 to +872
_write_json_file(path, payload)


Expand Down
135 changes: 135 additions & 0 deletions tests/gateway/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,141 @@ def test_write_runtime_status_explicit_none_clears_stale_fields(self, tmp_path,
assert payload["platforms"]["discord"]["error_code"] is None
assert payload["platforms"]["discord"]["error_message"] is None

def test_write_runtime_status_marks_disabled_platform_and_clears_stale_metadata(
self, tmp_path, monkeypatch
):
"""Regression: a platform disabled in the current config must not keep
stale connected/error metadata from a previous run (misleads status)."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))

# Previous run: telegram was connected, discord failed fatally.
state_path = tmp_path / "gateway_state.json"
state_path.write_text(json.dumps({
"pid": 99999,
"start_time": 1000.0,
"kind": "hermes-gateway",
"gateway_state": "running",
"platforms": {
"telegram": {
"state": "connected",
"error_code": None,
"error_message": None,
"updated_at": "2025-01-01T00:00:00Z",
},
"discord": {
"state": "fatal",
"error_code": "discord_timeout",
"error_message": "boom",
"updated_at": "2025-01-01T00:00:00Z",
},
},
"updated_at": "2025-01-01T00:00:00Z",
}))

# New run: both telegram and discord are explicitly disabled in config.
status.write_runtime_status(
gateway_state="starting",
disabled_platforms=["telegram", "discord"],
)

payload = status.read_runtime_status()
for name in ("telegram", "discord"):
assert payload["platforms"][name]["state"] == "disabled"
assert payload["platforms"][name]["error_code"] is None
assert payload["platforms"][name]["error_message"] is None
# PID/start_time are refreshed for the current run too.
assert payload["pid"] == os.getpid()

def test_write_runtime_status_disabled_preserves_enabled_platforms(
self, tmp_path, monkeypatch
):
"""Reconciling disabled platforms must not touch enabled ones."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))

state_path = tmp_path / "gateway_state.json"
state_path.write_text(json.dumps({
"pid": 99999,
"kind": "hermes-gateway",
"gateway_state": "running",
"platforms": {
"telegram": {
"state": "connected",
"error_code": None,
"error_message": None,
"updated_at": "2025-01-01T00:00:00Z",
},
"discord": {
"state": "paused",
"error_code": "x",
"error_message": "y",
"updated_at": "2025-01-01T00:00:00Z",
},
},
"updated_at": "2025-01-01T00:00:00Z",
}))

# Only discord is disabled now; telegram stays enabled.
status.write_runtime_status(
gateway_state="starting",
disabled_platforms=["discord"],
)

payload = status.read_runtime_status()
# Enabled platform is untouched.
assert payload["platforms"]["telegram"]["state"] == "connected"
# Disabled platform is reconciled.
assert payload["platforms"]["discord"]["state"] == "disabled"
assert payload["platforms"]["discord"]["error_code"] is None
assert payload["platforms"]["discord"]["error_message"] is None

def test_write_runtime_status_disabled_adds_missing_platform_entry(
self, tmp_path, monkeypatch
):
"""A disabled platform with no prior entry is written as disabled."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))

status.write_runtime_status(
gateway_state="starting",
disabled_platforms=["slack"],
)

payload = status.read_runtime_status()
assert payload["platforms"]["slack"]["state"] == "disabled"
assert payload["platforms"]["slack"]["error_code"] is None
assert payload["platforms"]["slack"]["error_message"] is None

def test_write_runtime_status_disabled_combines_with_platform_update(
self, tmp_path, monkeypatch
):
"""A single call can both reconcile disabled platforms and record an
enabled platform's live state without them clobbering each other."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))

state_path = tmp_path / "gateway_state.json"
state_path.write_text(json.dumps({
"kind": "hermes-gateway",
"platforms": {
"discord": {
"state": "connected",
"error_code": None,
"error_message": None,
"updated_at": "2025-01-01T00:00:00Z",
},
},
"updated_at": "2025-01-01T00:00:00Z",
}))

status.write_runtime_status(
gateway_state="starting",
platform="telegram",
platform_state="connecting",
disabled_platforms=["discord"],
)

payload = status.read_runtime_status()
assert payload["platforms"]["telegram"]["state"] == "connecting"
assert payload["platforms"]["discord"]["state"] == "disabled"


class TestGetProcessStartTime:
"""Start-time fingerprint backing the PID-reuse guard (#43846 / #50468).
Expand Down