From cfa294fd05d1ca2e838d2a7aabb57a81e35d34d4 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Tue, 17 Mar 2026 16:06:39 +0200 Subject: [PATCH] kin: auto-commit after pipeline --- tests/test_kin_arch_012_regression.py | 273 ++++++++++++++++++ .../src/__tests__/upload-warning.test.ts | 6 +- 2 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 tests/test_kin_arch_012_regression.py diff --git a/tests/test_kin_arch_012_regression.py b/tests/test_kin_arch_012_regression.py new file mode 100644 index 0000000..8b388ff --- /dev/null +++ b/tests/test_kin_arch_012_regression.py @@ -0,0 +1,273 @@ +""" +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." + ) diff --git a/web/frontend/src/__tests__/upload-warning.test.ts b/web/frontend/src/__tests__/upload-warning.test.ts index c376eb2..6fc9021 100644 --- a/web/frontend/src/__tests__/upload-warning.test.ts +++ b/web/frontend/src/__tests__/upload-warning.test.ts @@ -121,10 +121,12 @@ async function mountAndOpenAddTaskModal() { return wrapper } -/** Добавляет файлы в pendingFiles через внутреннее состояние