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:
2026-07-27 23:25:45 -04:00
co-authored by Claude Opus 5
parent 82d71b7702
commit 92615f474b
12 changed files with 2718 additions and 3 deletions
+134
View File
@@ -0,0 +1,134 @@
# 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`, never `running` (AC8).
* **Matching revisions are not a cohort.** `single_cohort` is derived only from
recorded cohort identity. Five members at one revision with an unknown cohort
yield `single_cohort: null` and a closed gate, never `true` (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:
1. Call `gitea_assess_fleet_inventory` from `gitea-controller` **or**
`gitea-reconciler` (any namespace holding `gitea.read` may call it).
2. If `mutation_gate_satisfied` is `true`, the exact-one-instance-per-profile,
single-cohort, single-revision invariant is proven; proceed.
3. Otherwise **stop and report `blocked_reason` verbatim**. 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.