{"repo":"NousResearch/hermes-agent","public_id":"4017","display_label":"Fix /update command process isolation method","summary":"These members address a bug where the /update Telegram command fails when the gateway runs as a system systemd service, proposing to replace systemd-run --user with setsid as the process isolation mechanism.","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":4104,"merged_at":"2026-03-31T03:22:12+00:00"},"resolution":{"state":"resolved","by":"pr-4104"},"freshness":{"state":"stale","last_verified_at":"2026-03-31T03:22:12+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":4017,"pr_number":4104,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-31T03:22:12+00:00"}],"members":[{"kind":"issue","number":4017,"title":"/update via Telegram fails when gateway runs under a system systemd service","url":null,"state":"closed"},{"kind":"pr","number":4024,"title":"fix(gateway): use setsid instead of systemd-run --user for /update","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4104","number":4104}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":4104,"title":"fix(gateway): use setsid instead of systemd-run --user for /update (salvage #4024)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/main.py","tests/gateway/test_update_command.py","tests/hermes_cli/test_update_gateway_restart.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -4686,8 +4686,8 @@ async def _handle_deny_command(self, event: MessageEvent) -> str:\n     async def _handle_update_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /update command — update Hermes Agent to the latest version.\n \n-        Spawns ``hermes update`` in a separate systemd scope so it survives the\n-        gateway restart that ``hermes update`` may trigger at the end. Marker\n+        Spawns ``hermes update`` in a detached session (via ``setsid``) so it\n+        survives the gateway restart that ``hermes update`` may trigger. Marker\n         files are written so either the current gateway process or the next one\n         can notify the user when the update finishes.\n         \"\"\"","@@ -4723,28 +4723,28 @@ async def _handle_update_command(self, event: MessageEvent) -> str:\n         pending_path.write_text(json.dumps(pending))\n         exit_code_path.unlink(missing_ok=True)\n \n-        # Spawn `hermes update` in a separate cgroup so it survives gateway\n-        # restart. systemd-run --user --scope creates a transient scope unit.\n+        # Spawn `hermes update` detached so it survives gateway restart.\n+        # Use setsid for portable session detach (works under system services\n+        # where systemd-run --user fails due to missing D-Bus session).\n         hermes_cmd_str = \" \".join(shlex.quote(part) for part in hermes_cmd)\n         update_cmd = (\n             f\"{hermes_cmd_str} update > {shlex.quote(str(output_path))} 2>&1; \"\n             f\"status=$?; printf '%s' \\\"$status\\\" > {shlex.quote(str(exit_code_path))}\"\n         )\n         try:\n-            systemd_run = shutil.which(\"systemd-run\")\n-            if systemd_run:\n+            setsid_bin = shutil.which(\"setsid\")\n+            if setsid_bin:\n+                # Preferred: setsid creates a new session, fully detached\n                 subprocess.Popen(\n-                    [systemd_run, \"--user\", \"--scope\",\n-                     \"--unit=hermes-update\", \"--\",\n-                     \"bash\", \"-c\", update_cmd],\n+                    [setsid_bin, \"bash\", \"-c\", update_cmd],\n                     stdout=subprocess.DEVNULL,\n                     stderr=subprocess.DEVNULL,\n                     start_new_session=True,\n                 )\n             else:\n-                # Fallback: best-effort detach with start_new_session\n+                # Fallback: start_new_session=True calls os.setsid() in child\n                 subprocess.Popen(\n-                    [\"bash\", \"-c\", f\"nohup {update_cmd} &\"],\n+                    [\"bash\", \"-c\", update_cmd],\n                     stdout=subprocess.DEVNULL,\n                     stderr=subprocess.DEVNULL,\n                     start_new_session=True,"]},{"file":"hermes_cli/main.py","hunks":["@@ -3156,6 +3156,7 @@ def cmd_update(args):\n             _gw_service_name = get_service_name()\n             existing_pid = get_running_pid()\n             has_systemd_service = False\n+            has_system_service = False\n             has_launchd_service = False\n \n             try:","@@ -3168,6 +3169,19 @@ def cmd_update(args):\n             except (FileNotFoundError, subprocess.TimeoutExpired):\n                 pass\n \n+            # Also check for a system-level service (hermes gateway install --system).\n+            # This covers gateways running under system systemd where --user\n+            # fails due to missing D-Bus session.\n+            if not has_systemd_service and is_linux():\n+                try:\n+                    check = subprocess.run(\n+                        [\"systemctl\", \"is-active\", _gw_service_name],\n+                        capture_output=True, text=True, timeout=5,\n+                    )\n+                    has_system_service = check.stdout.strip() == \"active\"\n+                except (FileNotFoundError, subprocess.TimeoutExpired):\n+                    pass\n+\n             # Check for macOS launchd service\n             if is_macos():\n                 try:","@@ -3182,7 +3196,7 @@ def cmd_update(args):\n                 except (FileNotFoundError, subprocess.TimeoutExpired):\n                     pass\n \n-            if existing_pid or has_systemd_service or has_launchd_service:\n+            if existing_pid or has_systemd_service or has_system_service or has_launchd_service:\n                 print()\n \n                 # When a service manager is handling the gateway, let it","@@ -3223,6 +3237,21 @@ def cmd_update(args):\n                                 print(\"    hermes gateway restart\")\n                             else:\n                                 print(\"  Try manually: hermes gateway restart\")\n+                elif has_system_service:\n+                    # System-level service (hermes gateway install --system).\n+                    # No D-Bus session needed — systemctl without --user talks\n+                    # directly to the system manager over /run/systemd/private.\n+                    print(\"→ Restarting system gateway service...\")\n+                    restart = subprocess.run(\n+                        [\"systemctl\", \"restart\", _gw_service_name],\n+                        capture_output=True, text=True, timeout=15,\n+                    )\n+                    if restart.returncode == 0:\n+                        print(\"✓ Gateway restarted (system service).\")\n+                    else:\n+                        print(f\"⚠ Gateway restart failed: {restart.stderr.strip()}\")\n+                        print(\"  System services may require root.  Try:\")\n+                        print(f\"    sudo systemctl restart {_gw_service_name}\")\n                 elif has_launchd_service:\n                     # Refresh the plist first (picks up --replace and other\n                     # changes from the update we just pulled)."]},{"file":"tests/gateway/test_update_command.py","hunks":["@@ -191,7 +191,7 @@ async def test_writes_pending_marker(self, tmp_path):\n \n         with patch(\"gateway.run._hermes_home\", hermes_home), \\\n              patch(\"gateway.run.__file__\", fake_file), \\\n-             patch(\"shutil.which\", side_effect=lambda x: \"/usr/bin/hermes\" if x == \"hermes\" else \"/usr/bin/systemd-run\"), \\\n+             patch(\"shutil.which\", side_effect=lambda x: \"/usr/bin/hermes\" if x == \"hermes\" else \"/usr/bin/setsid\"), \\\n              patch(\"subprocess.Popen\"):\n             result = await runner._handle_update_command(event)\n ","@@ -204,8 +204,8 @@ async def test_writes_pending_marker(self, tmp_path):\n         assert not (hermes_home / \".update_exit_code\").exists()\n \n     @pytest.mark.asyncio\n-    async def test_spawns_systemd_run(self, tmp_path):\n-        \"\"\"Uses systemd-run when available.\"\"\"\n+    async def test_spawns_setsid(self, tmp_path):\n+        \"\"\"Uses setsid when available.\"\"\"\n         runner = _make_runner()\n         event = _make_event()\n ","@@ -225,16 +225,16 @@ async def test_spawns_systemd_run(self, tmp_path):\n              patch(\"subprocess.Popen\", mock_popen):\n             result = await runner._handle_update_command(event)\n \n-        # Verify systemd-run was used\n+        # Verify setsid was used\n         call_args = mock_popen.call_args[0][0]\n-        assert call_args[0] == \"/usr/bin/systemd-run\"\n-        assert \"--scope\" in call_args\n+        assert call_args[0] == \"/usr/bin/setsid\"\n+        assert call_args[1] == \"bash\"\n         assert \".update_exit_code\" in call_args[-1]\n         assert \"Starting Hermes update\" in result\n \n     @pytest.mark.asyncio\n-    async def test_fallback_nohup_when_no_systemd_run(self, tmp_path):\n-        \"\"\"Falls back to nohup when systemd-run is not available.\"\"\"\n+    async def test_fallback_when_no_setsid(self, tmp_path):\n+        \"\"\"Falls back to start_new_session=True when setsid is not available.\"\"\"\n         runner = _make_runner()\n         event = _make_event()\n ","@@ -249,24 +249,27 @@ async def test_fallback_nohup_when_no_systemd_run(self, tmp_path):\n \n         mock_popen = MagicMock()\n \n-        def which_no_systemd(x):\n+        def which_no_setsid(x):\n             if x == \"hermes\":\n                 return \"/usr/bin/hermes\"\n-            if x == \"systemd-run\":\n+            if x == \"setsid\":\n                 return None\n             return None\n \n         with patch(\"gateway.run._hermes_home\", hermes_home), \\\n              patch(\"gateway.run.__file__\", fake_file), \\\n-             patch(\"shutil.which\", side_effect=which_no_systemd), \\\n+             patch(\"shutil.which\", side_effect=which_no_setsid), \\\n              patch(\"subprocess.Popen\", mock_popen):\n             result = await runner._handle_update_command(event)\n \n-        # Verify bash -c nohup fallback was used\n+        # Verify plain bash -c fallback (no nohup, no setsid)\n         call_args = mock_popen.call_args[0][0]\n         assert call_args[0] == \"bash\"\n-        assert \"nohup\" in call_args[2]\n+        assert \"nohup\" not in call_args[2]\n         assert \".update_exit_code\" in call_args[2]\n+        # start_new_session=True should be in kwargs\n+        call_kwargs = mock_popen.call_args[1]\n+        assert call_kwargs.get(\"start_new_session\") is True\n         assert \"Starting Hermes update\" in result\n \n     @pytest.mark.asyncio"]},{"file":"tests/hermes_cli/test_update_gateway_restart.py","hunks":["@@ -25,6 +25,8 @@ def _make_run_side_effect(\n     verify_ok=True,\n     commit_count=\"3\",\n     systemd_active=False,\n+    system_service_active=False,\n+    system_restart_rc=0,\n     launchctl_loaded=False,\n ):\n     \"\"\"Build a subprocess.run side_effect that simulates git + service commands.\"\"\"","@@ -45,14 +47,23 @@ def side_effect(cmd, **kwargs):\n         if \"rev-list\" in joined:\n             return subprocess.CompletedProcess(cmd, 0, stdout=f\"{commit_count}\\n\", stderr=\"\")\n \n-        # systemctl --user is-active\n+        # systemctl is-active — distinguish --user from system scope\n         if \"systemctl\" in joined and \"is-active\" in joined:\n-            if systemd_active:\n-                return subprocess.CompletedProcess(cmd, 0, stdout=\"active\\n\", stderr=\"\")\n-            return subprocess.CompletedProcess(cmd, 3, stdout=\"inactive\\n\", stderr=\"\")\n-\n-        # systemctl --user restart\n+            if \"--user\" in joined:\n+                if systemd_active:\n+                    return subprocess.CompletedProcess(cmd, 0, stdout=\"active\\n\", stderr=\"\")\n+                return subprocess.CompletedProcess(cmd, 3, stdout=\"inactive\\n\", stderr=\"\")\n+            else:\n+                # System-level check (no --user)\n+                if system_service_active:\n+                    return subprocess.CompletedProcess(cmd, 0, stdout=\"active\\n\", stderr=\"\")\n+                return subprocess.CompletedProcess(cmd, 3, stdout=\"inactive\\n\", stderr=\"\")\n+\n+        # systemctl restart — distinguish --user from system scope\n         if \"systemctl\" in joined and \"restart\" in joined:\n+            if \"--user\" not in joined and system_service_active:\n+                stderr = \"\" if system_restart_rc == 0 else \"Failed to restart: Permission denied\"\n+                return subprocess.CompletedProcess(cmd, system_restart_rc, stdout=\"\", stderr=stderr)\n             return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n \n         # launchctl list ai.hermes.gateway","@@ -393,3 +404,91 @@ def test_update_no_gateway_running_skips_restart(\n         assert \"Stopped gateway\" not in captured\n         assert \"Gateway restarted\" not in captured\n         assert \"Gateway restarted via launchd\" not in captured\n+\n+\n+# ---------------------------------------------------------------------------\n+# cmd_update — system-level systemd service detection\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestCmdUpdateSystemService:\n+    \"\"\"cmd_update detects system-level gateway services where --user fails.\"\"\"\n+\n+    @patch(\"shutil.which\", return_value=None)\n+    @patch(\"subprocess.run\")\n+    def test_update_detects_system_service_and_restarts(\n+        self, mock_run, _mock_which, mock_args, capsys, monkeypatch,\n+    ):\n+        \"\"\"When user systemd is inactive but a system service exists, restart via system scope.\"\"\"\n+        monkeypatch.setattr(gateway_cli, \"is_macos\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_linux\", lambda: True)\n+\n+        mock_run.side_effect = _make_run_side_effect(\n+            commit_count=\"3\",\n+            systemd_active=False,\n+            system_service_active=True,\n+        )\n+\n+        with patch(\"gateway.status.get_running_pid\", return_value=12345), \\\n+             patch(\"gateway.status.remove_pid_file\"):\n+            cmd_update(mock_args)\n+\n+        captured = capsys.readouterr().out\n+        assert \"system gateway service\" in captured.lower()\n+        assert \"Gateway restarted (system service)\" in captured\n+        # Verify systemctl restart (no --user) was called\n+        restart_calls = [\n+            c for c in mock_run.call_args_list\n+            if \"restart\" in \" \".join(str(a) for a in c.args[0])\n+            and \"systemctl\" in \" \".join(str(a) for a in c.args[0])\n+            and \"--user\" not in \" \".join(str(a) for a in c.args[0])\n+        ]\n+        assert len(restart_calls) == 1\n+\n+    @patch(\"shutil.which\", return_value=None)\n+    @patch(\"subprocess.run\")\n+    def test_update_system_service_restart_failure_shows_sudo_hint(\n+        self, mock_run, _mock_which, mock_args, capsys, monkeypatch,\n+    ):\n+        \"\"\"When system service restart fails (e.g. no root), show sudo hint.\"\"\"\n+        monkeypatch.setattr(gateway_cli, \"is_macos\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_linux\", lambda: True)\n+\n+        mock_run.side_effect = _make_run_side_effect(\n+            commit_count=\"3\",\n+            systemd_active=False,\n+            system_service_active=True,\n+            system_restart_rc=1,\n+        )\n+\n+        with patch(\"gateway.status.get_running_pid\", return_value=12345), \\\n+             patch(\"gateway.status.remove_pid_file\"):\n+            cmd_update(mock_args)\n+\n+        captured = capsys.readouterr().out\n+        assert \"sudo systemctl restart\" in captured\n+\n+    @patch(\"shutil.which\", return_value=None)\n+    @patch(\"subprocess.run\")\n+    def test_user_service_takes_priority_over_system(\n+        self, mock_run, _mock_which, mock_args, capsys, monkeypatch,\n+    ):\n+        \"\"\"When both user and system services are active, user wins.\"\"\"\n+        monkeypatch.setattr(gateway_cli, \"is_macos\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_linux\", lambda: True)\n+\n+        mock_run.side_effect = _make_run_side_effect(\n+            commit_count=\"3\",\n+            systemd_active=True,\n+            system_service_active=True,\n+        )\n+\n+        with patch(\"gateway.status.get_running_pid\", return_value=12345), \\\n+             patch(\"gateway.status.remove_pid_file\"), \\\n+             patch(\"os.kill\"):\n+            cmd_update(mock_args)\n+\n+        captured = capsys.readouterr().out\n+        # Should restart via user service, not system\n+        assert \"Gateway restarted.\" in captured\n+        assert \"(system service)\" not in captured\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}