feat(control-plane): add authoritative native MCP fleet inventory (#949)
Every pre-existing runtime surface is per-process. gitea_get_runtime_context and gitea_assess_master_parity describe only the server answering the call, and gitea_assess_mcp_namespace_health accepts process, probe_result and registered_tools from the caller, so it cannot constrain the caller. The control-plane sessions table records allocator task sessions, not server processes. Five self-reports of one revision therefore never proved that five processes exist, that no sixth exists, or that all five share one client cohort. Add gitea_assess_fleet_inventory, a strictly read-only capability that takes no evidence parameters. It combines two sources that must agree: - a control-plane runtime registry row each server writes about itself, from the official entrypoint, immediately after the native transport bind; - a process observation the answering server performs, never the caller. A member is running only when both agree and the observed process predates its registration, so configuration alone never counts and a recycled PID cannot impersonate an exited server. Classification is a pure function of the snapshot, so gitea-controller and gitea-reconciler return the same verdict. Missing, duplicate, unexpected, stale and unregistered members are reported in separate fields rather than collapsed into one error, because each needs a different operator action. single_cohort is derived only from recorded cohort identity and stays null when unknown, so matching revisions never establish a cohort. Any unreadable registry, unavailable listing, unregistered process, unknown cohort or unknown revision sets inventory_complete and mutation_gate_satisfied false with a specific blocked_reason. The capability performs no restart, reconnect, drain, lease mutation, issue mutation or process termination, never terminates a duplicate, and never manufactures restart evidence. The only signal sent is signal 0. Also resolves the fleet namespace from the expected roster: role_namespace_gate.infer_mcp_namespace recognises only author and reviewer and echoes the profile name otherwise, which would have mislabelled the controller, merger and reconciler members. Widening that shared helper is controller role-metadata work owned by #950 and is deliberately not done here. Schema v5 to v6 is additive and idempotent: one new table, no existing table, tool signature or result field changed. Two control-plane tests that pinned the schema version to the literal 5 now assert against SCHEMA_VERSION. #950 (controller role metadata), #951 (restart receipts) and #952 (stale-lease consistency) remain separate and untouched. Tests: 118 new across tests/test_mcp_fleet_inventory.py, tests/test_control_plane_db_server_runtimes.py and tests/test_fleet_inventory_tool.py, covering every acceptance criterion including the multi-LLM duplicate-server regression that motivated the issue. Closes #949 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NBaQKcRRKeKbZuhk72MhEf
This commit is contained in:
@@ -196,6 +196,7 @@ RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import canonical_repository_root as crr # noqa: E402 # #706 cross-repo canonical root
|
||||
import mcp_namespace_health # noqa: E402
|
||||
import mcp_fleet_inventory # noqa: E402 # #949 authoritative fleet inventory
|
||||
import stale_binding_recovery # noqa: E402
|
||||
|
||||
# Worktree env bindings inherited from the parent environment at daemon boot
|
||||
@@ -18811,6 +18812,116 @@ def gitea_assess_master_parity(
|
||||
return out
|
||||
|
||||
|
||||
def _fleet_namespace_for_profile(profile_name: str | None) -> str | None:
|
||||
"""Fleet namespace for *profile_name* (#949).
|
||||
|
||||
``role_namespace_gate.infer_mcp_namespace`` recognises only author and
|
||||
reviewer and returns the profile name for everything else, so it cannot
|
||||
name the controller, merger, or reconciler namespaces. The fleet roster
|
||||
carries that mapping; this defers to it and keeps the existing inference as
|
||||
the fallback for profiles outside the roster. Widening the shared helper is
|
||||
controller role-metadata work owned by #950.
|
||||
"""
|
||||
return mcp_fleet_inventory.namespace_for_profile(
|
||||
profile_name,
|
||||
default=role_namespace_gate.infer_mcp_namespace(profile_name),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_fleet_inventory(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: authoritative inventory of the running PRGS MCP fleet (#949).
|
||||
|
||||
Every other runtime surface is per-process. ``gitea_get_runtime_context``
|
||||
and ``gitea_assess_master_parity`` describe only the server answering the
|
||||
call; ``gitea_assess_mcp_namespace_health`` takes ``process`` and
|
||||
``probe_result`` from the caller and so cannot constrain it. This tool takes
|
||||
**no evidence parameters at all**: it combines the control-plane runtime
|
||||
registry, which each server writes about itself at native transport bind,
|
||||
with a process observation performed by this server. A member counts as
|
||||
running only when both agree, so configuration alone never counts as a
|
||||
running server and a caller cannot supply the answer.
|
||||
|
||||
Classification is a pure function of the snapshot, so ``gitea-controller``
|
||||
and ``gitea-reconciler`` return the same verdict for the same fleet;
|
||||
``answering_namespace`` is metadata and never changes it.
|
||||
|
||||
Fails closed. Any unreadable registry, unavailable process listing,
|
||||
unregistered server process, unknown cohort, or unknown startup revision
|
||||
sets ``inventory_complete`` false and ``mutation_gate_satisfied`` false with
|
||||
a specific ``blocked_reason``. Matching Git revisions never establish a
|
||||
single cohort — ``single_cohort`` is derived only from recorded cohort
|
||||
identity and stays ``null`` when that is unknown.
|
||||
|
||||
Strictly read-only: no restart, reconnect, lease mutation, issue mutation,
|
||||
repository write, or process termination, and duplicates are reported and
|
||||
never terminated. The only signal sent is ``signal 0`` liveness probing,
|
||||
which delivers nothing to the target process. ``mutations_performed`` is
|
||||
always an empty list.
|
||||
|
||||
Args:
|
||||
remote: Known instance — 'dadeschools' or 'prgs'. Declares which
|
||||
repository binding fleet members are expected to carry.
|
||||
host: Override the Gitea host.
|
||||
org: Override the expected owner/organization binding.
|
||||
repo: Override the expected repository binding.
|
||||
|
||||
Returns:
|
||||
dict with 'inventory_complete', 'configured_members', 'running_members',
|
||||
'missing_members', 'duplicate_members', 'unexpected_members',
|
||||
'stale_members', 'unregistered_processes', 'single_cohort',
|
||||
'mixed_cohort', 'mixed_revision', 'exactly_one_per_profile',
|
||||
'mutation_gate_satisfied', 'blocked_reason', and per-member evidence.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"read_only": True,
|
||||
"inventory_complete": False,
|
||||
"mutation_gate_satisfied": False,
|
||||
"blocked_reason": "the active profile may not read control-plane state",
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
"mutations_performed": [],
|
||||
}
|
||||
|
||||
ctx = session_ctx.get_session_context() or {}
|
||||
expected_binding = {
|
||||
"remote": remote or ctx.get("remote"),
|
||||
"org": org or ctx.get("org"),
|
||||
"repo": repo or ctx.get("repository"),
|
||||
}
|
||||
|
||||
db, db_errors = _control_plane_db_or_error()
|
||||
runtime_rows: list[dict] = []
|
||||
registry_error: str | None = None
|
||||
if db is None:
|
||||
registry_error = "; ".join(db_errors) or "control-plane DB unavailable"
|
||||
else:
|
||||
try:
|
||||
runtime_rows = db.list_mcp_server_runtimes(statuses=("running",))
|
||||
except Exception as exc: # noqa: BLE001 - unreadable registry is unknown evidence
|
||||
registry_error = f"runtime registry could not be read: {_redact(str(exc))}"
|
||||
|
||||
result = mcp_fleet_inventory.classify_fleet_inventory(
|
||||
runtime_rows=runtime_rows,
|
||||
process_scan=mcp_fleet_inventory.scan_mcp_server_processes(),
|
||||
expected_binding=expected_binding,
|
||||
registry_available=registry_error is None,
|
||||
registry_error=registry_error,
|
||||
answering_namespace=_fleet_namespace_for_profile(_active_profile_name(host)),
|
||||
)
|
||||
result["expected_repository_binding"] = expected_binding
|
||||
result["summary"] = mcp_fleet_inventory.summarize(result)
|
||||
return result
|
||||
|
||||
|
||||
# #781: documented in the canonical review workflow as a tool reviewers call
|
||||
# before workflow load, but the registration decorator had been lost, so the
|
||||
# documented inventory named something no namespace could reach.
|
||||
@@ -24344,6 +24455,72 @@ def gitea_quarantine_contaminated_review(
|
||||
}
|
||||
|
||||
|
||||
def _register_fleet_runtime(transport: str = "stdio") -> dict | None:
|
||||
"""Record this server process in the control-plane runtime registry (#949).
|
||||
|
||||
Called once from the official entrypoint immediately after the native
|
||||
transport bind, so the row can only ever describe the process writing it.
|
||||
This is the evidence ``gitea_assess_fleet_inventory`` reads; without it the
|
||||
fleet is unprovable, but a failure here must never prevent the server from
|
||||
serving, so every error is swallowed after being logged to stderr.
|
||||
"""
|
||||
try:
|
||||
profile_name = gitea_config.selected_profile_name()
|
||||
try:
|
||||
profile = get_profile()
|
||||
except Exception: # noqa: BLE001 - identity resolution must not block boot
|
||||
profile = {}
|
||||
allowed = (profile or {}).get("allowed_operations") or []
|
||||
forbidden = (profile or {}).get("forbidden_operations") or []
|
||||
role = _role_kind(allowed, forbidden) if allowed else None
|
||||
ctx = session_ctx.get_session_context() or {}
|
||||
env = os.environ
|
||||
provenance = (
|
||||
"client_managed"
|
||||
if (
|
||||
(env.get("GITEA_CLIENT_MANAGED") or "").strip().lower()
|
||||
in {"1", "true", "yes", "client_managed"}
|
||||
or (env.get("GITEA_MCP_CLIENT_MANAGED") or "").strip().lower()
|
||||
in {"1", "true", "yes", "client_managed"}
|
||||
or (env.get("GITEA_SERVER_PROVENANCE") or "").strip()
|
||||
== "client_managed"
|
||||
)
|
||||
else "manual_launch"
|
||||
)
|
||||
record = mcp_fleet_inventory.build_process_runtime_record(
|
||||
namespace=_fleet_namespace_for_profile(profile_name),
|
||||
profile=profile_name,
|
||||
role=role,
|
||||
remote=ctx.get("remote"),
|
||||
org=ctx.get("org"),
|
||||
repo=ctx.get("repository"),
|
||||
repository_root=PROJECT_ROOT,
|
||||
startup_head=_process_boot_head_sha,
|
||||
daemon_start_head=_process_boot_head_sha,
|
||||
transport=transport,
|
||||
client_provenance=provenance,
|
||||
env=env,
|
||||
)
|
||||
db, errors = _control_plane_db_or_error()
|
||||
if db is None:
|
||||
sys.stderr.write(
|
||||
f"--- fleet runtime registration skipped: {'; '.join(errors)} ---\n"
|
||||
)
|
||||
return None
|
||||
db.register_mcp_server_runtime(
|
||||
record,
|
||||
retention_seconds=mcp_fleet_inventory.RUNTIME_RETENTION_SECONDS,
|
||||
)
|
||||
sys.stderr.write(
|
||||
f"--- fleet runtime registered: {record['runtime_id']} "
|
||||
f"(cohort {record['cohort_id']}) ---\n"
|
||||
)
|
||||
return record
|
||||
except Exception as exc: # noqa: BLE001 - never block startup
|
||||
sys.stderr.write(f"--- fleet runtime registration failed: {exc} ---\n")
|
||||
return None
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -24353,6 +24530,11 @@ if __name__ == "__main__":
|
||||
# native transport; offline imports / standalone scripts fail closed.
|
||||
mcp_daemon_guard.mark_sanctioned_daemon()
|
||||
mcp_daemon_guard.bind_native_mcp_transport(transport="stdio")
|
||||
# #949: record this process in the control-plane runtime registry. Only a
|
||||
# transport-bound server reaches this point, so the row is authoritative
|
||||
# evidence that this namespace is actually running — evidence no caller of
|
||||
# gitea_assess_fleet_inventory can supply.
|
||||
_register_fleet_runtime(transport="stdio")
|
||||
# Lock this session's launch profile into the environment so child CLI
|
||||
# processes (e.g. review_pr.py) can detect and refuse profile
|
||||
# side-channel overrides (#199).
|
||||
|
||||
Reference in New Issue
Block a user