kin/tests/test_kin_arch_012_regression.py
2026-03-17 16:06:39 +02:00

273 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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."
)