From 6094069ef6ceeef02f18cf388b685c69eaf022ea Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 9 Jul 2026 14:20:05 -0400 Subject: [PATCH] feat: auto-restart Gitea MCP namespaces when stale or git HEAD advances (closes #591) --- gitea_mcp_server.py | 58 +++++++++++++++++++++++++++++-- tests/test_mcp_stale_runtime.py | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 209359e..487f2ee 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -159,6 +159,17 @@ if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) import json +import subprocess + +_process_boot_head_sha = None +try: + _process_boot_head_sha = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, text=True, check=True, cwd=PROJECT_ROOT + ).stdout.strip() +except Exception: + pass + _preflight_whoami_called = False _preflight_capability_called = False @@ -9698,6 +9709,34 @@ def gitea_route_task_session( ) +_restart_triggered = False + + +def _trigger_mcp_auto_restart(): + global _restart_triggered + if _restart_triggered or _preflight_in_test_mode(): + return + _restart_triggered = True + + config_path = os.environ.get( + "MCP_CONFIG_PATH", + os.path.expanduser("~/.gemini/config/mcp_config.json") + ) + + try: + if os.path.exists(config_path): + os.utime(config_path, None) + except Exception: + pass + + import threading + import time + def delayed_exit(): + time.sleep(1.0) + os._exit(0) + threading.Thread(target=delayed_exit, daemon=True).start() + + def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> list[str]: """Check running runtimes and return errors if they are missing or stale.""" import subprocess @@ -9719,6 +9758,19 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> except Exception as exc: return [f"stale-runtime: failed to list running processes: {exc}"] + current_head_sha = None + try: + current_head_sha = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, text=True, check=True, cwd=PROJECT_ROOT + ).stdout.strip() + except Exception: + pass + + git_stale = False + if _process_boot_head_sha and current_head_sha and _process_boot_head_sha != current_head_sha: + git_stale = True + self_pid = os.getpid() self_stale = False @@ -9754,7 +9806,7 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> if match: profile = match.group(1) - is_stale = start_time < code_mtime + is_stale = (start_time < code_mtime) or git_stale if pid == self_pid and is_stale: self_stale = True @@ -9766,9 +9818,11 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> } if self_stale: + _trigger_mcp_auto_restart() reasons.append( "stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). " - "Please fully restart the Gitea MCP server (e.g. touch /Users/jasonwalker/.gemini/config/mcp_config.json) and retry." + "Auto-restart has been triggered: touched mcp_config.json to reload the daemon. " + "The current process will cleanly exit shortly." ) if matching_profiles: diff --git a/tests/test_mcp_stale_runtime.py b/tests/test_mcp_stale_runtime.py index 19310c2..ddd6287 100644 --- a/tests/test_mcp_stale_runtime.py +++ b/tests/test_mcp_stale_runtime.py @@ -67,5 +67,66 @@ class TestMcpStaleRuntime(unittest.TestCase): # But does NOT contain missing author profile error self.assertFalse(any("None of the matching profiles for task" in r for r in reasons_author)) + @patch("subprocess.run") + @patch("os.path.getmtime") + @patch("os.path.exists") + @patch("os.getpid") + @patch.dict("os.environ", {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}) + def test_git_head_change_triggers_staleness(self, mock_getpid, mock_exists, mock_getmtime, mock_run): + # Set boot SHA to FAKE1 and current to FAKE2 + gitea_mcp_server._process_boot_head_sha = "FAKE1" + mock_getpid.return_value = 12345 + mock_exists.return_value = True + + # Code time is in the past (fresh process chronologically) + code_time = datetime(2026, 7, 8, 11, 0, 0) + mock_getmtime.return_value = code_time.timestamp() + + ps_output = ( + " PID LSTART COMMAND\n" + "12345 Wed Jul 8 12:00:00 2026 /path/to/python mcp_server.py\n" + ) + + mock_run_ps = MagicMock() + mock_run_ps.stdout = ps_output + + mock_run_env = MagicMock() + mock_run_env.stdout = "GITEA_MCP_PROFILE=prgs-author" + + mock_run_git = MagicMock() + mock_run_git.stdout = "FAKE2" # different SHA + + def side_effect(args, **kwargs): + if args[0] == "ps" and "eww" in args: + return mock_run_env + elif args[0] == "ps": + return mock_run_ps + elif args[0] == "git" and "rev-parse" in args: + return mock_run_git + raise ValueError(f"Unexpected: {args}") + + mock_run.side_effect = side_effect + + # Stale even though process started AFTER code mtime, because git HEAD changed + reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"]) + self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons)) + + @patch("threading.Thread") + @patch("os.utime") + @patch("os.path.exists") + @patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"}) + def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread): + mock_exists.return_value = True + gitea_mcp_server._restart_triggered = False + + # Ensure we are not skipped in test mode for testing purposes + with patch("gitea_mcp_server._preflight_in_test_mode", return_value=False): + gitea_mcp_server._trigger_mcp_auto_restart() + + mock_utime.assert_called_once_with("/tmp/mcp_config.json", None) + mock_thread.assert_called_once() + self.assertTrue(gitea_mcp_server._restart_triggered) + + if __name__ == "__main__": unittest.main()