""" 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() должна очищать такие записи при инициализации." )