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:
@@ -0,0 +1,282 @@
|
||||
"""Control-plane MCP server runtime registry (#949).
|
||||
|
||||
The registry is the half of the fleet evidence a caller cannot supply: each
|
||||
server writes exactly one row about itself at native transport bind. These tests
|
||||
pin the storage contract — additive migration, deterministic ordering, and
|
||||
PID-reuse/retention pruning that cannot hide a live duplicate.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
import control_plane_db
|
||||
import mcp_fleet_inventory as mfi
|
||||
|
||||
|
||||
def _stamp(delta_seconds=0):
|
||||
moment = datetime.now(timezone.utc) + timedelta(seconds=delta_seconds)
|
||||
return moment.replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class _DBCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.db_path = os.path.join(self._tmp.name, "control_plane.sqlite3")
|
||||
self.db = control_plane_db.ControlPlaneDB(db_path=self.db_path)
|
||||
|
||||
def record(self, namespace, profile, role, pid, **overrides):
|
||||
base = mfi.build_process_runtime_record(
|
||||
namespace=namespace,
|
||||
profile=profile,
|
||||
role=role,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
repository_root="/checkout/Gitea-Tools",
|
||||
pid=pid,
|
||||
startup_head="82d71b77028a7abd4f8ab4a4e4d89658a187f73d",
|
||||
transport="stdio",
|
||||
client_provenance="client_managed",
|
||||
env={mfi.COHORT_ID_ENV: "ppid:40990"},
|
||||
boot_id=f"boot{pid}",
|
||||
)
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestSchema(_DBCase):
|
||||
def test_schema_version_is_bumped(self):
|
||||
self.assertGreaterEqual(control_plane_db.SCHEMA_VERSION, 6)
|
||||
|
||||
def test_runtime_table_exists_on_a_fresh_database(self):
|
||||
self.assertEqual(self.db.list_mcp_server_runtimes(), [])
|
||||
|
||||
def test_migration_is_additive_and_idempotent(self):
|
||||
"""Re-opening an existing DB must not disturb the other tables."""
|
||||
self.db.upsert_session(session_id="s-a", role="author", profile="prgs-author")
|
||||
self.db.register_mcp_server_runtime(
|
||||
self.record("gitea-author", "prgs-author", "author", 41000)
|
||||
)
|
||||
reopened = control_plane_db.ControlPlaneDB(db_path=self.db_path)
|
||||
self.assertEqual(len(reopened.list_sessions()), 1)
|
||||
self.assertEqual(len(reopened.list_mcp_server_runtimes()), 1)
|
||||
|
||||
|
||||
class TestRegistration(_DBCase):
|
||||
def test_registration_round_trips_every_field(self):
|
||||
record = self.record("gitea-controller", "prgs-controller", "controller", 41001)
|
||||
stored = self.db.register_mcp_server_runtime(record)
|
||||
for key in (
|
||||
"runtime_id",
|
||||
"namespace",
|
||||
"profile",
|
||||
"role",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"repository_root",
|
||||
"pid",
|
||||
"cohort_id",
|
||||
"cohort_source",
|
||||
"client_provenance",
|
||||
"boot_id",
|
||||
"startup_head",
|
||||
"transport",
|
||||
"status",
|
||||
):
|
||||
self.assertEqual(stored[key], record[key], key)
|
||||
|
||||
def test_registration_requires_a_runtime_id(self):
|
||||
record = self.record("gitea-author", "prgs-author", "author", 41002)
|
||||
record["runtime_id"] = ""
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.register_mcp_server_runtime(record)
|
||||
|
||||
def test_registration_requires_a_namespace(self):
|
||||
record = self.record("gitea-author", "prgs-author", "author", 41003)
|
||||
record["namespace"] = " "
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.register_mcp_server_runtime(record)
|
||||
|
||||
def test_registration_requires_a_pid(self):
|
||||
record = self.record("gitea-author", "prgs-author", "author", 41004)
|
||||
record["pid"] = None
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.register_mcp_server_runtime(record)
|
||||
|
||||
def test_five_members_register_independently(self):
|
||||
for index, entry in enumerate(mfi.EXPECTED_PRGS_FLEET):
|
||||
self.db.register_mcp_server_runtime(
|
||||
self.record(
|
||||
entry["namespace"], entry["profile"], entry["role"], 41100 + index
|
||||
)
|
||||
)
|
||||
rows = self.db.list_mcp_server_runtimes()
|
||||
self.assertEqual(len(rows), 5)
|
||||
self.assertEqual(
|
||||
sorted(r["profile"] for r in rows),
|
||||
[
|
||||
"prgs-author",
|
||||
"prgs-controller",
|
||||
"prgs-merger",
|
||||
"prgs-reconciler",
|
||||
"prgs-reviewer",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestPruning(_DBCase):
|
||||
def test_reusing_a_pid_replaces_the_stale_row(self):
|
||||
first = self.record("gitea-author", "prgs-author", "author", 41200)
|
||||
self.db.register_mcp_server_runtime(first)
|
||||
second = self.record("gitea-reviewer", "prgs-reviewer", "reviewer", 41200)
|
||||
self.db.register_mcp_server_runtime(second)
|
||||
rows = self.db.list_mcp_server_runtimes()
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["runtime_id"], second["runtime_id"])
|
||||
|
||||
def test_pruning_never_removes_a_live_duplicate_on_another_pid(self):
|
||||
"""The defect this must not have: hiding a second running server."""
|
||||
first = self.record("gitea-author", "prgs-author", "author", 41300)
|
||||
self.db.register_mcp_server_runtime(first)
|
||||
second = self.record("gitea-author", "prgs-author", "author", 41301)
|
||||
self.db.register_mcp_server_runtime(
|
||||
second, retention_seconds=mfi.RUNTIME_RETENTION_SECONDS
|
||||
)
|
||||
rows = self.db.list_mcp_server_runtimes()
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(sorted(r["pid"] for r in rows), [41300, 41301])
|
||||
|
||||
def test_retention_drops_rows_older_than_the_window(self):
|
||||
old = self.record(
|
||||
"gitea-merger",
|
||||
"prgs-merger",
|
||||
"merger",
|
||||
41400,
|
||||
registered_at=_stamp(-(mfi.RUNTIME_RETENTION_SECONDS + 3600)),
|
||||
)
|
||||
self.db.register_mcp_server_runtime(old)
|
||||
fresh = self.record("gitea-author", "prgs-author", "author", 41401)
|
||||
self.db.register_mcp_server_runtime(
|
||||
fresh, retention_seconds=mfi.RUNTIME_RETENTION_SECONDS
|
||||
)
|
||||
rows = self.db.list_mcp_server_runtimes()
|
||||
self.assertEqual([r["pid"] for r in rows], [41401])
|
||||
|
||||
def test_retention_is_skipped_when_not_requested(self):
|
||||
old = self.record(
|
||||
"gitea-merger",
|
||||
"prgs-merger",
|
||||
"merger",
|
||||
41500,
|
||||
registered_at=_stamp(-(mfi.RUNTIME_RETENTION_SECONDS + 3600)),
|
||||
)
|
||||
self.db.register_mcp_server_runtime(old)
|
||||
self.db.register_mcp_server_runtime(
|
||||
self.record("gitea-author", "prgs-author", "author", 41501)
|
||||
)
|
||||
self.assertEqual(len(self.db.list_mcp_server_runtimes()), 2)
|
||||
|
||||
|
||||
class TestListing(_DBCase):
|
||||
def test_listing_is_deterministically_ordered(self):
|
||||
for index in range(5):
|
||||
self.db.register_mcp_server_runtime(
|
||||
self.record(
|
||||
"gitea-author",
|
||||
"prgs-author",
|
||||
"author",
|
||||
41600 + index,
|
||||
registered_at="2026-07-28T02:00:00Z",
|
||||
)
|
||||
)
|
||||
first = [r["runtime_id"] for r in self.db.list_mcp_server_runtimes()]
|
||||
second = [r["runtime_id"] for r in self.db.list_mcp_server_runtimes()]
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first, sorted(first))
|
||||
|
||||
def test_status_filter_selects_running_rows(self):
|
||||
running = self.record("gitea-author", "prgs-author", "author", 41700)
|
||||
self.db.register_mcp_server_runtime(running)
|
||||
stopped = self.record("gitea-merger", "prgs-merger", "merger", 41701)
|
||||
self.db.register_mcp_server_runtime(stopped)
|
||||
self.db.mark_mcp_server_runtime_stopped(stopped["runtime_id"])
|
||||
rows = self.db.list_mcp_server_runtimes(statuses=("running",))
|
||||
self.assertEqual([r["pid"] for r in rows], [41700])
|
||||
|
||||
def test_listing_without_a_filter_returns_stopped_rows_too(self):
|
||||
stopped = self.record("gitea-merger", "prgs-merger", "merger", 41800)
|
||||
self.db.register_mcp_server_runtime(stopped)
|
||||
self.db.mark_mcp_server_runtime_stopped(stopped["runtime_id"])
|
||||
rows = self.db.list_mcp_server_runtimes()
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["status"], "stopped")
|
||||
|
||||
|
||||
class TestHeartbeat(_DBCase):
|
||||
def test_heartbeat_advances_only_the_timestamp(self):
|
||||
record = self.record(
|
||||
"gitea-author",
|
||||
"prgs-author",
|
||||
"author",
|
||||
41900,
|
||||
last_heartbeat_at="2026-07-28T02:00:00Z",
|
||||
)
|
||||
self.db.register_mcp_server_runtime(record)
|
||||
self.db.heartbeat_mcp_server_runtime(record["runtime_id"])
|
||||
row = self.db.list_mcp_server_runtimes()[0]
|
||||
self.assertNotEqual(row["last_heartbeat_at"], "2026-07-28T02:00:00Z")
|
||||
self.assertEqual(row["status"], "running")
|
||||
self.assertEqual(row["pid"], 41900)
|
||||
|
||||
def test_heartbeat_for_an_unknown_runtime_is_a_no_op(self):
|
||||
self.db.heartbeat_mcp_server_runtime("does-not-exist")
|
||||
self.assertEqual(self.db.list_mcp_server_runtimes(), [])
|
||||
|
||||
|
||||
class TestEndToEndSnapshot(_DBCase):
|
||||
"""Registry rows feed the classifier without reshaping."""
|
||||
|
||||
def test_registered_fleet_classifies_as_healthy(self):
|
||||
pids = []
|
||||
for index, entry in enumerate(mfi.EXPECTED_PRGS_FLEET):
|
||||
pid = 42000 + index
|
||||
pids.append(pid)
|
||||
self.db.register_mcp_server_runtime(
|
||||
self.record(
|
||||
entry["namespace"],
|
||||
entry["profile"],
|
||||
entry["role"],
|
||||
pid,
|
||||
registered_at="2026-07-28T02:00:00Z",
|
||||
)
|
||||
)
|
||||
rows = self.db.list_mcp_server_runtimes(statuses=("running",))
|
||||
scan = {
|
||||
"available": True,
|
||||
"processes": [
|
||||
{"pid": pid, "started_at": "2026-07-28T01:00:00Z"} for pid in pids
|
||||
],
|
||||
"reason": None,
|
||||
}
|
||||
with mock.patch.object(mfi, "probe_pid_alive", return_value=True):
|
||||
result = mfi.classify_fleet_inventory(
|
||||
runtime_rows=rows,
|
||||
process_scan=scan,
|
||||
expected_binding={
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["inventory_complete"])
|
||||
self.assertTrue(result["mutation_gate_satisfied"])
|
||||
self.assertEqual(result["running_member_count"], 5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user