{"repo":"NousResearch/hermes-agent","public_id":"66892","display_label":"Gateway shutdown, restart, and hang hardening","summary":"Most members address problems with gateway processes hanging, failing to restart cleanly, or becoming unresponsive during SIGTERM/drain on Linux (systemd) and macOS (launchd), with a smaller cluster covering concurrent ddgs web search deadlocks and timeout gaps.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":66955,"merged_at":"2026-07-18T14:23:01+00:00"},"resolution":{"state":"resolved","by":"pr-66955"},"freshness":{"state":"verified","last_verified_at":"2026-07-18T14:23:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":66892,"pr_number":66955,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-18T14:23:01+00:00"}],"members":[{"kind":"issue","number":66892,"title":"Gateway event loop froze during SIGTERM drain — zombie process for 26h (KeepAlive can't detect it)","url":null,"state":"closed"},{"kind":"pr","number":66897,"title":"fix(gateway): thread shutdown watchdog + loop heartbeat for frozen SIGTERM drain (#66892)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#66955","number":66955}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":66918,"title":"fix(gateway): thread-based shutdown watchdog for drain freeze recovery (#66892)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":66955,"title":"fix(gateway): thread shutdown watchdog + loop heartbeat for frozen SIGTERM drain (#66892)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/shutdown_watchdog.py","tests/gateway/test_shutdown_watchdog.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -1917,6 +1917,12 @@ def _platform_has_bot_credential(platform: \"Platform\", platform_config: \"Platfor\n     merge_pending_message_event,\n     utf16_len,\n )\n+from gateway.shutdown_watchdog import (\n+    DEFAULT_HEARTBEAT_INTERVAL_S,\n+    arm_shutdown_watchdog,\n+    loop_heartbeat_forever,\n+    resolve_shutdown_watchdog_delay,\n+)\n from gateway.restart import (\n     DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,\n     GATEWAY_FATAL_CONFIG_EXIT_CODE,","@@ -2972,6 +2978,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew\n     _session_model_overrides: Dict[str, Dict[str, str]] = {}\n     _session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}\n     _startup_restore_in_progress: bool = False\n+    # Loop-liveness heartbeat / shutdown-watchdog handles (#66892). Class-level\n+    # defaults so partial construction in tests doesn't blow up on access; the\n+    # real values are set in __init__ / start() / stop().\n+    _loop_heartbeat_task: Optional[\"asyncio.Task\"] = None\n+    _gateway_started_at: float = 0.0\n+    _shutdown_watchdog_done: Optional[\"threading.Event\"] = None\n \n     def __init__(self, config: Optional[GatewayConfig] = None):\n         global _gateway_runner_ref","@@ -3297,6 +3309,12 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n         # Track background tasks to prevent garbage collection mid-execution\n         self._background_tasks: set = set()\n \n+        # Event-loop liveness heartbeat (#66892): rewritten every 30s while\n+        # the loop is dispatching. External supervisors use the file mtime /\n+        # updated_at to distinguish \"process alive\" from \"loop frozen\".\n+        self._gateway_started_at: float = time.time()\n+        self._loop_heartbeat_task: Optional[asyncio.Task] = None\n+\n         # scale-to-zero (Phase 0, F13): gateway-scoped \"last inbound seen\" clock.\n         # There is no such clock today (only a per-agent _last_activity_ts), so the\n         # idle predicate needs this. Stamped in _handle_message (the single inbound","@@ -7535,7 +7553,27 @@ async def start(self) -> bool:\n \n         self._running = True\n         self._update_runtime_status(\"running\")\n-        \n+\n+        # Loop-liveness heartbeat (#66892): an asyncio task so a frozen loop\n+        # stops refreshing ``state/gateway.heartbeat``. Cancelled with the\n+        # other background tasks during stop(). Best-effort — a liveness probe\n+        # must never be able to abort startup.\n+        try:\n+            _existing_hb = getattr(self, \"_loop_heartbeat_task\", None)\n+            if _existing_hb is None or _existing_hb.done():\n+                self._loop_heartbeat_task = asyncio.create_task(\n+                    loop_heartbeat_forever(\n+                        interval_s=DEFAULT_HEARTBEAT_INTERVAL_S,\n+                        start_time=getattr(self, \"_gateway_started_at\", 0.0),\n+                    )\n+                )\n+                _bg = getattr(self, \"_background_tasks\", None)\n+                if _bg is not None:\n+                    _bg.add(self._loop_heartbeat_task)\n+                    self._loop_heartbeat_task.add_done_callback(_bg.discard)\n+        except Exception:\n+            logger.debug(\"Failed to start gateway loop heartbeat\", exc_info=True)\n+\n         # Emit gateway:startup hook\n         hook_count = len(self.hooks.loaded_hooks)\n         if hook_count:","@@ -8499,11 +8537,57 @@ def _kill_tool_subprocesses(phase: str) -> None:\n                 except Exception as _e:\n                     logger.debug(\"cleanup_all_browsers (%s) error: %s\", phase, _e)\n \n+            # Thread-based shutdown watchdog (#66892): asyncio timeouts cannot\n+            # recover a frozen loop. Arm a plain OS thread at the start of\n+            # stop(); if teardown never finishes within drain+grace it dumps\n+            # faulthandler stacks and os._exit so KeepAlive/systemd can revive.\n+            # Skip under pytest so stop()-driving unit tests don't get a\n+            # delayed hard-exit in the worker.\n+            _watchdog_done = threading.Event()\n+            self._shutdown_watchdog_done = _watchdog_done\n+            _stop_started_at_box: dict[str, float] = {}\n+\n+            def _shutdown_watchdog_snapshot() -> dict:\n+                started = _stop_started_at_box.get(\"t\")\n+                return {\n+                    \"restart_requested\": bool(self._restart_requested),\n+                    \"draining\": bool(self._draining),\n+                    \"running\": bool(self._running),\n+                    \"active_agents\": self._running_agent_count(),\n+                    \"active_cron_jobs\": self._active_cron_job_count(),\n+                    \"active_api_runs\": self._active_api_run_count(),\n+                    \"restart_drain_timeout\": self._restart_drain_timeout,\n+                    \"watchdog_delay_s\": resolve_shutdown_watchdog_delay(\n+                        self._restart_drain_timeout\n+                    ),\n+                    \"phase_elapsed_s\": (\n+                        time.monotonic() - started if started is not None else None\n+                    ),\n+                }\n+\n+            if not os.environ.get(\"PYTEST_CURRENT_TEST\"):\n+                arm_shutdown_watchdog(\n+                    resolve_shutdown_watchdog_delay(self._restart_drain_timeout),\n+                    done_event=_watchdog_done,\n+                    snapshot_fn=_shutdown_watchdog_snapshot,\n+                    exit_code=1,\n+                )\n+\n+            try:\n+                await _stop_impl_body(\n+                    _kill_tool_subprocesses,\n+                    _stop_started_at_box,\n+                )\n+            finally:\n+                _watchdog_done.set()\n+\n+        async def _stop_impl_body(_kill_tool_subprocesses, _stop_started_at_box) -> None:\n             logger.info(\n                 \"Stopping gateway%s...\",\n                 \" for restart\" if self._restart_requested else \"\",\n             )\n             _stop_started_at = time.monotonic()\n+            _stop_started_at_box[\"t\"] = _stop_started_at\n \n             def _phase_elapsed() -> float:\n                 return time.monotonic() - _stop_started_at"]},{"file":"gateway/shutdown_watchdog.py","hunks":["@@ -0,0 +1,272 @@\n+\"\"\"Out-of-loop shutdown backstop + event-loop liveness heartbeat (#66892).\n+\n+When the asyncio loop freezes mid-drain, every asyncio-based recovery path is\n+structurally unable to fire: the drain deadline, status rewrites, and forensics\n+all need the same loop that is stuck. launchd/systemd KeepAlive only restarts a\n+*dead* process, so a wedged-but-alive gateway sits as a zombie until manual\n+SIGKILL.\n+\n+This module provides:\n+\n+1. A plain OS-thread shutdown watchdog armed at ``stop()``. If shutdown has not\n+   completed within ``restart_drain_timeout + grace``, it dumps all-thread\n+   stacks via ``faulthandler`` plus a metadata snapshot, then ``os._exit`` so\n+   the service manager can revive the process.\n+2. An event-loop heartbeat file at ``<HERMES_HOME>/state/gateway.heartbeat`` so\n+   external supervision can distinguish \"process alive\" from \"loop frozen\"\n+   (``gateway_state.json`` alone can't — it only rewrites on transitions/turns).\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import faulthandler\n+import json\n+import logging\n+import os\n+import sys\n+import threading\n+import time\n+from datetime import datetime, timezone\n+from pathlib import Path\n+from typing import Any, Callable, Dict, Optional\n+\n+from hermes_constants import get_hermes_home\n+from utils import atomic_json_write\n+\n+logger = logging.getLogger(__name__)\n+\n+# Extra leash beyond ``agent.restart_drain_timeout`` so a slow-but-progressing\n+# drain is not cut short. Matches the issue #66892 suggested hardening.\n+DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S = 60.0\n+DEFAULT_HEARTBEAT_INTERVAL_S = 30.0\n+_HEARTBEAT_RELATIVE = (\"state\", \"gateway.heartbeat\")\n+_WATCHDOG_DUMP_RELATIVE = (\"logs\", \"gateway-shutdown-watchdog.log\")\n+\n+\n+def _process_hermes_home() -> Path:\n+    \"\"\"HERMES_HOME for process-level identity files (ignore profile overrides).\"\"\"\n+    val = os.environ.get(\"HERMES_HOME\", \"\").strip()\n+    if val:\n+        return Path(val)\n+    return get_hermes_home()\n+\n+\n+def get_loop_heartbeat_path(home: Optional[Path] = None) -> Path:\n+    \"\"\"Return ``<HERMES_HOME>/state/gateway.heartbeat``.\"\"\"\n+    base = home if home is not None else _process_hermes_home()\n+    return base.joinpath(*_HEARTBEAT_RELATIVE)\n+\n+\n+def get_shutdown_watchdog_dump_path(home: Optional[Path] = None) -> Path:\n+    \"\"\"Return the faulthandler / metadata dump path for a fired watchdog.\"\"\"\n+    base = home if home is not None else _process_hermes_home()\n+    return base.joinpath(*_WATCHDOG_DUMP_RELATIVE)\n+\n+\n+def write_loop_heartbeat(\n+    *,\n+    pid: Optional[int] = None,\n+    start_time: Optional[float] = None,\n+    home: Optional[Path] = None,\n+    extra: Optional[Dict[str, Any]] = None,\n+) -> Path:\n+    \"\"\"Atomically rewrite the loop-liveness heartbeat file.\n+\n+    ``start_time`` is the gateway process start (``time.time()`` epoch seconds)\n+    so supervisors can detect PID reuse. Best-effort — never raises.\n+    \"\"\"\n+    path = get_loop_heartbeat_path(home)\n+    payload: Dict[str, Any] = {\n+        \"pid\": int(pid if pid is not None else os.getpid()),\n+        \"updated_at\": datetime.now(timezone.utc).isoformat(),\n+        \"monotonic\": time.monotonic(),\n+    }\n+    if start_time is not None:\n+        payload[\"start_time\"] = float(start_time)\n+    if extra:\n+        payload.update(extra)\n+    try:\n+        atomic_json_write(path, payload, indent=None)\n+    except Exception:\n+        logger.debug(\"Failed to write gateway loop heartbeat\", exc_info=True)\n+    return path\n+\n+\n+def resolve_shutdown_watchdog_delay(\n+    drain_timeout: float,\n+    *,\n+    grace_s: float = DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S,\n+) -> float:\n+    \"\"\"Return the wall-clock leash for the shutdown watchdog thread.\"\"\"\n+    try:\n+        drain = max(float(drain_timeout), 0.0)\n+    except (TypeError, ValueError):\n+        drain = 0.0\n+    try:\n+        grace = max(float(grace_s), 0.0)\n+    except (TypeError, ValueError):\n+        grace = DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S\n+    return drain + grace\n+\n+\n+def _write_watchdog_dump(\n+    dump_path: Path,\n+    *,\n+    delay_s: float,\n+    snapshot: Optional[Dict[str, Any]],\n+) -> None:\n+    \"\"\"Best-effort faulthandler + metadata dump before hard-exit.\"\"\"\n+    try:\n+        dump_path.parent.mkdir(parents=True, exist_ok=True)\n+    except OSError:\n+        return\n+\n+    header = {\n+        \"event\": \"shutdown_watchdog_fired\",\n+        \"pid\": os.getpid(),\n+        \"delay_s\": delay_s,\n+        \"fired_at\": datetime.now(timezone.utc).isoformat(),\n+        \"snapshot\": snapshot or {},\n+    }\n+    try:\n+        with open(dump_path, \"a\", encoding=\"utf-8\") as fh:\n+            fh.write(json.dumps(header, default=str) + \"\\n\")\n+            fh.write(\"--- faulthandler dump (all threads) ---\\n\")\n+            fh.flush()\n+            try:\n+                faulthandler.dump_traceback(file=fh, all_threads=True)\n+            except Exception:\n+                fh.write(\"(faulthandler.dump_traceback failed)\\n\")\n+            fh.write(\"--- end dump ---\\n\")\n+            fh.flush()\n+    except Exception:\n+        pass\n+\n+    # Also dump to stderr so journald/launchd capture it even if the file\n+    # write failed (wedged disk was one of the #66892 hypotheses).\n+    try:\n+        sys.stderr.write(\n+            f\"Gateway shutdown watchdog fired after {delay_s:.0f}s \"\n+            f\"(pid={os.getpid()}); dumping all thread stacks.\\n\"\n+        )\n+        sys.stderr.flush()\n+        faulthandler.dump_traceback(all_threads=True)\n+    except Exception:\n+        pass\n+\n+\n+def arm_shutdown_watchdog(\n+    delay_s: float,\n+    *,\n+    done_event: Optional[threading.Event] = None,\n+    snapshot_fn: Optional[Callable[[], Dict[str, Any]]] = None,\n+    exit_code: int = 1,\n+    dump_path: Optional[Path] = None,\n+    name: str = \"gateway-shutdown-watchdog\",\n+) -> threading.Event:\n+    \"\"\"Arm a daemon-thread hard-exit backstop for a wedged shutdown path.\n+\n+    If ``done_event`` is set before ``delay_s`` elapses, the thread exits\n+    quietly (normal / progressing shutdown completed). Otherwise it dumps\n+    diagnostics and calls ``os._exit(exit_code)``.\n+\n+    Never raises. Returns the ``done_event`` (creating one when omitted) so\n+    the caller can disarm on successful completion.\n+    \"\"\"\n+    done = done_event if done_event is not None else threading.Event()\n+    try:\n+        delay = max(float(delay_s), 0.0)\n+    except (TypeError, ValueError):\n+        delay = DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S\n+\n+    if delay <= 0:\n+        return done\n+\n+    def _watchdog() -> None:\n+        # Wait with interruptible chunks so a late disarm doesn't need the\n+        # full remaining sleep to observe done_event.\n+        deadline = time.monotonic() + delay\n+        while time.monotonic() < deadline:\n+            remaining = deadline - time.monotonic()\n+            if done.wait(timeout=min(remaining, 1.0)):\n+                return\n+        if done.is_set():\n+            return\n+\n+        snapshot: Optional[Dict[str, Any]] = None\n+        if snapshot_fn is not None:\n+            try:\n+                snapshot = snapshot_fn()\n+            except Exception as exc:\n+                snapshot = {\"snapshot_error\": repr(exc)}\n+\n+        target = dump_path if dump_path is not None else get_shutdown_watchdog_dump_path()\n+        _write_watchdog_dump(target, delay_s=delay, snapshot=snapshot)\n+\n+        try:\n+            logger.critical(\n+                \"Shutdown watchdog fired after %.0fs — forcing process exit \"\n+                \"(asyncio drain path appears wedged; see %s)\",\n+                delay,\n+                target,\n+            )\n+        except Exception:\n+            pass\n+\n+        for stream in (sys.stdout, sys.stderr):\n+            try:\n+                stream.flush()\n+            except Exception:\n+                pass\n+        # Mirror _exit_after_graceful_shutdown: release PID file + runtime\n+        # lock BEFORE the log drain (locks must never be stranded), then\n+        # drain the async log queue so the logger.critical above actually\n+        # reaches the file before os._exit bypasses atexit. (#66892)\n+        try:\n+            from gateway.status import remove_pid_file, release_gateway_runtime_lock\n+            remove_pid_file()\n+            release_gateway_runtime_lock()\n+        except Exception:\n+            pass\n+        try:\n+            from hermes_logging import drain_log_queue\n+            drain_log_queue(timeout=1.0)\n+        except Exception:\n+            pass\n+        os._exit(exit_code)\n+\n+    try:\n+        threading.Thread(target=_watchdog, daemon=True, name=name).start()\n+    except Exception:\n+        logger.debug(\"Failed to arm shutdown watchdog\", exc_info=True)\n+    return done\n+\n+\n+async def loop_heartbeat_forever(\n+    *,\n+    interval_s: float = DEFAULT_HEARTBEAT_INTERVAL_S,\n+    start_time: Optional[float] = None,\n+    home: Optional[Path] = None,\n+    should_continue: Optional[Callable[[], bool]] = None,\n+) -> None:\n+    \"\"\"Rewrite the loop heartbeat file on a cadence until cancelled / gated off.\n+\n+    Runs as an asyncio task on the gateway loop — if the loop freezes, this\n+    task stops and the file mtime/updated_at goes stale for external monitors.\n+    \"\"\"\n+    try:\n+        interval = max(float(interval_s), 1.0)\n+    except (TypeError, ValueError):\n+        interval = DEFAULT_HEARTBEAT_INTERVAL_S\n+\n+    # Immediate first write so monitors see a fresh file as soon as the\n+    # gateway is running, not after the first interval.\n+    write_loop_heartbeat(start_time=start_time, home=home)\n+    while True:\n+        if should_continue is not None and not should_continue():\n+            return\n+        await asyncio.sleep(interval)\n+        if should_continue is not None and not should_continue():\n+            return\n+        write_loop_heartbeat(start_time=start_time, home=home)"]},{"file":"tests/gateway/test_shutdown_watchdog.py","hunks":["@@ -0,0 +1,150 @@\n+\"\"\"Shutdown watchdog + loop heartbeat coverage for #66892.\n+\n+The drain path is asyncio-based; a frozen loop makes every asyncio timeout\n+structurally unable to fire. These tests pin the out-of-loop backstop\n+(thread watchdog) and the loop-liveness heartbeat file contract.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import json\n+import threading\n+import time\n+from unittest.mock import patch\n+\n+import pytest\n+\n+from gateway.shutdown_watchdog import (\n+    DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S,\n+    arm_shutdown_watchdog,\n+    get_loop_heartbeat_path,\n+    get_shutdown_watchdog_dump_path,\n+    loop_heartbeat_forever,\n+    resolve_shutdown_watchdog_delay,\n+    write_loop_heartbeat,\n+)\n+\n+def test_resolve_shutdown_watchdog_delay_adds_grace():\n+    assert resolve_shutdown_watchdog_delay(180) == 180 + DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S\n+    assert resolve_shutdown_watchdog_delay(0) == DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S\n+    assert resolve_shutdown_watchdog_delay(\"bad\") == DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S\n+    assert resolve_shutdown_watchdog_delay(10, grace_s=5) == 15.0\n+\n+\n+def test_write_loop_heartbeat_atomic_json(tmp_path, monkeypatch):\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    path = write_loop_heartbeat(pid=4242, start_time=100.5, home=tmp_path)\n+    assert path == tmp_path / \"state\" / \"gateway.heartbeat\"\n+    assert path.is_file()\n+    data = json.loads(path.read_text(encoding=\"utf-8\"))\n+    assert data[\"pid\"] == 4242\n+    assert data[\"start_time\"] == 100.5\n+    assert \"updated_at\" in data\n+    assert \"monotonic\" in data\n+    assert get_loop_heartbeat_path(tmp_path) == path\n+\n+\n+def test_arm_shutdown_watchdog_disarm_before_fire(tmp_path):\n+    done = threading.Event()\n+    exited = []\n+\n+    def fake_exit(code):\n+        exited.append(code)\n+        raise _ExitCalled(code)\n+\n+    with patch(\"gateway.shutdown_watchdog.os._exit\", side_effect=fake_exit):\n+        arm_shutdown_watchdog(\n+            0.4,\n+            done_event=done,\n+            dump_path=tmp_path / \"dump.log\",\n+            exit_code=7,\n+        )\n+        time.sleep(0.1)\n+        done.set()\n+        time.sleep(0.5)\n+\n+    assert exited == []\n+\n+\n+def test_arm_shutdown_watchdog_fires_with_dump_and_exit(tmp_path):\n+    done = threading.Event()\n+    fired = threading.Event()\n+    dump = tmp_path / \"logs\" / \"watchdog.log\"\n+    snapshot_calls = []\n+    exit_codes = []\n+\n+    def snapshot():\n+        snapshot_calls.append(1)\n+        return {\"active_agents\": 1, \"draining\": True}\n+\n+    def fake_exit(code):\n+        exit_codes.append(code)\n+        fired.set()\n+\n+    with patch(\"gateway.shutdown_watchdog.os._exit\", side_effect=fake_exit):\n+        arm_shutdown_watchdog(\n+            0.15,\n+            done_event=done,\n+            snapshot_fn=snapshot,\n+            dump_path=dump,\n+            exit_code=9,\n+        )\n+        assert fired.wait(timeout=5.0), \"watchdog did not fire\"\n+\n+    assert exit_codes == [9]\n+    assert snapshot_calls == [1]\n+    assert dump.is_file()\n+    text = dump.read_text(encoding=\"utf-8\")\n+    assert \"shutdown_watchdog_fired\" in text\n+    assert \"faulthandler dump\" in text\n+    assert get_shutdown_watchdog_dump_path(tmp_path).name == \"gateway-shutdown-watchdog.log\"\n+\n+\n[redacted:email]\n+async def test_loop_heartbeat_rewrites_until_cancelled(tmp_path):\n+    path = get_loop_heartbeat_path(tmp_path)\n+    task = asyncio.create_task(\n+        loop_heartbeat_forever(\n+            interval_s=0.05,\n+            start_time=12.0,\n+            home=tmp_path,\n+        )\n+    )\n+    try:\n+        # First write is immediate.\n+        for _ in range(50):\n+            if path.is_file():\n+                break\n+            await asyncio.sleep(0.02)\n+        assert path.is_file()\n+        first = path.read_text(encoding=\"utf-8\")\n+        assert json.loads(first)[\"start_time\"] == 12.0\n+\n+        # Poll until a refresh lands (monotonic / updated_at change).\n+        second = first\n+        for _ in range(100):\n+            await asyncio.sleep(0.03)\n+            second = path.read_text(encoding=\"utf-8\")\n+            if second != first:\n+                break\n+        assert second != first\n+        assert json.loads(second)[\"start_time\"] == 12.0\n+    finally:\n+        task.cancel()\n+        with pytest.raises(asyncio.CancelledError):\n+            await task\n+\n+\n+def test_gateway_runner_exposes_shutdown_watchdog_state():\n+    \"\"\"Attrs used by stop()/start() exist after normal construction hooks.\"\"\"\n+    from gateway.run import GatewayRunner\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner._shutdown_watchdog_done = threading.Event()\n+    runner._loop_heartbeat_task = None\n+    runner._gateway_started_at = time.time()\n+    assert not runner._shutdown_watchdog_done.is_set()\n+    runner._shutdown_watchdog_done.set()\n+    assert runner._shutdown_watchdog_done.is_set()\n+    assert runner._loop_heartbeat_task is None\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}