Files
orchestrator/tests/test_reconciler.py
claude-bot 595c382ac7 fix(reconciler): terminal-skip + state_uuid dedup on F-1 path
Закрывает F-1-пробел ORCH-068: терминал-исключение и in-memory dedup
(изначально только F-2) распространены на gate-side путь реконсилятора,
устраняя ложное «🔧 reconciler: ET-002 done разблокирована (потерян
webhook)» (особенно после рестарта).

- D1: новый _resolve_issue_status — один сетевой резолв Plane-статуса
  задачи за тик (states, groups, state_uuid) после дешёвых локальных
  гардов; never-raise -> ({}, {}, None) при сбое.
- D2: безусловный терминал-скип ДО Guard 2 (группа Plane completed/
  cancelled, fallback на логические ключи done/cancelled, либо стадия в
  БД орка ∈ {done, cancelled}); skipped_terminal_total++, не подчинён
  reconcile_skip_blocked_enabled.
- D3: _is_blocked_or_needs_input переиспользует резолв D1 (опц. аргументы,
  _UNSET -> самостоятельный резолв для прямых/легаси-вызовов; 1:1).
- D4: вызов _note_unblock на F-1 теперь передаёт state_uuid -> dedup
  работает на обоих путях (deduped_total++ на повторе).

Анти-регресс: легитимный unblock не-терминальной застрявшей задачи
по-прежнему advance + один Telegram. STAGE_TRANSITIONS / QG_CHECKS /
схема БД / сигнатуры advance_*/_note_unblock / форма status() / новые
флаги — без изменений; never-raise сохранён.

Тесты: tests/test_reconciler.py TC-86-01..09/11,
tests/test_reconciler_plane.py TC-86-10. Полный прогон зелёный (1069).

Refs: ORCH-086
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 02:26:49 +03:00

