Закрывает 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>
162 lines
6.3 KiB
Python
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)"
|