"""In-process MVP scheduler for persisted batch test runs.""" from __future__ import annotations import asyncio from dataclasses import dataclass, field from datetime import UTC, datetime from loguru import logger from sqlalchemy import select from db.models import ModelResource, TestCase, TestRun, TestRunCase from db.session import SessionLocal from models import RuntimeModelResource from services.config_resolver import resolve_runtime_config from services.test_runs.errors import TestExecutionError from services.test_runs.evaluator import EvaluationEngine from services.test_runs.text_runner import TextPipelineRunner from test_schemas import TestCaseDefinition @dataclass class _RunControl: stop_event: asyncio.Event = field(default_factory=asyncio.Event) reason: str | None = None case_tasks: set[asyncio.Task[None]] = field(default_factory=set) class TestRunOrchestrator: """Run cases concurrently while keeping PostgreSQL as the source of truth.""" def __init__(self) -> None: self._run_tasks: dict[str, asyncio.Task[None]] = {} self._controls: dict[str, _RunControl] = {} def start(self, run_id: str) -> None: current = self._run_tasks.get(run_id) if current is not None and not current.done(): return control = _RunControl() self._controls[run_id] = control task = asyncio.create_task(self._execute_run(run_id, control), name=f"test-run-{run_id}") self._run_tasks[run_id] = task task.add_done_callback(lambda _task: self._forget(run_id)) async def cancel(self, run_id: str) -> None: control = self._controls.get(run_id) if control is None: return control.reason = "manual" control.stop_event.set() for task in tuple(control.case_tasks): task.cancel() async def shutdown(self) -> None: tasks = [task for task in self._run_tasks.values() if not task.done()] for task in tasks: task.cancel() if tasks: await asyncio.gather(*tasks, return_exceptions=True) self._run_tasks.clear() self._controls.clear() def _forget(self, run_id: str) -> None: self._run_tasks.pop(run_id, None) self._controls.pop(run_id, None) async def _execute_run(self, run_id: str, control: _RunControl) -> None: try: async with SessionLocal() as session: run = await session.get(TestRun, run_id) if run is None: return run.status = "running" run.started_at = run.started_at or datetime.now(UTC) await session.commit() try: cfg = await resolve_runtime_config(session, str(run.assistant_id or "")) except Exception as exc: await self._fail_run_setup(run_id, str(exc)) return config = dict(run.config or {}) evaluator_resource_id = str( config.get("evaluatorModelResourceId") or "" ) evaluator_row = await session.get( ModelResource, evaluator_resource_id, ) if ( evaluator_row is None or evaluator_row.capability != "LLM" or not evaluator_row.enabled ): await self._fail_run_setup( run_id, "评估模型不存在、已停用或不是 LLM 资源", ) return evaluator_resource = RuntimeModelResource( id=evaluator_row.id, name=evaluator_row.name, capability=evaluator_row.capability, interface_type=evaluator_row.interface_type, values=evaluator_row.values or {}, secrets=evaluator_row.secrets or {}, support_image_input=bool(evaluator_row.support_image_input), ) rows = ( await session.execute( select(TestRunCase) .where(TestRunCase.run_id == run_id) .order_by(TestRunCase.position) ) ).scalars().all() semaphore = asyncio.Semaphore(int(config.get("concurrency") or 3)) tasks = { asyncio.create_task( self._execute_case( run_id, row.id, cfg, evaluator_resource, config, semaphore, control, ), name=f"test-run-case-{row.id}", ) for row in rows } control.case_tasks = tasks if tasks: await asyncio.gather(*tasks, return_exceptions=True) await self._finish_run(run_id, control) except asyncio.CancelledError: await self._interrupt_run(run_id) raise except Exception as exc: # noqa: BLE001 - background task must persist failure logger.exception(f"批量测试运行失败: run_id={run_id}: {exc}") await self._fail_run_setup(run_id, str(exc)) async def _execute_case( self, run_id: str, run_case_id: str, cfg, evaluator_resource: RuntimeModelResource, config: dict, semaphore: asyncio.Semaphore, control: _RunControl, ) -> None: async with semaphore: if control.stop_event.is_set(): await self._mark_skipped(run_case_id) return async with SessionLocal() as session: row = await session.get(TestRunCase, run_case_id) if row is None: return snapshot = dict(row.case_snapshot or {}) definition = TestCaseDefinition.model_validate(snapshot) max_attempts = row.max_attempts for attempt in range(1, max_attempts + 1): if control.stop_event.is_set(): await self._mark_skipped(run_case_id) return await self._mark_running(run_case_id, attempt) try: timeout_seconds = int( config.get("timeoutSecs") or config.get("timeout_secs") or 60 ) async with asyncio.timeout(timeout_seconds): execution = await TextPipelineRunner().run( cfg.model_copy(deep=True), definition, timeout_seconds=timeout_seconds, ) evaluated = await EvaluationEngine(evaluator_resource).evaluate( definition, execution, ) except asyncio.CancelledError: await self._mark_skipped(run_case_id) raise except TimeoutError: error = TestExecutionError( code="CASE_TIMEOUT", message=f"测试用例执行与评估超过 {timeout_seconds} 秒", stage="pipeline", retryable=True, ) if attempt < max_attempts and not control.stop_event.is_set(): continue await self._mark_error(run_case_id, error) if str( config.get("errorStrategy") or config.get("error_strategy") ) == "stop_on_error": await self._request_stop(run_id, control, "execution_error") return except TestExecutionError as exc: if exc.retryable and attempt < max_attempts and not control.stop_event.is_set(): continue await self._mark_error(run_case_id, exc) if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error": await self._request_stop(run_id, control, "execution_error") return except Exception as exc: # noqa: BLE001 - normalize unknown runtime errors error = TestExecutionError( code="UNEXPECTED_EXECUTION_ERROR", message=str(exc) or type(exc).__name__, stage="pipeline", retryable=False, ) await self._mark_error(run_case_id, error) if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error": await self._request_stop(run_id, control, "execution_error") return status = "pass" if evaluated.passed else "fail" await self._mark_evaluated(run_case_id, status, evaluated) if status == "fail" and str( config.get("failureStrategy") or config.get("failure_strategy") ) == "stop_on_fail": await self._request_stop(run_id, control, "assertion_failure") return async def _request_stop( self, run_id: str, control: _RunControl, reason: str, ) -> None: if control.reason is None: control.reason = reason control.stop_event.set() async with SessionLocal() as session: run = await session.get(TestRun, run_id) if run is not None and run.stop_reason is None: run.stop_reason = reason await session.commit() async def _mark_running(self, run_case_id: str, attempt: int) -> None: async with SessionLocal() as session: row = await session.get(TestRunCase, run_case_id) if row is None: return row.status = "running" row.attempt_count = attempt row.started_at = row.started_at or datetime.now(UTC) row.finished_at = None row.execution_error = None await session.commit() async def _mark_evaluated(self, run_case_id: str, status: str, evaluated) -> None: async with SessionLocal() as session: row = await session.get(TestRunCase, run_case_id) if row is None: return row.status = status row.result = { "turns": [ item.model_dump(mode="json", by_alias=True) for item in evaluated.turns ], "overallCriteria": [ item.model_dump(mode="json", by_alias=True) for item in evaluated.overall_criteria ], } row.execution_error = None row.finished_at = datetime.now(UTC) if row.test_case_id: test_case = await session.get(TestCase, row.test_case_id) if test_case is not None: test_case.last_result = status await session.commit() async def _mark_error( self, run_case_id: str, error: TestExecutionError, ) -> None: async with SessionLocal() as session: row = await session.get(TestRunCase, run_case_id) if row is None: return row.status = "error" row.execution_error = error.as_dict() row.finished_at = datetime.now(UTC) if row.test_case_id: test_case = await session.get(TestCase, row.test_case_id) if test_case is not None: test_case.last_result = "fail" await session.commit() async def _mark_skipped(self, run_case_id: str) -> None: async with SessionLocal() as session: row = await session.get(TestRunCase, run_case_id) if row is None or row.status in {"pass", "fail", "error", "skipped"}: return row.status = "skipped" row.finished_at = datetime.now(UTC) await session.commit() async def _finish_run(self, run_id: str, control: _RunControl) -> None: async with SessionLocal() as session: run = await session.get(TestRun, run_id) if run is None: return waiting = ( await session.execute( select(TestRunCase).where( TestRunCase.run_id == run_id, TestRunCase.status.in_(["waiting", "running"]), ) ) ).scalars().all() for row in waiting: row.status = "skipped" row.finished_at = datetime.now(UTC) manual = bool(run.cancel_requested or control.reason == "manual") run.status = "cancelled" if manual else "completed" run.stop_reason = "manual" if manual else control.reason or run.stop_reason run.finished_at = datetime.now(UTC) await session.commit() async def _fail_run_setup(self, run_id: str, message: str) -> None: error = TestExecutionError( code="RUN_SETUP_FAILED", message=message or "批量测试初始化失败", stage="pipeline", retryable=False, ) async with SessionLocal() as session: run = await session.get(TestRun, run_id) if run is None: return rows = ( await session.execute( select(TestRunCase).where( TestRunCase.run_id == run_id, TestRunCase.status.in_(["waiting", "running"]), ) ) ).scalars().all() now = datetime.now(UTC) for row in rows: row.status = "error" row.execution_error = error.as_dict() row.finished_at = now run.status = "completed" run.stop_reason = "execution_error" run.finished_at = now await session.commit() async def _interrupt_run(self, run_id: str) -> None: await _mark_run_interrupted(run_id, "后端服务停止,运行已中断") async def _mark_run_interrupted(run_id: str, message: str) -> None: async with SessionLocal() as session: run = await session.get(TestRun, run_id) if run is None or run.status not in {"queued", "running"}: return rows = ( await session.execute( select(TestRunCase).where( TestRunCase.run_id == run_id, TestRunCase.status.in_(["waiting", "running"]), ) ) ).scalars().all() now = datetime.now(UTC) for row in rows: if row.status == "running": row.status = "error" row.execution_error = { "code": "SERVER_RESTARTED", "message": message, "stage": "pipeline", "retryable": True, } else: row.status = "skipped" row.finished_at = now run.status = "completed" run.stop_reason = "execution_error" run.finished_at = now await session.commit() async def recover_interrupted_test_runs() -> None: async with SessionLocal() as session: run_ids = ( await session.execute( select(TestRun.id).where(TestRun.status.in_(["queued", "running"])) ) ).scalars().all() for run_id in run_ids: await _mark_run_interrupted(str(run_id), "后端服务重启,运行已中断") test_run_orchestrator = TestRunOrchestrator()