"""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)"