"""ORCH-026 Level A serialization (TC-A02..A05). The merge-lease window (ORCH-043/065) is what serialises "merge -> main-updated" per repo; ORCH-026 reuses it unchanged. These tests confirm the properties the ADR relies on: TC-A02 extended window: while A holds the lease, B of the SAME repo gets "merge-lock busy" -> defer (not rollback); holder-aware release does NOT delete A's lease. TC-A03 strict per-repo: an orchestrator lease never blocks an enduro-trails acquire (both claimable in parallel). TC-A04 restart-safe + proactive reclaim: a dead holder's lease is reclaimed (reclaim_stale_lease) so the pipeline never wedges forever. TC-A05 anti-livelock defer budget: merge_defer_max_attempts is bounded and positive -> exhaustion escalates instead of looping forever. """ import os import tempfile import pytest os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_serialize.db")) os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") from src import merge_gate # noqa: E402 @pytest.fixture def leases_dir(tmp_path, monkeypatch): monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path), raising=False) monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300, raising=False) monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "", raising=False) monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", True, raising=False) return tmp_path # ----------------------------------------------------------------- TC-A02 def test_second_task_same_repo_defers_not_rollback(leases_dir): okA, reasonA = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1") assert okA is True okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2") assert okB is False assert reasonB == "merge-lock busy" # -> caller DEFERS, never a rollback signal def test_holder_aware_release_keeps_foreign_lease(leases_dir): merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1") # A delayed release from B (which never held it) must NOT delete A's lease. merge_gate.release_merge_lease("orchestrator", "feature/B") okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2") assert okB is False and reasonB == "merge-lock busy" # A's own release frees it. merge_gate.release_merge_lease("orchestrator", "feature/A") okB2, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2") assert okB2 is True # ----------------------------------------------------------------- TC-A03 def test_serialization_is_strictly_per_repo(leases_dir): okA, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1") okET, _ = merge_gate.acquire_merge_lease("enduro-trails", "feature/E", "ET-1") assert okA is True assert okET is True, "a different repo must be claimable in parallel (AC-A3)" # ----------------------------------------------------------------- TC-A04 def test_dead_holder_lease_is_reclaimed(leases_dir, monkeypatch): merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1") # Holder pid is THIS process; simulate it being dead. monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False, raising=False) reclaimed = merge_gate.reclaim_stale_lease("orchestrator") assert reclaimed is True # After reclaim B can acquire -> pipeline does not wedge forever. okB, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2") assert okB is True def test_stale_lease_age_reclaimed_on_acquire(leases_dir, monkeypatch): # A very short timeout makes the existing lease look stale on B's acquire. merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1") monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 0, raising=False) okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2") assert okB is True assert "reclaimed" in reasonB # ----------------------------------------------------------------- TC-A05 def test_defer_budget_is_bounded(monkeypatch): """The defer budget is a positive finite int -> exhaustion escalates (AC-A6).""" from src.config import settings assert isinstance(settings.merge_defer_max_attempts, int) assert settings.merge_defer_max_attempts > 0 assert settings.merge_defer_delay_s > 0