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:
2026-06-08 19:06:22 +03:00
committed by stream
parent 9019e12d98
commit a74379f657
24 changed files with 1686 additions and 2 deletions

View File

@@ -58,6 +58,11 @@ def race_repo(tmp_path, monkeypatch):
monkeypatch.setattr(qg.settings, "merge_gate_enabled", True)
monkeypatch.setattr(qg.settings, "merge_gate_repos", repo)
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
# ORCH-026: this redrive test asserts the ORCH-043 ancestor-based short-circuit
# ("already caught up" -> skip expensive re-test). Pin the always-rebase
# kill-switch OFF so the legacy short-circuit path is exercised here; the new
# default (True) is covered by tests/test_orch026_premerge_rebase.py (TC-A01).
monkeypatch.setattr(qg.settings, "premerge_rebase_always", False, raising=False)
origin = tmp_path / "origin.git"
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)

View File

@@ -0,0 +1,118 @@
"""ORCH-026 conditionality / self-hosting safety (TC-A06, TC-A07).
TC-A06 kill-switch / out-of-scope: with the flag off (or for a repo outside the
merge-gate scope) the merge path behaves 1:1 as before ORCH-026 — no-op.
TC-A07 self-hosting safety: the new Level-A logic never pushes to main; the only
force op stays --force-with-lease on the task branch; STAGE_TRANSITIONS
and the QG_CHECKS registry are unchanged.
"""
import os
import tempfile
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_cond.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import merge_gate # noqa: E402
from src.qg import checks # noqa: E402
# ----------------------------------------------------------------- TC-A06
def test_out_of_scope_repo_is_noop_even_with_flag_on(monkeypatch):
"""A repo outside merge_gate scope -> N/A pass, regardless of premerge flag."""
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
monkeypatch.setattr(checks.settings, "merge_gate_repos", "orchestrator", raising=False)
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
# enduro-trails is NOT in the scope -> no lease, no rebase, just N/A.
called = {"acquire": 0, "rebase": 0}
monkeypatch.setattr(merge_gate, "acquire_merge_lease",
lambda *a, **k: (called.__setitem__("acquire", called["acquire"] + 1), (True, "x"))[1],
raising=False)
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main",
lambda *a, **k: (called.__setitem__("rebase", called["rebase"] + 1), (True, "x"))[1],
raising=False)
ok, reason = checks.check_branch_mergeable("enduro-trails", "ET-1", "feature/e")
assert ok is True
assert "N/A" in reason
assert called["acquire"] == 0 and called["rebase"] == 0
def test_task_deps_kill_switch_omits_gate(monkeypatch):
"""task_deps_enabled=False -> claim_next_job query is the ORCH-1 query (no gate)."""
import src.db as db
monkeypatch.setattr(db.settings, "task_deps_enabled", False, raising=False)
# Inspect the SQL the claim builds by stubbing the connection.
captured = {}
class _FakeConn:
def execute(self, sql, *a):
captured.setdefault("sql", sql)
class _R:
def fetchone(self_inner):
return None
return _R()
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
db.claim_next_job()
assert "NOT EXISTS" not in captured["sql"], "gate must be omitted when disabled"
def test_task_deps_enabled_adds_gate(monkeypatch):
import src.db as db
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
captured = {}
class _FakeConn:
def execute(self, sql, *a):
captured.setdefault("sql", sql)
class _R:
def fetchone(self_inner):
return None
return _R()
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
db.claim_next_job()
assert "NOT EXISTS" in captured["sql"], "gate must be present when enabled"
assert "job_deps" in captured["sql"]
# ----------------------------------------------------------------- TC-A07
def test_stage_transitions_unchanged():
"""ORCH-026 must not touch the state machine (AC-A5)."""
from src.stages import STAGE_TRANSITIONS
# The canonical happy-path edges must still exist exactly.
assert STAGE_TRANSITIONS["deploy-staging"]["next"] == "deploy"
assert STAGE_TRANSITIONS["deploy"]["next"] == "done"
assert STAGE_TRANSITIONS["development"]["next"] == "review"
def test_qg_registry_has_no_new_dep_gate():
"""The dependency gate is врезка in claim_next_job, NOT a registered QG."""
from src.qg.checks import QG_CHECKS
joined = " ".join(QG_CHECKS.keys())
assert "task_dep" not in joined and "dependency" not in joined
def test_premerge_only_force_with_lease_on_branch():
"""auto_rebase_onto_main never pushes to main; force is --force-with-lease only."""
import inspect
src = inspect.getsource(merge_gate.auto_rebase_onto_main)
assert "--force-with-lease" in src
# No raw 'push origin main' / force-push to main in the rebase path.
assert "push origin main" not in src
assert "--force " not in src # plain --force (not -with-lease) is forbidden

