Adds a sanctioned controller/reconciler capability that retires conclusively stale MCP worker-registry rows through a dry-run-first, compare-and-swap protected workflow (#980). The #978 fleet snapshot is read-only, and its registry_revision is seeded with snapshot_at at second precision, so a CAS gated on it can never pass. That token is left unchanged; retirement gets its own stable registry_fingerprint derived exclusively from canonical retirement-relevant registry content, plus a candidate_fingerprint pinning the approved set. Identical contents observed at different times produce the same token; row order never affects it; any retirement-relevant change moves it. WorkerRegistry.retire_stale_workers performs the whole decision inside one BEGIN IMMEDIATE transaction: re-read rows, recompute the fingerprint from those rows, recompute the eligibility plan from those rows (re-reading active workflow leases), compare the candidate fingerprint, revalidate every target, then retire each survivor with a guarded UPDATE asserting its status, heartbeat, generation, session, fencing epoch, and pid are unchanged. Drift retires zero workers and reports registry_revision_moved or candidate_set_moved; any exception rolls back and reports transaction_failed, so a partial write is never reported as success. Retirement requires a full conjunction: active status, complete registry fields, parsable heartbeat, not live, pid_alive false, expired heartbeat, stale ownership, canonical repository binding, no identity evidence shared with a live or unprobeable worker, and no active workflow-lease ownership. Everything else is preserved with a structured reason code. Retired rows become historical rather than stale and keep their history; nothing is deleted. Retirement does not repair untrusted live identity: live workers on legacy instance identities are preserved and keep their legacy_incomplete_identity blockers, so the result never claims the fleet became safe. Exposed as gitea_plan_stale_worker_retirement and gitea_apply_stale_worker_retirement, restricted to controller/reconciler role kinds. The Gitea operation gate stays gitea.read because the mutation lands in the local control-plane registry, matching the #601 lease lifecycle; no new Gitea write permission is introduced and no author permission is broadened. Tests: tests/test_issue_980_stale_worker_retirement.py (40 passed, 23 subtests), including the regression test proving the old snapshot_at derivation moved the token one second apart while the new registry CAS token does not. Full suite from the branch worktree matches the master baseline exactly: 28 failed / 6206 passed vs 28 failed / 6166 passed, identical failure set. Closes #980 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
9.0 KiB
Instance-level fleet identity and health snapshots
Issue #978. Companion primitives: #948 (worker ownership), #975 (heartbeat lifecycle), #951 (restart receipts).
Why this exists
The multi-instance fleet gate (#963) needs a native answer to:
- which application launches are live;
- which of the five namespace workers belong to each launch;
- whether two Codex (or Claude, Grok, …) launches are distinct;
- whether any live collision is real rather than a shared client type.
Process tables, PID proximity, configuration files, and direct SQLite access
are not production evidence. The sanctioned surface is the read-only MCP
tool gitea_snapshot_instance_fleet on controller and reconciler
namespaces.
Identity hierarchy
| Identity | Scope | Who generates it | Lifetime |
|---|---|---|---|
client_type |
Application family (codex, claude_code, gemini, grok, …) |
Launcher sets GITEA_MCP_CLIENT |
Stable for the product |
client_instance_id |
One running application launch | Trusted launcher, once per launch, as GITEA_MCP_CLIENT_INSTANCE |
Fresh launch → new ID; reconnect of same launch → same ID; full app restart → new ID |
fleet_run_id |
Operator-approved enrollment / canary cohort | Operator / controller sets GITEA_MCP_FLEET_RUN_ID |
Duration of the approved rollout |
namespace |
author | reviewer | merger | controller | reconciler |
Profile / MCP server binding | Process lifetime |
worker_id / worker_identity |
One namespace worker process | Runtime registry at first registration | New on worker restart; not reused |
session_id |
Client session ownership | Launcher GITEA_MCP_CLIENT_SESSION or runtime |
Session lifetime |
generation_id |
One daemon launch | Runtime at process boot | New on process restart |
process_identity / PID |
OS process | Runtime | Process lifetime |
Rules
- Multiple active instances may share the same
client_type. - Every application launch receives a distinct
client_instance_id. - All five namespace workers of one launch report the same
client_instance_id. - Each namespace worker has a distinct
worker_identity, process identity, generation, and PID. - Instance identity is never inferred from PID proximity, timestamps, or client type alone.
- Live reuse of one
client_instance_idwith conflicting workers fails closed. - Sharing only a profile or
client_typeis not a duplicate.
This deliberately replaces any permanent exactly_one_per_profile fleet
model (#949 assumption) as the operating rule for multi-instance fleets.
How five workers join one instance
-
The host starts one application instance (for example one Codex session).
-
The production application launcher (
mcp_application_launcher.build_application_mcp_servers/gitea_config.multi_namespace_launcher_entries) mints exactly oneclient_instance_idviamcp_fleet_snapshot.generate_client_instance_idand injects the same env into every namespace worker:GITEA_MCP_CLIENT=<client_type> GITEA_MCP_CLIENT_INSTANCE=<client_instance_id> GITEA_MCP_INSTANCE_PROVENANCE=trusted_launcher GITEA_MCP_CLIENT_SESSION=<session_id> # optional but recommended GITEA_MCP_FLEET_RUN_ID=<enrollment id> # when on an approved canary GITEA_CLIENT_MANAGED=1 GITEA_MCP_PROFILE=<role-profile> -
The MCP client starts the five namespace processes (
gitea-author,gitea-reviewer,gitea-merger,gitea-controller,gitea-reconciler) from that generatedmcpServersmap — each entry carries the same instance ID. -
Each worker registers once into the worker registry with its own
worker_identity,namespace,generation_id, and PID, but the sharedclient_instance_id. Workers never mint a trusted instance ID themselves.
Only IDs matching the launcher format inst-<client>-<timestamp>-<digest>
are trusted. Missing, legacy-pid-* / pid-* placeholders, and ordinary
user-supplied strings fail closed as untrusted and cannot authorize
multi-instance fleet mutation safety.
Worker reconnect (same process, same registration) keeps the instance ID.
Worker restart (new process) mints a new worker identity and generation but
must still receive the same GITEA_MCP_CLIENT_INSTANCE from the parent
application if it is the same launch. Full application restart mints a new
client_instance_id (call the launcher without a prior ID).
Mutation gate (instance-aware)
The capability / runtime diagnostic gate
(_check_mcp_runtimes_diagnostics) is instance-aware:
- Two legitimate application instances that share a profile (same
GITEA_MCP_PROFILE) are allowed when each has a distinct trustedclient_instance_id. - More than one live worker for the same
(client_instance_id, profile/namespace)fails closed as a duplicate namespace worker. - Multiple processes sharing a profile without trusted instance evidence still fail closed (indistinguishable from a duplicate).
Approved fleet manifest
An operator (or controller enrollment step) obtains an approved manifest as a list of expected instances, for example:
{
"instances": [
{
"client_type": "codex",
"client_instance_id": "inst-codex-20260730T120000Z-abc123def456",
"fleet_run_id": "canary-963-2026-07-30",
"namespaces": ["author", "reviewer", "merger", "controller", "reconciler"]
},
{
"client_type": "codex",
"client_instance_id": "inst-codex-20260730T120100Z-fed654cba321",
"fleet_run_id": "canary-963-2026-07-30",
"namespaces": ["author", "reviewer", "merger", "controller", "reconciler"]
}
]
}
Pass that list as expected_manifest to gitea_snapshot_instance_fleet.
When a manifest is supplied:
- instances on the manifest but not live →
missing_expected; - live instances not on the manifest →
unmanifested; - both are active blockers for fleet-gate safety.
Without a manifest, the snapshot still enumerates the live fleet and classifies identity collisions; it does not invent enrollment policy.
Snapshot consistency
Each snapshot includes:
snapshot_at— UTC timestamp;consistency_token/registry_revision— content digest over worker identity, instance id, status, heartbeat, fencing, and generation.
Two successive snapshots can prove heartbeat continuity via
mcp_fleet_snapshot.compare_snapshot_heartbeats.
Classification (active vs historical)
Active blockers (make live_fleet_safe=false):
- missing expected instance;
- unmanifested extra instance;
- duplicate namespace worker within one instance;
- live
client_instance_idcollision; - reused worker / session / generation / process / PID / fencing identity;
- orphaned or unowned workers;
- unknown client;
- foreign-repository workers;
- old-revision workers;
- stale workers still marked active;
- legacy incomplete instance identity.
Historical dead rows (status released/superseded) are reported as
historical_dead findings with active_blocker=false. They never
automatically make the live fleet unsafe.
Fail-closed behaviour and recovery
| Condition | Mutation safety | Diagnostic reads | Recovery |
|---|---|---|---|
Two instances, same client_type, distinct IDs |
Safe (if otherwise healthy) | Available | None needed |
Live reuse of one client_instance_id |
Unsafe | Available | Stop the colliding launch or re-issue a distinct ID |
| Two workers, same namespace, one instance | Unsafe | Available | Stop the extra worker |
| Legacy registration without trusted instance ID | Unsafe for fleet mutation | Available | Relaunch with launcher-issued GITEA_MCP_CLIENT_INSTANCE |
| Historical dead row only | Does not block alone | Available | No action required for fleet safety |
| Registry unavailable | Snapshot fails closed | N/A | Repair registry path / reconnect namespaces |
Incomplete or untrusted instance identity cannot authorize unsafe
mutation. Diagnostic reads remain available where gitea.read allows.
Permissions
- Allowed:
controller,reconcilerwithgitea.read. - Denied: author, reviewer, merger (even with
gitea.readfor other tools). - No new mutation permissions are granted to any role.
Related surfaces
mcp_worker_identity— registry, worker identity, heartbeats (#948, #975).mcp_fleet_snapshot— pure snapshot + classification (#978).gitea_snapshot_instance_fleet— sanctioned MCP tool (#978).gitea_get_runtime_context— single-process view (not fleet-wide).- stale-worker-retirement.md — CAS-protected
retirement of conclusively stale registrations (#980). Note that the
registry_revisionthis snapshot returns is time-seeded and unsuitable for compare-and-swap; retirement derives its own stableregistry_fingerprintfrom registry content alone.
Non-goals
- Starting the #963 canary.
- Purging historical registry rows.
- Preserving “exactly one process per profile” as the permanent model.
- Using shell / process-table / SQLite inspection as production fleet evidence.