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]>
250 lines
8.9 KiB
Python
250 lines
8.9 KiB
Python
"""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()
|