View File

@@ -0,0 +1,136 @@
"""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

View File

@@ -0,0 +1,79 @@
"""ORCH-026 Level B — blocked-task visibility (TC-B06).
A dep-blocked task surfaces a waiting-line ("⏳ ждёт ORCH-NNN") in its single
Telegram tracker card; the "one card per task" invariant is preserved (the line
is added to the SAME render, not a new message). Render is never broken by the
dependency lookup (never-raise).
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_visibility.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 notifications # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "vis.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(work_item_id, stage="development"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
"VALUES (?, ?, ?, ?, ?, ?)",
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}",
stage, f"title {work_item_id}"),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def test_blocked_task_shows_waiting_line():
a = _make_task("ORCH-90", stage="development")
b = _make_task("ORCH-91", stage="development")
db.add_dependency(b, a)
text = notifications.render_task_tracker(b)
assert "ждёт" in text
assert "ORCH-90" in text
def test_ready_task_has_no_waiting_line():
a = _make_task("ORCH-92", stage="done")
b = _make_task("ORCH-93", stage="development")
db.add_dependency(b, a)
text = notifications.render_task_tracker(b)
assert "ждёт" not in text
def test_done_task_has_no_waiting_line():
a = _make_task("ORCH-94", stage="development")
b = _make_task("ORCH-95", stage="done")
db.add_dependency(b, a)
text = notifications.render_task_tracker(b)
# A done task is terminal -> the waiting-line branch is skipped entirely.
assert "ждёт" not in text
def test_render_never_raises_on_dep_error(monkeypatch):
b = _make_task("ORCH-96", stage="development")
from src import task_deps
monkeypatch.setattr(task_deps, "is_task_ready",
lambda tid: (_ for _ in ()).throw(RuntimeError("boom")),
raising=False)
# Must still produce a card (no crash).
text = notifications.render_task_tracker(b)
assert "ORCH-96" in text

View File

