Files
orchestrator/tests/test_plane_to_analyse_resume.py
claude-bot 0dfddf93f0 feat(plane): осмысленная статусная модель Plane (слой B — индикация)
Приводит статусы доски 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>
2026-06-07 22:02:45 +00:00

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