274 lines
12 KiB
Python
274 lines
12 KiB
Python
|
|
"""
|
|||
|
|
Regression tests for KIN-ARCH-012:
|
|||
|
|
Устранить двойное создание pipeline в _execute_department_head_step.
|
|||
|
|
|
|||
|
|
Convention #304: имена тестов описывают сломанное поведение.
|
|||
|
|
Convention #305: отдельный класс на каждый уровень цепочки.
|
|||
|
|
|
|||
|
|
Уровень 1: _execute_department_head_step создаёт РОВНО ОДИН pipeline (не два).
|
|||
|
|
До фикса: явный models.create_pipeline() + run_pipeline() = 2 записи.
|
|||
|
|
|
|||
|
|
Уровень 2: Созданный child pipeline имеет корректные parent_pipeline_id и department
|
|||
|
|
(не None). До фикса: run_pipeline() создавал pipeline без этих полей.
|
|||
|
|
|
|||
|
|
Уровень 3: handoff.pipeline_id совпадает с pipeline_id реально выполнявшегося pipeline,
|
|||
|
|
а не orphaned записи.
|
|||
|
|
|
|||
|
|
Уровень 4: sub_result.get('pipeline_id') корректно fallback-ится на parent_pipeline_id
|
|||
|
|
когда sub_result.pipeline_id is None.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import pytest
|
|||
|
|
from unittest.mock import patch, MagicMock
|
|||
|
|
|
|||
|
|
from core.db import init_db
|
|||
|
|
from core import models
|
|||
|
|
from agents.runner import _execute_department_head_step
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Fixtures
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def conn():
|
|||
|
|
c = init_db(":memory:")
|
|||
|
|
models.create_project(c, "proj", "TestProject", "~/projects/test",
|
|||
|
|
tech_stack=["python", "vue3"])
|
|||
|
|
models.create_task(c, "PROJ-001", "proj", "Full-stack feature",
|
|||
|
|
brief={"route_type": "dept_feature"})
|
|||
|
|
yield c
|
|||
|
|
c.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _mock_claude_success(output_data):
|
|||
|
|
mock = MagicMock()
|
|||
|
|
mock.stdout = json.dumps(output_data) if isinstance(output_data, dict) else output_data
|
|||
|
|
mock.stderr = ""
|
|||
|
|
mock.returncode = 0
|
|||
|
|
return mock
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _dept_head_result_with_workers(workers=None):
|
|||
|
|
"""Валидный вывод dept head с sub_pipeline."""
|
|||
|
|
return {
|
|||
|
|
"raw_output": json.dumps({
|
|||
|
|
"status": "done",
|
|||
|
|
"sub_pipeline": workers or [
|
|||
|
|
{"role": "backend_dev", "brief": "implement endpoint"},
|
|||
|
|
],
|
|||
|
|
"artifacts": {"files_changed": ["api.py"]},
|
|||
|
|
"handoff_notes": "API endpoint implemented",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Уровень 1: РОВНО ОДИН pipeline создаётся, orphaned pipeline не появляется
|
|||
|
|
# Convention #304: имя описывает сломанное поведение
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class TestNoDuplicatePipelineCreation:
|
|||
|
|
"""Уровень 1: _execute_department_head_step не создаёт лишний orphaned pipeline."""
|
|||
|
|
|
|||
|
|
@patch("agents.runner.check_claude_auth")
|
|||
|
|
@patch("agents.runner.subprocess.run")
|
|||
|
|
def test_dept_head_does_not_create_orphaned_child_pipeline(
|
|||
|
|
self, mock_run, mock_auth, conn
|
|||
|
|
):
|
|||
|
|
"""Broken behavior (before KIN-ARCH-012): _execute_department_head_step вызывал
|
|||
|
|
models.create_pipeline() явно (orphaned), затем run_pipeline() создавал второй.
|
|||
|
|
Итого 2 записи с route_type='dept_sub' — orphaned + реальный.
|
|||
|
|
|
|||
|
|
Fixed: create_pipeline() удалён из _execute_department_head_step.
|
|||
|
|
Только run_pipeline() создаёт pipeline — ровно 1 запись в dept_sub.
|
|||
|
|
"""
|
|||
|
|
mock_auth.return_value = None
|
|||
|
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
|||
|
|
|
|||
|
|
parent_pipeline = models.create_pipeline(
|
|||
|
|
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_execute_department_head_step(
|
|||
|
|
conn, "PROJ-001", "proj",
|
|||
|
|
parent_pipeline_id=parent_pipeline["id"],
|
|||
|
|
step={"role": "backend_head", "brief": "plan backend"},
|
|||
|
|
dept_head_result=_dept_head_result_with_workers(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
dept_sub_pipelines = conn.execute(
|
|||
|
|
"SELECT * FROM pipelines WHERE route_type='dept_sub'"
|
|||
|
|
).fetchall()
|
|||
|
|
|
|||
|
|
assert len(dept_sub_pipelines) == 1, (
|
|||
|
|
f"Ожидался ровно 1 dept_sub pipeline, создано: {len(dept_sub_pipelines)}. "
|
|||
|
|
"Признак двойного создания (KIN-ARCH-012): до фикса orphaned pipeline создавался "
|
|||
|
|
"явным create_pipeline() в _execute_department_head_step до вызова run_pipeline()."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Уровень 2: Child pipeline имеет корректные parent_pipeline_id и department
|
|||
|
|
# Convention #305: отдельный класс для каждого уровня
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class TestChildPipelineAttributes:
|
|||
|
|
"""Уровень 2: Атрибуты созданного child pipeline корректны."""
|
|||
|
|
|
|||
|
|
@patch("agents.runner.check_claude_auth")
|
|||
|
|
@patch("agents.runner.subprocess.run")
|
|||
|
|
def test_dept_head_child_pipeline_has_correct_parent_id_and_department(
|
|||
|
|
self, mock_run, mock_auth, conn
|
|||
|
|
):
|
|||
|
|
"""Broken behavior (before KIN-ARCH-012): run_pipeline() создавался без
|
|||
|
|
parent_pipeline_id и department — поля были None в orphaned записи.
|
|||
|
|
|
|||
|
|
Fixed: run_pipeline() получает parent_pipeline_id и department от
|
|||
|
|
_execute_department_head_step → child pipeline содержит оба поля.
|
|||
|
|
"""
|
|||
|
|
mock_auth.return_value = None
|
|||
|
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
|||
|
|
|
|||
|
|
parent_pipeline = models.create_pipeline(
|
|||
|
|
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_execute_department_head_step(
|
|||
|
|
conn, "PROJ-001", "proj",
|
|||
|
|
parent_pipeline_id=parent_pipeline["id"],
|
|||
|
|
step={"role": "backend_head", "brief": "plan backend"},
|
|||
|
|
dept_head_result=_dept_head_result_with_workers(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
child = conn.execute(
|
|||
|
|
"SELECT * FROM pipelines WHERE route_type='dept_sub'"
|
|||
|
|
).fetchone()
|
|||
|
|
assert child is not None, "Child pipeline не создан в БД"
|
|||
|
|
child = dict(child)
|
|||
|
|
|
|||
|
|
assert child["parent_pipeline_id"] is not None, (
|
|||
|
|
"parent_pipeline_id не должен быть None — "
|
|||
|
|
"до фикса run_pipeline() создавал pipeline без этого поля"
|
|||
|
|
)
|
|||
|
|
assert child["parent_pipeline_id"] == parent_pipeline["id"], (
|
|||
|
|
f"parent_pipeline_id={child['parent_pipeline_id']!r}, "
|
|||
|
|
f"ожидался {parent_pipeline['id']!r}"
|
|||
|
|
)
|
|||
|
|
assert child["department"] is not None, (
|
|||
|
|
"department не должен быть None — "
|
|||
|
|
"до фикса поле не передавалось в run_pipeline()"
|
|||
|
|
)
|
|||
|
|
assert child["department"] == "backend", (
|
|||
|
|
f"department={child['department']!r}, ожидался 'backend'"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Уровень 3: handoff.pipeline_id совпадает с реально исполнявшимся pipeline
|
|||
|
|
# Convention #305: отдельный класс
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class TestHandoffPipelineIdMatchesExecutingPipeline:
|
|||
|
|
"""Уровень 3: handoff.pipeline_id указывает на реальный child pipeline, не orphaned."""
|
|||
|
|
|
|||
|
|
@patch("agents.runner.check_claude_auth")
|
|||
|
|
@patch("agents.runner.subprocess.run")
|
|||
|
|
def test_handoff_pipeline_id_matches_executing_pipeline_not_orphaned_record(
|
|||
|
|
self, mock_run, mock_auth, conn
|
|||
|
|
):
|
|||
|
|
"""Broken behavior (before KIN-ARCH-012): orphaned pipeline создавался раньше
|
|||
|
|
run_pipeline(), и handoff.pipeline_id мог указывать на него (parent_pipeline_id),
|
|||
|
|
а не на реальный child pipeline (sub_result.pipeline_id).
|
|||
|
|
|
|||
|
|
Fixed: sub_result.get('pipeline_id') or parent_pipeline_id — теперь
|
|||
|
|
handoff.pipeline_id == id реального child pipeline (dept_sub).
|
|||
|
|
"""
|
|||
|
|
mock_auth.return_value = None
|
|||
|
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
|||
|
|
|
|||
|
|
parent_pipeline = models.create_pipeline(
|
|||
|
|
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_execute_department_head_step(
|
|||
|
|
conn, "PROJ-001", "proj",
|
|||
|
|
parent_pipeline_id=parent_pipeline["id"],
|
|||
|
|
step={"role": "backend_head", "brief": "plan backend"},
|
|||
|
|
dept_head_result=_dept_head_result_with_workers(),
|
|||
|
|
next_department="frontend",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
child = conn.execute(
|
|||
|
|
"SELECT * FROM pipelines WHERE route_type='dept_sub'"
|
|||
|
|
).fetchone()
|
|||
|
|
assert child is not None, "Child pipeline не найден в БД"
|
|||
|
|
child_id = dict(child)["id"]
|
|||
|
|
|
|||
|
|
handoffs = models.get_handoffs_for_task(conn, "PROJ-001")
|
|||
|
|
assert len(handoffs) == 1, f"Ожидался 1 handoff, найдено: {len(handoffs)}"
|
|||
|
|
|
|||
|
|
handoff_pipeline_id = handoffs[0]["pipeline_id"]
|
|||
|
|
assert handoff_pipeline_id == child_id, (
|
|||
|
|
f"handoff.pipeline_id={handoff_pipeline_id!r} не совпадает с "
|
|||
|
|
f"child pipeline id={child_id!r}. "
|
|||
|
|
"До фикса handoff мог ссылаться на parent_pipeline_id или orphaned pipeline."
|
|||
|
|
)
|
|||
|
|
assert handoff_pipeline_id != parent_pipeline["id"], (
|
|||
|
|
"handoff.pipeline_id не должен совпадать с parent_pipeline_id — "
|
|||
|
|
"handoff описывает sub-pipeline, а не родительский"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Уровень 4: sub_result.pipeline_id fallback на parent_pipeline_id
|
|||
|
|
# Convention #305: отдельный класс
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class TestSubResultPipelineIdFallback:
|
|||
|
|
"""Уровень 4: sub_result.get('pipeline_id') or parent_pipeline_id — fallback работает."""
|
|||
|
|
|
|||
|
|
def test_sub_result_pipeline_id_fallbacks_to_parent_pipeline_id(self, conn):
|
|||
|
|
"""Если run_pipeline() вернул pipeline_id=None (dry_run или ошибка создания),
|
|||
|
|
handoff.pipeline_id должен fallback-нуться на parent_pipeline_id.
|
|||
|
|
|
|||
|
|
Это поведение: pipeline_id=sub_result.get('pipeline_id') or parent_pipeline_id.
|
|||
|
|
"""
|
|||
|
|
parent_pipeline = models.create_pipeline(
|
|||
|
|
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
sub_result_without_pipeline_id = {
|
|||
|
|
"success": True,
|
|||
|
|
"pipeline_id": None, # нет pipeline_id — должен сработать fallback
|
|||
|
|
"results": [{"role": "backend_dev", "output": "done"}],
|
|||
|
|
"steps_completed": 1,
|
|||
|
|
"total_cost_usd": 0.01,
|
|||
|
|
"total_tokens": 100,
|
|||
|
|
"total_duration_seconds": 2.0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with patch("agents.runner.run_pipeline", return_value=sub_result_without_pipeline_id):
|
|||
|
|
_execute_department_head_step(
|
|||
|
|
conn, "PROJ-001", "proj",
|
|||
|
|
parent_pipeline_id=parent_pipeline["id"],
|
|||
|
|
step={"role": "backend_head", "brief": "plan backend"},
|
|||
|
|
dept_head_result=_dept_head_result_with_workers(),
|
|||
|
|
next_department="frontend",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
handoffs = models.get_handoffs_for_task(conn, "PROJ-001")
|
|||
|
|
assert len(handoffs) == 1, f"Ожидался 1 handoff, найдено: {len(handoffs)}"
|
|||
|
|
|
|||
|
|
handoff_pipeline_id = handoffs[0]["pipeline_id"]
|
|||
|
|
assert handoff_pipeline_id is not None, (
|
|||
|
|
"handoff.pipeline_id не должен быть None — "
|
|||
|
|
"должен сработать fallback на parent_pipeline_id"
|
|||
|
|
)
|
|||
|
|
assert handoff_pipeline_id == parent_pipeline["id"], (
|
|||
|
|
f"При sub_result.pipeline_id=None ожидался fallback на parent_pipeline_id="
|
|||
|
|
f"{parent_pipeline['id']!r}, получено {handoff_pipeline_id!r}. "
|
|||
|
|
"Исправление: sub_result.get('pipeline_id') or parent_pipeline_id."
|
|||
|
|
)
|