fix(mcp): make capability resolver side-effect free (Closes #685)
Stale-runtime detection remains fail-closed, but gitea_resolve_task_capability must never touch mcp_config.json, spawn recovery threads, or call os._exit. - Remove _trigger_mcp_auto_restart from the read-only diagnostics path - Return blocker_kind=runtime_reconnect_required with mutation_performed=false - Keep exact_safe_next_action pointing at IDE/client reconnect when stale - Add regression suite across author/reviewer/merger/reconciler profiles
This commit is contained in:
+50
-46
@@ -13659,36 +13659,18 @@ 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()
|
||||
# #685: resolver stale-runtime detection is report-only. Config touch / os._exit
|
||||
# self-recovery was removed from the read-only path (was _trigger_mcp_auto_restart).
|
||||
# Recovery is owned exclusively by the IDE/client reconnect path.
|
||||
|
||||
|
||||
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."""
|
||||
"""Read-only: report missing or stale MCP runtimes (no config or process mutation).
|
||||
|
||||
#685: Never touches MCP client config, never spawns recovery threads, never
|
||||
calls ``os._exit``. Stale detection remains fail-closed via returned reasons
|
||||
only; the IDE/client owns reconnect/reload.
|
||||
"""
|
||||
import subprocess
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -13768,11 +13750,13 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
|
||||
}
|
||||
|
||||
if self_stale:
|
||||
_trigger_mcp_auto_restart()
|
||||
# #685: report-only — no config utime, no thread, no os._exit.
|
||||
reasons.append(
|
||||
"stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). "
|
||||
"Auto-restart has been triggered: touched mcp_config.json to reload the daemon. "
|
||||
"The current process will cleanly exit shortly."
|
||||
"stale-runtime: The active Gitea MCP server process is stale "
|
||||
"(running code from before changes were merged). "
|
||||
"Reconnect the IDE/client-managed MCP namespace for this profile "
|
||||
"so it reloads current master. The resolver does not touch "
|
||||
"mcp_config.json, spawn recovery threads, or terminate this process."
|
||||
)
|
||||
|
||||
if matching_profiles:
|
||||
@@ -13804,10 +13788,15 @@ def gitea_resolve_task_capability(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: Resolve which capability, profile, and namespace is required for a Gitea task.
|
||||
"""Read-only / side-effect free: resolve capability, profile, and namespace for a task.
|
||||
|
||||
Helps the client or LLM determine the correct namespace or profile before acting,
|
||||
and returns exact next action instructions if the current session is not authorized.
|
||||
Does **not** mutate MCP client configuration, spawn recovery threads, kill
|
||||
processes, or trigger daemon reloads (#685). Stale-runtime detection remains
|
||||
fail-closed: when the serving process is stale the result includes
|
||||
``blocker_kind=runtime_reconnect_required``, ``restart_required=true``,
|
||||
``stop_required=true``, and ``mutation_performed=false`` with a precise
|
||||
``exact_safe_next_action`` pointing at IDE/client reconnect. Recovery is
|
||||
owned by the client reconnect path — never by this resolver.
|
||||
|
||||
Args:
|
||||
task: The task/action to check (e.g. review_pr, create_issue).
|
||||
@@ -13992,31 +13981,32 @@ def gitea_resolve_task_capability(
|
||||
|
||||
configured = len(matching_profiles) > 0
|
||||
available_in_session = allowed_in_current_session
|
||||
runtime_stale_blocker = False
|
||||
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||
runtime_reasons = _check_mcp_runtimes_diagnostics(task, matching_profiles)
|
||||
if runtime_reasons:
|
||||
restart_required = True
|
||||
runtime_stale_blocker = True
|
||||
reason_msg = "; ".join(runtime_reasons)
|
||||
next_safe_action = (
|
||||
"stale-runtime: Gitea MCP runtime conflict or missing process detected. "
|
||||
"Please fully restart the Gitea MCP server and retry."
|
||||
)
|
||||
|
||||
if not allowed_in_current_session:
|
||||
if configured and switching:
|
||||
restart_required = True
|
||||
available_in_session = False
|
||||
reason_msg = (
|
||||
f"{required_role.capitalize()} profile exists but MCP server "
|
||||
"was added after session startup and is not attached."
|
||||
)
|
||||
if not reason_msg:
|
||||
reason_msg = (
|
||||
f"{required_role.capitalize()} profile exists but MCP server "
|
||||
"was added after session startup and is not attached."
|
||||
)
|
||||
elif not configured:
|
||||
reason_msg = (
|
||||
f"No profile configured with permission '{required_permission}'."
|
||||
)
|
||||
if not reason_msg:
|
||||
reason_msg = (
|
||||
f"No profile configured with permission '{required_permission}'."
|
||||
)
|
||||
elif role_mismatch_reason:
|
||||
reason_msg = role_mismatch_reason
|
||||
if not reason_msg:
|
||||
reason_msg = role_mismatch_reason
|
||||
different_namespace_required = False
|
||||
next_safe_action = "None; ready for operations."
|
||||
|
||||
@@ -14042,6 +14032,16 @@ def gitea_resolve_task_capability(
|
||||
"or use the corresponding MCP namespace."
|
||||
)
|
||||
|
||||
# #685: stale-runtime typed remediation wins for exact_next_action when the
|
||||
# serving process/profile inventory is stale — even if permission is OK.
|
||||
if runtime_stale_blocker:
|
||||
next_safe_action = (
|
||||
"blocker_kind=runtime_reconnect_required: reconnect/restart the "
|
||||
"IDE-managed Gitea MCP server for this profile so it reloads current "
|
||||
"master. Do not edit mcp_config.json by hand; the resolver does not "
|
||||
"touch config, spawn recovery threads, or terminate the process."
|
||||
)
|
||||
|
||||
# Task/role alignment guards (#167): the requested task, not the
|
||||
# available credential, decides what the session may do. A review/merge
|
||||
# task under a non-reviewer profile must stop — not silently degrade
|
||||
@@ -14103,12 +14103,16 @@ def gitea_resolve_task_capability(
|
||||
"configured": configured,
|
||||
"restart_required": restart_required,
|
||||
"stop_required": stop_required or restart_required,
|
||||
# #685: resolver is always side-effect free; never claims mutations.
|
||||
"mutation_performed": False,
|
||||
"task_role_guidance": task_role_guidance,
|
||||
"matching_configured_profile": matching_profiles,
|
||||
"runtime_switching_supported": switching,
|
||||
"different_mcp_namespace_required": different_namespace_required,
|
||||
"exact_safe_next_action": next_safe_action,
|
||||
}
|
||||
if runtime_stale_blocker:
|
||||
result["blocker_kind"] = "runtime_reconnect_required"
|
||||
if reason_msg:
|
||||
result["reason"] = reason_msg
|
||||
if task in ("review_pr", "merge_pr"):
|
||||
|
||||
Reference in New Issue
Block a user