@@ -0,0 +1,124 @@
"""ORCH-026 Level B — declarative dependencies integration (TC-B08).
End-to-end (DB level): B declared blocked-by A; queued B does not start until A
is 'done'; after A->done the worker can claim B. Also covers the plane/hybrid
ingestion path: Plane `blocked-by` relations are resolved to local task ids and
written into job_deps (the scheduler then reads only the DB).
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_depsint.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 / "depsint.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(work_item_id, stage="development", plane_id=None):
conn = get_db()
pid = plane_id or work_item_id
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) "
"VALUES (?, ?, ?, ?, ?, ?)",
(pid, work_item_id, "orchestrator", f"feature/{work_item_id}", stage, pid),
)
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()
def test_b_waits_for_a_then_runs():
a = _make_task("ORCH-200", stage="development")
b = _make_task("ORCH-201", stage="development")
db.add_dependency(b, a)
job_b = enqueue_job("developer", "orchestrator", "do B", task_id=b)
# While A is in flight, B is not claimable.
assert claim_next_job() is None
ready, waiting = task_deps.is_task_ready(b)
assert ready is False and "ORCH-200" in waiting
# A advances through to done.
_set_stage(a, "review")
assert claim_next_job() is None # still not terminal
_set_stage(a, "done")
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b
def test_multiple_predecessors_all_must_be_done():
a1 = _make_task("ORCH-210", stage="development")
a2 = _make_task("ORCH-211", stage="development")
b = _make_task("ORCH-212", stage="development")
db.add_dependency(b, a1)
db.add_dependency(b, a2)
job_b = enqueue_job("developer", "orchestrator", "B", task_id=b)
_set_stage(a1, "done")
assert claim_next_job() is None, "still blocked by a2"
_set_stage(a2, "done")
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b
# ---- plane/hybrid ingestion path (TC-B01) ---------------------------------
def test_ingest_plane_relations_writes_db(monkeypatch):
monkeypatch.setattr(db.settings, "task_deps_source", "hybrid", raising=False)
a = _make_task("ORCH-220", stage="development", plane_id="plane-uuid-A")
b = _make_task("ORCH-221", stage="development", plane_id="plane-uuid-B")
import src.plane_sync as plane_sync
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids",
lambda issue_id, project_id, **k: ["plane-uuid-A"],
raising=False)
n = task_deps.ingest_plane_relations(b, "plane-uuid-B", "proj-1")
assert n == 1
assert db.get_dependencies(b) == [a]
def test_ingest_noop_when_source_db(monkeypatch):
monkeypatch.setattr(db.settings, "task_deps_source", "db", raising=False)
b = _make_task("ORCH-230", stage="development", plane_id="plane-uuid-Z")
import src.plane_sync as plane_sync
called = {"n": 0}
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids",
lambda *a, **k: called.__setitem__("n", called["n"] + 1) or [],
raising=False)
n = task_deps.ingest_plane_relations(b, "plane-uuid-Z", "proj-1")
assert n == 0
assert called["n"] == 0, "default db source must not call Plane"
def test_ingest_never_raises_on_plane_outage(monkeypatch):
monkeypatch.setattr(db.settings, "task_deps_source", "plane", raising=False)
b = _make_task("ORCH-240", stage="development", plane_id="plane-uuid-Y")
import src.plane_sync as plane_sync
def _boom(*a, **k):
raise RuntimeError("plane down")
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids", _boom, raising=False)
assert task_deps.ingest_plane_relations(b, "plane-uuid-Y", "proj-1") == 0

View File

@@ -0,0 +1,95 @@
"""ORCH-026 Level A serialization (TC-A02..A05).
The merge-lease window (ORCH-043/065) is what serialises "merge -> main-updated"
per repo; ORCH-026 reuses it unchanged. These tests confirm the properties the
ADR relies on:
TC-A02 extended window: while A holds the lease, B of the SAME repo gets
"merge-lock busy" -> defer (not rollback); holder-aware release does
NOT delete A's lease.
TC-A03 strict per-repo: an orchestrator lease never blocks an enduro-trails
acquire (both claimable in parallel).
TC-A04 restart-safe + proactive reclaim: a dead holder's lease is reclaimed
(reclaim_stale_lease) so the pipeline never wedges forever.
TC-A05 anti-livelock defer budget: merge_defer_max_attempts is bounded and
positive -> exhaustion escalates instead of looping forever.
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_serialize.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import merge_gate # noqa: E402
@pytest.fixture
def leases_dir(tmp_path, monkeypatch):
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path), raising=False)
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300, raising=False)
monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "", raising=False)
monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", True, raising=False)
return tmp_path
# ----------------------------------------------------------------- TC-A02
def test_second_task_same_repo_defers_not_rollback(leases_dir):
okA, reasonA = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
assert okA is True
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB is False
assert reasonB == "merge-lock busy" # -> caller DEFERS, never a rollback signal
def test_holder_aware_release_keeps_foreign_lease(leases_dir):
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
# A delayed release from B (which never held it) must NOT delete A's lease.
merge_gate.release_merge_lease("orchestrator", "feature/B")
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB is False and reasonB == "merge-lock busy"
# A's own release frees it.
merge_gate.release_merge_lease("orchestrator", "feature/A")
okB2, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB2 is True
# ----------------------------------------------------------------- TC-A03
def test_serialization_is_strictly_per_repo(leases_dir):
okA, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
okET, _ = merge_gate.acquire_merge_lease("enduro-trails", "feature/E", "ET-1")
assert okA is True
assert okET is True, "a different repo must be claimable in parallel (AC-A3)"
# ----------------------------------------------------------------- TC-A04
def test_dead_holder_lease_is_reclaimed(leases_dir, monkeypatch):
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
# Holder pid is THIS process; simulate it being dead.
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False, raising=False)
reclaimed = merge_gate.reclaim_stale_lease("orchestrator")
assert reclaimed is True
# After reclaim B can acquire -> pipeline does not wedge forever.
okB, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB is True
def test_stale_lease_age_reclaimed_on_acquire(leases_dir, monkeypatch):
# A very short timeout makes the existing lease look stale on B's acquire.
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 0, raising=False)
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB is True
assert "reclaimed" in reasonB
# ----------------------------------------------------------------- TC-A05
def test_defer_budget_is_bounded(monkeypatch):
"""The defer budget is a positive finite int -> exhaustion escalates (AC-A6)."""
from src.config import settings
assert isinstance(settings.merge_defer_max_attempts, int)
assert settings.merge_defer_max_attempts > 0
assert settings.merge_defer_delay_s > 0

