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