kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-17 16:06:39 +02:00
parent 18a184bd5c
commit cfa294fd05
2 changed files with 277 additions and 2 deletions

View file

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

View file

@ -121,10 +121,12 @@ async function mountAndOpenAddTaskModal() {
return wrapper
}
/** Добавляет файлы в pendingFiles через внутреннее состояние <script setup> */
/** Добавляет файлы в pendingFiles через внутреннее состояние <script setup>.
* В Vue 3 setupState автоматически разворачивает refs, поэтому доступ без .value */
function pushPendingFiles(wrapper: ReturnType<typeof mount>, files: File[]) {
const setupState = (wrapper.vm as any).$.setupState
setupState.pendingFiles.value.push(...files)
// setupState unwraps refs — pendingFiles это уже массив, не ref
setupState.pendingFiles.push(...files)
}
beforeEach(() => {