feat(fleet): add CAS-protected stale worker retirement capability

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]>
This commit is contained in:
2026-07-30 15:22:30 -04:00
co-authored by Claude Opus 4.8
parent 108cbfa173
commit c0c6d14b73
8 changed files with 2362 additions and 1 deletions
+279 -1
View File
@@ -39,7 +39,7 @@ import threading
import time
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any, Iterator
from typing import Any, Callable, Iterator, Sequence
# --- Provenance verdicts -------------------------------------------------
@@ -224,6 +224,13 @@ def _heartbeat_expectation_drift(
STATUS_ACTIVE = "active"
STATUS_SUPERSEDED = "superseded"
STATUS_RELEASED = "released"
#: #980 terminal state for a registration whose owning process is conclusively
#: gone. Distinct from ``released`` (the worker said goodbye) and
#: ``superseded`` (a newer generation took over): ``retired`` records that the
#: *control plane* concluded the row was a stale orphan and retired it under a
#: compare-and-swap. Like every non-active status it is not live, so a retired
#: row counts as historical rather than stale in the #978 fleet snapshot.
STATUS_RETIRED = "retired"
_TRUE_VALUES = frozenset({"1", "true", "yes", "client_managed"})
_FALSE_VALUES = frozenset({"0", "false", "no", "manual", "manual_launch"})
@@ -315,6 +322,12 @@ _SCHEMA_OPTIONAL_COLUMNS: tuple[tuple[str, str], ...] = (
("parity_revision", "TEXT"),
("live_revision", "TEXT"),
("instance_id_provenance", "TEXT"),
# #980 retirement bookkeeping. Deliberately outside the CAS fingerprint
# field set: they record *that* a retirement happened, and are written only
# by the retirement transaction itself.
("retired_at", "TEXT"),
("retired_by", "TEXT"),
("retirement_reason", "TEXT"),
)
@@ -1217,6 +1230,271 @@ class WorkerRegistry:
"reasons": [],
}
def retire_stale_workers(
self,
*,
expected_registry_fingerprint: str,
expected_candidate_fingerprint: str,
worker_identities: Sequence[str],
fingerprint_fn: Callable[[list[dict[str, Any]]], str],
plan_fn: Callable[[list[dict[str, Any]]], dict[str, Any]],
retired_by: str | None = None,
retirement_reason: str = "",
now: datetime | None = None,
) -> dict[str, Any]:
"""Compare-and-swap retirement of conclusively stale registrations (#980).
The whole decision happens inside one ``BEGIN IMMEDIATE`` transaction:
the authoritative rows are re-read, the stable registry fingerprint is
recomputed from *those* rows, the eligibility plan is recomputed from
*those* rows, and only then are the approved targets retired — each
with a per-row guarded ``UPDATE`` that also asserts the row's identity,
liveness, and ownership columns are byte-identical to what the
revalidation just read. There is no window in which a safety check and
its matching write are separated by another statement, so a worker that
comes back to life, changes ownership, or is retired concurrently
cannot be deleted on the strength of a stale observation.
``fingerprint_fn`` and ``plan_fn`` are injected rather than imported so
the storage layer never depends on the decision layer; production wires
in :func:`mcp_fleet_retirement.registry_fingerprint` and
:func:`mcp_fleet_retirement.plan_stale_worker_retirement`, which is
exactly what the plan surface used.
Any exception — including a failure to commit — rolls the transaction
back and is reported as ``transaction_failed`` with zero retirements
and ``mutation_performed`` false, so a partial write can never be
reported as success.
"""
requested = [str(w) for w in (worker_identities or []) if str(w).strip()]
stamp = _ts(now or _utc_now())
result: dict[str, Any] | None = None
def _base(outcome: str) -> dict[str, Any]:
return {
"success": True,
"outcome": outcome,
"mutation_performed": False,
"retired": [],
"retired_count": 0,
"preserved": [],
"preserved_count": 0,
"requested_count": len(requested),
"expected_registry_fingerprint": expected_registry_fingerprint,
"expected_candidate_fingerprint": expected_candidate_fingerprint,
"acting_identity": retired_by,
"reasons": [],
}
if not requested:
outcome = _base("nothing_requested")
outcome["reasons"] = [
"no worker identities were supplied; nothing to retire"
]
return outcome
try:
with self._tx() as conn:
rows = [
self._row_to_record(r)
for r in conn.execute(
"SELECT * FROM worker_registrations"
).fetchall()
]
by_identity = {
str(row.get("worker_identity")): row for row in rows
}
current_registry_fingerprint = fingerprint_fn(rows)
if current_registry_fingerprint != expected_registry_fingerprint:
already = [
wid
for wid in requested
if str(
(by_identity.get(wid) or {}).get("status") or ""
)
== STATUS_RETIRED
]
idempotent = len(already) == len(requested)
result = _base(
"already_retired" if idempotent else "registry_revision_moved"
)
result["idempotent"] = idempotent
result["current_registry_fingerprint"] = (
current_registry_fingerprint
)
result["reasons"] = [
"the worker registry changed between plan and apply; "
"retiring zero workers"
if not idempotent
else "every requested registration is already retired; "
"safe no-op"
]
result["preserved"] = [
{
"worker_identity": wid,
"reason_code": "registry_revision_moved",
"detail": (
"aborted before any retirement: registry "
"fingerprint moved"
),
}
for wid in requested
]
result["preserved_count"] = len(requested)
return result
fresh_plan = plan_fn(rows)
current_candidate_fingerprint = fresh_plan.get(
"candidate_fingerprint"
)
if current_candidate_fingerprint != expected_candidate_fingerprint:
result = _base("candidate_set_moved")
result["current_registry_fingerprint"] = (
current_registry_fingerprint
)
result["current_candidate_fingerprint"] = (
current_candidate_fingerprint
)
result["reasons"] = [
"the retirement candidate set changed between plan and "
"apply; retiring zero workers"
]
result["preserved"] = [
{
"worker_identity": wid,
"reason_code": "candidate_set_moved",
"detail": (
"aborted before any retirement: candidate "
"fingerprint moved"
),
}
for wid in requested
]
result["preserved_count"] = len(requested)
return result
eligible = {
str(c.get("worker_identity")): c
for c in fresh_plan.get("candidates") or []
}
preserved_index = {
str(p.get("worker_identity")): p
for p in fresh_plan.get("preserved") or []
}
retired: list[dict[str, Any]] = []
preserved: list[dict[str, Any]] = []
for wid in requested:
row = by_identity.get(wid)
if row is None:
preserved.append(
{
"worker_identity": wid,
"reason_code": "registration_missing",
"detail": "no registration row with this identity",
}
)
continue
if wid not in eligible:
blocked = preserved_index.get(wid) or {}
preserved.append(
{
"worker_identity": wid,
"reason_code": blocked.get("reason_code")
or "not_in_current_plan",
"detail": blocked.get("detail")
or (
"revalidation immediately before retirement "
"no longer finds this worker eligible"
),
"evidence": blocked.get("evidence"),
}
)
continue
cursor = conn.execute(
"UPDATE worker_registrations "
"SET status = ?, retired_at = ?, retired_by = ?, "
" retirement_reason = ? "
"WHERE worker_identity = ? "
" AND status = ? "
" AND last_heartbeat_at = ? "
" AND generation_id = ? "
" AND session_id = ? "
" AND fencing_epoch = ? "
" AND IFNULL(pid, -1) = IFNULL(?, -1)",
(
STATUS_RETIRED,
stamp,
retired_by,
retirement_reason
or (eligible[wid].get("reason_code") or ""),
wid,
STATUS_ACTIVE,
row.get("last_heartbeat_at"),
row.get("generation_id"),
row.get("session_id"),
row.get("fencing_epoch"),
row.get("pid"),
),
)
if cursor.rowcount == 1:
retired.append(
{
"worker_identity": wid,
"reason_code": eligible[wid].get("reason_code"),
"retired_at": stamp,
"retired_by": retired_by,
"evidence": eligible[wid].get("evidence"),
}
)
else:
preserved.append(
{
"worker_identity": wid,
"reason_code": "row_changed_since_plan",
"detail": (
"guarded update matched no row; the "
"registration changed inside the retirement "
"transaction"
),
}
)
result = _base("applied")
result["mutation_performed"] = bool(retired)
result["retired"] = retired
result["retired_count"] = len(retired)
result["preserved"] = preserved
result["preserved_count"] = len(preserved)
result["current_registry_fingerprint"] = (
current_registry_fingerprint
)
result["current_candidate_fingerprint"] = (
current_candidate_fingerprint
)
result["retired_at"] = stamp if retired else None
except Exception as exc: # rolled back by _tx; report, never half-claim
failure = _base("transaction_failed")
failure["success"] = False
failure["reasons"] = [
"retirement transaction failed and was rolled back; zero "
f"registrations were retired: {type(exc).__name__}: {exc}"
]
failure["preserved"] = [
{
"worker_identity": wid,
"reason_code": "transaction_failed",
"detail": "transaction rolled back before any commit",
}
for wid in requested
]
failure["preserved_count"] = len(requested)
return failure
return result
def _public_record(record: dict[str, Any]) -> dict[str, Any]:
"""Registry row minus anything that should not travel to an LLM surface."""