365 lines
17 KiB
Python
365 lines
17 KiB
Python
"""
|
||
Regression tests for KIN-ARCH-017:
|
||
Более строгая проверка отсутствия двойного создания pipeline в _execute_department_head_step.
|
||
|
||
Решение #354: тест 'WHERE route_type=X AND count=1' НЕ обнаруживает дубликаты
|
||
с ДРУГИМ route_type. Если orphaned pipeline создаётся с route_type='dept_feature'
|
||
(а не 'dept_sub'), старый тест пропускает дубликат и ложно-зелёный.
|
||
|
||
Исправление (KIN-ARCH-017): проверять ОБЩИЙ count без фильтра по route_type,
|
||
сравнивая количество pipeline ДО и ПОСЛЕ вызова _execute_department_head_step.
|
||
Ровно один новый pipeline должен создаваться при любом route_type дубликата.
|
||
|
||
Convention #304: имена тестов описывают сломанное поведение.
|
||
Convention #305: отдельный класс на каждый уровень цепочки.
|
||
"""
|
||
|
||
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=None):
|
||
mock = MagicMock()
|
||
mock.stdout = json.dumps(output_data or {"result": "ok"})
|
||
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": "Backend API done",
|
||
})
|
||
}
|
||
|
||
|
||
def _count_all_pipelines(conn) -> int:
|
||
"""Возвращает общее количество pipelines в БД без фильтра по route_type."""
|
||
row = conn.execute("SELECT COUNT(*) FROM pipelines").fetchone()
|
||
return row[0]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Уровень 1: Ровно ОДИН pipeline создаётся — проверка ОБЩЕГО count (решение #354)
|
||
#
|
||
# Слабость старого теста KIN-ARCH-012: WHERE route_type='dept_sub' AND count=1
|
||
# не ловит orphaned pipeline с другим route_type (например, 'dept_feature').
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestTotalPipelineCountAfterDeptHeadStep:
|
||
"""Уровень 1: Общий count всех pipeline до/после — ровно +1 запись в БД."""
|
||
|
||
@patch("agents.runner.check_claude_auth")
|
||
@patch("agents.runner.subprocess.run")
|
||
def test_exactly_one_new_pipeline_created_total_without_route_type_filter(
|
||
self, mock_run, mock_auth, conn
|
||
):
|
||
"""Решение #354: проверяем ОБЩИЙ count без фильтра по route_type.
|
||
|
||
Сломанное поведение (до KIN-ARCH-012/017): явный create_pipeline() в
|
||
_execute_department_head_step создавал orphaned pipeline (с любым route_type),
|
||
затем run_pipeline() создавал второй. Итого +2 pipeline за один вызов.
|
||
|
||
Тест 'WHERE route_type=dept_sub AND count=1' пропускал дубликат, если orphaned
|
||
pipeline имел другой route_type (например, dept_feature).
|
||
|
||
Fixed: сравниваем total_count_after - total_count_before == 1, без фильтра.
|
||
"""
|
||
mock_auth.return_value = None
|
||
mock_run.return_value = _mock_claude_success()
|
||
|
||
parent_pipeline = models.create_pipeline(
|
||
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
|
||
)
|
||
|
||
count_before = _count_all_pipelines(conn)
|
||
assert count_before == 1, "До вызова должен быть только parent pipeline"
|
||
|
||
_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(),
|
||
)
|
||
|
||
count_after = _count_all_pipelines(conn)
|
||
new_pipelines_count = count_after - count_before
|
||
|
||
assert new_pipelines_count == 1, (
|
||
f"Ожидалось создание ровно 1 нового pipeline, создано: {new_pipelines_count}. "
|
||
"Признак двойного создания (KIN-ARCH-012/017): если новых pipeline > 1, "
|
||
"значит _execute_department_head_step снова вызывает явный create_pipeline() "
|
||
"до run_pipeline(). Этот тест ловит дубликаты с ЛЮБЫМ route_type — "
|
||
"в отличие от 'WHERE route_type=dept_sub' из решения #354."
|
||
)
|
||
|
||
@patch("agents.runner.check_claude_auth")
|
||
@patch("agents.runner.subprocess.run")
|
||
def test_no_orphaned_pipeline_with_different_route_type_created(
|
||
self, mock_run, mock_auth, conn
|
||
):
|
||
"""Нет orphaned pipeline с route_type != 'dept_sub' после вызова.
|
||
|
||
Решение #354: если старый баг возвращается, orphaned pipeline может
|
||
создаваться с dept_feature или custom route_type. Явно проверяем:
|
||
все новые pipeline (кроме parent) должны иметь route_type='dept_sub'.
|
||
"""
|
||
mock_auth.return_value = None
|
||
mock_run.return_value = _mock_claude_success()
|
||
|
||
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(),
|
||
)
|
||
|
||
# Все pipeline кроме parent должны иметь route_type='dept_sub'
|
||
child_pipelines = conn.execute(
|
||
"SELECT id, route_type FROM pipelines WHERE id != ?",
|
||
(parent_pipeline["id"],)
|
||
).fetchall()
|
||
|
||
for row in child_pipelines:
|
||
row = dict(row)
|
||
assert row["route_type"] == "dept_sub", (
|
||
f"Найден pipeline id={row['id']} с route_type='{row['route_type']}', "
|
||
"ожидался 'dept_sub'. "
|
||
"Это orphaned pipeline — признак двойного создания (KIN-ARCH-012/017). "
|
||
"Решение #354: старый тест не замечал дубликаты с другим route_type."
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Уровень 2: route_type созданного pipeline = 'dept_sub'
|
||
# Convention #305: отдельный класс
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestChildPipelineRouteType:
|
||
"""Уровень 2: Созданный child pipeline имеет route_type='dept_sub'."""
|
||
|
||
@patch("agents.runner.check_claude_auth")
|
||
@patch("agents.runner.subprocess.run")
|
||
def test_child_pipeline_route_type_is_dept_sub(
|
||
self, mock_run, mock_auth, conn
|
||
):
|
||
"""Единственный child pipeline должен иметь route_type='dept_sub'.
|
||
|
||
runner.py:1267: effective_route_type = 'dept_sub' if parent_pipeline_id else route_type
|
||
Проверяем что это условие работает и route_type корректен.
|
||
"""
|
||
mock_auth.return_value = None
|
||
mock_run.return_value = _mock_claude_success()
|
||
|
||
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 id != ?",
|
||
(parent_pipeline["id"],)
|
||
).fetchone()
|
||
assert child is not None, "Child pipeline не найден в БД"
|
||
child = dict(child)
|
||
|
||
assert child["route_type"] == "dept_sub", (
|
||
f"route_type={child['route_type']!r}, ожидался 'dept_sub'. "
|
||
"runner.py:1267: effective_route_type = 'dept_sub' if parent_pipeline_id else route_type"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Уровень 3: parent_pipeline_id и department корректны
|
||
# Convention #305: отдельный класс
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestChildPipelineParentAndDepartment:
|
||
"""Уровень 3: Единственный child pipeline имеет корректные parent_pipeline_id и department."""
|
||
|
||
@patch("agents.runner.check_claude_auth")
|
||
@patch("agents.runner.subprocess.run")
|
||
def test_child_pipeline_parent_id_and_department_are_not_none(
|
||
self, mock_run, mock_auth, conn
|
||
):
|
||
"""Оба поля parent_pipeline_id и department не None у child pipeline.
|
||
|
||
runner.py:1223-1224: run_pipeline() принимает parent_pipeline_id и department.
|
||
runner.py:1270-1271: models.create_pipeline() вызывается с этими полями.
|
||
"""
|
||
mock_auth.return_value = None
|
||
mock_run.return_value = _mock_claude_success()
|
||
|
||
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 id != ?",
|
||
(parent_pipeline["id"],)
|
||
).fetchone()
|
||
assert child is not None, "Child pipeline не найден"
|
||
child = dict(child)
|
||
|
||
assert child["parent_pipeline_id"] is not None, \
|
||
"parent_pipeline_id не должен быть None"
|
||
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"
|
||
assert child["department"] == "backend", (
|
||
f"department={child['department']!r}, ожидался 'backend' "
|
||
"(от role='backend_head' → dept_name = role.replace('_head', ''))"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Уровень 4: handoff.pipeline_id указывает на child (не orphaned) pipeline
|
||
# Convention #305: отдельный класс
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestHandoffPipelineIdPointsToChildNotOrphaned:
|
||
"""Уровень 4: handoff.pipeline_id = id единственного корректного child pipeline."""
|
||
|
||
@patch("agents.runner.check_claude_auth")
|
||
@patch("agents.runner.subprocess.run")
|
||
def test_handoff_pipeline_id_matches_only_child_pipeline_in_db(
|
||
self, mock_run, mock_auth, conn
|
||
):
|
||
"""handoff.pipeline_id должен совпадать с child pipeline в БД.
|
||
|
||
Усиленная проверка по сравнению с KIN-ARCH-012: явно сверяем что
|
||
handoff.pipeline_id не равен parent и не orphaned (не в 'running') записи.
|
||
|
||
runner.py:1142: pipeline_id = sub_result.get('pipeline_id') or parent_pipeline_id
|
||
"""
|
||
mock_auth.return_value = None
|
||
mock_run.return_value = _mock_claude_success()
|
||
|
||
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 id != ?",
|
||
(parent_pipeline["id"],)
|
||
).fetchone()
|
||
assert child is not None, "Child pipeline не найден"
|
||
child = dict(child)
|
||
|
||
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"]
|
||
|
||
# handoff должен ссылаться на child, а не parent
|
||
assert handoff_pipeline_id != parent_pipeline["id"], (
|
||
"handoff.pipeline_id совпадает с parent_pipeline_id — "
|
||
"handoff должен описывать sub-pipeline, не родительский"
|
||
)
|
||
# handoff должен ссылаться на реальный child в БД
|
||
assert handoff_pipeline_id == child["id"], (
|
||
f"handoff.pipeline_id={handoff_pipeline_id!r} не совпадает "
|
||
f"с child pipeline id={child['id']!r}. "
|
||
"До фикса KIN-ARCH-012/017 handoff мог ссылаться на orphaned pipeline."
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Уровень 5: Нет running dept_sub pipeline после завершения (DB-cleanup)
|
||
# Convention #305: отдельный класс
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestNoRunningDeptSubPipelinesAfterCompletion:
|
||
"""Уровень 5: Нет pipeline с route_type='dept_sub' AND status='running' после вызова."""
|
||
|
||
@patch("agents.runner.check_claude_auth")
|
||
@patch("agents.runner.subprocess.run")
|
||
def test_no_dept_sub_pipeline_remains_in_running_status(
|
||
self, mock_run, mock_auth, conn
|
||
):
|
||
"""После завершения _execute_department_head_step нет 'running' dept_sub pipeline.
|
||
|
||
db.py:666-668: _migrate() очищает orphaned running dept_sub pipeline.
|
||
Тест проверяет финальное состояние — ни один dept_sub не должен висеть
|
||
в 'running' после вызова (иначе watchdog будет ложно блокировать задачи).
|
||
"""
|
||
mock_auth.return_value = None
|
||
mock_run.return_value = _mock_claude_success()
|
||
|
||
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(),
|
||
)
|
||
|
||
running_dept_sub = conn.execute(
|
||
"SELECT id, status, route_type FROM pipelines "
|
||
"WHERE route_type='dept_sub' AND status='running'"
|
||
).fetchall()
|
||
|
||
assert len(running_dept_sub) == 0, (
|
||
f"Найдено {len(running_dept_sub)} pipeline с route_type='dept_sub' AND status='running' "
|
||
"после завершения вызова. Это признак orphaned pipeline из старого бага "
|
||
"двойного создания (KIN-ARCH-012/017). "
|
||
"db.py:666-668: _migrate() должна очищать такие записи при инициализации."
|
||
)
|