Files
orchestrator/tests/test_reconciler_plane.py
claude-bot 5a7f8d4000
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 17s
feat(reconciler): sweeper потерянных webhook (реконсиляция застрявших стадий)
Конвейер продвигается только входящими webhook; потерянное событие (502 на
ребилде, отсутствие ретраев у Plane/Gitea, неразрезолвленный sha→branch)
оставляет задачу молча застрявшей (класс инцидента ORCH-044). Новый фоновый
daemon-поток src/reconciler.py (паттерн queue_worker) доигрывает пропущенный
переход через те же штатные гейты/обработчики, что и webhook:

- F-1 gate-side: для задач stage≠done, без активного job и age(updated_at) ≥
  grace_for_stage(stage) — read-only пред-оценка канонического QG; зелёный →
  stage_engine.advance_stage(..., finished_agent=None); красный → тишина (спам
  нотификаций структурно невозможен). analysis F-1 не трогает (человеческий гейт).
- F-2 plane-side: опрос Plane API per-project (plane_sync.list_issues_by_state,
  курсорная пагинация, never-raise) → реплей In Progress/Approved/Rejected через
  существующие handle_status_start/handle_verdict (async из sync-потока, asyncio.run).
- F-3: усиление sha→branch в handle_ci_status — БД-fallback по единственной
  development-задаче repo (неоднозначность → не резолвим), debug→info.
- Анти-дубль на создании (db.create_task_atomic под process-wide Lock): гонка
  reconcile↔webhook не плодит второй task/branch/worktree/analyst-job (AC-4).
- F-4 observability: лог-строка разблокировки + Telegram + блок reconcile в /queue.

Старт/стоп в main.lifespan (после worker.start() / перед worker.stop()),
restart-safe, never-raise на единицу работы. Kill-switches ORCH_RECONCILE_ENABLED
/ ORCH_RECONCILE_PLANE_ENABLED + grace-настройки. Схема БД и реестры
STAGE_TRANSITIONS/QG_CHECKS не менялись.

Тесты: test_reconciler.py, test_reconciler_plane.py, test_gitea_sha_resolve.py,
test_config.py (33 новых, 563 всего зелёные). Документация обновлена (golden source):
architecture/README.md, INFRA.md, README.md, CHANGELOG.md, adr-0007 → accepted.

Refs: ORCH-053

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:37:01 +00:00

298 lines
11 KiB
Python

