feat: auto-restart Gitea MCP namespaces when stale or git HEAD advances (#591) #593
+56
-2
@@ -159,6 +159,17 @@ if PROJECT_ROOT not in sys.path:
|
|||||||
sys.path.insert(0, PROJECT_ROOT)
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
import json
|
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_whoami_called = False
|
||||||
_preflight_capability_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]:
|
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."""
|
"""Check running runtimes and return errors if they are missing or stale."""
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -9719,6 +9758,19 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return [f"stale-runtime: failed to list running processes: {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_pid = os.getpid()
|
||||||
self_stale = False
|
self_stale = False
|
||||||
|
|
||||||
@@ -9754,7 +9806,7 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
|
|||||||
if match:
|
if match:
|
||||||
profile = match.group(1)
|
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:
|
if pid == self_pid and is_stale:
|
||||||
self_stale = True
|
self_stale = True
|
||||||
|
|
||||||
@@ -9766,9 +9818,11 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self_stale:
|
if self_stale:
|
||||||
|
_trigger_mcp_auto_restart()
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). "
|
"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:
|
if matching_profiles:
|
||||||
|
|||||||
@@ -67,5 +67,66 @@ class TestMcpStaleRuntime(unittest.TestCase):
|
|||||||
# But does NOT contain missing author profile error
|
# 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))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user