Level A — merge/deploy serialization within one repo: reuse the existing ORCH-043/065 merge-lease (no new mechanism); the only new logic is an unconditional pre-merge rebase in check_branch_mergeable — under the held lease, auto_rebase_onto_main is ALWAYS called when premerge_rebase_always (default True), not just when the branch is behind. No-op on an up-to-date branch (rebase keeps HEAD, force-with-lease -> "Everything up-to-date", CI not triggered). Kill-switch off -> ORCH-043 behaviour 1:1. Level B — declarative task dependencies: additive job_deps table (CREATE ... IF NOT EXISTS, no live-DB migration); claim_next_job gate (NOT EXISTS) defers a job whose depends-on tasks are not yet 'done' without occupying a max_concurrency slot; inert on empty job_deps -> zero regression. New leaf src/task_deps.py (never-raise): is_task_ready (fail-open), DFS cycle detection + Blocked/alert, declare/ingest_plane_relations (db source never hits the network on the hot path), snapshot. Telegram waiting-line, /queue observability, reconciler skip + cycle backstop, reaper untouched. Invariants unchanged: STAGE_TRANSITIONS, QG_CHECKS registry (dep gate is a claim_next_job врезка, not a registered QG), DB schema of existing tables, HTTP endpoints; non-self repos remain a no-op on empty deps/scope. Flags: ORCH_PREMERGE_REBASE_ALWAYS, ORCH_TASK_DEPS_ENABLED, ORCH_TASK_DEPS_SOURCE. Docs: docs/architecture/README.md, CLAUDE.md, .env.example, CHANGELOG.md, adr-0015. Tests: tests/test_orch026_*.py (64 tests); full suite 991 green. Refs: ORCH-026 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
96 lines
4.4 KiB
Python
96 lines
4.4 KiB
Python
"""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
|