feat: auto-restart Gitea MCP namespaces when stale or git HEAD advances (#591) #593

Merged
sysadmin merged 1 commits from feat/issue-591-auto-restart-mcp into master 2026-07-09 15:46:34 -05:00
2 changed files with 117 additions and 2 deletions
+56 -2
View File
@@ -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:
+61
View File
@@ -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()