Files
orchestrator/tests/test_orch026_task_deps.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

162 lines
6.3 KiB
Python

"""ORCH-026 Level B — declarative task dependencies (TC-B01/B02/B05/B07).
Real SQLite (tmp db). We drive tasks + job_deps directly and assert:
TC-B01 add_dependency declares an edge; get_dependencies resolves it; a
self-edge is rejected; never-raise on a bad input.
TC-B02 is_task_ready: a task with an un-done predecessor is NOT ready; when
every predecessor reaches 'done' it becomes ready.
TC-B05 claim_next_job does NOT claim a dep-blocked job (no slot taken); once
the predecessor is 'done' the job becomes claimable.
TC-B07 reconciler skip helper: is_task_ready=False is honoured (the gate task
is left waiting).
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_task_deps.db")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db # noqa: E402
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
from src import task_deps # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "deps.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
init_db()
yield
def _make_task(stage="development", work_item_id="ORCH-1", repo="orchestrator"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _set_stage(task_id, stage):
conn = get_db()
conn.execute("UPDATE tasks SET stage=? WHERE id=?", (stage, task_id))
conn.commit()
conn.close()
# ----------------------------------------------------------------- TC-B01
def test_add_dependency_declares_and_resolves():
a = _make_task(work_item_id="ORCH-10", stage="development")
b = _make_task(work_item_id="ORCH-11", stage="development")
assert db.add_dependency(b, a) is True
assert db.get_dependencies(b) == [a]
# Idempotent: re-declaring the same edge is a no-op.
assert db.add_dependency(b, a) is False
def test_self_edge_rejected():
a = _make_task(work_item_id="ORCH-12")
assert db.add_dependency(a, a) is False
assert db.get_dependencies(a) == []
def test_add_dependency_never_raises_on_bad_input():
assert db.add_dependency(None, 1) is False
assert db.add_dependency(1, None) is False
# ----------------------------------------------------------------- TC-B02
def test_is_task_ready_blocked_then_ready():
a = _make_task(work_item_id="ORCH-20", stage="development")
b = _make_task(work_item_id="ORCH-21", stage="development")
db.add_dependency(b, a)
ready, waiting = task_deps.is_task_ready(b)
assert ready is False
assert "ORCH-20" in waiting
_set_stage(a, "done")
ready2, waiting2 = task_deps.is_task_ready(b)
assert ready2 is True
assert waiting2 == []
def test_is_task_ready_no_deps_is_ready():
a = _make_task(work_item_id="ORCH-22")
ready, waiting = task_deps.is_task_ready(a)
assert ready is True and waiting == []
# ----------------------------------------------------------------- TC-B05
def test_claim_skips_dep_blocked_job():
a = _make_task(work_item_id="ORCH-30", stage="development")
b = _make_task(work_item_id="ORCH-31", stage="development")
db.add_dependency(b, a)
job_b = enqueue_job("developer", "orchestrator", "do B", task_id=b)
# B is blocked by un-done A -> claim must NOT pick it (no slot taken).
claimed = claim_next_job()
assert claimed is None, "dep-blocked job must not be claimed"
# A finishes -> B becomes claimable.
_set_stage(a, "done")
claimed2 = claim_next_job()
assert claimed2 is not None
assert claimed2["id"] == job_b
def test_claim_prefers_unblocked_job_over_blocked():
a = _make_task(work_item_id="ORCH-40", stage="development")
b = _make_task(work_item_id="ORCH-41", stage="development")
c = _make_task(work_item_id="ORCH-42", stage="development")
db.add_dependency(b, a) # b blocked by a
job_b = enqueue_job("developer", "orchestrator", "B", task_id=b) # older id
job_c = enqueue_job("developer", "orchestrator", "C", task_id=c) # not blocked
claimed = claim_next_job()
assert claimed is not None
assert claimed["id"] == job_c, "blocked B skipped, unblocked C claimed"
assert job_b # referenced
# ----------------------------------------------------------------- TC-B07
def test_reconciler_skip_helper_honours_block(monkeypatch):
"""The reconciler reads is_task_ready; a not-ready task must be skipped."""
from src import reconciler as rec
a = _make_task(work_item_id="ORCH-50", stage="development")
b = _make_task(work_item_id="ORCH-51", stage="development")
db.add_dependency(b, a)
advanced = {"called": False}
monkeypatch.setattr(rec, "advance_if_gate_passed",
lambda *a, **k: advanced.__setitem__("called", True),
raising=False)
monkeypatch.setattr(rec, "has_active_job_for_task", lambda tid: False, raising=False)
monkeypatch.setattr(rec, "developer_retry_count", lambda tid: 0, raising=False)
monkeypatch.setattr(rec.settings, "task_deps_enabled", True, raising=False)
monkeypatch.setattr(rec.settings, "reconcile_enabled", True, raising=False)
monkeypatch.setattr(rec.settings, "reconcile_grace_default_s", 0, raising=False)
r = rec.Reconciler()
# Bypass Guard 2 (networked) so we isolate Guard 3. ORCH-086: the production
# call now passes the resolved (states, state_uuid), so accept extra args.
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda *a, **k: False)
# ORCH-086: the D1 resolve now runs before Guard 2 (for the terminal-skip) —
# keep it offline so this Guard-3 test stays deterministic.
monkeypatch.setattr(r, "_resolve_issue_status", lambda task: ({}, {}, None))
task_row = {"id": b, "stage": "development", "repo": "orchestrator",
"work_item_id": "ORCH-51", "branch": "feature/ORCH-51", "age_s": 9999}
r._reconcile_gate_task(task_row)
assert advanced["called"] is False, "dep-blocked task must not be advanced (B-5)"