View File

@@ -0,0 +1,83 @@
"""ORCH-026 — additive job_deps migration (TC-G01, AC-G4).
The migration must be additive (CREATE TABLE/INDEX IF NOT EXISTS), idempotent,
and safe on a pre-existing DB with data: existing columns of jobs/tasks/
agent_runs/events are untouched.
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_migration.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
@pytest.fixture
def dbfile(tmp_path, monkeypatch):
f = tmp_path / "mig.db"
monkeypatch.setattr(db.settings, "db_path", str(f))
return f
def _columns(conn, table):
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
def test_job_deps_table_created(dbfile):
init_db()
conn = get_db()
cols = _columns(conn, "job_deps")
conn.close()
assert set(cols) == {"task_id", "depends_on_task_id", "created_at"}
def test_job_deps_indices_created(dbfile):
init_db()
conn = get_db()
idx = {r[1] for r in conn.execute("PRAGMA index_list(job_deps)").fetchall()}
conn.close()
assert "idx_job_deps_task" in idx
assert "idx_job_deps_depends" in idx
def test_primary_key_idempotent_insert(dbfile):
init_db()
conn = get_db()
conn.execute("INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) VALUES (1, 2)")
conn.execute("INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) VALUES (1, 2)")
conn.commit()
n = conn.execute("SELECT COUNT(*) FROM job_deps").fetchone()[0]
conn.close()
assert n == 1, "PK (task_id, depends_on_task_id) prevents dup rows"
def test_migration_idempotent_and_preserves_data(dbfile):
# First init + seed legacy data.
init_db()
conn = get_db()
conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES ('ET-1','ET-1','enduro-trails','feature/x','development')"
)
conn.execute(
"INSERT INTO jobs (agent, repo, status) VALUES ('developer','enduro-trails','queued')"
)
conn.commit()
tasks_cols_before = _columns(conn, "tasks")
jobs_cols_before = _columns(conn, "jobs")
conn.close()
# Re-run init_db (simulates a restart on a live DB) -> must be a no-op.
init_db()
conn = get_db()
assert _columns(conn, "tasks") == tasks_cols_before, "tasks columns unchanged"
assert _columns(conn, "jobs") == jobs_cols_before, "jobs columns unchanged"
# Legacy data survives.
assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 1
conn.close()

View File

@@ -0,0 +1,82 @@
"""ORCH-026 Level A (TC-A01): proactive pre-merge rebase.
check_branch_mergeable must ALWAYS rebase the task branch onto the current
origin/main under the held merge-lease when ``premerge_rebase_always`` is on —
even when ``branch_is_behind_main`` would short-circuit (no conflict, formally
not behind). With the flag OFF the ORCH-043 short-circuit is restored 1:1.
These are pure unit tests: every merge_gate primitive is monkeypatched, so no
git/network is touched — we assert the CONTROL FLOW (was auto_rebase_onto_main
called?) and the verdict.
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_premerge.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import merge_gate # noqa: E402
from src.qg import checks # noqa: E402
@pytest.fixture
def patched_gate(monkeypatch):
"""Patch merge_gate primitives; record whether auto_rebase ran."""
calls = {"rebase": 0, "retest": 0, "released": 0, "behind_checked": 0}
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
monkeypatch.setattr(checks.settings, "merge_gate_repos", "", raising=False)
monkeypatch.setattr(merge_gate, "acquire_merge_lease",
lambda *a, **k: (True, "lease acquired"), raising=False)
def _behind(repo, branch):
calls["behind_checked"] += 1
return False # NOT behind -> ORCH-043 would short-circuit
def _rebase(repo, branch):
calls["rebase"] += 1
return True, "rebased (noop)"
def _retest(repo, branch):
calls["retest"] += 1
return True, "green"
def _release(repo, branch=None):
calls["released"] += 1
monkeypatch.setattr(merge_gate, "branch_is_behind_main", _behind, raising=False)
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", _rebase, raising=False)
monkeypatch.setattr(merge_gate, "retest_branch", _retest, raising=False)
monkeypatch.setattr(merge_gate, "release_merge_lease", _release, raising=False)
return calls
def test_always_rebases_even_when_not_behind(patched_gate, monkeypatch):
"""premerge_rebase_always=True -> auto_rebase_onto_main ALWAYS called (AC-A2)."""
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
assert ok is True
assert patched_gate["rebase"] == 1, "rebase must run even when not behind"
assert patched_gate["retest"] == 1, "re-test must run after the proactive rebase"
def test_flag_off_short_circuits_like_orch043(patched_gate, monkeypatch):
"""premerge_rebase_always=False -> not-behind short-circuit, no rebase (AC-A7)."""
monkeypatch.setattr(checks.settings, "premerge_rebase_always", False, raising=False)
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
assert ok is True
assert reason == "branch up-to-date with main"
assert patched_gate["rebase"] == 0, "must NOT rebase when not behind and flag off"
def test_disabled_gate_is_noop(monkeypatch):
"""merge_gate_enabled=False -> pass-through, no lease/rebase at all (AC-G2)."""
monkeypatch.setattr(checks.settings, "merge_gate_enabled", False, raising=False)
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
assert ok is True
assert "disabled" in reason

