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:
2026-07-16 21:21:39 -04:00
parent 67e4a2b5e9
commit c780ded653
3 changed files with 383 additions and 61 deletions
+50 -46
View File
@@ -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"):