feat(serial-gate): per-repo serial gate + deferred branch cut + rollback-freeze (ORCH-088)
Этап 1 (serial e2e) пакетного автономного режима. Новая задача репо не входит в analysis (analyst-job не выбирается, ветка не режется), пока в репо есть более ранняя незавершённая задача (FIFO, t2.id < jobs.task_id) ИЛИ репо заморожен. - src/serial_gate.py — новый leaf (never-raise): build_claim_clause (fail-OPEN), is_repo_frozen (fail-CLOSED), set/clear_repo_freeze, serial_gate_applies, snapshot. - src/db.py — идемпотентная миграция repo_freeze + serial_gate-фрагмент в claim_next_job. - src/webhooks/plane.py + src/agents/launcher.py — отложенный срез ветки: start_pipeline не создаёт Gitea-ветку/docs для применимого репо; релокация в _materialize_deferred_branch на момент claim analyst-job (база = свежий origin/main с кодом предшественника, AC-6). - src/stage_engine.py — post-deploy DEGRADED → durable per-repo freeze + Telegram-алерт. - src/main.py — блок serial_gate в GET /queue + POST /serial-gate/unfreeze. - src/config.py — serial_gate_enabled / serial_gate_repos / serial_gate_freeze_enabled. FIFO-уточнение реализации (FR-2): ADR-001 D1 фиксировал t2.id != jobs.task_id; при != пакет одновременно созданных свежих задач взаимно блокировался бы (дедлок). t2.id < jobs.task_id допускает самую раннюю задачу и сериализует остальные, сохраняя AC-1/R-7. STAGE_TRANSITIONS / QG_CHECKS / check_* — без изменений. Аддитивно, под kill-switch, never-raise, restart-safe; при выключенном флаге — нулевая регрессия (enduro не затронут). Тесты: TC-01..TC-22 (test_serial_gate*.py + test_queue_endpoint.py); полный прогон 1114 зелёных. Docs: README (serial gate / /queue / API / БД), CLAUDE.md, CHANGELOG.md, .env.example. Refs: ORCH-088 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,11 @@ def setup(monkeypatch):
|
||||
monkeypatch.setattr(P.settings, "db_path", _test_db)
|
||||
import src.db as _db
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
# ORCH-088: these are pre-ORCH-088 repo-routing tests that assert the branch is
|
||||
# cut DURING start_pipeline. With the serial gate ON (default) the branch cut is
|
||||
# deferred to the analyst-job claim, so pin them to the kill-switch-off (legacy)
|
||||
# path — branch timing is out of scope here (covered by test_serial_gate_branch).
|
||||
monkeypatch.setattr(_db.settings, "serial_gate_enabled", False, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
|
||||
61
tests/test_queue_endpoint.py
Normal file
61
tests/test_queue_endpoint.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""ORCH-088 — GET /queue additive serial_gate block (AC-10 / TC-20).
|
||||
|
||||
The /queue payload must gain an additive ``serial_gate`` block WITHOUT changing
|
||||
any pre-existing key (counts/max_concurrency/reconcile/reaper/post_deploy/
|
||||
task_deps/recent ...).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_queue_endpoint.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 # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "q.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def test_queue_has_serial_gate_block_and_keeps_existing_keys():
|
||||
import asyncio
|
||||
from src import main
|
||||
|
||||
payload = asyncio.run(main.queue())
|
||||
|
||||
# Pre-existing keys are all still present (no contract break).
|
||||
for key in (
|
||||
"counts", "max_concurrency", "poll_interval", "resilience", "reconcile",
|
||||
"reaper", "post_deploy", "merge_verify", "task_deps", "recent",
|
||||
):
|
||||
assert key in payload, f"existing /queue key '{key}' must be preserved"
|
||||
|
||||
# New additive block.
|
||||
assert "serial_gate" in payload
|
||||
sg = payload["serial_gate"]
|
||||
assert sg["enabled"] is True
|
||||
assert "repos" in sg and "freeze_enabled" in sg
|
||||
assert isinstance(sg["per_repo"], dict)
|
||||
|
||||
|
||||
def test_queue_serial_gate_reflects_freeze():
|
||||
import asyncio
|
||||
from src import main
|
||||
from src import serial_gate
|
||||
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-900")
|
||||
payload = asyncio.run(main.queue())
|
||||
per = payload["serial_gate"]["per_repo"]
|
||||
assert "orchestrator" in per
|
||||
assert per["orchestrator"]["frozen"] is True
|
||||
assert per["orchestrator"]["frozen_reason"] == "DEGRADED"
|
||||
188
tests/test_serial_gate.py
Normal file
188
tests/test_serial_gate.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""ORCH-088 — per-repo serial gate, unit tests (real tmp SQLite).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-01 claim_next_job does NOT claim an analyst-job of a NEW task B while the
|
||||
repo has an unfinished task A (gate closed).
|
||||
TC-02 serial_gate_applies: enabled + empty CSV -> True for any repo; CSV
|
||||
membership -> True; repo outside CSV -> False; disabled -> False.
|
||||
TC-03 jobs of an ALREADY-active task (architect/developer/.../deployer) are
|
||||
never gated — the single active task advances freely.
|
||||
TC-08 per-repo: an active orchestrator task does NOT gate an enduro analyst-job.
|
||||
TC-15 kill-switch off -> claim is 1:1 as before ORCH-088.
|
||||
TC-16 repo outside a non-empty CSV -> gate inert for that repo.
|
||||
TC-17 DB/build error in the gate -> fail-OPEN: claim does not crash, still claims.
|
||||
TC-19 snapshot() shape + never-raise.
|
||||
TC-21 STAGE_TRANSITIONS / QG_CHECKS registries unchanged (no new QG check).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate.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 serial_gate # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "sg.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
# Feature ON by default; freeze layer ON; empty CSV (all repos).
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
# Keep the unrelated dep-gate inert so claim semantics isolate the serial gate.
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="analysis", repo="orchestrator"):
|
||||
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, repo, f"feature/{work_item_id}", stage, work_item_id),
|
||||
)
|
||||
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-01
|
||||
def test_gate_closed_when_repo_has_active_task():
|
||||
_make_task("ORCH-201", stage="development") # active predecessor
|
||||
b = _make_task("ORCH-202", stage="analysis") # new task
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
# A is unfinished -> the analyst-job of B is NOT claimable.
|
||||
assert claim_next_job() is None, "analyst-job must be gated by active task A"
|
||||
# /queue shows B waiting + an active task for the repo.
|
||||
snap = serial_gate.snapshot()
|
||||
per = snap["per_repo"]["orchestrator"]
|
||||
assert per["active_task"]["work_item_id"] == "ORCH-201"
|
||||
assert any(w["job_id"] == job_b for w in per["waiting"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-02
|
||||
def test_serial_gate_applies_scopes(monkeypatch):
|
||||
# enabled + empty CSV -> all repos.
|
||||
assert serial_gate.serial_gate_applies("orchestrator") is True
|
||||
assert serial_gate.serial_gate_applies("enduro-trails") is True
|
||||
# CSV membership.
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "orchestrator", raising=False)
|
||||
assert serial_gate.serial_gate_applies("orchestrator") is True
|
||||
assert serial_gate.serial_gate_applies("enduro-trails") is False
|
||||
# kill-switch off -> never applies.
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", False, raising=False)
|
||||
assert serial_gate.serial_gate_applies("orchestrator") is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-03
|
||||
def test_non_analyst_job_of_active_task_passes():
|
||||
a = _make_task("ORCH-210", stage="development")
|
||||
# an unrelated unfinished task in the same repo (would close the gate for analyst)
|
||||
_make_task("ORCH-211", stage="analysis")
|
||||
for role in ("architect", "developer", "reviewer", "tester", "deployer"):
|
||||
jid = enqueue_job(role, "orchestrator", role, task_id=a)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == jid, (
|
||||
f"{role}-job of an active task must never be gated"
|
||||
)
|
||||
# finish it so the next role's job is the only queued one.
|
||||
db.mark_job(jid, "done")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-08
|
||||
def test_per_repo_isolation():
|
||||
# orchestrator busy; enduro gets a brand-new analyst-job.
|
||||
_make_task("ORCH-220", stage="development", repo="orchestrator")
|
||||
b = _make_task("ET-220", stage="analysis", repo="enduro-trails")
|
||||
job_b = enqueue_job("analyst", "enduro-trails", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"orchestrator's active task must not gate enduro's analyst-job"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-15
|
||||
def test_kill_switch_off_is_inert(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", False, raising=False)
|
||||
_make_task("ORCH-230", stage="development") # active task
|
||||
b = _make_task("ORCH-231", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"with the kill-switch off the gate must be inert (claims as before)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-16
|
||||
def test_repo_outside_csv_not_gated(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "enduro-trails", raising=False)
|
||||
_make_task("ORCH-240", stage="development") # active orchestrator task
|
||||
b = _make_task("ORCH-241", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"orchestrator is outside the CSV scope -> gate must not apply"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-17
|
||||
def test_build_clause_error_fails_open(monkeypatch):
|
||||
"""A build error in the gate clause must fail-OPEN (claim still proceeds)."""
|
||||
_make_task("ORCH-250", stage="development") # would close the gate
|
||||
b = _make_task("ORCH-251", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("clause build down")
|
||||
|
||||
monkeypatch.setattr(serial_gate, "build_claim_clause", _boom, raising=True)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"a gate build error must fail-OPEN, not wedge the queue (AC-8)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-19
|
||||
def test_snapshot_shape_and_never_raises(monkeypatch):
|
||||
snap = serial_gate.snapshot()
|
||||
assert snap["enabled"] is True
|
||||
assert "repos" in snap and "freeze_enabled" in snap
|
||||
assert isinstance(snap["per_repo"], dict)
|
||||
# never-raise: a DB failure -> minimal dict with flags, empty per_repo.
|
||||
monkeypatch.setattr(
|
||||
serial_gate, "_known_repos",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
|
||||
raising=True,
|
||||
)
|
||||
snap2 = serial_gate.snapshot()
|
||||
assert snap2["per_repo"] == {}
|
||||
assert snap2["enabled"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-21
|
||||
def test_registries_unchanged():
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
from src.qg.checks import QG_CHECKS
|
||||
assert set(STAGE_TRANSITIONS) == {
|
||||
"created", "analysis", "architecture", "development", "review",
|
||||
"testing", "deploy-staging", "deploy", "done",
|
||||
}
|
||||
# No serial-gate QG check was introduced (the gate is a scheduler condition).
|
||||
assert not any("serial" in k for k in QG_CHECKS), "no new QG check expected"
|
||||
153
tests/test_serial_gate_branch.py
Normal file
153
tests/test_serial_gate_branch.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""ORCH-088 — deferred branch cut / anti-stale-base (FR-1/AC-6).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-13 while the serial gate applies, start_pipeline does NOT create the Gitea
|
||||
branch / initial docs (the cut is deferred to the analyst-job claim);
|
||||
with the kill-switch off it creates them immediately (1:1 as before).
|
||||
TC-14 a branch cut at claim time (ensure_worktree on a not-yet-existing branch)
|
||||
is based on a FRESH origin/main that already contains the predecessor:
|
||||
git merge-base --is-ancestor <sha A> <base B> is true.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate_branch.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 # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "branch.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_source", "db", raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-13
|
||||
async def _drive_start_pipeline(monkeypatch, gate_applies: bool):
|
||||
from src.webhooks import plane
|
||||
from src import plane_sync
|
||||
from src.projects import ProjectConfig
|
||||
|
||||
proj = ProjectConfig(
|
||||
plane_project_id="proj-uuid",
|
||||
repo="orchestrator",
|
||||
work_item_prefix="ORCH",
|
||||
name="orch",
|
||||
)
|
||||
monkeypatch.setattr(plane, "get_project_by_plane_id", lambda pid: proj)
|
||||
monkeypatch.setattr(plane, "_qg0_errors", lambda name, desc: [])
|
||||
monkeypatch.setattr(plane, "ensure_unique_work_item_id", lambda wid, repo: wid)
|
||||
monkeypatch.setattr(
|
||||
plane, "create_task_atomic",
|
||||
lambda *a, **k: ({"id": 1, "work_item_id": "ORCH-500"}, True),
|
||||
)
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_sequence_id", lambda *a, **k: 500)
|
||||
monkeypatch.setattr(plane_sync, "set_issue_analysis", lambda *a, **k: None)
|
||||
monkeypatch.setattr(plane_sync, "add_comment", lambda *a, **k: None)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", gate_applies, raising=False)
|
||||
|
||||
enq = []
|
||||
monkeypatch.setattr(plane, "enqueue_job", lambda *a, **k: (enq.append((a, k)) or 99))
|
||||
|
||||
branch_calls, docs_calls = [], []
|
||||
|
||||
async def _branch_spy(repo, branch):
|
||||
branch_calls.append((repo, branch))
|
||||
|
||||
async def _docs_spy(repo, branch, wi, name):
|
||||
docs_calls.append((repo, branch, wi, name))
|
||||
|
||||
monkeypatch.setattr(plane, "_create_gitea_branch", _branch_spy)
|
||||
monkeypatch.setattr(plane, "_create_initial_docs", _docs_spy)
|
||||
|
||||
data = {
|
||||
"id": "issue-uuid-1",
|
||||
"name": "Add serial gate",
|
||||
"description_stripped": "A sufficiently long description for QG-0 to pass.",
|
||||
"project": "proj-uuid",
|
||||
}
|
||||
await plane.start_pipeline(data, project_id="proj-uuid")
|
||||
return branch_calls, docs_calls, enq
|
||||
|
||||
|
||||
def test_branch_cut_deferred_when_gate_applies(monkeypatch):
|
||||
import asyncio
|
||||
branch_calls, docs_calls, enq = asyncio.run(
|
||||
_drive_start_pipeline(monkeypatch, gate_applies=True)
|
||||
)
|
||||
assert branch_calls == [], "branch must NOT be cut in start_pipeline while gated"
|
||||
assert docs_calls == [], "initial docs must NOT be created while gated"
|
||||
# The analyst-job is still enqueued (it waits in the queue without a branch).
|
||||
assert any(a[0] == "analyst" for a, k in enq), "analyst-job must still be enqueued"
|
||||
|
||||
|
||||
def test_branch_cut_immediate_when_kill_switch_off(monkeypatch):
|
||||
import asyncio
|
||||
branch_calls, docs_calls, enq = asyncio.run(
|
||||
_drive_start_pipeline(monkeypatch, gate_applies=False)
|
||||
)
|
||||
assert branch_calls, "with the gate off the branch is cut in start_pipeline (1:1)"
|
||||
assert docs_calls, "with the gate off initial docs are created in start_pipeline"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-14
|
||||
def _git(*args, cwd):
|
||||
env = {
|
||||
**os.environ,
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
}
|
||||
return subprocess.run(["git", *args], cwd=cwd, env=env,
|
||||
capture_output=True, text=True, check=True)
|
||||
|
||||
|
||||
def test_deferred_branch_base_contains_predecessor(tmp_path, monkeypatch):
|
||||
"""A branch cut at claim time is based on a fresh origin/main with A's code."""
|
||||
from src import git_worktree
|
||||
|
||||
origin = tmp_path / "origin.git"
|
||||
origin.mkdir()
|
||||
_git("init", "--bare", "-b", "main", str(origin), cwd=tmp_path)
|
||||
|
||||
repos_dir = tmp_path / "repos"
|
||||
wt_dir = tmp_path / "wt"
|
||||
repos_dir.mkdir()
|
||||
wt_dir.mkdir()
|
||||
repo = "orchestrator"
|
||||
clone = repos_dir / repo
|
||||
_git("clone", str(origin), str(clone), cwd=tmp_path)
|
||||
|
||||
# Predecessor A: commit on main + push to origin (== "A merged at its done").
|
||||
(clone / "a.txt").write_text("A's code\n")
|
||||
_git("add", "a.txt", cwd=clone)
|
||||
_git("commit", "-m", "task A", cwd=clone)
|
||||
_git("push", "origin", "main", cwd=clone)
|
||||
sha_a = _git("rev-parse", "HEAD", cwd=clone).stdout.strip()
|
||||
|
||||
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir), raising=False)
|
||||
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir), raising=False)
|
||||
|
||||
# Branch B does not exist yet -> ensure_worktree cuts it from fresh origin/main.
|
||||
wt = git_worktree.ensure_worktree(repo, "feature/ORCH-B")
|
||||
head_b = _git("rev-parse", "HEAD", cwd=wt).stdout.strip()
|
||||
|
||||
# AC-6: A's commit is an ancestor of B's base.
|
||||
r = subprocess.run(
|
||||
["git", "-C", wt, "merge-base", "--is-ancestor", sha_a, head_b],
|
||||
capture_output=True,
|
||||
)
|
||||
assert r.returncode == 0, "branch B base must contain predecessor A's commit (AC-6)"
|
||||
113
tests/test_serial_gate_e2e.py
Normal file
113
tests/test_serial_gate_e2e.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""ORCH-088 — serial gate end-to-end queue behaviour (real tmp SQLite).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-04 after A.stage='done' the waiting analyst-job of B is claimed (gate opens
|
||||
automatically — no manual action).
|
||||
TC-05 a queue of 3 tasks of one repo is processed strictly one-at-a-time, FIFO
|
||||
by jobs.id: while A is unfinished neither B nor C starts.
|
||||
TC-06 restart-safe: the active task is derived from the DB (tasks.repo +
|
||||
stage!='done'), not in-memory — re-reading state keeps the gate closed.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate_e2e.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 config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "e2e.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="analysis", 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-04
|
||||
def test_next_starts_automatically_when_predecessor_done():
|
||||
a = _make_task("ORCH-301", stage="development")
|
||||
b = _make_task("ORCH-302", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
assert claim_next_job() is None, "B gated while A unfinished"
|
||||
|
||||
# A reaches done -> the gate opens on the NEXT claim tick, no manual action.
|
||||
_set_stage(a, "done")
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-05
|
||||
def test_three_tasks_processed_one_at_a_time_fifo():
|
||||
a = _make_task("ORCH-310", stage="analysis")
|
||||
b = _make_task("ORCH-311", stage="analysis")
|
||||
c = _make_task("ORCH-312", stage="analysis")
|
||||
job_a = enqueue_job("analyst", "orchestrator", "A", task_id=a)
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
job_c = enqueue_job("analyst", "orchestrator", "C", task_id=c)
|
||||
|
||||
# Only the FIFO-first task (A, lowest id) is claimable.
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_a
|
||||
assert claim_next_job() is None, "B and C must wait while A is unfinished"
|
||||
|
||||
# A runs through to done; now B (next) is claimable, C still waits.
|
||||
db.mark_job(job_a, "done")
|
||||
_set_stage(a, "done")
|
||||
claimed_b = claim_next_job()
|
||||
assert claimed_b is not None and claimed_b["id"] == job_b
|
||||
assert claim_next_job() is None, "C must wait while B is unfinished"
|
||||
|
||||
# B done -> C claimable last (strict FIFO order preserved).
|
||||
db.mark_job(job_b, "done")
|
||||
_set_stage(b, "done")
|
||||
claimed_c = claim_next_job()
|
||||
assert claimed_c is not None and claimed_c["id"] == job_c
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-06
|
||||
def test_restart_safe_active_task_from_db():
|
||||
a = _make_task("ORCH-320", stage="development")
|
||||
b = _make_task("ORCH-321", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
assert claim_next_job() is None
|
||||
|
||||
# Simulate a restart: there is NO in-memory state — the gate recomputes purely
|
||||
# from the DB. Re-running init_db (idempotent) + a fresh claim must still gate B.
|
||||
init_db()
|
||||
assert claim_next_job() is None, "after restart the gate is still closed (DB-derived)"
|
||||
|
||||
_set_stage(a, "done")
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
160
tests/test_serial_gate_freeze.py
Normal file
160
tests/test_serial_gate_freeze.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""ORCH-088 — rollback-freeze layer (FR-5) tests (real tmp SQLite).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-07 freeze survives a restart (durable in DB) — next task stays gated.
|
||||
TC-09 freeze of orchestrator does NOT affect enduro-trails (per-repo).
|
||||
TC-10 post-deploy DEGRADED -> durable freeze row + Telegram alert attempted.
|
||||
TC-11 an active freeze gates the next analyst-job even with NO unfinished task
|
||||
(the degraded task is already done — BR-7).
|
||||
TC-12 manual clear_repo_freeze -> next task is claimable again.
|
||||
TC-18 is_repo_frozen fails CLOSED on a read error (frozen=True on doubt).
|
||||
TC-22 repo_freeze migration is idempotent (re-init does not dup / crash).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate_freeze.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 serial_gate # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "freeze.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="analysis", 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
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-07
|
||||
def test_freeze_survives_restart():
|
||||
b = _make_task("ORCH-401", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
assert serial_gate.set_repo_freeze("orchestrator", "post-deploy DEGRADED", "ORCH-400") is True
|
||||
|
||||
assert claim_next_job() is None, "frozen repo gates the analyst-job"
|
||||
# Simulate restart: no in-memory state, re-init (idempotent) -> still frozen.
|
||||
init_db()
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is True
|
||||
assert claim_next_job() is None, "freeze is durable across restart"
|
||||
assert job_b # referenced
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-09
|
||||
def test_freeze_is_per_repo():
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-410")
|
||||
b = _make_task("ET-410", stage="analysis", repo="enduro-trails")
|
||||
job_b = enqueue_job("analyst", "enduro-trails", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"an orchestrator freeze must not gate enduro-trails"
|
||||
)
|
||||
assert serial_gate.is_repo_frozen("enduro-trails") is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-10
|
||||
def test_post_deploy_degraded_sets_freeze_and_alerts(tmp_path, monkeypatch):
|
||||
from src import stage_engine, post_deploy
|
||||
|
||||
# Sandbox the post-deploy sentinel state dir so a prior DONE marker can't
|
||||
# short-circuit the tick (state lives under settings.repos_dir).
|
||||
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path), raising=False)
|
||||
|
||||
a = _make_task("ORCH-420", stage="done", repo="orchestrator")
|
||||
job = {"task_id": a, "repo": "orchestrator"}
|
||||
|
||||
# Avoid network / git / worktree; force a DEGRADED verdict.
|
||||
monkeypatch.setattr(post_deploy, "probe_signals",
|
||||
lambda *a, **k: post_deploy.ProbeResult(False, 2, 2, "down"))
|
||||
monkeypatch.setattr(post_deploy, "classify", lambda *a, **k: post_deploy.DEGRADED)
|
||||
monkeypatch.setattr(post_deploy, "write_post_deploy_log", lambda *a, **k: True)
|
||||
monkeypatch.setattr(stage_engine, "set_issue_blocked", lambda *a, **k: None)
|
||||
|
||||
alerts = []
|
||||
monkeypatch.setattr(stage_engine, "_notify_post_deploy",
|
||||
lambda wi, msg: alerts.append(msg))
|
||||
|
||||
stage_engine.run_post_deploy_monitor(job)
|
||||
|
||||
# Durable freeze row written + a freeze alert attempted.
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is True
|
||||
assert any("ЗАМОРОЖЕН" in m for m in alerts), f"freeze alert missing: {alerts}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-11
|
||||
def test_freeze_gates_even_without_unfinished_task():
|
||||
_make_task("ORCH-430", stage="done") # degraded task already done
|
||||
b = _make_task("ORCH-431", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
# Without freeze B would be claimable (A done, no earlier unfinished). Freeze it.
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-430")
|
||||
assert claim_next_job() is None, "active freeze gates the next analyst-job (BR-7)"
|
||||
assert job_b
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-12
|
||||
def test_manual_unfreeze_lets_next_start():
|
||||
_make_task("ORCH-440", stage="done")
|
||||
b = _make_task("ORCH-441", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-440")
|
||||
assert claim_next_job() is None
|
||||
|
||||
cleared = serial_gate.clear_repo_freeze("orchestrator")
|
||||
assert cleared >= 1
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is False
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
# Idempotent: clearing again clears nothing.
|
||||
assert serial_gate.clear_repo_freeze("orchestrator") == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-18
|
||||
def test_is_repo_frozen_fails_closed(monkeypatch):
|
||||
def _boom(repo):
|
||||
raise RuntimeError("freeze read down")
|
||||
|
||||
monkeypatch.setattr(serial_gate, "_active_freeze_row", _boom, raising=True)
|
||||
# Freeze layer enabled + cannot confirm absence -> fail CLOSED (True).
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is True
|
||||
# Freeze layer OFF -> never frozen, even on a read error.
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", False, raising=False)
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-22
|
||||
def test_repo_freeze_migration_idempotent():
|
||||
# Re-running init_db must not crash or duplicate the table/index.
|
||||
init_db()
|
||||
init_db()
|
||||
conn = get_db()
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(repo_freeze)").fetchall()]
|
||||
conn.close()
|
||||
assert {"repo", "frozen_at", "reason", "work_item_id", "cleared_at"}.issubset(set(cols))
|
||||
# A freeze still functions after repeated migration.
|
||||
assert serial_gate.set_repo_freeze("orchestrator", "x", "ORCH-450") is True
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is True
|
||||
@@ -39,6 +39,11 @@ def setup(monkeypatch):
|
||||
monkeypatch.setattr(P.settings, "db_path", _test_db)
|
||||
import src.db as _db
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
# ORCH-088: this suite asserts the branch is cut DURING start_pipeline. With the
|
||||
# serial gate ON (default) the cut is deferred to the analyst-job claim, so pin
|
||||
# to the kill-switch-off (legacy) path — branch timing is out of scope here
|
||||
# (covered by test_serial_gate_branch).
|
||||
monkeypatch.setattr(_db.settings, "serial_gate_enabled", False, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
|
||||
Reference in New Issue
Block a user