Этап 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>
267 lines
12 KiB
Python
267 lines
12 KiB
Python
"""ORCH-6: Plane webhook project-filter + repo-resolution tests.
|
|
|
|
Verifies the core of the 2026-06-02 incident fix:
|
|
* webhook from an UNKNOWN Plane project -> {"status": "ignored"} and no task
|
|
* webhook from the orchestrator project -> task created with repo=orchestrator
|
|
* webhook from the enduro project -> task created with repo=enduro-trails
|
|
|
|
launcher.launch is mocked so no real agents are spawned. Gitea branch/doc
|
|
creation is mocked (network). FastAPI TestClient drives the real endpoint.
|
|
|
|
This module configures its own registry via monkeypatch + reload_projects so it
|
|
is independent of ORCH_PROJECTS_JSON set by other test modules.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
# Test DB / disable signature checks (same convention as test_webhooks.py).
|
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_plane.db")
|
|
os.environ["ORCH_DB_PATH"] = _test_db
|
|
os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "")
|
|
os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "")
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
|
|
from unittest.mock import patch, AsyncMock # noqa: E402
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from src.main import app # noqa: E402
|
|
from src.db import init_db, get_db # noqa: E402
|
|
from src import projects as P # noqa: E402
|
|
from src.projects import reload_projects # noqa: E402
|
|
import src.plane_sync as plane_sync # noqa: E402
|
|
|
|
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
|
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
|
UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000"
|
|
|
|
# ORCH-39: after ORCH-10 the webhook resolves Plane state UUIDs per-project via
|
|
# get_project_states(project_id). Hardcoding the enduro in_progress UUID for an
|
|
# ORCH-project payload no longer matches, so the pipeline never starts. We mock
|
|
# get_project_states with a deterministic per-project map (no network) and send
|
|
# each request with the UUID that matches its own project.
|
|
_PROJECT_STATES = {
|
|
ENDURO_PLANE_ID: {
|
|
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
|
# ORCH-066: To Analyse is the start trigger; absent -> aliases in_progress.
|
|
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
|
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
|
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
|
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
|
},
|
|
ORCH_PLANE_ID: {
|
|
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
|
"to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
|
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
|
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
|
"cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85",
|
|
},
|
|
}
|
|
|
|
|
|
def _fake_get_project_states(project_id):
|
|
"""Deterministic per-project state map; mirrors get_project_states' fallback
|
|
for unknown projects so the webhook still behaves sensibly."""
|
|
return _PROJECT_STATES.get(project_id, _PROJECT_STATES[ENDURO_PLANE_ID])
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup(monkeypatch):
|
|
"""Fresh DB + a known two-project registry for each test."""
|
|
# settings.db_path is resolved once at import; force it to our isolated DB so
|
|
# this suite is independent of whichever test module imported config first.
|
|
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()
|
|
|
|
# The webhook signature secret may be baked into the runtime env; this suite
|
|
# focuses on the project filter, so bypass signature verification.
|
|
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True)
|
|
|
|
# ORCH-39: resolve Plane states deterministically per-project (no network)
|
|
# and start from a clean per-project cache so suites don't leak into each
|
|
# other. plane.py imports get_project_states locally from ..plane_sync, so
|
|
# patch it at the src.plane_sync source.
|
|
plane_sync.reload_project_states()
|
|
monkeypatch.setattr(plane_sync, "get_project_states", _fake_get_project_states)
|
|
|
|
registry_json = (
|
|
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
|
|
f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
|
|
f' {{"plane_project_id": "{ORCH_PLANE_ID}", "repo": "orchestrator",'
|
|
f' "work_item_prefix": "ORCH", "name": "orchestrator"}}]'
|
|
)
|
|
monkeypatch.setattr(P.settings, "projects_json", registry_json)
|
|
reload_projects()
|
|
|
|
yield
|
|
|
|
reload_projects() # restore from env
|
|
plane_sync.reload_project_states()
|
|
if os.path.exists(_test_db):
|
|
os.unlink(_test_db)
|
|
|
|
|
|
# Feature 1: the pipeline now starts on a status change to In Progress (not on
|
|
# creation). _post_created drives that status-change event so these ORCH-6
|
|
# routing tests still exercise task creation through the new trigger.
|
|
# ORCH-39: the in_progress UUID is now project-specific, so derive it from the
|
|
# project being posted to (matches get_project_states resolution above).
|
|
def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item title"):
|
|
in_progress = _fake_get_project_states(plane_project_id)["in_progress"]
|
|
return client.post(
|
|
"/webhook/plane",
|
|
json={
|
|
"event": "issue",
|
|
"action": "updated",
|
|
"data": {
|
|
"id": plane_id,
|
|
"name": name,
|
|
"description_stripped": "This is a sufficiently long description.",
|
|
"project": plane_project_id,
|
|
"state": {"id": in_progress, "name": "In Progress", "group": "started"},
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filter: unknown project is ignored, no side effects
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@patch("src.webhooks.plane.launcher")
|
|
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
|
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
|
def test_unknown_project_ignored(mock_branch, mock_docs, mock_launcher):
|
|
resp = _post_created(UNKNOWN_PLANE_ID, plane_id="ignore-me")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "ignored"
|
|
assert resp.json().get("reason") == "unknown project"
|
|
|
|
# No task, no branch, no agent.
|
|
conn = get_db()
|
|
task = conn.execute("SELECT * FROM tasks WHERE plane_id='ignore-me'").fetchone()
|
|
conn.close()
|
|
assert task is None
|
|
mock_branch.assert_not_called()
|
|
mock_launcher.launch.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# orchestrator project -> repo=orchestrator, prefix ORCH
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@patch("src.webhooks.plane.launcher")
|
|
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
|
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
|
def test_orchestrator_project_routes_to_orchestrator_repo(mock_branch, mock_docs, mock_launcher):
|
|
mock_launcher.launch.return_value = 1
|
|
resp = _post_created(ORCH_PLANE_ID, plane_id="orch-1")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "accepted"
|
|
|
|
conn = get_db()
|
|
task = conn.execute("SELECT * FROM tasks WHERE plane_id='orch-1'").fetchone()
|
|
conn.close()
|
|
assert task is not None
|
|
assert task["repo"] == "orchestrator"
|
|
assert task["work_item_id"].startswith("ORCH-")
|
|
assert task["stage"] == "analysis"
|
|
# Branch created against the orchestrator repo.
|
|
args = mock_branch.call_args.args
|
|
assert args[0] == "orchestrator"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# enduro project -> repo=enduro-trails, prefix ET
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@patch("src.webhooks.plane.launcher")
|
|
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
|
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
|
def test_enduro_project_routes_to_enduro_repo(mock_branch, mock_docs, mock_launcher):
|
|
mock_launcher.launch.return_value = 1
|
|
resp = _post_created(ENDURO_PLANE_ID, plane_id="et-1")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "accepted"
|
|
|
|
conn = get_db()
|
|
task = conn.execute("SELECT * FROM tasks WHERE plane_id='et-1'").fetchone()
|
|
conn.close()
|
|
assert task is not None
|
|
assert task["repo"] == "enduro-trails"
|
|
assert task["work_item_id"].startswith("ET-")
|
|
args = mock_branch.call_args.args
|
|
assert args[0] == "enduro-trails"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# prefixes are independent per repo (ORCH-001 vs ET-001 in parallel)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@patch("src.webhooks.plane.launcher")
|
|
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
|
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
|
def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher):
|
|
mock_launcher.launch.return_value = 1
|
|
_post_created(ORCH_PLANE_ID, plane_id="o1", name="Orchestrator item one")
|
|
_post_created(ENDURO_PLANE_ID, plane_id="e1", name="Enduro item one")
|
|
_post_created(ORCH_PLANE_ID, plane_id="o2", name="Orchestrator item two")
|
|
|
|
conn = get_db()
|
|
rows = {r["plane_id"]: r["work_item_id"] for r in
|
|
conn.execute("SELECT plane_id, work_item_id FROM tasks").fetchall()}
|
|
conn.close()
|
|
assert rows["o1"] == "ORCH-001"
|
|
assert rows["o2"] == "ORCH-002"
|
|
assert rows["e1"] == "ET-001"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ORCH-066 TC-15 (AC-15): Cancelled is a valid human exit — the orchestrator
|
|
# performs NO advance/rollback (indication, not control).
|
|
# ---------------------------------------------------------------------------
|
|
@patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock)
|
|
@patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock)
|
|
@patch("src.webhooks.plane.launcher")
|
|
def test_cancelled_state_does_no_pipeline_action(mock_launcher, mock_start, mock_verdict):
|
|
cancelled = _PROJECT_STATES[ORCH_PLANE_ID]["cancelled"]
|
|
resp = client.post(
|
|
"/webhook/plane",
|
|
json={
|
|
"event": "issue",
|
|
"action": "updated",
|
|
"data": {
|
|
"id": "cancel-1",
|
|
"name": "A cancelled work item",
|
|
"description_stripped": "This is a sufficiently long description.",
|
|
"project": ORCH_PLANE_ID,
|
|
"state": {"id": cancelled, "name": "Cancelled", "group": "cancelled"},
|
|
},
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
# Neither the start nor the verdict (advance/rollback) handler ran.
|
|
mock_start.assert_not_called()
|
|
mock_verdict.assert_not_called()
|
|
mock_launcher.launch.assert_not_called()
|
|
# No task created off a Cancelled transition.
|
|
conn = get_db()
|
|
task = conn.execute("SELECT * FROM tasks WHERE plane_id='cancel-1'").fetchone()
|
|
conn.close()
|
|
assert task is None
|