feat(mcp): post-restart reconciliation and completion proof (Closes #662)
Add pure post_restart_reconcile.reconcile_after_restart classifier with a machine-readable completion proof covering service health, sessions, leases, capabilities, worktrees, interrupted mutations (never auto-resumed), duplicates, and queue state. Soft-depends on #660 checkpoints (skipped with reason when the schema module is absent). Wire read-only MCP tool gitea_reconcile_after_restart, boot-once hook via gitea_assess_master_parity, log_only/enforce modes (mutation_hold), and docs. Closes #662 Related: #655 #652 #653 #660 #661 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
"""Tests for post-restart MCP reconciliation and completion proof (#662)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import post_restart_reconcile as prr
|
||||
|
||||
NOW = datetime(2026, 7, 24, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _base_inventory(**overrides):
|
||||
inv = {
|
||||
"inventory_complete": True,
|
||||
"incomplete_reasons": [],
|
||||
"service_health": {"healthy": True},
|
||||
"clients": [{"session_id": "c1", "connected": True}],
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": "s-live",
|
||||
"status": "active",
|
||||
"pid": os.getpid(),
|
||||
"role": "author",
|
||||
}
|
||||
],
|
||||
"leases": [],
|
||||
"checkpoints_available": False,
|
||||
"worktree_bindings": [{"path": "/tmp/wt", "exists": True}],
|
||||
"pending_mutations": [],
|
||||
"capabilities": {"stale": False},
|
||||
"boot_head_sha": "a" * 40,
|
||||
"current_head_sha": "a" * 40,
|
||||
"queue_state": {"safe_to_resume": True},
|
||||
}
|
||||
inv.update(overrides)
|
||||
return inv
|
||||
|
||||
|
||||
class IncompleteInventoryTest(unittest.TestCase):
|
||||
def test_incomplete_inventory_fails_closed(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
{
|
||||
"inventory_complete": False,
|
||||
"incomplete_reasons": ["control-plane DB unavailable"],
|
||||
},
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
reconcile_id="test-incomplete",
|
||||
)
|
||||
self.assertEqual(proof.overall_status, prr.STATUS_FAILED)
|
||||
self.assertTrue(proof.mutation_hold)
|
||||
self.assertFalse(proof.inventory_complete)
|
||||
self.assertTrue(proof.proposed_follow_ups)
|
||||
self.assertIn("control-plane DB unavailable", proof.incomplete_reasons)
|
||||
|
||||
|
||||
class HappyPathTest(unittest.TestCase):
|
||||
def test_clean_restart_is_complete_without_mutation_hold(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
reconcile_id="test-clean",
|
||||
)
|
||||
self.assertEqual(proof.overall_status, prr.STATUS_COMPLETE)
|
||||
self.assertFalse(proof.mutation_hold)
|
||||
self.assertEqual(proof.unresolved_count, 0)
|
||||
cp = next(i for i in proof.items if i.dimension == prr.DIM_CHECKPOINTS)
|
||||
self.assertEqual(cp.status, prr.ITEM_SKIPPED)
|
||||
links = proof.as_dict()["links"]
|
||||
self.assertEqual(links["umbrella"], 655)
|
||||
self.assertEqual(links["issue"], 662)
|
||||
self.assertEqual(links["vision"], 652)
|
||||
self.assertEqual(links["roadmap"], 653)
|
||||
|
||||
|
||||
class InterruptedMutationTest(unittest.TestCase):
|
||||
def test_mutating_lease_with_dead_owner_is_unresolved(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
leases=[
|
||||
{
|
||||
"lease_id": "lease-mut",
|
||||
"session_id": "s-dead",
|
||||
"phase": "implementing",
|
||||
"work_kind": "issue",
|
||||
"work_number": 662,
|
||||
"worktree_path": "/tmp/wt-662",
|
||||
"freshness": {"freshness": "stale_dead_process"},
|
||||
}
|
||||
]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
mut = next(i for i in proof.items if i.dimension == prr.DIM_MUTATIONS)
|
||||
self.assertEqual(mut.status, prr.ITEM_UNRESOLVED)
|
||||
self.assertTrue(mut.follow_up_required)
|
||||
interrupted = mut.details["interrupted"]
|
||||
self.assertEqual(len(interrupted), 1)
|
||||
self.assertFalse(interrupted[0]["resume_allowed"])
|
||||
self.assertTrue(proof.mutation_hold)
|
||||
self.assertTrue(
|
||||
any(f.dimension == prr.DIM_MUTATIONS for f in proof.proposed_follow_ups)
|
||||
)
|
||||
|
||||
def test_explicit_pending_mutation_inventory(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
pending_mutations=[
|
||||
{
|
||||
"session_id": "s1",
|
||||
"phase": "publishing",
|
||||
"work_kind": "pr",
|
||||
"work_number": 856,
|
||||
"reason": "push interrupted mid-flight",
|
||||
}
|
||||
]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_LOG_ONLY,
|
||||
)
|
||||
mut = next(i for i in proof.items if i.dimension == prr.DIM_MUTATIONS)
|
||||
self.assertEqual(mut.status, prr.ITEM_UNRESOLVED)
|
||||
# log_only never holds mutations even when unresolved
|
||||
self.assertFalse(proof.mutation_hold)
|
||||
self.assertEqual(proof.overall_status, prr.STATUS_DEGRADED)
|
||||
|
||||
|
||||
class DuplicateClaimsTest(unittest.TestCase):
|
||||
def test_duplicate_live_claims_flagged(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
leases=[
|
||||
{
|
||||
"lease_id": "l1",
|
||||
"session_id": "s1",
|
||||
"phase": "allocated",
|
||||
"work_kind": "issue",
|
||||
"work_number": 100,
|
||||
"freshness": {"freshness": "active"},
|
||||
},
|
||||
{
|
||||
"lease_id": "l2",
|
||||
"session_id": "s2",
|
||||
"phase": "allocated",
|
||||
"work_kind": "issue",
|
||||
"work_number": 100,
|
||||
"freshness": {"freshness": "active"},
|
||||
},
|
||||
]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
dups = next(i for i in proof.items if i.dimension == prr.DIM_DUPLICATES)
|
||||
self.assertEqual(dups.status, prr.ITEM_UNRESOLVED)
|
||||
self.assertEqual(dups.details["duplicates"][0]["claim_count"], 2)
|
||||
self.assertTrue(proof.mutation_hold)
|
||||
|
||||
|
||||
class OrphanSessionTest(unittest.TestCase):
|
||||
def test_active_session_dead_pid_is_unresolved(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
sessions=[
|
||||
{
|
||||
"session_id": "ghost",
|
||||
"status": "active",
|
||||
"pid": 2_000_000_000,
|
||||
"role": "author",
|
||||
}
|
||||
]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
sess = next(i for i in proof.items if i.dimension == prr.DIM_SESSIONS)
|
||||
self.assertEqual(sess.status, prr.ITEM_UNRESOLVED)
|
||||
self.assertIn("ghost", sess.details["orphan_session_ids"])
|
||||
|
||||
|
||||
class CapabilityStaleTest(unittest.TestCase):
|
||||
def test_stale_runtime_unresolved(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(capabilities={"stale": True, "startup_head": "aaa"}),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
caps = next(i for i in proof.items if i.dimension == prr.DIM_CAPABILITIES)
|
||||
self.assertEqual(caps.status, prr.ITEM_UNRESOLVED)
|
||||
self.assertTrue(proof.mutation_hold)
|
||||
|
||||
|
||||
class MutationsAllowedHelperTest(unittest.TestCase):
|
||||
def test_mutations_allowed_respects_hold(self) -> None:
|
||||
held = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
pending_mutations=[{"phase": "merging", "session_id": "x"}]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
self.assertFalse(prr.mutations_allowed(held))
|
||||
self.assertFalse(prr.mutations_allowed(held.as_dict()))
|
||||
clean = prr.reconcile_after_restart(
|
||||
_base_inventory(), now=NOW, mode=prr.MODE_ENFORCE
|
||||
)
|
||||
self.assertTrue(prr.mutations_allowed(clean))
|
||||
|
||||
|
||||
class CheckpointSoftDependencyTest(unittest.TestCase):
|
||||
def test_checkpoints_when_schema_present(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
checkpoints_available=True,
|
||||
checkpoints=[{"session_id": "s1", "stale": False}],
|
||||
),
|
||||
now=NOW,
|
||||
)
|
||||
cp = next(i for i in proof.items if i.dimension == prr.DIM_CHECKPOINTS)
|
||||
self.assertEqual(cp.status, prr.ITEM_RESOLVED)
|
||||
|
||||
def test_stale_checkpoints_unresolved(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
checkpoints_available=True,
|
||||
checkpoints=[{"session_id": "s1", "stale": True}],
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
cp = next(i for i in proof.items if i.dimension == prr.DIM_CHECKPOINTS)
|
||||
self.assertEqual(cp.status, prr.ITEM_UNRESOLVED)
|
||||
|
||||
|
||||
class ProofSerializationTest(unittest.TestCase):
|
||||
def test_as_dict_is_json_friendly(self) -> None:
|
||||
proof = prr.reconcile_after_restart(_base_inventory(), now=NOW)
|
||||
blob = json.dumps(proof.as_dict())
|
||||
self.assertIn("reconcile_id", blob)
|
||||
self.assertIn("proposed_follow_ups", blob)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user