{"repo":"NousResearch/hermes-agent","public_id":"33778","display_label":"Windows gateway stop/restart marker file fixes","summary":"These items address a bug where the planned_stop_marker file is not written on Windows during gateway stop or restart, causing session context to be lost across restarts. Fixes aim to align Windows behavior with Unix.","subsystem":"hermes_cli","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":33798,"merged_at":"2026-05-28T10:25:33+00:00"},"resolution":{"state":"resolved","by":"pr-33798"},"freshness":{"state":"verified","last_verified_at":"2026-05-28T10:25:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":33778,"pr_number":33798,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-28T10:25:33+00:00"}],"members":[{"kind":"issue","number":33778,"title":"[Bug, Windows] hermes gateway restart loses session context — planned_stop_marker not written before SIGTERM","url":null,"state":"closed"},{"kind":"pr","number":33798,"title":"fix(gateway): drain on Windows `hermes gateway stop` so sessions survive restart (#33778)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/gateway_windows.py","tests/gateway/test_planned_stop_watcher.py","tests/hermes_cli/test_gateway_windows.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -18193,6 +18193,72 @@ async def _delete_all() -> None:\n         return response\n \n \n+def _run_planned_stop_watcher(\n+    stop_event: threading.Event,\n+    runner,\n+    loop: asyncio.AbstractEventLoop,\n+    shutdown_handler,\n+    *,\n+    poll_interval: float = 0.5,\n+) -> None:\n+    \"\"\"Poll for the planned-stop marker and trigger graceful shutdown.\n+\n+    On Windows, ``asyncio.add_signal_handler`` raises NotImplementedError\n+    for SIGTERM/SIGINT, so the standard signal-driven shutdown path\n+    never runs when ``hermes gateway stop`` signals the gateway. The\n+    consequence is that the drain loop is skipped — in-flight agent\n+    sessions are killed mid-turn and ``resume_pending`` is never set,\n+    so the next gateway boot has no idea those sessions need to be\n+    auto-resumed (issue #33778, v0.13.0 session-resume feature broken\n+    on native Windows).\n+\n+    This watcher runs on every platform (cheap, defensive) and bridges\n+    the gap on Windows by translating a filesystem marker into the\n+    same shutdown-handler invocation a real SIGTERM would have produced\n+    on POSIX. The CLI's ``hermes_cli.gateway_windows.stop()`` writes\n+    the marker via ``write_planned_stop_marker(pid)`` and then waits\n+    for the gateway PID to exit; this watcher is what makes that\n+    exit happen cleanly.\n+\n+    On POSIX this is a no-op safety net — the signal handler always\n+    races us to consuming the marker file because it fires synchronously\n+    from the kernel's signal delivery.\n+\n+    Args:\n+        stop_event: cleared by start_gateway() during normal shutdown\n+            to tell the watcher to exit.\n+        runner: the GatewayRunner instance; we check ``_running`` and\n+            ``_draining`` to avoid triggering shutdown if the gateway\n+            is already in one of those states.\n+        loop: the asyncio event loop the shutdown handler must run on.\n+        shutdown_handler: same callable that's wired to SIGTERM —\n+            tolerates a ``None`` signal argument (planned stop case)\n+            and consumes the marker via\n+            ``consume_planned_stop_marker_for_self()``.\n+        poll_interval: seconds between marker checks. 0.5s gives a\n+            responsive shutdown without burning CPU.\n+    \"\"\"\n+    from gateway.status import _get_planned_stop_marker_path\n+    marker_path = _get_planned_stop_marker_path()\n+    while not stop_event.is_set():\n+        try:\n+            if (\n+                marker_path.exists()\n+                and not getattr(runner, \"_draining\", False)\n+                and getattr(runner, \"_running\", False)\n+            ):\n+                # Drive the same path as a real signal handler.\n+                # Pass signal=None — the handler tolerates that and consumes\n+                # the marker via consume_planned_stop_marker_for_self,\n+                # which also validates target_pid + start_time match us.\n+                loop.call_soon_threadsafe(shutdown_handler, None)\n+                # Done — the handler will set _draining; we exit on next tick.\n+                break\n+        except Exception as _e:\n+            logger.debug(\"Planned-stop watcher tick error: %s\", _e)\n+        stop_event.wait(poll_interval)\n+\n+\n def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60):\n     \"\"\"\n     Background thread that ticks the cron scheduler at a regular interval.","@@ -18597,7 +18663,28 @@ def restart_signal_handler():\n                 pass\n     else:\n         logger.info(\"Skipping signal handlers (not running in main thread).\")\n-    \n+\n+    # Windows fallback: asyncio.add_signal_handler raises NotImplementedError\n+    # on Windows, so `hermes gateway stop`'s SIGTERM (which Python maps to\n+    # TerminateProcess on Windows) never invokes shutdown_signal_handler.\n+    # That means the drain loop never runs, mark_resume_pending never fires,\n+    # and sessions are silently lost across restarts (issue #33778).\n+    #\n+    # The fix is a marker-polling thread: `hermes gateway stop` writes the\n+    # planned-stop marker BEFORE killing, and this thread notices it and\n+    # drives the same shutdown path the signal handler would have.  Runs\n+    # on every platform (cheap, defensive) so non-signal-bearing\n+    # environments (Windows native, sandboxed CI runners that mask\n+    # SIGTERM) still get a clean drain.\n+    _planned_stop_watcher_stop = threading.Event()\n+    _planned_stop_watcher_thread = threading.Thread(\n+        target=_run_planned_stop_watcher,\n+        args=(_planned_stop_watcher_stop, runner, loop, shutdown_signal_handler),\n+        daemon=True,\n+        name=\"planned-stop-watcher\",\n+    )\n+    _planned_stop_watcher_thread.start()\n+\n     # Claim the PID file BEFORE bringing up any platform adapters.\n     # This closes the --replace race window: two concurrent `gateway run\n     # --replace` invocations both pass the termination-wait above, but","@@ -18675,6 +18762,10 @@ def restart_signal_handler():\n     cron_stop.set()\n     cron_thread.join(timeout=5)\n \n+    # Stop the planned-stop watcher (daemon=True so this is belt-and-suspenders).\n+    _planned_stop_watcher_stop.set()\n+    _planned_stop_watcher_thread.join(timeout=2)\n+\n     # Close MCP server connections\n     try:\n         from tools.mcp_tool import shutdown_mcp_servers"]},{"file":"hermes_cli/gateway_windows.py","hunks":["@@ -1014,12 +1014,70 @@ def start() -> None:\n     _report_gateway_start(f\"direct spawn (PID {pid})\")\n \n \n+def _drain_gateway_pid(pid: int, drain_timeout: float) -> bool:\n+    \"\"\"Write the planned-stop marker and wait for the gateway PID to exit.\n+\n+    Windows cannot deliver POSIX signals to a Python asyncio loop\n+    (``loop.add_signal_handler`` raises NotImplementedError), so writing\n+    the marker is the ONLY way to ask a running gateway to drain\n+    in-flight agents and persist ``resume_pending`` before exit. The\n+    gateway's planned-stop watcher thread (gateway/run.py) polls for\n+    the marker and drives the same shutdown path the SIGTERM handler\n+    would have on POSIX.\n+\n+    Returns True if the PID exited within the timeout, False if it\n+    didn't (caller should escalate to schtasks /End + taskkill).\n+    \"\"\"\n+    if pid <= 0:\n+        return False\n+    try:\n+        from gateway.status import write_planned_stop_marker, _pid_exists\n+    except ImportError:\n+        return False\n+\n+    try:\n+        write_planned_stop_marker(pid)\n+    except Exception:\n+        # Best-effort: if the marker can't be written, we have no choice\n+        # but to fall through to a hard kill.  Caller decides escalation.\n+        pass\n+\n+    deadline = time.monotonic() + max(drain_timeout, 1.0)\n+    while time.monotonic() < deadline:\n+        if not _pid_exists(pid):\n+            return True\n+        time.sleep(0.5)\n+    return False\n+\n+\n def stop() -> None:\n-    \"\"\"Stop the gateway. Tries /End on the scheduled task, then kills any stragglers.\"\"\"\n+    \"\"\"Stop the gateway.\n+\n+    Writes the planned-stop marker first so the gateway can drain\n+    in-flight agents and persist ``resume_pending`` before exit (the\n+    gateway's marker-watcher thread picks this up — Windows asyncio\n+    can't deliver SIGTERM to the loop, so the marker is our only IPC).\n+    Then escalates: ``schtasks /End`` (kills the scheduled-task tree)\n+    + ``kill_gateway_processes(force=True)`` for any strays.\n+    \"\"\"\n     _assert_windows()\n-    from hermes_cli.gateway import kill_gateway_processes\n+    from hermes_cli.gateway import kill_gateway_processes, _get_restart_drain_timeout\n+    from gateway.status import get_running_pid\n+\n+    # Phase 1: ask the running gateway (if any) to drain itself by writing\n+    # the planned-stop marker, then wait briefly for it to exit cleanly.\n+    # On clean exit, sessions land with resume_pending=True and the next\n+    # boot will auto-resume them.\n+    pid = get_running_pid()\n+    drained = False\n+    if pid is not None:\n+        try:\n+            drain_timeout = float(_get_restart_drain_timeout() or 30.0)\n+        except Exception:\n+            drain_timeout = 30.0\n+        drained = _drain_gateway_pid(pid, drain_timeout)\n \n-    stopped_any = False\n+    stopped_any = drained\n     if is_task_registered():\n         code, _out, err = _exec_schtasks([\"/End\", \"/TN\", get_task_name()])\n         # schtasks returns nonzero when the task isn't currently running — don't treat that as an error.","@@ -1028,12 +1086,19 @@ def stop() -> None:\n         elif \"not running\" not in (err or \"\").lower():\n             print(f\"⚠ schtasks /End returned code {code}: {err.strip()}\")\n \n-    killed = kill_gateway_processes(all_profiles=False)\n+    # Phase 3: hard-kill any strays.  When drain succeeded this is a no-op;\n+    # when drain timed out this is the escalation that ensures the PID\n+    # actually exits.  Use force=True on Windows so taskkill /T /F walks\n+    # the descendant tree (browser helpers, etc.).\n+    killed = kill_gateway_processes(all_profiles=False, force=not drained)\n     if killed:\n         stopped_any = True\n         print(f\"✓ Killed {killed} gateway process(es)\")\n     if stopped_any:\n-        print(\"✓ Gateway stopped\")\n+        if drained:\n+            print(\"✓ Gateway stopped (drained cleanly)\")\n+        else:\n+            print(\"✓ Gateway stopped\")\n     else:\n         print(\"✗ No gateway was running\")\n "]},{"file":"tests/gateway/test_planned_stop_watcher.py","hunks":["@@ -0,0 +1,267 @@\n+\"\"\"Tests for the planned-stop marker watcher thread (gateway/run.py).\n+\n+The watcher is the Windows-fallback path for the v0.13.0 session-resume\n+feature — on Windows ``asyncio.add_signal_handler`` raises\n+NotImplementedError, so the SIGTERM signal handler never runs and the\n+shutdown drain (which writes ``resume_pending=True``) is skipped. The\n+watcher closes this gap by polling for the planned-stop marker file\n+and translating its existence into the same shutdown-handler call a\n+real SIGTERM would have produced.\n+\n+See issue #33778 for the original Windows session-loss bug report.\n+\"\"\"\n+\n+import asyncio\n+import threading\n+import time\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+from gateway.run import _run_planned_stop_watcher\n+\n+\n+class _FakeRunner:\n+    \"\"\"Stand-in for GatewayRunner — only exposes the two flags the watcher reads.\"\"\"\n+\n+    def __init__(self, *, running: bool = True, draining: bool = False):\n+        self._running = running\n+        self._draining = draining\n+\n+\n+def _make_loop_capturing_calls():\n+    \"\"\"Build a fake asyncio loop whose call_soon_threadsafe records its args.\"\"\"\n+    loop = MagicMock(spec=asyncio.AbstractEventLoop)\n+    loop._captured = []\n+\n+    def fake_call_soon_threadsafe(fn, *args):\n+        loop._captured.append((fn, args))\n+\n+    loop.call_soon_threadsafe = fake_call_soon_threadsafe\n+    return loop\n+\n+\n+def test_watcher_fires_shutdown_when_marker_appears(tmp_path, monkeypatch):\n+    \"\"\"When the marker file exists, the watcher must call the shutdown handler.\"\"\"\n+    marker = tmp_path / \".gateway-planned-stop.json\"\n+\n+    # Patch the marker-path resolver so the watcher polls our temp location.\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"_get_planned_stop_marker_path\", lambda: marker)\n+\n+    runner = _FakeRunner(running=True, draining=False)\n+    loop = _make_loop_capturing_calls()\n+    shutdown_handler = MagicMock(name=\"shutdown_signal_handler\")\n+    stop_event = threading.Event()\n+\n+    # Drop the marker before the thread starts.\n+    marker.write_text('{\"target_pid\": 1234}', encoding=\"utf-8\")\n+\n+    watcher = threading.Thread(\n+        target=_run_planned_stop_watcher,\n+        args=(stop_event, runner, loop, shutdown_handler),\n+        kwargs={\"poll_interval\": 0.05},\n+        daemon=True,\n+    )\n+    watcher.start()\n+    watcher.join(timeout=2.0)\n+\n+    assert not watcher.is_alive(), \"Watcher should exit after firing\"\n+    assert len(loop._captured) == 1, (\n+        f\"Expected exactly one shutdown invocation, got {loop._captured}\"\n+    )\n+    fn, args = loop._captured[0]\n+    assert fn is shutdown_handler\n+    # The handler must be called with signal=None (planned stop sentinel).\n+    assert args == (None,)\n+\n+\n+def test_watcher_does_not_fire_when_marker_absent(tmp_path, monkeypatch):\n+    \"\"\"No marker = no shutdown call. Watcher just spins until stop_event.\"\"\"\n+    marker = tmp_path / \".gateway-planned-stop.json\"\n+    # Deliberately do NOT create the marker.\n+\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"_get_planned_stop_marker_path\", lambda: marker)\n+\n+    runner = _FakeRunner(running=True, draining=False)\n+    loop = _make_loop_capturing_calls()\n+    shutdown_handler = MagicMock()\n+    stop_event = threading.Event()\n+\n+    watcher = threading.Thread(\n+        target=_run_planned_stop_watcher,\n+        args=(stop_event, runner, loop, shutdown_handler),\n+        kwargs={\"poll_interval\": 0.05},\n+        daemon=True,\n+    )\n+    watcher.start()\n+    time.sleep(0.3)  # let it poll a few times\n+    stop_event.set()\n+    watcher.join(timeout=2.0)\n+\n+    assert not watcher.is_alive()\n+    assert loop._captured == [], (\n+        f\"No marker present, but watcher fired shutdown: {loop._captured}\"\n+    )\n+    shutdown_handler.assert_not_called()\n+\n+\n+def test_watcher_skips_when_runner_already_draining(tmp_path, monkeypatch):\n+    \"\"\"If shutdown is already in progress, don't re-fire the handler.\n+\n+    This prevents a race where the SIGTERM handler is mid-drain and the\n+    watcher would double-tap the shutdown path. We check ``_draining``\n+    so the watcher backs off once any shutdown is in flight.\n+    \"\"\"\n+    marker = tmp_path / \".gateway-planned-stop.json\"\n+    marker.write_text('{\"target_pid\": 1234}', encoding=\"utf-8\")\n+\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"_get_planned_stop_marker_path\", lambda: marker)\n+\n+    # Already draining — watcher should be a no-op.\n+    runner = _FakeRunner(running=False, draining=True)\n+    loop = _make_loop_capturing_calls()\n+    shutdown_handler = MagicMock()\n+    stop_event = threading.Event()\n+\n+    watcher = threading.Thread(\n+        target=_run_planned_stop_watcher,\n+        args=(stop_event, runner, loop, shutdown_handler),\n+        kwargs={\"poll_interval\": 0.05},\n+        daemon=True,\n+    )\n+    watcher.start()\n+    time.sleep(0.2)\n+    stop_event.set()\n+    watcher.join(timeout=2.0)\n+\n+    assert loop._captured == [], \"Watcher fired while runner was already draining\"\n+\n+\n+def test_watcher_skips_when_runner_not_started(tmp_path, monkeypatch):\n+    \"\"\"If the runner hasn't started, the marker is for a previous instance —\n+    we shouldn't shutdown a not-yet-running gateway.\n+    \"\"\"\n+    marker = tmp_path / \".gateway-planned-stop.json\"\n+    marker.write_text('{\"target_pid\": 9999}', encoding=\"utf-8\")\n+\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"_get_planned_stop_marker_path\", lambda: marker)\n+\n+    runner = _FakeRunner(running=False, draining=False)\n+    loop = _make_loop_capturing_calls()\n+    shutdown_handler = MagicMock()\n+    stop_event = threading.Event()\n+\n+    watcher = threading.Thread(\n+        target=_run_planned_stop_watcher,\n+        args=(stop_event, runner, loop, shutdown_handler),\n+        kwargs={\"poll_interval\": 0.05},\n+        daemon=True,\n+    )\n+    watcher.start()\n+    time.sleep(0.2)\n+    stop_event.set()\n+    watcher.join(timeout=2.0)\n+\n+    assert loop._captured == [], \"Watcher fired before runner was running\"\n+\n+\n+def test_watcher_responds_to_stop_event_promptly(tmp_path, monkeypatch):\n+    \"\"\"Setting stop_event must exit the watcher within ~poll_interval seconds.\"\"\"\n+    marker = tmp_path / \".gateway-planned-stop.json\"\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"_get_planned_stop_marker_path\", lambda: marker)\n+\n+    runner = _FakeRunner(running=True, draining=False)\n+    loop = _make_loop_capturing_calls()\n+    stop_event = threading.Event()\n+\n+    watcher = threading.Thread(\n+        target=_run_planned_stop_watcher,\n+        args=(stop_event, runner, loop, MagicMock()),\n+        kwargs={\"poll_interval\": 0.1},\n+        daemon=True,\n+    )\n+    watcher.start()\n+    time.sleep(0.05)\n+    started_stop = time.monotonic()\n+    stop_event.set()\n+    watcher.join(timeout=2.0)\n+    elapsed = time.monotonic() - started_stop\n+\n+    assert not watcher.is_alive()\n+    assert elapsed < 0.5, f\"Watcher took {elapsed:.2f}s to honour stop_event\"\n+\n+\n+def test_watcher_fires_only_once_when_marker_persists(tmp_path, monkeypatch):\n+    \"\"\"Marker file existing for multiple polls must NOT spam the handler.\n+\n+    The watcher fires once and exits its loop (the shutdown handler is\n+    responsible for consuming the marker on its own thread). If we\n+    re-fired on every tick, the handler would be invoked dozens of\n+    times before the gateway actually shuts down.\n+    \"\"\"\n+    marker = tmp_path / \".gateway-planned-stop.json\"\n+    marker.write_text('{\"target_pid\": 1234}', encoding=\"utf-8\")\n+\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"_get_planned_stop_marker_path\", lambda: marker)\n+\n+    runner = _FakeRunner(running=True, draining=False)\n+    loop = _make_loop_capturing_calls()\n+    stop_event = threading.Event()\n+\n+    watcher = threading.Thread(\n+        target=_run_planned_stop_watcher,\n+        args=(stop_event, runner, loop, MagicMock()),\n+        kwargs={\"poll_interval\": 0.05},\n+        daemon=True,\n+    )\n+    watcher.start()\n+    # Let the watcher tick several times — but it should exit after the first fire.\n+    watcher.join(timeout=1.0)\n+\n+    assert not watcher.is_alive()\n+    assert len(loop._captured) == 1, (\n+        f\"Watcher fired {len(loop._captured)} times; should fire once \"\n+        f\"and exit (events={loop._captured})\"\n+    )\n+\n+\n+def test_watcher_tolerates_marker_path_resolution_errors(tmp_path, monkeypatch, caplog):\n+    \"\"\"If _get_planned_stop_marker_path() raises, the watcher logs and continues.\"\"\"\n+    from gateway import status as status_mod\n+\n+    call_count = [0]\n+    def explode():\n+        call_count[0] += 1\n+        # First call (the one outside the loop, at thread start) is fine —\n+        # but subsequent .exists() calls on a corrupt Path could explode.\n+        if call_count[0] == 1:\n+            return tmp_path / \"nonexistent\"\n+        raise OSError(\"filesystem failed\")\n+\n+    monkeypatch.setattr(status_mod, \"_get_planned_stop_marker_path\", explode)\n+\n+    runner = _FakeRunner(running=True, draining=False)\n+    loop = _make_loop_capturing_calls()\n+    stop_event = threading.Event()\n+\n+    watcher = threading.Thread(\n+        target=_run_planned_stop_watcher,\n+        args=(stop_event, runner, loop, MagicMock()),\n+        kwargs={\"poll_interval\": 0.05},\n+        daemon=True,\n+    )\n+    watcher.start()\n+    time.sleep(0.2)\n+    stop_event.set()\n+    watcher.join(timeout=2.0)\n+\n+    assert not watcher.is_alive(), \"Watcher should still honour stop_event after errors\"\n+    # No shutdown fired because the marker never reported existence.\n+    assert loop._captured == []"]},{"file":"tests/hermes_cli/test_gateway_windows.py","hunks":["@@ -481,4 +481,221 @@ def test_uninstall_access_denied_declined_keeps_task_and_cleans_files(monkeypatc\n     out = capsys.readouterr().out\n     assert \"Skipped elevation\" in out\n     assert \"UAC is Windows' admin approval prompt\" in out\n-    assert \"Scheduled Task still registered\" in out\n\\ No newline at end of file\n+    assert \"Scheduled Task still registered\" in out\n+\n+\n+# ---------------------------------------------------------------------------\n+# stop() drain semantics — issue #33778\n+#\n+# Background: on Windows, asyncio.add_signal_handler raises NotImplementedError,\n+# so the gateway's SIGTERM handler (which drains in-flight agents and writes\n+# resume_pending=True) never fires when `hermes gateway stop` kills the\n+# process. The fix: stop() writes the planned_stop_marker first, waits for\n+# the gateway's marker-watcher thread to drain + exit cleanly, then escalates\n+# to taskkill if drain times out.\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_stop_writes_planned_stop_marker_before_killing(monkeypatch):\n+    \"\"\"stop() must write the planned-stop marker BEFORE any kill signal.\n+\n+    Without this, the gateway's drain loop never runs on Windows and\n+    sessions silently lose context across restarts.\n+    \"\"\"\n+    pid = 99999\n+    events = []\n+\n+    monkeypatch.setattr(gateway_windows, \"_assert_windows\", lambda: None)\n+    monkeypatch.setattr(gateway_windows, \"is_task_registered\", lambda: False)\n+\n+    # Stub the marker write so we can record the order of operations.\n+    from gateway import status as status_mod\n+\n+    def fake_write_marker(target_pid):\n+        events.append((\"write_marker\", target_pid))\n+        return True\n+\n+    def fake_pid_exists(check_pid):\n+        # Drain succeeds: pid \"exits\" right after the marker write.\n+        return (\"write_marker\", pid) not in events\n+\n+    monkeypatch.setattr(status_mod, \"write_planned_stop_marker\", fake_write_marker)\n+    monkeypatch.setattr(status_mod, \"_pid_exists\", fake_pid_exists)\n+    monkeypatch.setattr(status_mod, \"get_running_pid\", lambda: pid)\n+\n+    def fake_kill(**kwargs):\n+        events.append((\"kill\", kwargs.get(\"force\", False)))\n+        return 0\n+\n+    monkeypatch.setattr(\"hermes_cli.gateway.kill_gateway_processes\", fake_kill)\n+    monkeypatch.setattr(\"hermes_cli.gateway._get_restart_drain_timeout\", lambda: 5.0)\n+\n+    gateway_windows.stop()\n+\n+    # Marker MUST be written before any kill.\n+    kinds = [e[0] for e in events]\n+    assert \"write_marker\" in kinds, \"stop() never wrote the planned-stop marker\"\n+    marker_idx = kinds.index(\"write_marker\")\n+    kill_idx = kinds.index(\"kill\") if \"kill\" in kinds else len(kinds)\n+    assert marker_idx < kill_idx, (\n+        f\"stop() killed before writing the marker (events={events})\"\n+    )\n+\n+\n+def test_stop_waits_for_graceful_drain_before_force_kill(monkeypatch):\n+    \"\"\"When drain succeeds, stop() should NOT force-kill the gateway.\n+\n+    drained=True means the gateway exited cleanly after seeing the\n+    marker — escalating to taskkill /F afterwards would be wasted\n+    work and may emit confusing \"killed N processes\" output.\n+    \"\"\"\n+    pid = 88888\n+    events = []\n+\n+    monkeypatch.setattr(gateway_windows, \"_assert_windows\", lambda: None)\n+    monkeypatch.setattr(gateway_windows, \"is_task_registered\", lambda: False)\n+\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"write_planned_stop_marker\", lambda p: True)\n+\n+    # Simulate the gateway exiting cleanly after one poll tick.\n+    poll_count = [0]\n+    def fake_pid_exists(check_pid):\n+        poll_count[0] += 1\n+        return poll_count[0] < 2  # alive on first poll, gone on second\n+    monkeypatch.setattr(status_mod, \"_pid_exists\", fake_pid_exists)\n+    monkeypatch.setattr(status_mod, \"get_running_pid\", lambda: pid)\n+\n+    def fake_kill(**kwargs):\n+        events.append((\"kill\", kwargs.get(\"force\", False)))\n+        return 0\n+    monkeypatch.setattr(\"hermes_cli.gateway.kill_gateway_processes\", fake_kill)\n+    monkeypatch.setattr(\"hermes_cli.gateway._get_restart_drain_timeout\", lambda: 5.0)\n+\n+    gateway_windows.stop()\n+\n+    # kill_gateway_processes is still called as the no-op sweep, but\n+    # NOT with force=True — drain succeeded, gateway is already gone.\n+    assert events == [(\"kill\", False)], (\n+        f\"After clean drain, force kill should be disabled (events={events})\"\n+    )\n+\n+\n+def test_stop_escalates_to_force_kill_when_drain_times_out(monkeypatch):\n+    \"\"\"When drain times out, stop() MUST escalate to force=True.\n+\n+    Drain timeout = gateway is stuck or unresponsive. Without the\n+    taskkill /T /F escalation, the gateway stays alive and the next\n+    `hermes gateway start` fails with \"another instance is running\".\n+    \"\"\"\n+    pid = 77777\n+    events = []\n+\n+    monkeypatch.setattr(gateway_windows, \"_assert_windows\", lambda: None)\n+    monkeypatch.setattr(gateway_windows, \"is_task_registered\", lambda: False)\n+\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"write_planned_stop_marker\", lambda p: True)\n+    # PID never exits — drain times out.\n+    monkeypatch.setattr(status_mod, \"_pid_exists\", lambda check_pid: True)\n+    monkeypatch.setattr(status_mod, \"get_running_pid\", lambda: pid)\n+\n+    def fake_kill(**kwargs):\n+        events.append((\"kill\", kwargs.get(\"force\", False)))\n+        return 1\n+    monkeypatch.setattr(\"hermes_cli.gateway.kill_gateway_processes\", fake_kill)\n+    # Tiny drain timeout to keep the test fast.\n+    monkeypatch.setattr(\"hermes_cli.gateway._get_restart_drain_timeout\", lambda: 1.0)\n+\n+    gateway_windows.stop()\n+\n+    # When drain times out, kill is invoked with force=True so taskkill /T /F\n+    # walks the process tree.\n+    assert events == [(\"kill\", True)], (\n+        f\"After drain timeout, kill must use force=True (events={events})\"\n+    )\n+\n+\n+def test_stop_no_running_gateway_skips_drain(monkeypatch):\n+    \"\"\"When no gateway is running, skip the drain wait entirely.\"\"\"\n+    events = []\n+\n+    monkeypatch.setattr(gateway_windows, \"_assert_windows\", lambda: None)\n+    monkeypatch.setattr(gateway_windows, \"is_task_registered\", lambda: False)\n+\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"get_running_pid\", lambda: None)\n+\n+    def fake_write_marker(target_pid):\n+        events.append((\"write_marker\", target_pid))\n+        return True\n+    monkeypatch.setattr(status_mod, \"write_planned_stop_marker\", fake_write_marker)\n+    monkeypatch.setattr(status_mod, \"_pid_exists\", lambda check_pid: False)\n+\n+    def fake_kill(**kwargs):\n+        events.append((\"kill\", kwargs.get(\"force\", False)))\n+        return 0\n+    monkeypatch.setattr(\"hermes_cli.gateway.kill_gateway_processes\", fake_kill)\n+    monkeypatch.setattr(\"hermes_cli.gateway._get_restart_drain_timeout\", lambda: 5.0)\n+\n+    gateway_windows.stop()\n+\n+    # With no PID to drain, no marker is written.  Kill sweep still runs\n+    # (defensive — covers the case where a stray gateway is alive without\n+    # a PID file).  force=True because drained=False.\n+    assert (\"write_marker\", None) not in events\n+    assert all(e[0] != \"write_marker\" for e in events), (\n+        f\"Should not write marker when no PID is running (events={events})\"\n+    )\n+    assert events == [(\"kill\", True)]\n+\n+\n+def test_drain_helper_handles_invalid_pid(monkeypatch):\n+    \"\"\"_drain_gateway_pid returns False for invalid PIDs without crashing.\"\"\"\n+    assert gateway_windows._drain_gateway_pid(0, 5.0) is False\n+    assert gateway_windows._drain_gateway_pid(-1, 5.0) is False\n+\n+\n+def test_drain_helper_returns_true_when_pid_exits_quickly(monkeypatch):\n+    \"\"\"_drain_gateway_pid polls _pid_exists until it returns False.\"\"\"\n+    pid = 66666\n+    poll_count = [0]\n+\n+    def fake_pid_exists(check_pid):\n+        poll_count[0] += 1\n+        return poll_count[0] < 3  # alive twice, then gone\n+\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"write_planned_stop_marker\", lambda p: True)\n+    monkeypatch.setattr(status_mod, \"_pid_exists\", fake_pid_exists)\n+\n+    assert gateway_windows._drain_gateway_pid(pid, drain_timeout=5.0) is True\n+\n+\n+def test_drain_helper_returns_false_on_timeout(monkeypatch):\n+    \"\"\"_drain_gateway_pid returns False when the PID never exits.\"\"\"\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"write_planned_stop_marker\", lambda p: True)\n+    monkeypatch.setattr(status_mod, \"_pid_exists\", lambda check_pid: True)\n+\n+    assert gateway_windows._drain_gateway_pid(55555, drain_timeout=1.0) is False\n+\n+\n+def test_drain_helper_still_waits_if_marker_write_fails(monkeypatch):\n+    \"\"\"Marker-write failures are swallowed; drain still polls for PID exit.\n+\n+    If the marker can't be written (disk full, permission error), the\n+    gateway can't drain — but the wait still happens so a slow-shutdown\n+    gateway from a different code path (e.g. SIGTERM working on this\n+    platform after all) still gets observed cleanly.\n+    \"\"\"\n+    pid = 44444\n+    def fake_write(target_pid):\n+        raise OSError(\"disk full\")\n+\n+    from gateway import status as status_mod\n+    monkeypatch.setattr(status_mod, \"write_planned_stop_marker\", fake_write)\n+    monkeypatch.setattr(status_mod, \"_pid_exists\", lambda check_pid: False)\n+\n+    # Returns True because _pid_exists immediately says \"gone\".\n+    assert gateway_windows._drain_gateway_pid(pid, drain_timeout=5.0) is True\n\\ No newline at end of file\n"]}]},{"kind":"pr","number":33823,"title":"fix(windows): write planned_stop_marker on stop() to match Unix behavior (#33778)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33798","number":33798}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":59501,"title":"fix(windows): write restart-pending marker before gateway relaunch","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}