Files
Gitea-Tools/tests/test_issue_980_stale_worker_retirement.py
T
sysadminandClaude Opus 4.8 c0c6d14b73 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]>
2026-07-30 15:22:30 -04:00

807 lines
32 KiB
Python

"""CAS-protected stale worker retirement (#980).
Covers the acceptance criteria: the registry CAS token is stable across time
and row order but moves on any retirement-relevant change, plan mutates
nothing, apply fails closed on drift, every target is revalidated inside the
retirement transaction, and live / ambiguous / incomplete rows are preserved.
The regression test that matters most is
``test_snapshot_at_would_have_moved_the_token``: it demonstrates the exact
defect the R3-C review found — ``mcp_fleet_snapshot._consistency_token``
changes when only the observation time changed — and proves the new token does
not.
All identifiers are synthetic.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from typing import Any
import mcp_fleet_retirement as retire
import mcp_fleet_snapshot as fleet
import mcp_worker_identity as mwi
NOW = datetime(2026, 7, 30, 7, 0, 0, tzinfo=timezone.utc)
TTL = 900.0
REPO = "/synthetic/repo/Gitea-Tools"
_ROW_COLUMNS = (
"worker_identity",
"client_name",
"client_instance_id",
"session_id",
"generation_id",
"role",
"profile",
"namespace",
"remote",
"repository_binding",
"pid",
"process_identity",
"transport",
"token_fingerprint",
"started_at",
"last_heartbeat_at",
"heartbeat_ttl_seconds",
"fencing_epoch",
"status",
"fleet_run_id",
"authenticated_account",
"instance_id_provenance",
)
def _registry() -> mwi.WorkerRegistry:
handle, path = tempfile.mkstemp(suffix=".sqlite3")
os.close(handle)
os.unlink(path)
return mwi.WorkerRegistry(path)
def _row(
*,
identity: str,
pid: int | None,
instance: str = "inst-codex-20260730T070000Z-0123456789ab",
session: str = "sess-a",
generation: str = "gen-a",
namespace: str = "author",
heartbeat: datetime | None = None,
status: str = mwi.STATUS_ACTIVE,
repository_binding: str | None = REPO,
ttl: float = TTL,
**overrides: Any,
) -> dict[str, Any]:
"""One synthetic ``worker_registrations`` row."""
record = {
"worker_identity": identity,
"client_name": "codex",
"client_instance_id": instance,
"session_id": session,
"generation_id": generation,
"role": namespace,
"profile": f"prgs-{namespace}",
"namespace": namespace,
"remote": "prgs",
"repository_binding": repository_binding,
"pid": pid,
"process_identity": f"pid-{pid}" if pid is not None else None,
"transport": "stdio",
"token_fingerprint": None,
"started_at": "2026-07-30T06:00:00Z",
"last_heartbeat_at": mwi._ts(heartbeat or (NOW - timedelta(seconds=7200))),
"heartbeat_ttl_seconds": ttl,
"fencing_epoch": 1,
"status": status,
"fleet_run_id": "run-canary",
"authenticated_account": "synthetic-user",
"instance_id_provenance": fleet.INSTANCE_ID_PROVENANCE_TRUSTED,
}
record.update(overrides)
return record
def _dead(_pid: int | None) -> bool:
return False
def _alive(_pid: int | None) -> bool:
return True
def _selective(alive_pids: set[int]):
def probe(pid: int | None) -> bool:
return pid in alive_pids
return probe
def _plan(rows, *, probe=_dead, now=NOW, **kwargs):
return retire.plan_stale_worker_retirement(
rows,
now=now,
pid_alive_probe=probe,
canonical_repository=REPO,
**kwargs,
)
def _register(registry: mwi.WorkerRegistry, row: dict[str, Any]) -> None:
"""Insert a synthetic row directly, bypassing register()'s live-now stamps."""
columns = [name for name in _ROW_COLUMNS if name in row]
placeholders = ", ".join("?" for _ in columns)
with registry._tx() as conn:
conn.execute(
f"INSERT INTO worker_registrations ({', '.join(columns)}) "
f"VALUES ({placeholders})",
[row[name] for name in columns],
)
class RegistryFingerprintStabilityTests(unittest.TestCase):
"""AC: identical contents observed at different times produce one token."""
def test_same_contents_different_observation_times_same_token(self):
rows = [_row(identity="w-1", pid=101), _row(identity="w-2", pid=102)]
self.assertEqual(
retire.registry_fingerprint(rows), retire.registry_fingerprint(rows)
)
# Recompute after the clock has moved a full hour: the token is derived
# from content only, so it cannot notice.
plan_early = _plan(rows, now=NOW)
plan_late = _plan(rows, now=NOW + timedelta(hours=1))
self.assertEqual(
plan_early["registry_fingerprint"], plan_late["registry_fingerprint"]
)
def test_snapshot_at_would_have_moved_the_token(self):
"""Regression: the old time-seeded derivation moved, the new one does not."""
rows = [_row(identity="w-1", pid=101)]
old_early = fleet._consistency_token(rows, "2026-07-30T07:00:00Z")
old_late = fleet._consistency_token(rows, "2026-07-30T07:00:01Z")
self.assertNotEqual(
old_early,
old_late,
"the #980 defect: one second of observation drift changed the token",
)
self.assertEqual(
retire.registry_fingerprint(rows), retire.registry_fingerprint(rows)
)
def test_row_order_does_not_change_the_token(self):
rows = [
_row(identity="w-1", pid=101),
_row(identity="w-2", pid=102),
_row(identity="w-3", pid=103),
]
self.assertEqual(
retire.registry_fingerprint(rows),
retire.registry_fingerprint(list(reversed(rows))),
)
def test_numeric_typing_does_not_change_the_token(self):
as_float = [_row(identity="w-1", pid=101, heartbeat_ttl_seconds=900.0)]
as_int = [_row(identity="w-1", pid=101, heartbeat_ttl_seconds=900)]
self.assertEqual(
retire.registry_fingerprint(as_float),
retire.registry_fingerprint(as_int),
)
def test_retirement_relevant_changes_move_the_token(self):
base = [_row(identity="w-1", pid=101)]
baseline = retire.registry_fingerprint(base)
mutations = {
"row added": base + [_row(identity="w-2", pid=102)],
"row removed": [],
"status changed": [_row(identity="w-1", pid=101, status="released")],
"identity changed": [
_row(
identity="w-1",
pid=101,
instance="inst-codex-20260730T070000Z-ffffffffffff",
)
],
"ownership changed": [_row(identity="w-1", pid=101, session="sess-other")],
"generation changed": [
_row(identity="w-1", pid=101, generation="gen-other")
],
"pid changed": [_row(identity="w-1", pid=999)],
"repository binding changed": [
_row(identity="w-1", pid=101, repository_binding="/elsewhere")
],
}
for label, rows in mutations.items():
with self.subTest(change=label):
self.assertNotEqual(baseline, retire.registry_fingerprint(rows))
def test_heartbeat_change_moves_the_token(self):
base = [_row(identity="w-1", pid=101)]
moved = [_row(identity="w-1", pid=101, heartbeat=NOW - timedelta(seconds=30))]
self.assertNotEqual(
retire.registry_fingerprint(base), retire.registry_fingerprint(moved)
)
def test_ttl_change_moves_the_token(self):
base = [_row(identity="w-1", pid=101)]
moved = [_row(identity="w-1", pid=101, ttl=60.0)]
self.assertNotEqual(
retire.registry_fingerprint(base), retire.registry_fingerprint(moved)
)
class PlanEligibilityTests(unittest.TestCase):
"""AC: only conclusively stale orphans are selected; everything else stays."""
def test_plan_selects_dead_stale_orphan(self):
plan = _plan([_row(identity="w-1", pid=101)])
self.assertEqual(plan["candidate_count"], 1)
self.assertEqual(plan["candidate_worker_identities"], ["w-1"])
self.assertEqual(plan["candidates"][0]["reason_code"], retire.REASON_ELIGIBLE)
self.assertFalse(plan["mutation_performed"])
self.assertTrue(plan["read_only"])
def test_plan_performs_no_mutation(self):
registry = _registry()
_register(registry, _row(identity="w-1", pid=101))
before = registry.list_workers(status=None)
plan = _plan(before)
after = registry.list_workers(status=None)
self.assertEqual(plan["candidate_count"], 1)
self.assertEqual(before, after)
self.assertEqual([r["status"] for r in after], [mwi.STATUS_ACTIVE])
self.assertFalse(plan["mutation_performed"])
def test_live_worker_is_preserved(self):
row = _row(identity="w-live", pid=101, heartbeat=NOW - timedelta(seconds=10))
plan = _plan([row], probe=_alive)
self.assertEqual(plan["candidate_count"], 0)
self.assertEqual(
plan["preserved"][0]["reason_code"], retire.REASON_WORKER_LIVE
)
def test_fresh_heartbeat_with_dead_pid_still_fails_closed(self):
"""A dead pid withdraws liveness; the unexpired heartbeat still preserves."""
row = _row(identity="w-fresh", pid=101, heartbeat=NOW - timedelta(seconds=10))
plan = _plan([row], probe=_dead)
self.assertEqual(plan["candidate_count"], 0)
self.assertEqual(
plan["preserved"][0]["reason_code"], retire.REASON_HEARTBEAT_FRESH
)
def test_unprobeable_pid_is_preserved(self):
plan = _plan([_row(identity="w-1", pid=101)], probe=lambda _pid: None)
self.assertEqual(plan["candidate_count"], 0)
self.assertEqual(
plan["preserved"][0]["reason_code"], retire.REASON_PID_UNKNOWN
)
def test_incomplete_legacy_identity_is_preserved(self):
"""AC: incomplete legacy identities remain fail-closed."""
rows = [
_row(identity="w-nopid", pid=None, instance="legacy-pid-27833"),
_row(identity="w-nosession", pid=102, session=""),
]
plan = _plan(rows)
self.assertEqual(plan["candidate_count"], 0)
self.assertEqual(
{p["reason_code"] for p in plan["preserved"]},
{retire.REASON_INCOMPLETE_IDENTITY},
)
detail = next(
p["detail"] for p in plan["preserved"] if p["worker_identity"] == "w-nopid"
)
self.assertIn("pid", detail)
def test_unparsable_heartbeat_is_preserved(self):
rows = [_row(identity="w-1", pid=101, last_heartbeat_at="not-a-stamp")]
plan = _plan(rows)
self.assertEqual(plan["candidate_count"], 0)
self.assertEqual(
plan["preserved"][0]["reason_code"], retire.REASON_UNPARSABLE_HEARTBEAT
)
def test_identity_shared_with_live_worker_is_preserved(self):
"""Two rows, one live: the dead one shares session evidence, so it stays."""
rows = [
_row(
identity="w-live",
pid=101,
session="sess-shared",
heartbeat=NOW - timedelta(seconds=5),
),
_row(identity="w-dead", pid=102, session="sess-shared"),
]
plan = _plan(rows, probe=_selective({101}))
self.assertEqual(plan["candidate_count"], 0)
codes = {p["reason_code"] for p in plan["preserved"]}
self.assertIn(retire.REASON_CONFLICTING_IDENTITY, codes)
def test_foreign_or_missing_repository_binding_is_preserved(self):
rows = [
_row(identity="w-foreign", pid=101, repository_binding="/other/repo"),
_row(identity="w-unbound", pid=102, repository_binding=None),
]
plan = _plan(rows)
self.assertEqual(plan["candidate_count"], 0)
self.assertEqual(
{p["reason_code"] for p in plan["preserved"]},
{retire.REASON_FOREIGN_REPOSITORY},
)
def test_active_workflow_owner_is_preserved(self):
rows = [_row(identity="w-1", pid=101, session="sess-leased")]
plan = _plan(rows, protected_session_ids=["sess-leased"])
self.assertEqual(plan["candidate_count"], 0)
self.assertEqual(
plan["preserved"][0]["reason_code"], retire.REASON_PROTECTED_OWNER
)
def test_terminal_rows_are_not_retired_again(self):
rows = [_row(identity="w-1", pid=101, status=mwi.STATUS_RELEASED)]
plan = _plan(rows)
self.assertEqual(plan["candidate_count"], 0)
self.assertEqual(
plan["preserved"][0]["reason_code"], retire.REASON_ALREADY_TERMINAL
)
def test_mixed_fleet_produces_correct_per_worker_outcomes(self):
rows = [
_row(identity="w-stale-1", pid=101, session="s1", generation="g1"),
_row(identity="w-stale-2", pid=102, session="s2", generation="g2"),
_row(
identity="w-live",
pid=103,
session="s3",
generation="g3",
heartbeat=NOW - timedelta(seconds=5),
),
_row(identity="w-nopid", pid=None, session="s4", generation="g4"),
_row(
identity="w-foreign",
pid=105,
session="s5",
generation="g5",
repository_binding="/other",
),
_row(
identity="w-terminal",
pid=106,
session="s6",
generation="g6",
status=mwi.STATUS_SUPERSEDED,
),
]
plan = _plan(rows, probe=_selective({103}))
self.assertEqual(
sorted(plan["candidate_worker_identities"]), ["w-stale-1", "w-stale-2"]
)
by_identity = {
p["worker_identity"]: p["reason_code"] for p in plan["preserved"]
}
self.assertEqual(by_identity["w-live"], retire.REASON_WORKER_LIVE)
self.assertEqual(by_identity["w-nopid"], retire.REASON_INCOMPLETE_IDENTITY)
self.assertEqual(by_identity["w-foreign"], retire.REASON_FOREIGN_REPOSITORY)
self.assertEqual(by_identity["w-terminal"], retire.REASON_ALREADY_TERMINAL)
self.assertEqual(plan["assessed_count"], 6)
self.assertEqual(plan["preserved_count"], 4)
def test_plan_is_deterministic_for_identical_contents(self):
rows = [_row(identity="w-1", pid=101), _row(identity="w-2", pid=102)]
first = _plan(rows)
second = _plan(list(reversed(rows)), now=NOW + timedelta(minutes=5))
self.assertEqual(first["registry_fingerprint"], second["registry_fingerprint"])
self.assertEqual(
first["candidate_fingerprint"], second["candidate_fingerprint"]
)
self.assertEqual(
sorted(first["candidate_worker_identities"]),
sorted(second["candidate_worker_identities"]),
)
class ApplyCasTests(unittest.TestCase):
"""AC: apply is compare-and-swap protected and revalidates every target."""
def setUp(self) -> None:
self.registry = _registry()
self.probe = _dead
def _rows(self):
return self.registry.list_workers(status=None)
def _apply(self, plan, *, probe=None, identities=None, **kwargs):
chosen = probe or self.probe
return self.registry.retire_stale_workers(
expected_registry_fingerprint=plan["registry_fingerprint"],
expected_candidate_fingerprint=plan["candidate_fingerprint"],
worker_identities=(
identities
if identities is not None
else plan["candidate_worker_identities"]
),
fingerprint_fn=retire.registry_fingerprint,
plan_fn=lambda rows: _plan(rows, probe=chosen, **kwargs),
retired_by="synthetic-user/prgs-reconciler",
now=NOW,
)
def test_matching_token_retires_the_planned_set(self):
_register(self.registry, _row(identity="w-1", pid=101, session="s1"))
_register(self.registry, _row(identity="w-2", pid=102, session="s2"))
plan = _plan(self._rows(), probe=self.probe)
result = self._apply(plan)
self.assertEqual(result["outcome"], "applied")
self.assertTrue(result["mutation_performed"])
self.assertEqual(result["retired_count"], 2)
statuses = {r["worker_identity"]: r["status"] for r in self._rows()}
self.assertEqual(
statuses, {"w-1": mwi.STATUS_RETIRED, "w-2": mwi.STATUS_RETIRED}
)
retired_row = self.registry.get("w-1")
self.assertEqual(retired_row["retired_at"], mwi._ts(NOW))
self.assertEqual(retired_row["retired_by"], "synthetic-user/prgs-reconciler")
def test_moved_registry_token_retires_zero_workers(self):
_register(self.registry, _row(identity="w-1", pid=101))
plan = _plan(self._rows(), probe=self.probe)
# An unrelated registration lands between plan and apply.
_register(self.registry, _row(identity="w-2", pid=102, session="s2"))
result = self._apply(plan)
self.assertEqual(result["outcome"], retire.OUTCOME_REGISTRY_MOVED)
self.assertEqual(result["retired_count"], 0)
self.assertFalse(result["mutation_performed"])
self.assertTrue(all(r["status"] == mwi.STATUS_ACTIVE for r in self._rows()))
def test_moved_candidate_set_retires_zero_workers(self):
"""Registry unchanged, but the eligibility verdict is no longer the same."""
_register(self.registry, _row(identity="w-1", pid=101))
plan = _plan(self._rows(), probe=self.probe)
# The row did not change; the process came back (pid probes alive), so
# revalidation inside the transaction finds no candidates at all.
result = self._apply(plan, probe=_alive)
self.assertEqual(result["outcome"], retire.OUTCOME_CANDIDATES_MOVED)
self.assertEqual(result["retired_count"], 0)
self.assertFalse(result["mutation_performed"])
self.assertEqual(self.registry.get("w-1")["status"], mwi.STATUS_ACTIVE)
def test_worker_that_becomes_live_between_plan_and_apply_is_preserved(self):
_register(self.registry, _row(identity="w-dead", pid=101, session="s1"))
_register(self.registry, _row(identity="w-back", pid=102, session="s2"))
plan = _plan(self._rows(), probe=self.probe)
self.assertEqual(len(plan["candidate_worker_identities"]), 2)
# w-back's process is alive by the time apply runs, so the candidate
# fingerprint moves and nothing at all is retired.
result = self._apply(plan, probe=_selective({102}))
self.assertEqual(result["outcome"], retire.OUTCOME_CANDIDATES_MOVED)
self.assertEqual(result["retired_count"], 0)
self.assertEqual(self.registry.get("w-back")["status"], mwi.STATUS_ACTIVE)
self.assertEqual(self.registry.get("w-dead")["status"], mwi.STATUS_ACTIVE)
def test_worker_that_becomes_ambiguous_between_plan_and_apply_is_preserved(self):
_register(self.registry, _row(identity="w-1", pid=101, session="s1"))
plan = _plan(self._rows(), probe=self.probe)
result = self._apply(plan, probe=lambda _pid: None)
self.assertEqual(result["outcome"], retire.OUTCOME_CANDIDATES_MOVED)
self.assertEqual(result["retired_count"], 0)
self.assertEqual(self.registry.get("w-1")["status"], mwi.STATUS_ACTIVE)
def test_apply_revalidates_and_will_not_narrow_the_approved_set(self):
"""A protection appearing after plan moves the CAS, so nothing is retired."""
_register(self.registry, _row(identity="w-1", pid=101, session="s1"))
_register(
self.registry, _row(identity="w-protected", pid=102, session="sess-leased")
)
unprotected_plan = _plan(self._rows(), probe=self.probe)
self.assertEqual(unprotected_plan["candidate_count"], 2)
result = self._apply(
unprotected_plan, protected_session_ids=["sess-leased"]
)
self.assertEqual(result["outcome"], retire.OUTCOME_CANDIDATES_MOVED)
self.assertEqual(result["retired_count"], 0)
self.assertEqual(
self.registry.get("w-protected")["status"], mwi.STATUS_ACTIVE
)
def test_target_outside_the_current_plan_is_preserved(self):
_register(
self.registry,
_row(identity="w-1", pid=101, session="s1", generation="g1"),
)
_register(
self.registry,
_row(
identity="w-live",
pid=102,
session="s2",
generation="g2",
heartbeat=NOW - timedelta(seconds=5),
),
)
probe = _selective({102})
plan = _plan(self._rows(), probe=probe)
self.assertEqual(plan["candidate_worker_identities"], ["w-1"])
# A caller that appends a live identity to the approved list gets it
# preserved with the live reason code; only the real candidate retires.
result = self._apply(plan, probe=probe, identities=["w-1", "w-live"])
self.assertEqual(result["outcome"], "applied")
self.assertEqual(result["retired_count"], 1)
self.assertEqual(result["retired"][0]["worker_identity"], "w-1")
preserved = {
p["worker_identity"]: p["reason_code"] for p in result["preserved"]
}
self.assertEqual(preserved["w-live"], retire.REASON_WORKER_LIVE)
self.assertEqual(self.registry.get("w-live")["status"], mwi.STATUS_ACTIVE)
def test_missing_registration_is_reported_not_invented(self):
_register(self.registry, _row(identity="w-1", pid=101))
plan = _plan(self._rows(), probe=self.probe)
result = self._apply(plan, identities=["w-1", "w-ghost"])
self.assertEqual(result["retired_count"], 1)
preserved = {
p["worker_identity"]: p["reason_code"] for p in result["preserved"]
}
self.assertEqual(preserved["w-ghost"], "registration_missing")
def test_reapplying_a_completed_plan_is_a_safe_no_op(self):
_register(self.registry, _row(identity="w-1", pid=101))
plan = _plan(self._rows(), probe=self.probe)
first = self._apply(plan)
self.assertEqual(first["retired_count"], 1)
second = self._apply(plan)
self.assertEqual(second["outcome"], retire.OUTCOME_ALREADY_RETIRED)
self.assertTrue(second["idempotent"])
self.assertEqual(second["retired_count"], 0)
self.assertFalse(second["mutation_performed"])
self.assertEqual(self.registry.get("w-1")["status"], mwi.STATUS_RETIRED)
def test_empty_target_list_reports_nothing_requested(self):
_register(self.registry, _row(identity="w-1", pid=101))
plan = _plan(self._rows(), probe=self.probe)
result = self._apply(plan, identities=[])
self.assertEqual(result["outcome"], retire.OUTCOME_NOTHING_REQUESTED)
self.assertFalse(result["mutation_performed"])
self.assertEqual(self.registry.get("w-1")["status"], mwi.STATUS_ACTIVE)
def test_transaction_failure_cannot_report_partial_success(self):
_register(self.registry, _row(identity="w-1", pid=101, session="s1"))
_register(self.registry, _row(identity="w-2", pid=102, session="s2"))
plan = _plan(self._rows(), probe=self.probe)
calls = {"n": 0}
def exploding_plan(rows):
calls["n"] += 1
raise RuntimeError("synthetic revalidation failure")
result = self.registry.retire_stale_workers(
expected_registry_fingerprint=plan["registry_fingerprint"],
expected_candidate_fingerprint=plan["candidate_fingerprint"],
worker_identities=plan["candidate_worker_identities"],
fingerprint_fn=retire.registry_fingerprint,
plan_fn=exploding_plan,
retired_by="synthetic-user/prgs-reconciler",
now=NOW,
)
self.assertEqual(calls["n"], 1)
self.assertFalse(result["success"])
self.assertEqual(result["outcome"], "transaction_failed")
self.assertEqual(result["retired_count"], 0)
self.assertFalse(result["mutation_performed"])
self.assertTrue(
all(r["status"] == mwi.STATUS_ACTIVE for r in self._rows()),
"a failed transaction must roll back every retirement",
)
def test_structured_result_fields_are_accurate(self):
_register(self.registry, _row(identity="w-1", pid=101))
plan = _plan(self._rows(), probe=self.probe)
blocked = self._apply(plan, probe=_alive)
self.assertFalse(blocked["mutation_performed"])
self.assertEqual(blocked["retired"], [])
self.assertEqual(blocked["requested_count"], 1)
self.assertEqual(
blocked["expected_registry_fingerprint"], plan["registry_fingerprint"]
)
applied = self._apply(plan)
self.assertTrue(applied["mutation_performed"])
self.assertEqual(applied["acting_identity"], "synthetic-user/prgs-reconciler")
self.assertEqual(applied["retired"][0]["reason_code"], retire.REASON_ELIGIBLE)
self.assertIn("evidence", applied["retired"][0])
class PostRetirementFleetCompatibilityTests(unittest.TestCase):
"""AC: retired rows leave the stale count and never fake fleet safety."""
def test_retired_rows_are_historical_not_stale(self):
registry = _registry()
_register(
registry, _row(identity="w-1", pid=101, session="s1", generation="g1")
)
_register(
registry,
_row(
identity="w-live",
pid=102,
session="s2",
generation="g2",
instance="legacy-pid-27833",
instance_id_provenance=fleet.INSTANCE_ID_PROVENANCE_LEGACY,
heartbeat=NOW - timedelta(seconds=5),
),
)
probe = _selective({102})
before = fleet.snapshot_instance_fleet(
registry.list_workers(status=None),
now=NOW,
pid_alive_probe=probe,
canonical_repository=REPO,
)
self.assertEqual(before["stale_worker_count"], 1)
plan = _plan(registry.list_workers(status=None), probe=probe)
result = registry.retire_stale_workers(
expected_registry_fingerprint=plan["registry_fingerprint"],
expected_candidate_fingerprint=plan["candidate_fingerprint"],
worker_identities=plan["candidate_worker_identities"],
fingerprint_fn=retire.registry_fingerprint,
plan_fn=lambda rows: _plan(rows, probe=probe),
retired_by="synthetic-user/prgs-reconciler",
now=NOW,
)
self.assertEqual(result["retired_count"], 1)
after = fleet.snapshot_instance_fleet(
registry.list_workers(status=None),
now=NOW,
pid_alive_probe=probe,
canonical_repository=REPO,
)
self.assertEqual(after["stale_worker_count"], 0)
self.assertEqual(after["live_worker_count"], 1)
self.assertEqual(after["historical_worker_count"], 1)
# The surviving live worker still carries a legacy instance identity, so
# the fleet must not be declared safe.
self.assertFalse(after["live_fleet_safe"])
self.assertIn(
fleet.CLASS_LEGACY_INCOMPLETE,
{f["classification"] for f in after["active_blockers"]},
)
def test_existing_snapshot_shape_is_unchanged(self):
rows = [_row(identity="w-1", pid=101)]
snapshot = fleet.snapshot_instance_fleet(
rows, now=NOW, pid_alive_probe=_dead, canonical_repository=REPO
)
for key in (
"consistency_token",
"registry_revision",
"snapshot_at",
"live_workers",
"stale_workers",
"historical_workers",
"findings",
"live_fleet_safe",
):
self.assertIn(key, snapshot)
self.assertTrue(snapshot["registry_revision"].startswith("fleetrev-"))
self.assertTrue(snapshot["read_only"])
self.assertFalse(snapshot.get("mutation_performed", False))
class CapabilityExposureTests(unittest.TestCase):
"""AC: only controller/reconciler reach the surface; author policy unchanged."""
def test_capability_map_declares_controller_role(self):
import task_capability_map as tcm
for task in (
"plan_stale_worker_retirement",
"gitea_plan_stale_worker_retirement",
"apply_stale_worker_retirement",
"gitea_apply_stale_worker_retirement",
):
with self.subTest(task=task):
self.assertEqual(tcm.required_permission(task), "gitea.read")
self.assertEqual(tcm.required_role(task), "controller")
def test_ordinary_author_capability_resolution_is_unaffected(self):
import task_capability_map as tcm
self.assertEqual(tcm.required_permission("work_issue"), "gitea.pr.create")
self.assertEqual(tcm.required_role("work_issue"), "author")
self.assertEqual(tcm.required_permission("create_pr"), "gitea.pr.create")
self.assertEqual(tcm.required_role("create_pr"), "author")
self.assertEqual(tcm.required_permission("commit_files"), "gitea.repo.commit")
self.assertEqual(tcm.required_permission("push_branch"), "gitea.branch.push")
self.assertEqual(
tcm.required_permission("snapshot_instance_fleet"), "gitea.read"
)
def test_no_new_gitea_write_permission_is_introduced(self):
import task_capability_map as tcm
for task in ("plan_stale_worker_retirement", "apply_stale_worker_retirement"):
with self.subTest(task=task):
self.assertNotIn(
tcm.required_permission(task),
{
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
"gitea.branch.push",
"gitea.repo.commit",
},
)
class SurfaceRegistrationTests(unittest.TestCase):
"""AC: the dry-run and apply modes are exposed as sanctioned native tools."""
def test_tools_are_registered_and_role_restricted(self):
import inspect
import gitea_mcp_server as server
for name in (
"gitea_plan_stale_worker_retirement",
"gitea_apply_stale_worker_retirement",
):
with self.subTest(tool=name):
tool = getattr(server, name)
self.assertTrue(callable(tool))
source = inspect.getsource(tool)
# Both tools route their role check through the shared guard.
self.assertIn(f'_retirement_role_block("{name}")', source)
guard = inspect.getsource(server._retirement_role_block)
self.assertIn('{"controller", "reconciler"}', guard)
self.assertIn("required_roles", guard)
apply_source = inspect.getsource(server.gitea_apply_stale_worker_retirement)
self.assertIn("registry_fingerprint", apply_source)
self.assertIn("candidate_fingerprint", apply_source)
self.assertIn("_retirement_runtime_block", apply_source)
self.assertIn("classify_cohort", apply_source)
# Revalidation inside the transaction re-reads lease protection rather
# than reusing the pre-transaction snapshot.
self.assertIn("_retirement_revalidation_plan", apply_source)
revalidation = inspect.getsource(server._retirement_revalidation_plan)
self.assertIn("_retirement_protected_owners", revalidation)
self.assertIn("raise RuntimeError", revalidation)
def test_plan_tool_declares_itself_read_only(self):
import inspect
import gitea_mcp_server as server
source = inspect.getsource(server.gitea_plan_stale_worker_retirement)
self.assertIn("read_only", source)
self.assertNotIn("retire_stale_workers", source)
def test_docs_exist(self):
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = os.path.join(root, "docs", "stale-worker-retirement.md")
self.assertTrue(os.path.isfile(path))
with open(path, encoding="utf-8") as handle:
text = handle.read()
for token in (
"registry_fingerprint",
"candidate_fingerprint",
"gitea_plan_stale_worker_retirement",
"gitea_apply_stale_worker_retirement",
"eligible_stale_orphan",
"registry_revision_moved",
"does not repair untrusted live identity",
):
with self.subTest(token=token):
self.assertIn(token, text)
if __name__ == "__main__": # pragma: no cover
unittest.main()