feat(ORCH-026): task dependencies (B waits for A) + single-repo merge serialization
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>
This commit is contained in:
90
tests/test_orch026_queue_observability.py
Normal file
90
tests/test_orch026_queue_observability.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""ORCH-026 — /queue task_deps observability (TC-G02, G-2).
|
||||
|
||||
task_deps.snapshot() is a read-only summary (NOT a source of truth) exposing the
|
||||
declared edges, blocked tasks and any detected cycle. It must never raise.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_queue_obs.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 / "obs.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
monkeypatch.setattr(db.settings, "task_deps_source", "db", raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
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_snapshot_shape_empty():
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["enabled"] is True
|
||||
assert snap["source"] == "db"
|
||||
assert snap["edges"] == 0
|
||||
assert snap["blocked_tasks"] == []
|
||||
assert snap["cycle"] is None
|
||||
|
||||
|
||||
def test_snapshot_reports_blocked_task():
|
||||
a = _make_task("ORCH-100", stage="development")
|
||||
b = _make_task("ORCH-101", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["edges"] == 1
|
||||
assert len(snap["blocked_tasks"]) == 1
|
||||
bt = snap["blocked_tasks"][0]
|
||||
assert bt["work_item_id"] == "ORCH-101"
|
||||
assert "ORCH-100" in bt["waiting_on"]
|
||||
assert snap["cycle"] is None
|
||||
|
||||
|
||||
def test_snapshot_reports_cycle():
|
||||
a = _make_task("ORCH-102")
|
||||
b = _make_task("ORCH-103")
|
||||
db.add_dependency(a, b)
|
||||
db.add_dependency(b, a)
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["cycle"] is not None
|
||||
assert "ORCH-102" in snap["cycle"] or "ORCH-103" in snap["cycle"]
|
||||
|
||||
|
||||
def test_snapshot_never_raises(monkeypatch):
|
||||
monkeypatch.setattr(db, "get_dependency_edges",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
|
||||
raising=False)
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["edges"] == 0
|
||||
assert snap["blocked_tasks"] == []
|
||||
|
||||
|
||||
def test_queue_endpoint_includes_task_deps(monkeypatch):
|
||||
"""GET /queue payload carries the task_deps block (read-only)."""
|
||||
import asyncio
|
||||
from src import main
|
||||
payload = asyncio.run(main.queue())
|
||||
assert "task_deps" in payload
|
||||
assert "enabled" in payload["task_deps"]
|
||||
Reference in New Issue
Block a user