View 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"]

View File

@@ -0,0 +1,65 @@
"""ORCH-026 Level A — serialization integration (TC-A08).
Scenario (no network, lease + gate level): two tasks of the SAME repo race for
the merge edge. While A holds the merge-lease (the merge->main-updated window),
B's check_branch_mergeable returns "merge-lock busy" -> the engine DEFERS B (it
does NOT roll back). After A releases (A reached main / done), B acquires, is
proactively rebased onto the now-current main (carrying A's code) and merges.
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_serint.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import merge_gate # noqa: E402
from src.qg import checks # noqa: E402
@pytest.fixture
def env(tmp_path, monkeypatch):
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path), raising=False)
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300, raising=False)
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
monkeypatch.setattr(checks.settings, "merge_gate_repos", "", raising=False)
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
# Make the git/test primitives deterministic no-ops; A's rebase is a no-op,
# B's rebase is the real "catch up to A's code".
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: False, raising=False)
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", lambda r, b: (True, "ok"), raising=False)
monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "green"), raising=False)
return tmp_path
def test_serialized_merge_window(env, monkeypatch):
repo = "orchestrator"
# A reaches the merge edge first: gate passes and HOLDS the lease.
okA, reasonA = checks.check_branch_mergeable(repo, "ORCH-1", "feature/A")
assert okA is True
# Lease is held by A.
assert merge_gate._read_lease(merge_gate._lease_path(repo))["branch"] == "feature/A"
# B reaches the merge edge while A still holds the window -> busy -> DEFER.
okB, reasonB = checks.check_branch_mergeable(repo, "ORCH-2", "feature/B")
assert okB is False
assert reasonB == "merge-lock busy" # NOT a rollback; engine re-queues via available_at
# B's defer must NOT have stolen / cleared A's lease.
assert merge_gate._read_lease(merge_gate._lease_path(repo))["branch"] == "feature/A"
# A completes (PR merged / deploy->done) -> lease released.
merge_gate.release_merge_lease(repo, "feature/A")
# B retries: now acquires, is proactively rebased onto current main, merges.
rebased = {"called": 0}
def _rebase(r, b):
rebased["called"] += 1
return True, "rebased onto A"
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", _rebase, raising=False)
okB2, reasonB2 = checks.check_branch_mergeable(repo, "ORCH-2", "feature/B")
assert okB2 is True
assert rebased["called"] == 1, "B must be proactively rebased onto the fresh main (A's code)"

View File

@@ -0,0 +1,157 @@
"""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.
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda task: False)
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)"

View File

@@ -58,6 +58,12 @@ def lease_spy(monkeypatch):
# Default merge_gate scope: real for the self-hosting orchestrator repo.
monkeypatch.setattr(qg.settings, "merge_gate_enabled", True)
monkeypatch.setattr(qg.settings, "merge_gate_repos", "")
# ORCH-026: these ORCH-043 composition tests assert the ancestor-based
# short-circuit ("branch up-to-date with main" -> no rebase). That is now the
# `premerge_rebase_always=False` kill-switch path; pin it OFF here so they
# keep testing the legacy ORCH-043 behaviour. The new always-rebase default
# (True) is covered by tests/test_orch026_premerge_rebase.py (TC-A01).
monkeypatch.setattr(qg.settings, "premerge_rebase_always", False, raising=False)
return state