Files
orchestrator/docs/architecture/adr/adr-0004-ci-poll-retry.md
stream 0eff781d13
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
feat(qg): ORCH-045 — poll check_ci_green with retry to fix CI race (pending->success)
2026-06-05 19:59:06 +00:00

5.6 KiB
Raw Blame History

adr-0004: Поллинг с ретраем в quality-gate check_ci_green (фикс CI-race)

  • Статус: accepted
  • Дата: 2026-06-05
  • Задача: ORCH-045

Контекст

Quality-gate check_ci_green(repo, branch) (src/qg/checks.py) проверяет combined commit-status ветки через Gitea API сразу после того, как developer-агент запушил код. Реализация была single-shot: один GET /repos/{owner}/{repo}/commits/{branch}/status, чтение data["state"]success → пропуск, иначе → сразу False.

Это создавало race condition. Gitea-CI после пуша 13 секунды держит combined state pending, пока не отработают чек-раннеры. Если гейт опрашивал статус в этом окне, он получал pending и возвращал False ровно один раз — повторного опроса не было. Combined state затем дозеленевал до success, но гейт уже промахнулся, и задача застревала насмерть без видимой причины.

Реальный инцидент ORCH-017: гейт опросил статус в 17:58:54 → pending; CI дозеленел в 17:58:55. Задача встала в тупик (см. docs/history / lessons ORCH-017).

Решение

check_ci_green превращён из single-shot в polling с ретраем:

  • state == "success"(True, "CI green") немедленно.
  • state in ("failure", "error")(False, "CI state: <state>") немедленно — CI красный, ретрай бессмыслен (терминальное состояние).
  • state == "pending" (или unknown / иное не-терминальное) → time.sleep(interval) и опрос снова, до N попыток.
  • После исчерпания всех попыток при всё ещё pending(False, "CI still pending after <T>s")явный провал с причиной, чтобы оператор видел тупик, а не молчаливый стол.
  • 404(False, "Branch ... not found or no status") — как раньше.
  • Транзиентная httpx.HTTPError на отдельной попытке — не падаем сразу: логируем и пробуем ещё в рамках лимита попыток; если все попытки — сетевая ошибка → (False, "API error: <e>").

Параметры вынесены в src/config.py (pydantic-settings, env-prefix ORCH_, единый стиль с остальными настройками):

  • ci_poll_max_attempts (env ORCH_CI_POLL_MAX_ATTEMPTS, дефолт 12)
  • ci_poll_interval_s (env ORCH_CI_POLL_INTERVAL_S, дефолт 10)

Итого по умолчанию гейт ждёт pending до ~2 минут (12 × 10s) перед тем как явно провалиться. Каждая не-финальная попытка логируется через существующий logger (check_ci_green: attempt i/N, state=..., retrying in Ns). timeout=10 на каждый отдельный запрос сохранён.

Сигнатура check_ci_green(repo, branch) -> tuple[bool, str] не менялась — её зовёт stage_engine и реестр гейтов QG_CHECKS.

Альтернативы

  • Оставить single-shot, опрашивать гейт повторно снаружи (на уровне stage_engine/воркера). Отклонено: размазывает логику CI-ожидания по слоям, дублирует таймауты; гейт — естественное место знания о combined-status.
  • Webhook от Gitea на завершение CI вместо поллинга. Отложено: требует надёжной доставки/дедупликации вебхуков именно по CI-статусу и переписывания триггера стадии; поллинг — минимальный, локализованный фикс race-а здесь и сейчас.
  • Бесконечный ретрай до зелёного. Отклонено: задача могла бы висеть вечно при реально зависшем CI; ограниченный бюджет + явный False с причиной даёт оператору сигнал.

Последствия

  • CI-race ORCH-017 закрыт: транзиентный pending переживается ретраем, гейт не промахивается.
  • check_ci_green теперь блокирующий до ~max_attempts × interval секунд при затяжном pending (по умолчанию ~2 мин). Это осознанный trade-off; для красного CI и success — выход немедленный, без задержки.
  • Тупик больше не молчаливый: истечение попыток → (False, "CI still pending after <T>s"), причина видна.
  • Бюджет/интервал настраиваемы через env без правки кода.
  • check_tests_passed / _parse_tests_verdict (ORCH-47) не затронуты.

Связи

ORCH-017 (инцидент-первоисточник: deadlock shared-gate из-за CI-race), реестр гейтов QG_CHECKS (check_ci_green), стадия development. Тесты: tests/test_qg.py::TestCheckCIGreen.