""" Regression tests for KIN-ARCH-014: blocked_reason из dept_result должен корректно пробрасываться в БД при неуспешном sub-pipeline. Два уровня цепочки (convention #305 — отдельный тест на каждый уровень): Уровень 1: _execute_department_head_step должен включать blocked_reason из sub_result в возвращаемый dict. Без фикса — dict возвращался без этого поля. Уровень 2: run_pipeline должен передавать dept_result.blocked_reason в update_task (а не generic 'Department X sub-pipeline failed'). """ import json import pytest from unittest.mock import patch, MagicMock # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def conn(): from core.db import init_db from core import models c = init_db(":memory:") models.create_project(c, "proj1", "Project1", "/tmp/proj1", tech_stack=["python"]) models.create_task(c, "PROJ1-001", "proj1", "Parent task", brief={"route_type": "department"}) yield c c.close() def _dept_head_result_with_sub_pipeline(): """Валидный вывод dept head с sub_pipeline — используется в обоих уровнях.""" return { "raw_output": json.dumps({ "sub_pipeline": [ {"role": "backend_dev", "brief": "implement feature"} ], "artifacts": {}, "handoff_notes": "proceed", }) } def _failing_sub_result(blocked_reason=None, error=None): return { "success": False, "blocked_reason": blocked_reason, "error": error, "total_cost_usd": 0, "total_tokens": 0, "total_duration_seconds": 0, "steps_completed": 0, "results": [], "pipeline_id": None, } def _mock_agent_success(output="done"): """Мок успешного subprocess.run для run_agent.""" m = MagicMock() m.stdout = json.dumps({"result": output}) m.stderr = "" m.returncode = 0 return m # --------------------------------------------------------------------------- # Уровень 1: _execute_department_head_step propagates blocked_reason # Convention #304: имя теста описывает сломанное поведение # --------------------------------------------------------------------------- class TestDeptHeadStepBlockedReasonPropagation: """Уровень 1 цепочки: _execute_department_head_step → sub_result → returned dict.""" def test_blocked_reason_dropped_from_returned_dict_when_sub_pipeline_fails(self, conn): """Broken behavior: _execute_department_head_step отбрасывал blocked_reason из sub_result — возвращаемый dict не содержал этого поля. Fixed (KIN-ARCH-014): blocked_reason из sub_result прокидывается в dict. """ from agents.runner import _execute_department_head_step failing = _failing_sub_result( blocked_reason="Конкретная причина: tester заблокировал задачу", error="TestError: endpoint not found", ) with patch("agents.runner.run_pipeline", return_value=failing), \ patch("agents.runner.models.create_pipeline", return_value={"id": 99}), \ patch("agents.runner.models.create_handoff"): result = _execute_department_head_step( conn=conn, task_id="PROJ1-001", project_id="proj1", parent_pipeline_id=None, step={"role": "backend_head", "brief": "plan"}, dept_head_result=_dept_head_result_with_sub_pipeline(), ) assert result["success"] is False assert "blocked_reason" in result, ( "blocked_reason должен быть в возвращаемом dict " "— без фикса KIN-ARCH-014 это поле отсутствовало" ) assert result["blocked_reason"] == "Конкретная причина: tester заблокировал задачу" def test_error_field_used_as_fallback_when_blocked_reason_is_none(self, conn): """Если sub_result.blocked_reason is None, но есть error — error должен использоваться как blocked_reason (fallback). """ from agents.runner import _execute_department_head_step failing = _failing_sub_result( blocked_reason=None, error="RuntimeError: Claude CLI exited with code 1", ) with patch("agents.runner.run_pipeline", return_value=failing), \ patch("agents.runner.models.create_pipeline", return_value={"id": 99}), \ patch("agents.runner.models.create_handoff"): result = _execute_department_head_step( conn=conn, task_id="PROJ1-001", project_id="proj1", parent_pipeline_id=None, step={"role": "backend_head", "brief": "plan"}, dept_head_result=_dept_head_result_with_sub_pipeline(), ) assert result["success"] is False assert result.get("blocked_reason") == "RuntimeError: Claude CLI exited with code 1" def test_success_result_does_not_inject_blocked_reason(self, conn): """При успешном sub_result blocked_reason не добавляется в dict.""" from agents.runner import _execute_department_head_step success_sub_result = { "success": True, "total_cost_usd": 0.01, "total_tokens": 100, "total_duration_seconds": 5.0, "steps_completed": 1, "results": [{"role": "backend_dev", "output": "done"}], "pipeline_id": 99, } with patch("agents.runner.run_pipeline", return_value=success_sub_result), \ patch("agents.runner.models.create_pipeline", return_value={"id": 99}), \ patch("agents.runner.models.create_handoff"): result = _execute_department_head_step( conn=conn, task_id="PROJ1-001", project_id="proj1", parent_pipeline_id=None, step={"role": "backend_head", "brief": "plan"}, dept_head_result=_dept_head_result_with_sub_pipeline(), ) assert result["success"] is True assert not result.get("blocked_reason"), ( "blocked_reason не должен появляться при успешном sub_result" ) # --------------------------------------------------------------------------- # Уровень 2: run_pipeline passes dept_result.blocked_reason to update_task # Convention #305: отдельный класс для каждого уровня # --------------------------------------------------------------------------- class TestRunPipelineDeptBlockedReasonSavedToDb: """Уровень 2 цепочки: run_pipeline → dept_result → models.update_task → БД.""" @patch("agents.runner._run_autocommit") @patch("agents.runner._execute_department_head_step") @patch("agents.runner._is_department_head") @patch("agents.runner.subprocess.run") @patch("agents.runner.check_claude_auth") def test_generic_error_msg_saved_instead_of_dept_blocked_reason( self, mock_auth, mock_run, mock_is_dept, mock_dept_step, mock_autocommit, conn ): """Broken behavior: run_pipeline сохранял в БД generic строку 'Department X sub-pipeline failed' вместо реального blocked_reason из dept_result. Fixed (KIN-ARCH-014): в БД сохраняется dept_result["blocked_reason"]. """ from agents.runner import run_pipeline from core import models mock_run.return_value = _mock_agent_success() mock_is_dept.return_value = True mock_dept_step.return_value = { "success": False, "blocked_reason": "Специфическая причина: backend_dev упал с OOM", "error": "OOMError", "output": "", "cost_usd": 0, "tokens_used": 0, "duration_seconds": 0, } steps = [{"role": "backend_head", "brief": "plan the work"}] result = run_pipeline(conn, "PROJ1-001", steps) assert result["success"] is False task = models.get_task(conn, "PROJ1-001") assert task["status"] == "blocked" assert task["blocked_reason"] == "Специфическая причина: backend_dev упал с OOM", ( f"Ожидался реальный blocked_reason из dept_result, " f"получено: {task['blocked_reason']!r}" ) @patch("agents.runner._run_autocommit") @patch("agents.runner._execute_department_head_step") @patch("agents.runner._is_department_head") @patch("agents.runner.subprocess.run") @patch("agents.runner.check_claude_auth") def test_generic_string_not_saved_when_specific_reason_available( self, mock_auth, mock_run, mock_is_dept, mock_dept_step, mock_autocommit, conn ): """run_pipeline НЕ должен сохранять generic 'Department X sub-pipeline failed' в blocked_reason когда dept_result содержит конкретный blocked_reason. """ from agents.runner import run_pipeline from core import models specific_reason = "Tester blocked: API endpoint /api/users не реализован" mock_run.return_value = _mock_agent_success() mock_is_dept.return_value = True mock_dept_step.return_value = { "success": False, "blocked_reason": specific_reason, "error": specific_reason, "output": "", "cost_usd": 0, "tokens_used": 0, "duration_seconds": 0, } steps = [{"role": "backend_head", "brief": "plan"}] run_pipeline(conn, "PROJ1-001", steps) task = models.get_task(conn, "PROJ1-001") # Generic строка из old code не должна быть в БД assert "sub-pipeline failed" not in (task["blocked_reason"] or ""), ( f"Найдена generic строка в blocked_reason: {task['blocked_reason']!r}. " f"Ожидался специфичный blocked_reason из dept_result." ) assert task["blocked_reason"] == specific_reason @patch("agents.runner._run_autocommit") @patch("agents.runner._execute_department_head_step") @patch("agents.runner._is_department_head") @patch("agents.runner.subprocess.run") @patch("agents.runner.check_claude_auth") def test_fallback_to_generic_when_dept_result_has_no_blocked_reason( self, mock_auth, mock_run, mock_is_dept, mock_dept_step, mock_autocommit, conn ): """Если dept_result не содержит ни blocked_reason, ни error — используется fallback generic строка (не KeyError, задача блокируется). """ from agents.runner import run_pipeline from core import models mock_run.return_value = _mock_agent_success() mock_is_dept.return_value = True mock_dept_step.return_value = { "success": False, "blocked_reason": None, "error": None, "output": "", "cost_usd": 0, "tokens_used": 0, "duration_seconds": 0, } steps = [{"role": "backend_head", "brief": "plan"}] result = run_pipeline(conn, "PROJ1-001", steps) assert result["success"] is False task = models.get_task(conn, "PROJ1-001") assert task["status"] == "blocked" # Должен быть хоть какой-то blocked_reason (fallback, не None) assert task["blocked_reason"] is not None assert len(task["blocked_reason"]) > 0