Level A — merge/deploy serialization within one repo: reuse the existing ORCH-043/065 merge-lease (no new mechanism); the only new logic is an unconditional pre-merge rebase in check_branch_mergeable — under the held lease, auto_rebase_onto_main is ALWAYS called when premerge_rebase_always (default True), not just when the branch is behind. No-op on an up-to-date branch (rebase keeps HEAD, force-with-lease -> "Everything up-to-date", CI not triggered). Kill-switch off -> ORCH-043 behaviour 1:1. Level B — declarative task dependencies: additive job_deps table (CREATE ... IF NOT EXISTS, no live-DB migration); claim_next_job gate (NOT EXISTS) defers a job whose depends-on tasks are not yet 'done' without occupying a max_concurrency slot; inert on empty job_deps -> zero regression. New leaf src/task_deps.py (never-raise): is_task_ready (fail-open), DFS cycle detection + Blocked/alert, declare/ingest_plane_relations (db source never hits the network on the hot path), snapshot. Telegram waiting-line, /queue observability, reconciler skip + cycle backstop, reaper untouched. Invariants unchanged: STAGE_TRANSITIONS, QG_CHECKS registry (dep gate is a claim_next_job врезка, not a registered QG), DB schema of existing tables, HTTP endpoints; non-self repos remain a no-op on empty deps/scope. Flags: ORCH_PREMERGE_REBASE_ALWAYS, ORCH_TASK_DEPS_ENABLED, ORCH_TASK_DEPS_SOURCE. Docs: docs/architecture/README.md, CLAUDE.md, .env.example, CHANGELOG.md, adr-0015. Tests: tests/test_orch026_*.py (64 tests); full suite 991 green. Refs: ORCH-026 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
"""ORCH-026 Level B — dependency cycle / deadlock detection (TC-B03, TC-B04).
|
|
|
|
TC-B03 detect_cycle is deterministic: A->B->A (and longer) is detected; an
|
|
acyclic graph yields None. Pure function (edges injected).
|
|
TC-B04 a detected cycle escalates: set_issue_blocked + a Telegram alert, with
|
|
no worker crash and no blocking of other tasks (never-raise).
|
|
"""
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_cycles.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 # noqa: E402
|
|
from src import task_deps # noqa: E402
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fresh_db(tmp_path, monkeypatch):
|
|
dbfile = tmp_path / "cycles.db"
|
|
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
|
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
|
init_db()
|
|
yield
|
|
|
|
|
|
# ----------------------------------------------------------------- TC-B03
|
|
def test_detect_two_node_cycle():
|
|
# edge (B, A) means "B depends on A"; 1->2 and 2->1 is a 2-cycle.
|
|
edges = [(1, 2), (2, 1)]
|
|
cyc = task_deps.detect_cycle(1, edges=edges)
|
|
assert cyc is not None
|
|
assert cyc[0] == cyc[-1] # closed cycle
|
|
assert set(cyc) == {1, 2}
|
|
|
|
|
|
def test_detect_longer_cycle():
|
|
edges = [(1, 2), (2, 3), (3, 1)]
|
|
cyc = task_deps.detect_cycle(1, edges=edges)
|
|
assert cyc is not None
|
|
assert set(cyc) >= {1, 2, 3}
|
|
|
|
|
|
def test_acyclic_graph_has_no_cycle():
|
|
edges = [(1, 2), (2, 3), (1, 3)] # DAG
|
|
assert task_deps.detect_cycle(1, edges=edges) is None
|
|
assert task_deps.find_any_cycle(edges=edges) is None
|
|
|
|
|
|
def test_find_any_cycle_scans_whole_graph():
|
|
# A disconnected cycle 10<->11 not reachable from node 1.
|
|
edges = [(1, 2), (10, 11), (11, 10)]
|
|
assert task_deps.detect_cycle(1, edges=edges) is None
|
|
cyc = task_deps.find_any_cycle(edges=edges)
|
|
assert cyc is not None
|
|
assert set(cyc) == {10, 11}
|
|
|
|
|
|
def test_detect_cycle_never_raises_on_garbage():
|
|
assert task_deps.detect_cycle(None) is None
|
|
# Malformed edge list -> swallowed -> None.
|
|
assert task_deps.detect_cycle(1, edges="not-a-list") is None
|
|
|
|
|
|
# ----------------------------------------------------------------- TC-B04
|
|
def _make_task(work_item_id, stage="development"):
|
|
conn = get_db()
|
|
cur = conn.execute(
|
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}", stage),
|
|
)
|
|
tid = cur.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
return tid
|
|
|
|
|
|
def test_handle_cycle_blocks_and_alerts(monkeypatch):
|
|
a = _make_task("ORCH-60")
|
|
b = _make_task("ORCH-61")
|
|
db.add_dependency(a, b)
|
|
db.add_dependency(b, a) # cycle a<->b
|
|
|
|
blocked = []
|
|
alerts = []
|
|
import src.plane_sync as plane_sync
|
|
import src.notifications as notifications
|
|
monkeypatch.setattr(plane_sync, "set_issue_blocked",
|
|
lambda wi, *a, **k: blocked.append(wi), raising=False)
|
|
monkeypatch.setattr(notifications, "send_telegram",
|
|
lambda text, *a, **k: alerts.append(text), raising=False)
|
|
|
|
cyc = task_deps.detect_cycle(a)
|
|
assert cyc is not None
|
|
ok = task_deps.handle_cycle(cyc)
|
|
assert ok is True
|
|
assert set(blocked) == {"ORCH-60", "ORCH-61"}
|
|
assert len(alerts) == 1
|
|
assert "ORCH-60" in alerts[0] and "ORCH-61" in alerts[0]
|
|
|
|
|
|
def test_handle_cycle_never_raises_when_notify_fails(monkeypatch):
|
|
a = _make_task("ORCH-70")
|
|
b = _make_task("ORCH-71")
|
|
db.add_dependency(a, b)
|
|
db.add_dependency(b, a)
|
|
import src.plane_sync as plane_sync
|
|
import src.notifications as notifications
|
|
|
|
def _boom(*a, **k):
|
|
raise RuntimeError("plane down")
|
|
|
|
monkeypatch.setattr(plane_sync, "set_issue_blocked", _boom, raising=False)
|
|
monkeypatch.setattr(notifications, "send_telegram", _boom, raising=False)
|
|
cyc = task_deps.detect_cycle(a)
|
|
# Must not propagate the exception (AC-G1).
|
|
assert task_deps.handle_cycle(cyc) in (True, False)
|
|
|
|
|
|
def test_declare_dependency_escalates_cycle(monkeypatch):
|
|
"""declare_dependency surfaces a freshly-introduced cycle at declaration."""
|
|
a = _make_task("ORCH-80")
|
|
b = _make_task("ORCH-81")
|
|
handled = []
|
|
monkeypatch.setattr(task_deps, "handle_cycle",
|
|
lambda cyc: handled.append(cyc), raising=False)
|
|
assert task_deps.declare_dependency(a, b) is True
|
|
assert handled == [] # no cycle yet
|
|
# Closing the loop -> handle_cycle invoked.
|
|
assert task_deps.declare_dependency(b, a) is True
|
|
assert len(handled) == 1
|