{"repo":"NousResearch/hermes-agent","public_id":"1005","display_label":"Telegram gateway stops on headless server logout","summary":"These items address the Telegram gateway stopping when a terminal session is closed on headless servers. A proposed fix involves automatically enabling systemd linger during gateway installation to keep the service running after logout.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1334,"merged_at":"2026-03-14T18:50:23+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-03-14T18:50:23+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":1005,"pr_number":1334,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-03-14T18:50:23+00:00"}],"members":[{"kind":"issue","number":1005,"title":"Telegram gateway stops responding when terminal is closed on headless servers","url":null,"state":"closed"},{"kind":"pr","number":1023,"title":"fix: auto-enable systemd linger during gateway install on headless servers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1334","number":1334}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1334,"title":"fix: auto-enable systemd linger during gateway install on headless servers","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/gateway.py","tests/hermes_cli/test_gateway.py","tests/hermes_cli/test_gateway_linger.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -251,7 +251,6 @@ def generate_systemd_unit() -> str:\n WantedBy=default.target\n \"\"\"\n \n-\n def _normalize_service_definition(text: str) -> str:\n     return \"\\n\".join(line.rstrip() for line in text.strip().splitlines())\n ","@@ -279,6 +278,65 @@ def refresh_systemd_unit_if_needed() -> bool:\n     return True\n \n \n+\n+def _print_linger_enable_warning(username: str, detail: str | None = None) -> None:\n+    print()\n+    print(\"⚠ Linger not enabled — gateway may stop when you close this terminal.\")\n+    if detail:\n+        print(f\"  Auto-enable failed: {detail}\")\n+    print()\n+    print(\"  On headless servers (VPS, cloud instances) run:\")\n+    print(f\"    sudo loginctl enable-linger {username}\")\n+    print()\n+    print(\"  Then restart the gateway:\")\n+    print(f\"    systemctl --user restart {SERVICE_NAME}.service\")\n+    print()\n+\n+\n+\n+def _ensure_linger_enabled() -> None:\n+    \"\"\"Enable linger when possible so the user gateway survives logout.\"\"\"\n+    if not is_linux():\n+        return\n+\n+    import getpass\n+    import shutil\n+\n+    username = getpass.getuser()\n+    linger_file = Path(f\"/var/lib/systemd/linger/{username}\")\n+    if linger_file.exists():\n+        print(\"✓ Systemd linger is enabled (service survives logout)\")\n+        return\n+\n+    linger_enabled, linger_detail = get_systemd_linger_status()\n+    if linger_enabled is True:\n+        print(\"✓ Systemd linger is enabled (service survives logout)\")\n+        return\n+\n+    if not shutil.which(\"loginctl\"):\n+        _print_linger_enable_warning(username, linger_detail or \"loginctl not found\")\n+        return\n+\n+    print(\"Enabling linger so the gateway survives SSH logout...\")\n+    try:\n+        result = subprocess.run(\n+            [\"loginctl\", \"enable-linger\", username],\n+            capture_output=True,\n+            text=True,\n+            check=False,\n+        )\n+    except Exception as e:\n+        _print_linger_enable_warning(username, str(e))\n+        return\n+\n+    if result.returncode == 0:\n+        print(\"✓ Linger enabled — gateway will persist after logout\")\n+        return\n+\n+    detail = (result.stderr or result.stdout or f\"exit {result.returncode}\").strip()\n+    _print_linger_enable_warning(username, detail or linger_detail)\n+\n+\n def systemd_install(force: bool = False):\n     unit_path = get_systemd_unit_path()\n     ","@@ -302,7 +360,7 @@ def systemd_install(force: bool = False):\n     print(f\"  hermes gateway status             # Check status\")\n     print(f\"  journalctl --user -u {SERVICE_NAME} -f  # View logs\")\n     print()\n-    print_systemd_linger_guidance()\n+    _ensure_linger_enabled()\n \n def systemd_uninstall():\n     subprocess.run([\"systemctl\", \"--user\", \"stop\", SERVICE_NAME], check=False)"]},{"file":"tests/hermes_cli/test_gateway.py","hunks":["@@ -59,15 +59,16 @@ def test_systemd_install_checks_linger_status(monkeypatch, tmp_path, capsys):\n     unit_path = tmp_path / \"systemd\" / \"user\" / \"hermes-gateway.service\"\n \n     monkeypatch.setattr(gateway, \"get_systemd_unit_path\", lambda: unit_path)\n-    monkeypatch.setattr(gateway, \"get_systemd_linger_status\", lambda: (False, \"\"))\n \n     calls = []\n+    helper_calls = []\n \n     def fake_run(cmd, check=False, **kwargs):\n         calls.append((cmd, check))\n         return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n \n     monkeypatch.setattr(gateway.subprocess, \"run\", fake_run)\n+    monkeypatch.setattr(gateway, \"_ensure_linger_enabled\", lambda: helper_calls.append(True))\n \n     gateway.systemd_install(force=False)\n ","@@ -77,6 +78,5 @@ def fake_run(cmd, check=False, **kwargs):\n         [\"systemctl\", \"--user\", \"daemon-reload\"],\n         [\"systemctl\", \"--user\", \"enable\", gateway.SERVICE_NAME],\n     ]\n+    assert helper_calls == [True]\n     assert \"Service installed and enabled\" in out\n-    assert \"Systemd linger is disabled\" in out\n-    assert \"loginctl enable-linger\" in out"]},{"file":"tests/hermes_cli/test_gateway_linger.py","hunks":["@@ -0,0 +1,120 @@\n+\"\"\"Tests for gateway linger auto-enable behavior on headless Linux installs.\"\"\"\n+\n+from types import SimpleNamespace\n+\n+import hermes_cli.gateway as gateway\n+\n+\n+class TestEnsureLingerEnabled:\n+    def test_linger_already_enabled_via_file(self, monkeypatch, capsys):\n+        monkeypatch.setattr(gateway, \"is_linux\", lambda: True)\n+        monkeypatch.setattr(\"getpass.getuser\", lambda: \"testuser\")\n+        monkeypatch.setattr(gateway, \"Path\", lambda _path: SimpleNamespace(exists=lambda: True))\n+\n+        calls = []\n+        monkeypatch.setattr(gateway.subprocess, \"run\", lambda *args, **kwargs: calls.append((args, kwargs)))\n+\n+        gateway._ensure_linger_enabled()\n+\n+        out = capsys.readouterr().out\n+        assert \"Systemd linger is enabled\" in out\n+        assert calls == []\n+\n+    def test_status_enabled_skips_enable(self, monkeypatch, capsys):\n+        monkeypatch.setattr(gateway, \"is_linux\", lambda: True)\n+        monkeypatch.setattr(\"getpass.getuser\", lambda: \"testuser\")\n+        monkeypatch.setattr(gateway, \"Path\", lambda _path: SimpleNamespace(exists=lambda: False))\n+        monkeypatch.setattr(gateway, \"get_systemd_linger_status\", lambda: (True, \"\"))\n+\n+        calls = []\n+        monkeypatch.setattr(gateway.subprocess, \"run\", lambda *args, **kwargs: calls.append((args, kwargs)))\n+\n+        gateway._ensure_linger_enabled()\n+\n+        out = capsys.readouterr().out\n+        assert \"Systemd linger is enabled\" in out\n+        assert calls == []\n+\n+    def test_loginctl_success_enables_linger(self, monkeypatch, capsys):\n+        monkeypatch.setattr(gateway, \"is_linux\", lambda: True)\n+        monkeypatch.setattr(\"getpass.getuser\", lambda: \"testuser\")\n+        monkeypatch.setattr(gateway, \"Path\", lambda _path: SimpleNamespace(exists=lambda: False))\n+        monkeypatch.setattr(gateway, \"get_systemd_linger_status\", lambda: (False, \"\"))\n+        monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/loginctl\")\n+\n+        run_calls = []\n+\n+        def fake_run(cmd, capture_output=False, text=False, check=False):\n+            run_calls.append((cmd, capture_output, text, check))\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway.subprocess, \"run\", fake_run)\n+\n+        gateway._ensure_linger_enabled()\n+\n+        out = capsys.readouterr().out\n+        assert \"Enabling linger\" in out\n+        assert \"Linger enabled\" in out\n+        assert run_calls == [([\"loginctl\", \"enable-linger\", \"testuser\"], True, True, False)]\n+\n+    def test_missing_loginctl_shows_manual_guidance(self, monkeypatch, capsys):\n+        monkeypatch.setattr(gateway, \"is_linux\", lambda: True)\n+        monkeypatch.setattr(\"getpass.getuser\", lambda: \"testuser\")\n+        monkeypatch.setattr(gateway, \"Path\", lambda _path: SimpleNamespace(exists=lambda: False))\n+        monkeypatch.setattr(gateway, \"get_systemd_linger_status\", lambda: (None, \"loginctl not found\"))\n+        monkeypatch.setattr(\"shutil.which\", lambda name: None)\n+\n+        calls = []\n+        monkeypatch.setattr(gateway.subprocess, \"run\", lambda *args, **kwargs: calls.append((args, kwargs)))\n+\n+        gateway._ensure_linger_enabled()\n+\n+        out = capsys.readouterr().out\n+        assert \"sudo loginctl enable-linger testuser\" in out\n+        assert \"loginctl not found\" in out\n+        assert calls == []\n+\n+    def test_loginctl_failure_shows_manual_guidance(self, monkeypatch, capsys):\n+        monkeypatch.setattr(gateway, \"is_linux\", lambda: True)\n+        monkeypatch.setattr(\"getpass.getuser\", lambda: \"testuser\")\n+        monkeypatch.setattr(gateway, \"Path\", lambda _path: SimpleNamespace(exists=lambda: False))\n+        monkeypatch.setattr(gateway, \"get_systemd_linger_status\", lambda: (False, \"\"))\n+        monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/loginctl\")\n+        monkeypatch.setattr(\n+            gateway.subprocess,\n+            \"run\",\n+            lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout=\"\", stderr=\"Permission denied\"),\n+        )\n+\n+        gateway._ensure_linger_enabled()\n+\n+        out = capsys.readouterr().out\n+        assert \"sudo loginctl enable-linger testuser\" in out\n+        assert \"Permission denied\" in out\n+\n+\n+def test_systemd_install_calls_linger_helper(monkeypatch, tmp_path, capsys):\n+    unit_path = tmp_path / \"systemd\" / \"user\" / \"hermes-gateway.service\"\n+\n+    monkeypatch.setattr(gateway, \"get_systemd_unit_path\", lambda: unit_path)\n+\n+    calls = []\n+\n+    def fake_run(cmd, check=False, **kwargs):\n+        calls.append((cmd, check))\n+        return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+    helper_calls = []\n+    monkeypatch.setattr(gateway.subprocess, \"run\", fake_run)\n+    monkeypatch.setattr(gateway, \"_ensure_linger_enabled\", lambda: helper_calls.append(True))\n+\n+    gateway.systemd_install(force=False)\n+\n+    out = capsys.readouterr().out\n+    assert unit_path.exists()\n+    assert [cmd for cmd, _ in calls] == [\n+        [\"systemctl\", \"--user\", \"daemon-reload\"],\n+        [\"systemctl\", \"--user\", \"enable\", gateway.SERVICE_NAME],\n+    ]\n+    assert helper_calls == [True]\n+    assert \"Service installed and enabled\" in out\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}