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
7.3 KiB
Authoritative MCP fleet inventory (#949)
gitea_assess_fleet_inventory is the read-only native capability that answers a
question no other surface could: is exactly one server running for each
configured PRGS profile, and do they all belong to one client cohort?
Why the existing surfaces were not enough
| Surface | What it proves | Why it cannot prove the fleet |
|---|---|---|
gitea_get_runtime_context |
Profile, identity, workspace binding of the process answering the call | Says nothing about the other four namespaces |
gitea_assess_master_parity |
Startup vs current vs live revision of that same process | Five self-reports of one revision do not establish five processes, nor the absence of a sixth |
gitea_assess_mcp_namespace_health |
Whether one named namespace can invoke one tool | Accepts process, probe_result and registered_tools from the caller — a capability whose inputs come from the party it constrains is not evidence |
control-plane sessions table |
Allocator task sessions | A session is a unit of work, not a server process; nothing recorded that a server exists |
Before this capability, satisfying a strict five-process/single-cohort gate required shell process inspection, cached JSON, or source reading — none of which are sanctioned workflow evidence.
Evidence model
Two independent sources must agree before a member counts as running.
1. Control-plane runtime registry — table mcp_server_runtimes.
Each server writes exactly one row about itself, from the official entrypoint,
immediately after the native MCP transport bind. Only a transport-bound process
reaches that line, and no MCP caller can reach it at all. The row is
authoritative for identity: namespace, profile, role, repository binding,
cohort, startup revision, transport, and PID.
2. Server-side process observation — a process listing performed by the server answering the inventory call, never by the caller. It is authoritative for existence and liveness, and it is the only source that can reveal a running server the registry does not know about.
A member is live only when a registry row has a matching running process whose
start time precedes the registration — so a recycled PID cannot impersonate a
server that has since exited.
Deliberate non-inferences
- Configuration is not existence. A configured profile with no live
corroborated row is
missing, neverrunning(AC8). - Matching revisions are not a cohort.
single_cohortis derived only from recorded cohort identity. Five members at one revision with an unknown cohort yieldsingle_cohort: nulland a closed gate, nevertrue(AC7). - Parent-client status is not member health. Each member is classified from its own evidence.
Cohort identity comes from GITEA_MCP_CLIENT_COHORT_ID when the client sets it,
otherwise from the parent process that launched the server. A server whose
parent has gone away (reparented to init) reports an unknown cohort rather than
guessing.
Result shape
Top-level verdict fields:
| Field | Meaning |
|---|---|
inventory_complete |
All evidence was obtainable. False whenever anything below is unknown. |
incomplete_reasons |
Every distinct reason completeness failed. |
configured_members |
The five expected members with their instance counts and health. |
running_members |
Live, corroborated instances of expected profiles. |
missing_members |
Expected profiles with no live instance. |
duplicate_members |
Expected profiles with more than one live instance, with every PID. |
unexpected_members |
Live members outside the expected roster. Never folded into duplicates. |
stale_members |
Registry rows whose process is dead, unobserved, PID-recycled, or unknown. |
unregistered_processes |
Running MCP server processes with no registry row. |
repository_binding_mismatches |
Live members bound to another repository, or with an incomplete binding. |
role_mismatches |
Live members whose declared role or namespace contradicts the configured profile. |
single_cohort / mixed_cohort |
true/false, or null when cohort evidence is unknown. |
mixed_revision / startup_revisions |
Revision spread across live members. |
exactly_one_per_profile |
No missing and no duplicate expected members. |
mutation_gate_satisfied |
The full invariant held. |
blocked_reason / blocked_reasons |
Why the gate is closed; the first is the headline. |
mutations_performed |
Always []. |
Ordering is deterministic — every list sorts by namespace, profile, PID, then runtime id — so two callers reading one snapshot see identical structure.
How controller and reconciler gates consume it
Classification is a pure function of the snapshot. The answering namespace is
reported as answering_namespace metadata and never affects the verdict, so
gitea-controller and gitea-reconciler return the same result for the same
fleet. Neither is privileged over the other.
Consume it like this:
- Call
gitea_assess_fleet_inventoryfromgitea-controllerorgitea-reconciler(any namespace holdinggitea.readmay call it). - If
mutation_gate_satisfiedistrue, the exact-one-instance-per-profile, single-cohort, single-revision invariant is proven; proceed. - Otherwise stop and report
blocked_reasonverbatim. Distinguish the cases — they need different operator actions:missing_members— the client did not launch that namespace; reconnect it.duplicate_members— a second client or a manual launch is running that profile; the operator quits the extra client. This capability never terminates a process.unexpected_members— an unconfigured PRGS server is live; investigate before trusting any gate.unregistered_processes— a server is running code that predates this capability, or failed to register; the inventory is incomplete by construction and must not be reported as healthy.mixed_cohort/mixed_revision— the fleet is not one coherent unit.single_cohort: null— cohort evidence is missing; this is unknown, not healthy.
Never treat inventory_complete: false as a soft warning. It means the
inventory cannot describe the whole fleet, which is exactly the state the
mutation gate exists to refuse.
Read-only guarantees
The capability performs no restart, reconnect, drain, lease mutation, issue
mutation, repository write, or process termination, and it never manufactures
historical restart evidence. The only signal it sends is signal 0, a
liveness/permission check that delivers nothing to the target process. Registry
writes happen exclusively on the startup path of the process being described,
never on this read path.
Scope boundaries
This capability is observation only. Related concerns live elsewhere and are deliberately not absorbed here:
- #950 — controller role metadata and capability-routing consistency.
- #951 — durable synchronization and restart receipts.
- #952 — stale-lease inspection, dashboard, and executor consistency.
- #900 — cohort lifecycle supervision (draining and reaping superseded cohorts) is a mutating transition that would consume this evidence.
- #948 — the client/session/generation ownership model that this evidence feeds.