feat(mcp): expose sanctioned Codex MCP reconnect request (Closes #678)
Add gitea_request_mcp_reconnect as a report-only callable surface so Codex and other agent hosts can request host/IDE reconnect with typed blockers and exact UI steps. Never kills processes or edits config. Co-Authored-By: Grok 4.5 <[email protected]>
This commit is contained in:
+142
-8
@@ -2092,6 +2092,7 @@ import root_checkout_guard # noqa: E402
|
||||
import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards
|
||||
import stable_branch_push_guard # noqa: E402
|
||||
import runtime_recovery_guard # noqa: E402 # #630 manual daemon-kill contamination
|
||||
import mcp_client_reconnect # noqa: E402 # #678 sanctioned Codex reconnect request
|
||||
import remote_repo_guard # noqa: E402
|
||||
import anti_stomp_preflight # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
@@ -14024,11 +14025,16 @@ def _classify_operation_gate_reasons(reasons: list[str]) -> dict:
|
||||
|
||||
|
||||
def _stale_runtime_reconnect_action() -> str:
|
||||
"""Sanctioned recovery for a stale daemon — reconnect only (#685/#897)."""
|
||||
"""Sanctioned recovery for a stale daemon — reconnect only (#685/#897/#678)."""
|
||||
return (
|
||||
"Reconnect the IDE/client MCP session so the server reloads at the "
|
||||
"current master head. Do not call gitea_activate_profile or switch "
|
||||
"MCP role sessions — profile switching does not clear a stale daemon."
|
||||
"blocker_kind=runtime_reconnect_required: call "
|
||||
"gitea_request_mcp_reconnect(namespace=<active gitea-* namespace>, "
|
||||
"reason='stale-runtime', client='codex') for a typed operator "
|
||||
"reconnect blocker with exact UI steps, then reconnect the IDE/client "
|
||||
"MCP session so the server reloads at the current master head. Do not "
|
||||
"call gitea_activate_profile, pkill, touch configs, or switch MCP role "
|
||||
"sessions — profile switching does not clear a stale daemon. After "
|
||||
"reconnect restart from gitea_whoami → gitea_resolve_task_capability."
|
||||
)
|
||||
|
||||
|
||||
@@ -20998,10 +21004,15 @@ def gitea_resolve_task_capability(
|
||||
# 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."
|
||||
"blocker_kind=runtime_reconnect_required: call "
|
||||
"gitea_request_mcp_reconnect(namespace=<active gitea-* namespace>, "
|
||||
"reason='stale-runtime', client='codex') for a typed operator "
|
||||
"blocker with exact UI steps, then reconnect/reload the IDE-managed "
|
||||
"Gitea MCP server for this profile so it reloads current master. "
|
||||
"Do not edit mcp_config.json by hand, pkill, or touch configs; the "
|
||||
"resolver does not touch config, spawn recovery threads, or "
|
||||
"terminate the process. After reconnect restart from gitea_whoami → "
|
||||
"gitea_resolve_task_capability."
|
||||
)
|
||||
|
||||
# Task/role alignment guards (#167): the requested task, not the
|
||||
@@ -22559,6 +22570,129 @@ def gitea_workflow_dashboard(
|
||||
return payload
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_request_mcp_reconnect(
|
||||
namespace: str | None = None,
|
||||
reason: str | None = None,
|
||||
client: str = "codex",
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Request a sanctioned host/IDE MCP reconnect for a named namespace (#678).
|
||||
|
||||
Codex and other agent hosts can detect stale or closed Gitea MCP runtimes
|
||||
(``stop_required`` / ``restart_required`` from capability resolution, transport
|
||||
EOF, missing namespace attachment). The **host owns the transport** — this
|
||||
process cannot reopen the client's stdio pipe. This tool is the callable
|
||||
surface agents use to:
|
||||
|
||||
1. Report reconnect status fields (namespace, profile, pid/session,
|
||||
startup SHA, current master SHA, boundary status).
|
||||
2. Return a **typed blocker** with exact operator UI steps for Codex (or
|
||||
another client) when reconnect is required.
|
||||
|
||||
This tool **never** restarts, kills, reloads, or reconfigures an MCP
|
||||
process. Forbidden recovery paths (pkill, touch/mtime hacks, config/.env
|
||||
edits, session-state edits, raw API) are never recommended.
|
||||
|
||||
After the operator reconnects, workflows must restart from preflight:
|
||||
``gitea_whoami`` → ``gitea_resolve_task_capability`` → task.
|
||||
|
||||
Args:
|
||||
namespace: MCP namespace to reconnect (e.g. ``gitea-author``). Defaults
|
||||
to the active profile's inferred namespace.
|
||||
reason: Why reconnect is requested: ``stale-runtime``, ``transport_eof``,
|
||||
``missing_namespace``, ``not_required``, or free-form (normalized).
|
||||
client: Operator UI surface — ``codex`` (default), ``claude_code``, or
|
||||
``generic``.
|
||||
remote: Known instance — ``dadeschools`` or ``prgs`` (parity context).
|
||||
host: Optional host override for parity context.
|
||||
session_id: Optional session id to echo in the report.
|
||||
|
||||
Returns:
|
||||
dict with reconnect report fields, ``reconnect_performed=False``,
|
||||
``typed_blocker`` when reconnect is required, and
|
||||
``forbidden_recovery_paths``.
|
||||
"""
|
||||
# Read-only: gitea.read is sufficient. Never a mutation.
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"read_only": True,
|
||||
"reconnect_performed": False,
|
||||
"mutation_performed": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
"forbidden_recovery_paths": list(
|
||||
mcp_client_reconnect.FORBIDDEN_RECOVERY_PATHS
|
||||
),
|
||||
}
|
||||
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip() or None
|
||||
inferred_ns = role_namespace_gate.infer_mcp_namespace(profile_name)
|
||||
ns = (namespace or "").strip() or inferred_ns or "gitea-tools"
|
||||
|
||||
parity = _current_master_parity()
|
||||
startup_sha = (
|
||||
parity.get("daemon_start_head")
|
||||
or parity.get("startup_head")
|
||||
or _process_boot_head_sha
|
||||
)
|
||||
current_sha = parity.get("local_head") or parity.get("current_head")
|
||||
if not current_sha:
|
||||
try:
|
||||
current_sha = master_parity_gate.read_git_head(PROJECT_ROOT)
|
||||
except Exception: # noqa: BLE001
|
||||
current_sha = None
|
||||
|
||||
boundary = mcp_client_reconnect.classify_boundary_status(
|
||||
startup_sha=startup_sha if isinstance(startup_sha, str) else None,
|
||||
current_master_sha=current_sha if isinstance(current_sha, str) else None,
|
||||
live_stale=bool(parity.get("live_stale")) if parity.get("live_known") else None,
|
||||
in_parity=parity.get("in_parity") if parity.get("determinable") else None,
|
||||
)
|
||||
|
||||
# Infer reason from parity when caller left it unspecified.
|
||||
effective_reason = reason
|
||||
if not (effective_reason or "").strip():
|
||||
if parity.get("restart_required") or parity.get("live_stale"):
|
||||
effective_reason = mcp_client_reconnect.REASON_STALE_RUNTIME
|
||||
elif boundary == mcp_client_reconnect.BOUNDARY_CLEAN:
|
||||
effective_reason = mcp_client_reconnect.REASON_NOT_REQUIRED
|
||||
else:
|
||||
effective_reason = mcp_client_reconnect.REASON_UNSPECIFIED
|
||||
|
||||
payload = mcp_client_reconnect.build_reconnect_request(
|
||||
namespace=ns,
|
||||
profile=profile_name,
|
||||
pid=os.getpid(),
|
||||
session_id=session_id
|
||||
or f"{(profile_name or 'session')}-{os.getpid()}",
|
||||
startup_sha=startup_sha if isinstance(startup_sha, str) else None,
|
||||
current_master_sha=current_sha if isinstance(current_sha, str) else None,
|
||||
boundary_status=boundary,
|
||||
reason=effective_reason,
|
||||
client=client,
|
||||
live_stale=bool(parity.get("live_stale")) if parity.get("live_known") else None,
|
||||
in_parity=parity.get("in_parity") if parity.get("determinable") else None,
|
||||
restart_required=bool(parity.get("restart_required")),
|
||||
stop_required=bool(parity.get("restart_required")),
|
||||
extra={
|
||||
"remote": remote if remote in REMOTES else remote,
|
||||
"host": host,
|
||||
"session_context_audit": session_ctx.mutation_context_audit_fields(),
|
||||
"parity_summary": master_parity_gate.format_parity(parity),
|
||||
"live_stale": parity.get("live_stale"),
|
||||
"live_known": parity.get("live_known"),
|
||||
"in_parity": parity.get("in_parity"),
|
||||
},
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_request_mcp_restart(
|
||||
remote: str = "dadeschools",
|
||||
|
||||
Reference in New Issue
Block a user