Files
orchestrator/tests/test_config.py
claude-bot 7d2d77217a 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:55:25 +00:00

118 lines
4.6 KiB
Python

"""ORCH-042: Settings.tracker_mode config field.
AC-1: tracker_mode defaults to "edit" and is read from env ORCH_TRACKER_MODE.
Settings is a Pydantic BaseSettings reading env at instantiation, so each case
builds a FRESH Settings() (the process-wide singleton is not mutated).
"""
from src.config import Settings
def test_tracker_mode_defaults_to_edit(monkeypatch):
# No env var -> default "edit" (TC-01 / AC-1).
monkeypatch.delenv("ORCH_TRACKER_MODE", raising=False)
assert Settings().tracker_mode == "edit"
def test_tracker_mode_reads_env_bump(monkeypatch):
# ORCH_TRACKER_MODE=bump -> "bump" (TC-01 / AC-1).
monkeypatch.setenv("ORCH_TRACKER_MODE", "bump")
assert Settings().tracker_mode == "bump"
def test_tracker_mode_reads_env_arbitrary(monkeypatch):
# The field is read verbatim from env; mode RESOLUTION (anything != "bump"
# -> edit) happens in notifications, not here (AC-1/AC-2 split).
monkeypatch.setenv("ORCH_TRACKER_MODE", "garbage")
assert Settings().tracker_mode == "garbage"
# ---------------------------------------------------------------------------
# ORCH-043 / TC-25: merge-gate settings defaults + env override.
# ---------------------------------------------------------------------------
_MERGE_ENV = (
"ORCH_MERGE_GATE_ENABLED",
"ORCH_MERGE_GATE_REPOS",
"ORCH_MERGE_RETEST_TIMEOUT_S",
"ORCH_MERGE_RETEST_TARGET",
"ORCH_MERGE_LOCK_TIMEOUT_S",
"ORCH_MERGE_DEFER_DELAY_S",
"ORCH_MERGE_DEFER_MAX_ATTEMPTS",
)
def test_merge_gate_settings_defaults(monkeypatch):
"""TC-25 / AC-10: documented defaults when no env is set."""
for name in _MERGE_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.merge_gate_enabled is True
assert s.merge_gate_repos == ""
assert s.merge_retest_timeout_s == 600
assert s.merge_retest_target == "tests/"
assert s.merge_lock_timeout_s == 300
assert s.merge_defer_delay_s == 60
assert s.merge_defer_max_attempts == 5
def test_merge_gate_settings_env_override(monkeypatch):
"""TC-25 / AC-10: each field is read from its ORCH_* env var."""
monkeypatch.setenv("ORCH_MERGE_GATE_ENABLED", "false")
monkeypatch.setenv("ORCH_MERGE_GATE_REPOS", "orchestrator,enduro-trails")
monkeypatch.setenv("ORCH_MERGE_RETEST_TIMEOUT_S", "120")
monkeypatch.setenv("ORCH_MERGE_RETEST_TARGET", "tests/unit")
monkeypatch.setenv("ORCH_MERGE_LOCK_TIMEOUT_S", "90")
monkeypatch.setenv("ORCH_MERGE_DEFER_DELAY_S", "5")
monkeypatch.setenv("ORCH_MERGE_DEFER_MAX_ATTEMPTS", "9")
s = Settings()
assert s.merge_gate_enabled is False
assert s.merge_gate_repos == "orchestrator,enduro-trails"
assert s.merge_retest_timeout_s == 120
assert s.merge_retest_target == "tests/unit"
assert s.merge_lock_timeout_s == 90
assert s.merge_defer_delay_s == 5
assert s.merge_defer_max_attempts == 9
# ---------------------------------------------------------------------------
# ORCH-053 / TC-22: reconcile_* settings defaults + env override.
# ---------------------------------------------------------------------------
_RECONCILE_ENV = (
"ORCH_RECONCILE_ENABLED",
"ORCH_RECONCILE_INTERVAL_S",
"ORCH_RECONCILE_PLANE_ENABLED",
"ORCH_RECONCILE_GRACE_DEFAULT_S",
"ORCH_RECONCILE_GRACE_OVERRIDES_JSON",
"ORCH_RECONCILE_NOTIFY_UNBLOCK",
)
def test_reconcile_settings_defaults(monkeypatch):
"""TC-22 / AC-13: documented defaults when no env is set."""
for name in _RECONCILE_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.reconcile_enabled is True
assert s.reconcile_interval_s == 120
assert s.reconcile_plane_enabled is True
assert s.reconcile_grace_default_s == 600
assert s.reconcile_grace_overrides_json == ""
assert s.reconcile_notify_unblock is True
def test_reconcile_settings_env_override(monkeypatch):
"""TC-22 / AC-13: each field is read from its ORCH_* env var."""
monkeypatch.setenv("ORCH_RECONCILE_ENABLED", "false")
monkeypatch.setenv("ORCH_RECONCILE_INTERVAL_S", "300")
monkeypatch.setenv("ORCH_RECONCILE_PLANE_ENABLED", "false")
monkeypatch.setenv("ORCH_RECONCILE_GRACE_DEFAULT_S", "900")
monkeypatch.setenv("ORCH_RECONCILE_GRACE_OVERRIDES_JSON", '{"development": 300}')
monkeypatch.setenv("ORCH_RECONCILE_NOTIFY_UNBLOCK", "false")
s = Settings()
assert s.reconcile_enabled is False
assert s.reconcile_interval_s == 300
assert s.reconcile_plane_enabled is False
assert s.reconcile_grace_default_s == 900
assert s.reconcile_grace_overrides_json == '{"development": 300}'
assert s.reconcile_notify_unblock is False