"""ORCH-053: tests for the Plane-side reconciler (F-2) + sha-resolve helpers.
F-2 polls the Plane API per project (``list_issues_by_state``) and REPLAYS a
missed In Progress / Approved / Rejected transition through the EXISTING
``webhooks.plane.handle_status_start`` / ``handle_verdict`` handlers — it never
duplicates pipeline logic. These tests mock those handlers (AsyncMock) and the
Plane API helpers, and verify the dispatch / idempotency / multi-project rules.
TC-15 is the AC-4 anti-dup integration test for ``create_task_atomic`` against a
real isolated sqlite DB under concurrency.
TC-16 exercises ``plane_sync.list_issues_by_state`` directly (pagination + the
never-raise contract).
"""
import os
import tempfile
import threading
from types import SimpleNamespace
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_reconciler_plane.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import AsyncMock, MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db, enqueue_job, create_task_atomic # noqa: E402
from src import reconciler as reconciler_mod # noqa: E402
from src import plane_sync # noqa: E402
from src.reconciler import Reconciler # noqa: E402
_IN_PROGRESS = "uuid-in-progress"
_APPROVED = "uuid-approved"
_REJECTED = "uuid-rejected"
_OLD_TS = "2020-01-01T00:00:00Z" # well past any grace
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
yield
@pytest.fixture
def single_project(monkeypatch):
"""Restrict F-2 to a single fake project and stub its state resolution."""
proj = SimpleNamespace(
plane_project_id="proj-1", repo="enduro-trails", work_item_prefix="ET",
)
monkeypatch.setattr(reconciler_mod.projects, "PROJECTS", [proj])
monkeypatch.setattr(
reconciler_mod, "get_project_states",
lambda pid: {
"in_progress": _IN_PROGRESS,
"approved": _APPROVED,
"rejected": _REJECTED,
},
)
return proj
def _make_task(plane_id, stage="review", repo="enduro-trails",
branch="feature/ET-001-x", wi="ET-001"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) "
"VALUES (?, ?, ?, ?, ?, ?)",
(plane_id, wi, repo, branch, stage, plane_id),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _patch_handlers(monkeypatch):
start = AsyncMock()
verdict = AsyncMock()
monkeypatch.setattr(reconciler_mod, "handle_status_start", start)
monkeypatch.setattr(reconciler_mod, "handle_verdict", verdict)
return start, verdict
def _patch_issues(monkeypatch, issues):
monkeypatch.setattr(
reconciler_mod, "list_issues_by_state", lambda pid, states: list(issues)
)
# ---------------------------------------------------------------------------
# TC-11: In Progress without a task -> handle_status_start once.
# ---------------------------------------------------------------------------
def test_tc11_in_progress_without_task_starts_pipeline(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_issues(monkeypatch, [
{"id": "iss-1", "state": {"id": _IN_PROGRESS}, "updated_at": _OLD_TS,
"name": "Some issue"},
])
Reconciler().reconcile_plane_once()
assert start.call_count == 1
issue_data, project_id = start.call_args.args
assert issue_data["id"] == "iss-1"
assert issue_data["state"]["id"] == _IN_PROGRESS
assert project_id == "proj-1"
verdict.assert_not_called()
# ---------------------------------------------------------------------------
# TC-12: Approved with an existing task, no active job -> handle_verdict(True).
# ---------------------------------------------------------------------------
def test_tc12_approved_replays_verdict(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_make_task("iss-2", stage="review")
_patch_issues(monkeypatch, [
{"id": "iss-2", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
Reconciler().reconcile_plane_once()
assert verdict.call_count == 1
assert verdict.call_args.kwargs.get("approved") is True
start.assert_not_called()
# ---------------------------------------------------------------------------
# TC-13: Rejected with an existing task -> handle_verdict(False).
# ---------------------------------------------------------------------------
def test_tc13_rejected_replays_verdict(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_make_task("iss-3", stage="review")
_patch_issues(monkeypatch, [
{"id": "iss-3", "state": {"id": _REJECTED}, "updated_at": _OLD_TS},
])
Reconciler().reconcile_plane_once()
assert verdict.call_count == 1
assert verdict.call_args.kwargs.get("approved") is False
start.assert_not_called()
# ---------------------------------------------------------------------------
# TC-14: idempotency — an active job means a live webhook is in flight -> skip.
# ---------------------------------------------------------------------------
def test_tc14_active_job_skips(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
tid = _make_task("iss-4", stage="review")
enqueue_job("reviewer", "enduro-trails", task_id=tid) # active
_patch_issues(monkeypatch, [
{"id": "iss-4", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
Reconciler().reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
# ---------------------------------------------------------------------------
# TC-14b: within-grace issue is left alone (lost, not merely delayed).
# ---------------------------------------------------------------------------
def test_tc14b_within_grace_skipped(monkeypatch, single_project):
from datetime import datetime, timezone
start, verdict = _patch_handlers(monkeypatch)
_make_task("iss-5", stage="review")
fresh_ts = datetime.now(timezone.utc).isoformat()
_patch_issues(monkeypatch, [
{"id": "iss-5", "state": {"id": _APPROVED}, "updated_at": fresh_ts},
])
Reconciler().reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
# ---------------------------------------------------------------------------
# TC-15 (AC-4): atomic anti-dup — concurrent create_task_atomic for one
# plane_id yields exactly ONE row and ONE created=True.
# ---------------------------------------------------------------------------
def test_tc15_create_task_atomic_no_duplicate():
results = []
barrier = threading.Barrier(8)
def worker():
barrier.wait() # maximise the race
row, created = create_task_atomic(
"plane-dup", "ET-099", "enduro-trails",
"feature/ET-099-x", "analysis", "Dup race",
)
results.append((row["id"], created))
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
created_flags = [c for _, c in results]
assert created_flags.count(True) == 1 # exactly one winner
assert created_flags.count(False) == 7 # the rest see the existing row
conn = get_db()
n = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE plane_id = 'plane-dup'"
).fetchone()[0]
conn.close()
assert n == 1 # only one task row ever created
# All callers see the same row id (the single task).
assert len({rid for rid, _ in results}) == 1
# ---------------------------------------------------------------------------
# TC-16: list_issues_by_state — never-raise on API error, filter+paginate on OK.
# ---------------------------------------------------------------------------
def test_tc16_list_issues_never_raises_on_error(monkeypatch):
def boom(*a, **k):
raise RuntimeError("plane down")
monkeypatch.setattr(plane_sync.httpx, "get", boom)
out = plane_sync.list_issues_by_state("proj-1", [_APPROVED])
assert out == []
def test_tc16_list_issues_paginates_and_filters(monkeypatch):
page1 = {
"results": [
{"id": "a", "state": {"id": _APPROVED}},
{"id": "b", "state": {"id": "other"}},
],
"next_page_results": True,
"next_cursor": "cur2",
}
page2 = {
"results": [
{"id": "c", "state": _APPROVED}, # bare-uuid state shape
{"id": "d", "state": {"id": _REJECTED}},
],
"next_page_results": False,
"next_cursor": None,
}
pages = iter([page1, page2])
def fake_get(url, headers=None, params=None, timeout=None):
resp = MagicMock()
resp.json.return_value = next(pages)
resp.raise_for_status.return_value = None
return resp
monkeypatch.setattr(plane_sync.httpx, "get", fake_get)
out = plane_sync.list_issues_by_state("proj-1", [_APPROVED, _REJECTED])
ids = {i["id"] for i in out}
assert ids == {"a", "c", "d"} # 'b' filtered out (state 'other')
# ---------------------------------------------------------------------------
# TC-17: F-2 polls EVERY registry project and resolves states per-project.
# ---------------------------------------------------------------------------
def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch):
_patch_handlers(monkeypatch)
from src import projects as projects_mod
projects_mod.reload_projects()
expected_ids = {p.plane_project_id for p in projects_mod.PROJECTS}
assert len(expected_ids) >= 2 # enduro + orchestrator in the default registry
states_calls = []
issues_calls = []
def fake_states(pid):
states_calls.append(pid)
return {"in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED}
def fake_issues(pid, states):
issues_calls.append((pid, tuple(states)))
return []
monkeypatch.setattr(reconciler_mod, "get_project_states", fake_states)
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_issues)
Reconciler().reconcile_plane_once()
assert set(states_calls) == expected_ids
assert {pid for pid, _ in issues_calls} == expected_ids
# state uuids are resolved per-project (not hardcoded): each call carries them.
for _pid, states in issues_calls:
assert set(states) == {_IN_PROGRESS, _APPROVED, _REJECTED}