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:
+164
-1
@@ -32,7 +32,7 @@ from typing import Any, Iterator, Sequence
|
||||
import dependency_graph
|
||||
import gitea_audit
|
||||
|
||||
SCHEMA_VERSION = 5
|
||||
SCHEMA_VERSION = 6
|
||||
|
||||
# Assignable work kinds only — raw monitoring incidents are never work items.
|
||||
WORK_KINDS = frozenset({"issue", "pr"})
|
||||
@@ -65,6 +65,33 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
-- Fleet-level MCP server runtime registry (#949). Distinct from ``sessions``:
|
||||
-- a session is one allocator *task*, while a row here is one *server process*
|
||||
-- that bound native MCP transport. Each server writes its own row and no other,
|
||||
-- so a caller can never supply this evidence. Creating the table is itself the
|
||||
-- v5→v6 migration: additive, idempotent, and it touches no existing table.
|
||||
CREATE TABLE IF NOT EXISTS mcp_server_runtimes (
|
||||
runtime_id TEXT PRIMARY KEY,
|
||||
namespace TEXT NOT NULL,
|
||||
profile TEXT,
|
||||
role TEXT,
|
||||
remote TEXT,
|
||||
org TEXT,
|
||||
repo TEXT,
|
||||
repository_root TEXT,
|
||||
pid INTEGER NOT NULL,
|
||||
cohort_id TEXT,
|
||||
cohort_source TEXT,
|
||||
client_provenance TEXT,
|
||||
boot_id TEXT,
|
||||
startup_head TEXT,
|
||||
daemon_start_head TEXT,
|
||||
transport TEXT,
|
||||
registered_at TEXT NOT NULL,
|
||||
last_heartbeat_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work_items (
|
||||
work_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
remote TEXT NOT NULL,
|
||||
@@ -910,6 +937,142 @@ class ControlPlaneDB:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── MCP server runtime registry (#949) ────────────────────────────────
|
||||
|
||||
_RUNTIME_COLUMNS: tuple[str, ...] = (
|
||||
"runtime_id",
|
||||
"namespace",
|
||||
"profile",
|
||||
"role",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"repository_root",
|
||||
"pid",
|
||||
"cohort_id",
|
||||
"cohort_source",
|
||||
"client_provenance",
|
||||
"boot_id",
|
||||
"startup_head",
|
||||
"daemon_start_head",
|
||||
"transport",
|
||||
"registered_at",
|
||||
"last_heartbeat_at",
|
||||
"status",
|
||||
)
|
||||
|
||||
def register_mcp_server_runtime(
|
||||
self,
|
||||
record: dict[str, Any],
|
||||
*,
|
||||
retention_seconds: int | None = None,
|
||||
now: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record that *this* server process is running (#949).
|
||||
|
||||
Only the described process ever calls this, at native transport bind.
|
||||
Two housekeeping deletions happen here — on the startup write path, never
|
||||
on the read path — so the registry cannot grow without bound:
|
||||
|
||||
* rows that recorded the **same PID**, which the calling process now
|
||||
owns and which therefore cannot still be a live server;
|
||||
* rows older than *retention_seconds*.
|
||||
|
||||
Neither can hide a live duplicate: a concurrently running server holds a
|
||||
different PID and writes a fresher row.
|
||||
"""
|
||||
stamp = now or _ts()
|
||||
row = {key: record.get(key) for key in self._RUNTIME_COLUMNS}
|
||||
row["runtime_id"] = str(record.get("runtime_id") or "").strip()
|
||||
if not row["runtime_id"]:
|
||||
raise ValueError("runtime_id is required (fail closed)")
|
||||
row["namespace"] = str(record.get("namespace") or "").strip()
|
||||
if not row["namespace"]:
|
||||
raise ValueError("namespace is required (fail closed)")
|
||||
if record.get("pid") is None:
|
||||
raise ValueError("pid is required (fail closed)")
|
||||
row["pid"] = int(record["pid"])
|
||||
row["registered_at"] = record.get("registered_at") or stamp
|
||||
row["last_heartbeat_at"] = record.get("last_heartbeat_at") or stamp
|
||||
row["status"] = record.get("status") or "running"
|
||||
|
||||
columns = ", ".join(self._RUNTIME_COLUMNS)
|
||||
placeholders = ", ".join("?" for _ in self._RUNTIME_COLUMNS)
|
||||
values = [row[key] for key in self._RUNTIME_COLUMNS]
|
||||
|
||||
with self._tx() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM mcp_server_runtimes WHERE pid = ? AND runtime_id != ?",
|
||||
(row["pid"], row["runtime_id"]),
|
||||
)
|
||||
if retention_seconds and retention_seconds > 0:
|
||||
cutoff = _ts(
|
||||
_utc_now() - timedelta(seconds=int(retention_seconds))
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM mcp_server_runtimes WHERE registered_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT OR REPLACE INTO mcp_server_runtimes({columns}) "
|
||||
f"VALUES ({placeholders})",
|
||||
values,
|
||||
)
|
||||
stored = conn.execute(
|
||||
"SELECT * FROM mcp_server_runtimes WHERE runtime_id = ?",
|
||||
(row["runtime_id"],),
|
||||
).fetchone()
|
||||
return dict(stored)
|
||||
|
||||
def heartbeat_mcp_server_runtime(self, runtime_id: str) -> None:
|
||||
"""Refresh a runtime row's liveness timestamp.
|
||||
|
||||
Never called by the inventory read path: liveness is established from
|
||||
process evidence so that reading the fleet mutates nothing (#949 AC11).
|
||||
"""
|
||||
with self._tx() as conn:
|
||||
conn.execute(
|
||||
"UPDATE mcp_server_runtimes SET last_heartbeat_at = ? "
|
||||
"WHERE runtime_id = ?",
|
||||
(_ts(), runtime_id),
|
||||
)
|
||||
|
||||
def mark_mcp_server_runtime_stopped(self, runtime_id: str) -> None:
|
||||
"""Mark a runtime row stopped (graceful shutdown bookkeeping)."""
|
||||
with self._tx() as conn:
|
||||
conn.execute(
|
||||
"UPDATE mcp_server_runtimes SET status = 'stopped', "
|
||||
"last_heartbeat_at = ? WHERE runtime_id = ?",
|
||||
(_ts(), runtime_id),
|
||||
)
|
||||
|
||||
def list_mcp_server_runtimes(
|
||||
self,
|
||||
*,
|
||||
statuses: Sequence[str] | None = None,
|
||||
limit: int = 500,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List runtime registry rows, newest registration first (read-only).
|
||||
|
||||
Ordering is fully deterministic: ties on ``registered_at`` break on
|
||||
``runtime_id`` so two callers reading one snapshot see one order.
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if statuses:
|
||||
placeholders = ", ".join("?" for _ in statuses)
|
||||
clauses.append(f"status IN ({placeholders})")
|
||||
params.extend(statuses)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
params.append(max(1, int(limit)))
|
||||
with self._tx(immediate=False) as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM mcp_server_runtimes {where} "
|
||||
"ORDER BY registered_at DESC, runtime_id ASC LIMIT ?",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── work items ────────────────────────────────────────────────────────
|
||||
|
||||
def upsert_work_item(
|
||||
|
||||
Reference in New Issue
Block a user