289 lines
12 KiB
Python
289 lines
12 KiB
Python
|
|
"""
|
|||
|
|
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
|