kin/tests/test_kin_arch_014_regression.py
2026-03-17 16:02:47 +02:00

288 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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