Files
orchestrator/tests/test_orch026_merge_serialize.py
claude-bot a74379f657 feat(ORCH-026): task dependencies (B waits for A) + single-repo merge serialization
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>
2026-06-08 19:17:44 +03:00

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