Приводит статусы доски Plane к смыслу стадий конвейера, сохраняя инвариант «статус — индикация, а не управление». Меняется только слой B (отображение: src/plane_sync.py + точки выставления статуса в stage_engine.py/webhooks/plane.py/reconciler.py); слой A — машина стадий src/stages.py::STAGE_TRANSITIONS — остаётся байт-в-байт неизменным (AC-21). - 6 новых логических ключей статуса (to_analyse, analysis, code_review, awaiting_deploy, deploying, monitoring) + сеттеры и диспетчер set_issue_stage_state. - Project-relative alias-fallback (BR-12): новый ключ деградирует на базовый UUID того же проекта → нулевая регрессия для enduro-trails. - Самодеплой (ORCH-036) индицирует фазы: Awaiting Deploy / Deploying; terminal-sync для self-hosting → Monitoring after Deploy, для прочих → терминальный Done. - Post-deploy монитор (ORCH-021): HEALTHY → Done, DEGRADED → Blocked (только индикация; self-hosting ALERT_ONLY, прод не трогается, BR-5). - Reconciler: триггер старта/резюма на To Analyse; Guard 2 учитывает новые активные ожидания без расширения skip-set на алиасах. - never-raise контракт сеттеров и резолвера состояний сохранён. - Раскатка — созданием статусов в Plane оператором, без kill-switch. Инварианты не менялись: STAGE_TRANSITIONS, QG_CHECKS (12 чеков), check_deploy_status, exit-код-контракт хука, merge-gate, схема БД. ADR: docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md Тесты: test_plane_status_model, test_plane_to_analyse_resume, test_plane_status_failclosed + TC в существующих наборах. 774 passed. Refs: ORCH-066 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
115 lines
4.5 KiB
Python
115 lines
4.5 KiB
Python
"""ORCH-066: To Analyse resume semantics (F-1 status-only model).
|
|
|
|
`handle_status_start` forks on (existing task?) + (active job?):
|
|
|
|
* TC-02 (AC-2, BR-11) — an EXISTING task with NO active job + To Analyse ->
|
|
RELAUNCH the current stage's agent (the analyst resumes from Needs Input);
|
|
NO second task is created; the issue is re-indicated `Analysis`.
|
|
* TC-04 (AC-4) — an EXISTING task WITH an active job + To Analyse ->
|
|
busy-guard: NO relaunch (no double launch).
|
|
|
|
handle_status_start is exercised directly; enqueue_job + Plane side-effects are
|
|
mocked. A real isolated sqlite DB backs get_task_by_plane_id / the job guard.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orch066_to_analyse_resume.db")
|
|
os.environ["ORCH_DB_PATH"] = _test_db
|
|
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
|
|
from unittest.mock import patch, AsyncMock, MagicMock # noqa: E402
|
|
|
|
import src.db as _db # noqa: E402
|
|
from src.db import init_db, get_db # noqa: E402
|
|
from src.webhooks.plane import handle_status_start # noqa: E402
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fresh_db(monkeypatch):
|
|
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
|
if os.path.exists(_test_db):
|
|
os.unlink(_test_db)
|
|
init_db()
|
|
yield
|
|
if os.path.exists(_test_db):
|
|
os.unlink(_test_db)
|
|
|
|
|
|
def _make_task(plane_id="resume-1", stage="analysis", repo="enduro-trails",
|
|
branch="feature/ET-001-x", wi="ET-001"):
|
|
conn = get_db()
|
|
cur = conn.execute(
|
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(plane_id, wi, repo, branch, stage),
|
|
)
|
|
tid = cur.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
return tid
|
|
|
|
|
|
def _count(plane_id):
|
|
conn = get_db()
|
|
n = conn.execute("SELECT COUNT(*) FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()[0]
|
|
conn.close()
|
|
return n
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-02 (AC-2 / BR-11): existing task, no active job -> RELAUNCH (resume), no dup.
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.asyncio
|
|
async def test_tc02_to_analyse_resume_relaunches_analyst_no_duplicate():
|
|
_make_task("resume-1", stage="analysis")
|
|
data = {"id": "resume-1", "state": {"id": "ip-uuid", "name": "To Analyse"}}
|
|
|
|
with patch("src.webhooks.plane.enqueue_job", return_value=7) as mock_enqueue, \
|
|
patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \
|
|
patch("src.plane_sync.add_comment", MagicMock()), \
|
|
patch("src.plane_sync.set_issue_analysis") as mock_analysis:
|
|
await handle_status_start(data, "proj-1")
|
|
|
|
# No new pipeline start (it is a resume, not a fresh task).
|
|
mock_start.assert_not_called()
|
|
assert _count("resume-1") == 1 # NO duplicate task
|
|
# The current stage's agent (analyst) was relaunched exactly once.
|
|
assert mock_enqueue.call_count == 1
|
|
assert mock_enqueue.call_args.args[0] == "analyst"
|
|
# AC-3: the resumed analysis stage is re-indicated as Analysis.
|
|
mock_analysis.assert_called_once_with("ET-001")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-04 (AC-4): existing task WITH active job -> busy-guard, NO relaunch.
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.asyncio
|
|
async def test_tc04_to_analyse_with_active_job_does_not_relaunch():
|
|
tid = _make_task("resume-2", stage="analysis")
|
|
# Seed an active (queued) job so has_active_job_for_task reports busy.
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO jobs (agent, repo, task_id, status) VALUES (?, ?, ?, 'queued')",
|
|
("analyst", "enduro-trails", tid),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
data = {"id": "resume-2", "state": {"id": "ip-uuid", "name": "To Analyse"}}
|
|
with patch("src.webhooks.plane.enqueue_job", return_value=9) as mock_enqueue, \
|
|
patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \
|
|
patch("src.plane_sync.add_comment", MagicMock()), \
|
|
patch("src.plane_sync.set_issue_analysis") as mock_analysis:
|
|
await handle_status_start(data, "proj-1")
|
|
|
|
mock_start.assert_not_called()
|
|
mock_enqueue.assert_not_called() # busy-guard held: NO double launch
|
|
mock_analysis.assert_not_called()
|
|
assert _count("resume-2") == 1
|