977 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ORCH-053: tests for the gate-side stuck-task reconciler (F-1) + lifecycle.
These cover the F-1 sweeper (``Reconciler.reconcile_gate_once``), the per-stage
grace / config (``grace_for_stage``), the no-spam guarantee, the analysis carve-
out (AC-16), never-raise isolation, the kill-switch, the unblock observability
(AC-12 / F-4) and the restart-safe daemon thread (AC-11).
Everything that touches the network (the quality gate, Plane sync, Telegram) is
mocked at the src.stage_engine / src.reconciler level so the reconciler runs
against a real isolated sqlite DB (same convention as test_stage_engine.py).
"""
import os
import tempfile
import pytest
# Isolated test DB (set BEFORE importing src.* so settings picks it up).
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_reconciler.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 MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db, enqueue_job # noqa: E402
from src import stage_engine # noqa: E402
from src import reconciler as reconciler_mod # noqa: E402
from src.reconciler import Reconciler, grace_for_stage # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
"""Fresh isolated DB per test."""
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
"""No-op every Plane/Telegram/notification side effect in the engine so the
real advance_stage runs deterministically and offline."""
for name in (
"notify_stage_change",
"notify_qg_failure",
"notify_approve_requested",
"notify_error",
"send_telegram",
"plane_notify_stage",
"plane_notify_qg",
"plane_add_comment",
"set_issue_in_review",
"set_issue_needs_input",
"set_issue_in_progress",
"set_issue_blocked",
"set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
def _make_task(stage, *, repo="enduro-trails", branch="feature/ET-001-x",
wi="ET-001", age_s=None):
"""Insert a task; if age_s is given, backdate updated_at by that many secs."""
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
if age_s is not None:
conn.execute(
"UPDATE tasks SET updated_at = datetime('now', ?) WHERE id = ?",
(f"-{int(age_s)} seconds", task_id),
)
conn.commit()
conn.close()
return task_id
def _stage_of(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id = ?", (task_id,)).fetchone()
conn.close()
return row["stage"]
def _jobs_for(task_id, agent=None):
conn = get_db()
if agent:
rows = conn.execute(
"SELECT * FROM jobs WHERE task_id = ? AND agent = ?", (task_id, agent)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM jobs WHERE task_id = ?", (task_id,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def _green_ci(monkeypatch, value=(True, "CI green")):
"""Patch the check_ci_green entry in QG_CHECKS; return the mock."""
m = MagicMock(return_value=value)
monkeypatch.setitem(stage_engine.QG_CHECKS, "check_ci_green", m)
return m
# --- ORCH-060 fixtures / helpers -------------------------------------------
# State uuids the default "not blocked" fixture maps Blocked / Needs Input to.
_BLOCKED_UUID = "blocked-state-uuid"
_NEEDS_INPUT_UUID = "needs-input-state-uuid"
@pytest.fixture(autouse=True)
def plane_state_not_blocked(monkeypatch):
"""ORCH-060 Guard 2 boundary: by default Plane says the issue is NOT in a
human gate, so the F-1 happy path runs deterministically offline (no real
httpx call). Tests that exercise Guard 2 override ``fetch_issue_state`` to
return ``_BLOCKED_UUID`` / ``_NEEDS_INPUT_UUID`` (or raise)."""
monkeypatch.setattr(
reconciler_mod, "fetch_issue_state",
MagicMock(return_value="some-non-gated-state"),
)
monkeypatch.setattr(
reconciler_mod, "get_project_states",
MagicMock(return_value={
"blocked": _BLOCKED_UUID,
"needs_input": _NEEDS_INPUT_UUID,
}),
)
monkeypatch.setattr(
reconciler_mod.projects, "get_project_by_repo",
MagicMock(return_value=MagicMock(plane_project_id="proj-test")),
)
def _add_dev_runs(task_id, n, agent="developer"):
"""Model N developer retries by inserting N agent_runs rows (ORCH-060)."""
conn = get_db()
for _ in range(n):
conn.execute(
"INSERT INTO agent_runs (task_id, agent) VALUES (?, ?)",
(task_id, agent),
)
conn.commit()
conn.close()
# ---------------------------------------------------------------------------
# TC-01: happy path — stuck development task is advanced to review
# ---------------------------------------------------------------------------
def test_tc01_advances_stuck_development_task(monkeypatch):
_green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600) # well past grace
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "review"
reviewer_jobs = _jobs_for(task_id, "reviewer")
assert len(reviewer_jobs) == 1
# ---------------------------------------------------------------------------
# TC-02: source of truth is the gate — advance goes through advance_stage
# with finished_agent=None (no own update_task_stage/enqueue_job).
# ---------------------------------------------------------------------------
def test_tc02_advances_via_advance_stage_finished_agent_none(monkeypatch):
_green_ci(monkeypatch)
spy = MagicMock(wraps=stage_engine.advance_stage)
# advance_if_gate_passed resolves advance_stage as a module global.
monkeypatch.setattr(stage_engine, "advance_stage", spy)
task_id = _make_task("development", age_s=3600)
Reconciler().reconcile_gate_once()
assert spy.call_count == 1
# finished_agent must be None (the webhook path).
_args, kwargs = spy.call_args
assert kwargs.get("finished_agent", "MISSING") is None
assert spy.call_args.args[0] == task_id
# ---------------------------------------------------------------------------
# TC-03: task with an active job is skipped — gate not evaluated, no advance.
# ---------------------------------------------------------------------------
def test_tc03_active_job_skipped(monkeypatch):
ci = _green_ci(monkeypatch)
spy = MagicMock(wraps=stage_engine.advance_stage)
monkeypatch.setattr(stage_engine, "advance_stage", spy)
task_id = _make_task("development", age_s=3600)
enqueue_job("reviewer", "enduro-trails", task_id=task_id) # active (queued)
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "development"
ci.assert_not_called()
spy.assert_not_called()
# ---------------------------------------------------------------------------
# TC-04: per-stage grace — fresh task untouched, at-threshold task eligible.
# ---------------------------------------------------------------------------
def test_tc04_grace_boundary(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_grace_default_s", 600)
_green_ci(monkeypatch)
fresh = _make_task("development", branch="feature/ET-002-fresh",
wi="ET-002", age_s=10) # < grace -> untouched
stuck = _make_task("development", branch="feature/ET-003-stuck",
wi="ET-003", age_s=3600) # >= grace -> advanced
Reconciler().reconcile_gate_once()
assert _stage_of(fresh) == "development"
assert _stage_of(stuck) == "review"
# ---------------------------------------------------------------------------
# TC-05: grace_for_stage reads overrides JSON; bad JSON -> default, no crash.
# ---------------------------------------------------------------------------
def test_tc05_grace_for_stage_overrides(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_grace_default_s", 600)
monkeypatch.setattr(
reconciler_mod.settings,
"reconcile_grace_overrides_json",
'{"development": 30, "review": 7200}',
)
assert grace_for_stage("development") == 30
assert grace_for_stage("review") == 7200
# missing key -> default
assert grace_for_stage("testing") == 600
def test_tc05_grace_for_stage_invalid_json_falls_back(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_grace_default_s", 600)
monkeypatch.setattr(
reconciler_mod.settings, "reconcile_grace_overrides_json", "{not valid json"
)
# Must not raise, must fall back to the default.
assert grace_for_stage("development") == 600
# ---------------------------------------------------------------------------
# TC-06: no spam — a stable-red gate never advances and never notifies, even
# across many ticks.
# ---------------------------------------------------------------------------
def test_tc06_red_gate_no_spam(monkeypatch):
_green_ci(monkeypatch, value=(False, "CI red"))
task_id = _make_task("development", age_s=3600)
rec = Reconciler()
for _ in range(5):
rec.reconcile_gate_once()
assert _stage_of(task_id) == "development"
# The QG-failure notification branch inside advance_stage must never fire,
# because advance_if_gate_passed returns None on a red gate (no advance call).
stage_engine.notify_qg_failure.assert_not_called()
stage_engine.plane_notify_qg.assert_not_called()
assert rec.unblocked_total == 0
# ---------------------------------------------------------------------------
# TC-07: silence when in sync — done / busy / within-grace tasks => no advance.
# ---------------------------------------------------------------------------
def test_tc07_silence_when_in_sync(monkeypatch):
_green_ci(monkeypatch)
spy = MagicMock(wraps=stage_engine.advance_stage)
monkeypatch.setattr(stage_engine, "advance_stage", spy)
_make_task("done", branch="feature/ET-010-done", wi="ET-010", age_s=3600)
fresh = _make_task("development", branch="feature/ET-011-fresh",
wi="ET-011", age_s=5)
busy = _make_task("development", branch="feature/ET-012-busy",
wi="ET-012", age_s=3600)
enqueue_job("reviewer", "enduro-trails", task_id=busy)
rec = Reconciler()
rec.reconcile_gate_once()
spy.assert_not_called()
assert rec.unblocked_total == 0
assert _stage_of(fresh) == "development"
# ---------------------------------------------------------------------------
# TC-08 (AC-16): F-1 never advances the human analysis gate.
# ---------------------------------------------------------------------------
def test_tc08_analysis_not_advanced_by_f1(monkeypatch):
# Even if the analysis gate would "pass", F-1 must not touch analysis.
monkeypatch.setitem(
stage_engine.QG_CHECKS, "check_analysis_approved",
MagicMock(return_value=(True, "approved")),
)
spy = MagicMock(wraps=stage_engine.advance_stage)
monkeypatch.setattr(stage_engine, "advance_stage", spy)
task_id = _make_task("analysis", age_s=3600)
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "analysis"
spy.assert_not_called()
# ---------------------------------------------------------------------------
# TC-09: never-raise — one task blowing up does not stop the others.
# ---------------------------------------------------------------------------
def test_tc09_never_raise_isolates_failure(monkeypatch):
calls = []
def boom(task_id, stage, repo, wi, branch):
calls.append(task_id)
raise RuntimeError("boom")
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", boom)
t1 = _make_task("development", branch="feature/ET-020-a", wi="ET-020", age_s=3600)
t2 = _make_task("development", branch="feature/ET-021-b", wi="ET-021", age_s=3600)
# Must not raise despite both tasks raising inside advance_if_gate_passed.
Reconciler().reconcile_gate_once()
assert set(calls) == {t1, t2} # both attempted
# ---------------------------------------------------------------------------
# TC-10: kill-switches.
# ---------------------------------------------------------------------------
def test_tc10_kill_switch_disables_gate(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", False)
spy = MagicMock(wraps=stage_engine.advance_stage)
monkeypatch.setattr(stage_engine, "advance_stage", spy)
_green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600)
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "development"
spy.assert_not_called()
def test_tc10_plane_switch_mutes_only_f2(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", True)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_plane_enabled", False)
plane_pass = MagicMock()
monkeypatch.setattr(reconciler_mod.Reconciler, "_reconcile_plane_project", plane_pass)
# F-2 muted -> reconcile_plane_once is a no-op.
Reconciler().reconcile_plane_once()
plane_pass.assert_not_called()
# F-1 still runs.
_green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600)
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "review"
# ---------------------------------------------------------------------------
# TC-20: observability — explicit unblock log line + telegram (AC-12 / F-4).
# ---------------------------------------------------------------------------
def test_tc20_unblock_logs_and_notifies(monkeypatch, caplog):
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
_make_task("development", wi="ET-042", age_s=3600)
rec = Reconciler()
with caplog.at_level("INFO", logger="orchestrator.reconciler"):
rec.reconcile_gate_once()
# Exact AC-12 contract string.
assert "reconciler: ET-042 development разблокирована (потерян webhook)" in caplog.text
assert rec.unblocked_total == 1
assert rec.last_unblocked == "ET-042"
tg.assert_called_once()
def test_tc20_no_telegram_when_disabled(monkeypatch):
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", False)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
_make_task("development", wi="ET-043", age_s=3600)
Reconciler().reconcile_gate_once()
tg.assert_not_called()
# ---------------------------------------------------------------------------
# TC-21: restart-safe daemon thread — start/stop/idempotent start.
# ---------------------------------------------------------------------------
def test_tc21_daemon_thread_lifecycle(monkeypatch):
# Avoid any real work in the loop: disable both branches, big interval.
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", False)
rec = Reconciler(interval_s=60)
rec.start()
assert rec._thread is not None and rec._thread.is_alive()
first_thread = rec._thread
# Idempotent: a second start does not spawn a new thread.
rec.start()
assert rec._thread is first_thread
rec.stop(timeout=5.0)
assert not first_thread.is_alive()
# ===========================================================================
# ORCH-060: F-1 skips escalated (max developer retries) / Blocked / Needs Input
# ===========================================================================
# ---------------------------------------------------------------------------
# TC-01 (AC-1): escalated dev task (exactly MAX_DEVELOPER_RETRIES dev runs) at a
# green gate is NOT unblocked — stays development, no job, count 0.
# ---------------------------------------------------------------------------
def test_tc060_01_escalated_at_limit_skipped(monkeypatch):
_green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600)
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "development"
assert rec.unblocked_total == 0
assert _jobs_for(task_id, "reviewer") == []
# ---------------------------------------------------------------------------
# TC-02 (AC-2): more dev runs than the cap (45) -> also skipped (>= boundary).
# ---------------------------------------------------------------------------
def test_tc060_02_over_limit_skipped(monkeypatch):
_green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600)
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES + 2)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "development"
assert rec.unblocked_total == 0
# ---------------------------------------------------------------------------
# TC-03 (AC-3): regression — retry < cap (here 2) still advances to review.
# ---------------------------------------------------------------------------
def test_tc060_03_under_limit_still_advances(monkeypatch):
_green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600)
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES - 1)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "review"
assert rec.unblocked_total == 1
# ---------------------------------------------------------------------------
# TC-04 (AC-4): twins — one at the cap (skip), one at cap-1 (advance). Exactly
# one advances.
# ---------------------------------------------------------------------------
def test_tc060_04_boundary_exactly_one_advances(monkeypatch):
_green_ci(monkeypatch)
at_limit = _make_task("development", branch="feature/ET-200-a",
wi="ET-200", age_s=3600)
below = _make_task("development", branch="feature/ET-201-b",
wi="ET-201", age_s=3600)
_add_dev_runs(at_limit, stage_engine.MAX_DEVELOPER_RETRIES)
_add_dev_runs(below, stage_engine.MAX_DEVELOPER_RETRIES - 1)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(at_limit) == "development" # skipped
assert _stage_of(below) == "review" # advanced
assert rec.unblocked_total == 1
# ---------------------------------------------------------------------------
# TC-05 (AC-5): explicit Plane Blocked (retry < cap) -> skipped.
# ---------------------------------------------------------------------------
def test_tc060_05_blocked_skipped(monkeypatch):
_green_ci(monkeypatch)
monkeypatch.setattr(
reconciler_mod, "fetch_issue_state",
MagicMock(return_value=_BLOCKED_UUID),
)
task_id = _make_task("development", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "development"
assert rec.unblocked_total == 0
# ---------------------------------------------------------------------------
# TC-06 (AC-6): explicit Plane Needs Input (retry < cap) -> skipped.
# ---------------------------------------------------------------------------
def test_tc060_06_needs_input_skipped(monkeypatch):
_green_ci(monkeypatch)
monkeypatch.setattr(
reconciler_mod, "fetch_issue_state",
MagicMock(return_value=_NEEDS_INPUT_UUID),
)
task_id = _make_task("development", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "development"
assert rec.unblocked_total == 0
# ---------------------------------------------------------------------------
# TC-07 (AC-7): no spam — escalated task triggers no unblock log / telegram /
# QG-failure notification, across several ticks.
# ---------------------------------------------------------------------------
def test_tc060_07_escalated_no_spam(monkeypatch, caplog):
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
task_id = _make_task("development", wi="ET-210", age_s=3600)
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES)
rec = Reconciler()
with caplog.at_level("INFO", logger="orchestrator.reconciler"):
for _ in range(3):
rec.reconcile_gate_once()
assert "разблокирована" not in caplog.text
tg.assert_not_called()
stage_engine.notify_qg_failure.assert_not_called()
assert rec.unblocked_total == 0
# ---------------------------------------------------------------------------
# TC-08 (AC-8): the gate (check_ci_green) is NOT even evaluated for an escalated
# task — Guard 1 skips before the pre-evaluation.
# ---------------------------------------------------------------------------
def test_tc060_08_no_gate_call_on_escalated(monkeypatch):
ci = _green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600)
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES)
Reconciler().reconcile_gate_once()
ci.assert_not_called()
# ---------------------------------------------------------------------------
# TC-09 (AC-9): F-2 never replays Blocked / Needs Input — those states are not
# in the polled set, so the handlers are never invoked.
# ---------------------------------------------------------------------------
def test_tc060_09_f2_does_not_replay_blocked(monkeypatch):
states = {
"in_progress": "IP", "to_analyse": "IP", "approved": "AP", "rejected": "RJ",
"blocked": "BL", "needs_input": "NI",
}
monkeypatch.setattr(
reconciler_mod, "get_project_states", MagicMock(return_value=states)
)
captured = {}
def fake_list(pid, state_uuids):
captured["states"] = list(state_uuids)
# Plane filters client-side to the requested states, so a Blocked /
# Needs Input issue is structurally excluded from the result.
return []
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_list)
hss = MagicMock()
hv = MagicMock()
monkeypatch.setattr(reconciler_mod, "handle_status_start", hss)
monkeypatch.setattr(reconciler_mod, "handle_verdict", hv)
monkeypatch.setattr(
reconciler_mod.projects, "PROJECTS",
[MagicMock(repo="enduro-trails", plane_project_id="P")],
)
rec = Reconciler()
rec.reconcile_plane_once()
assert "BL" not in captured["states"]
assert "NI" not in captured["states"]
hss.assert_not_called()
hv.assert_not_called()
assert rec.unblocked_total == 0
# ---------------------------------------------------------------------------
# TC-10 (AC-10): never-raise — a Guard 2 lookup that raises for one task is
# isolated (that task is conservatively skipped); a neighbour
# still advances and the tick does not blow up.
# ---------------------------------------------------------------------------
def test_tc060_10_guard2_never_raise(monkeypatch):
_green_ci(monkeypatch)
bad = _make_task("development", branch="feature/ET-220-bad",
wi="ET-220", age_s=3600)
ok = _make_task("development", branch="feature/ET-221-ok",
wi="ET-221", age_s=3600)
def flaky(issue_id, project_id):
if issue_id == "plane-ET-220":
raise RuntimeError("plane boom")
return "some-non-gated-state"
monkeypatch.setattr(
reconciler_mod, "fetch_issue_state", MagicMock(side_effect=flaky)
)
rec = Reconciler()
rec.reconcile_gate_once() # must not raise
assert _stage_of(bad) == "development" # conservative skip
assert _stage_of(ok) == "review" # neighbour advanced
assert rec.unblocked_total == 1
# ---------------------------------------------------------------------------
# TC-11 (AC-11): the cutoff comes from MAX_DEVELOPER_RETRIES, not a literal 3.
# Patching the constant to 2 makes a 2-run task escalate (it would
# have advanced under a hardcoded 3).
# ---------------------------------------------------------------------------
def test_tc060_11_limit_from_constant(monkeypatch):
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod, "MAX_DEVELOPER_RETRIES", 2)
task_id = _make_task("development", age_s=3600)
_add_dev_runs(task_id, 2) # == patched cap -> skip
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "development"
assert rec.unblocked_total == 0
# ---------------------------------------------------------------------------
# AC-10 extra: the sub-flag reconcile_skip_blocked_enabled=False mutes ONLY
# Guard 2 (a Blocked task would then be reconciled), while Guard 1
# (escalated) stays active.
# ---------------------------------------------------------------------------
def test_tc060_subflag_disables_only_guard2(monkeypatch):
_green_ci(monkeypatch)
monkeypatch.setattr(
reconciler_mod.settings, "reconcile_skip_blocked_enabled", False
)
monkeypatch.setattr(
reconciler_mod, "fetch_issue_state",
MagicMock(return_value=_BLOCKED_UUID),
)
# Guard 2 disabled -> a Blocked task with retry < cap advances again.
blocked = _make_task("development", branch="feature/ET-230-a",
wi="ET-230", age_s=3600)
# Guard 1 stays active regardless of the sub-flag.
escalated = _make_task("development", branch="feature/ET-231-b",
wi="ET-231", age_s=3600)
_add_dev_runs(escalated, stage_engine.MAX_DEVELOPER_RETRIES)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(blocked) == "review" # Guard 2 muted
assert _stage_of(escalated) == "development" # Guard 1 still skips
# ---------------------------------------------------------------------------
# ORCH-066 TC-21 (AC-20 / BR-13): Guard 2 skips the active orchestrator waits
# (Awaiting Deploy / Deploying / Monitoring after Deploy) ONLY when they are
# DISTINCT statuses — an aliased (enduro) project must NOT widen the skip-set.
# ---------------------------------------------------------------------------
def _guard2(monkeypatch, states, cur_state):
"""Drive _is_blocked_or_needs_input with a chosen project state map + the
issue's current Plane state uuid."""
monkeypatch.setattr(reconciler_mod, "get_project_states",
MagicMock(return_value=states))
monkeypatch.setattr(reconciler_mod, "fetch_issue_state",
MagicMock(return_value=cur_state))
monkeypatch.setattr(
reconciler_mod.projects, "get_project_by_repo",
MagicMock(return_value=MagicMock(plane_project_id="proj-test")),
)
monkeypatch.setattr(
reconciler_mod.settings, "reconcile_skip_blocked_enabled", True
)
task = {"id": 1, "repo": "orchestrator", "plane_id": "iss-1"}
return Reconciler()._is_blocked_or_needs_input(task)
# orchestrator has the three new statuses as DISTINCT UUIDs.
_DISTINCT_STATES = {
"backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u",
"review": "rev-u", "architecture": "arch-u", "development": "dev-u",
"testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u",
"blocked": "blocked-u", "needs_input": "ni-u",
"awaiting_deploy": "await-u", "deploying": "deploying-u", "monitoring": "monitor-u",
}
def test_tc21_guard2_skips_distinct_active_waits(monkeypatch):
# Each active-wait status (distinct UUID) -> skipped (not revived).
assert _guard2(monkeypatch, _DISTINCT_STATES, "await-u") is True
assert _guard2(monkeypatch, _DISTINCT_STATES, "deploying-u") is True
assert _guard2(monkeypatch, _DISTINCT_STATES, "monitor-u") is True
# Explicit human gates still skip.
assert _guard2(monkeypatch, _DISTINCT_STATES, "blocked-u") is True
assert _guard2(monkeypatch, _DISTINCT_STATES, "ni-u") is True
# A normal working state is NOT skipped (gets reconciled).
assert _guard2(monkeypatch, _DISTINCT_STATES, "ip-u") is False
def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch):
# enduro: the new keys alias onto base working statuses -> they must NOT make
# F-1 skip a genuinely In Progress / In Review / Done task (anti-regress).
aliased = {
"backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u",
"review": "rev-u", "architecture": "arch-u", "development": "dev-u",
"testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u",
"blocked": "blocked-u", "needs_input": "ni-u",
# aliased onto base UUIDs (project did not create dedicated statuses).
"awaiting_deploy": "inrev-u", "deploying": "ip-u", "monitoring": "done-u",
}
# In Progress / In Review / Done are base working states -> NOT skipped.
assert _guard2(monkeypatch, aliased, "ip-u") is False
assert _guard2(monkeypatch, aliased, "inrev-u") is False
assert _guard2(monkeypatch, aliased, "done-u") is False
# The explicit human gates still skip.
assert _guard2(monkeypatch, aliased, "blocked-u") is True
# ===========================================================================
# ORCH-086: terminal-skip + state_uuid dedup on the F-1 (gate-side) path.
# Closes the gap of ORCH-068 (which covered only F-2). The spurious
# "ET-002 ... разблокирована (потерян webhook)" notification for a task that is
# already terminal in Plane (but drifted in the orchestrator DB) is suppressed.
# ===========================================================================
def _plane_terminal(monkeypatch, *, state_uuid="done-uuid",
states=None, groups=None):
"""Make Plane report ``state_uuid`` as the issue's current state, with the
given {key->uuid} states and {uuid->group} groups maps."""
monkeypatch.setattr(reconciler_mod, "fetch_issue_state",
MagicMock(return_value=state_uuid))
monkeypatch.setattr(reconciler_mod, "get_project_states",
MagicMock(return_value=states if states is not None
else {"done": "done-uuid"}))
monkeypatch.setattr(reconciler_mod, "get_project_state_groups",
MagicMock(return_value=groups if groups is not None
else {"done-uuid": "completed"}))
# --- TC-86-01 (AC-1) -------------------------------------------------------
def test_tc86_01_terminal_in_plane_not_unblocked(monkeypatch):
"""enduro task NOT-done in the DB but terminal in Plane (group=completed),
green gate: F-1 must NOT call _note_unblock / send_telegram — neither on a
normal tick nor on the first pass of a fresh Reconciler (clean dedup)."""
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
note = MagicMock()
monkeypatch.setattr(reconciler_mod.Reconciler, "_note_unblock", note)
_plane_terminal(monkeypatch) # Plane says Done (group=completed)
task_id = _make_task("development", wi="ET-002", age_s=3600)
# Fresh Reconciler -> empty _unblock_dedup -> the "first pass after restart"
# symptom is exercised; the terminal-skip must fire regardless of dedup.
rec = Reconciler()
rec.reconcile_gate_once()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "development" # never advanced
note.assert_not_called()
tg.assert_not_called()
assert rec.unblocked_total == 0
assert rec.skipped_terminal_total >= 1
# --- TC-86-02 (AC-2) -------------------------------------------------------
def test_tc86_02_terminal_skip_counter_no_advance(monkeypatch):
"""Terminal-skip bumps skipped_terminal_total and never reaches
advance_if_gate_passed."""
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
_plane_terminal(monkeypatch)
_make_task("development", wi="ET-002", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()
# --- TC-86-03 (AC-2 / R1) --------------------------------------------------
def test_tc86_03_terminal_by_group_cancelled(monkeypatch):
"""Terminal detection by Plane state GROUP works for cancelled too, and is
project-independent (group discriminator, not a per-project key)."""
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
_plane_terminal(
monkeypatch, state_uuid="cancel-uuid",
states={"done": "done-uuid", "cancelled": "cancel-uuid"},
groups={"cancel-uuid": "cancelled"},
)
_make_task("development", wi="ET-002", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()
# --- TC-86-04 (AC-2 / R1) --------------------------------------------------
def test_tc86_04_terminal_fallback_logical_key_empty_groups(monkeypatch):
"""Fallback when groups are unavailable ({}): terminality by the project's
logical done/cancelled key."""
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
_plane_terminal(
monkeypatch, state_uuid="done-key-uuid",
states={"done": "done-key-uuid", "cancelled": "cancel-key-uuid"},
groups={}, # group unknown -> logical-key fallback
)
_make_task("development", wi="ET-002", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()
# --- TC-86-05 (AC-2) -------------------------------------------------------
def test_tc86_05_terminal_by_db_stage_cancelled(monkeypatch):
"""DB-side terminal drift: a task with stage='cancelled' (NOT filtered by
get_active_tasks_for_reconcile, which only drops 'done') is skipped locally
without reaching _note_unblock / advance — and bumps skipped_terminal_total."""
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
note = MagicMock()
monkeypatch.setattr(reconciler_mod.Reconciler, "_note_unblock", note)
# A networked resolve must not even be needed for the DB-side guard.
monkeypatch.setattr(
reconciler_mod, "fetch_issue_state",
MagicMock(side_effect=AssertionError("must not hit Plane for DB-cancelled")),
)
_make_task("cancelled", wi="ET-002", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()
note.assert_not_called()
# --- TC-86-06 (AC-3) -------------------------------------------------------
def test_tc86_06_legit_unblock_passes_state_uuid(monkeypatch):
"""A legitimate unblock calls _note_unblock with a non-empty state_uuid; the
dedup guard stores issue_id -> state_uuid."""
_green_ci(monkeypatch)
# Default fixture: fetch_issue_state -> 'some-non-gated-state', groups {} ->
# not terminal, not blocked -> the task advances.
task_id = _make_task("development", wi="ET-300", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "review"
assert rec.unblocked_total == 1
assert rec._unblock_dedup.get("ET-300") == "some-non-gated-state"
# --- TC-86-07 (AC-3) -------------------------------------------------------
def test_tc86_07_repeat_tick_deduped(monkeypatch):
"""A repeat F-1 tick for the same issue+state_uuid is suppressed by the dedup
guard: deduped_total += 1 and no second send_telegram."""
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
# advance "succeeds" but leaves the stage put, so each tick reaches
# _note_unblock again with the SAME resolved state_uuid.
monkeypatch.setattr(
reconciler_mod, "advance_if_gate_passed",
MagicMock(return_value=MagicMock(advanced=True)),
)
_make_task("development", wi="ET-301", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once() # first: notifies
rec.reconcile_gate_once() # second: same issue+state -> deduped
assert tg.call_count == 1
assert rec.unblocked_total == 1
assert rec.deduped_total == 1
# --- TC-86-08 (AC-4, anti-regress) -----------------------------------------
def test_tc86_08_legit_unblock_still_notifies(monkeypatch):
"""A NON-terminal genuinely stuck task (working Plane status, past grace, no
active job, green gate) is STILL advanced and notifies exactly once."""
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
task_id = _make_task("development", wi="ET-302", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "review"
tg.assert_called_once()
assert rec.unblocked_total == 1
assert rec.skipped_terminal_total == 0
# --- TC-86-09 (AC-5, never-raise) ------------------------------------------
def test_tc86_09_never_raise_no_false_notify(monkeypatch):
"""An exception in the terminal-detect / fetch_issue_state path does not blow
up the tick AND does not produce a false unblock (conservative)."""
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
monkeypatch.setattr(
reconciler_mod, "fetch_issue_state",
MagicMock(side_effect=RuntimeError("plane boom")),
)
task_id = _make_task("development", wi="ET-303", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once() # must not raise
# resolve failed -> state_uuid None -> not terminal, Guard 2 conservative skip.
assert _stage_of(task_id) == "development"
tg.assert_not_called()
assert rec.unblocked_total == 0
# --- TC-86-11 (AC-6) -------------------------------------------------------
def test_tc86_11_terminal_skip_independent_of_guard2_flag(monkeypatch):
"""reconcile_skip_blocked_enabled=False (Guard 2 escape hatch) does NOT
disable the unconditional terminal-skip: a terminal task is still skipped."""
monkeypatch.setattr(
reconciler_mod.settings, "reconcile_skip_blocked_enabled", False
)
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
_plane_terminal(monkeypatch) # group=completed
_make_task("development", wi="ET-304", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()