PMFlow/backend/tests/test_ai_orchestration_run_s...

313 lines
13 KiB
Python

import asyncio
import os
import time
import unittest
from datetime import datetime, timedelta, timezone
from tempfile import NamedTemporaryFile
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.models import Base
from app.services.ai_orchestration_run_service import AIOrchestrationRunService
from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService
def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
class _FastModeSignalService(SignalSourceOrchestrationService):
session_factory: async_sessionmaker[AsyncSession] | None = None
async def _load_project(self, **_: object):
return SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", owner_id=1)
async def _load_signal(self, **_: object):
return SimpleNamespace(
id=9,
title="新增 GitHub 信号源",
summary="监控 issue",
signal_type="新需求",
recommended_action="enter_only",
content="新增 GitHub 信号源并自动拆任务",
)
async def _plan_onboarding(self, **_: object):
return (
{
"should_onboard": True,
"summary": "接入 GitHub 仓库信号源。",
"source": {
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
},
"worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"],
"openclaw_instruction": "创建接入任务。",
},
"planned",
{
"used_fallback": False,
"error_code": None,
"source_field": "message.content",
"provider": "glm",
"model": "GLM-4.7",
},
)
async def _create_or_reuse_registry(self, **_: object):
return {
"status": "created",
"registry_id": 11,
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
}
async def _resolve_operator_user(self, **_: object):
return SimpleNamespace(id=1, role="BOSS", name="Boss", username="boss")
async def _dispatch_openclaw(self, **_: object):
return {
"status": "completed",
"result": {"trace_id": "openclaw-123"},
}
@classmethod
async def _execute_workers_and_finalize(cls, **kwargs: object):
await asyncio.sleep(0.2)
assert cls.session_factory is not None
async with cls.session_factory() as db:
service = cls(db)
trace_id = str(kwargs["trace_id"])
summary = str(kwargs["summary"])
planner_status = str(kwargs["planner_status"])
planner_diagnostics = kwargs["planner_diagnostics"]
registry_result = dict(kwargs["registry_result"])
openclaw_result = dict(kwargs["openclaw_result"])
worker_role_keys = list(kwargs["worker_role_keys"])
worker_results = [
{
"worker_role_key": role_key,
"worker_role_name": role_key,
"status": "completed",
"summary": f"{role_key} done",
}
for role_key in worker_role_keys
]
final_result = service._build_orchestration_result(
trace_id=trace_id,
response_mode="fast",
planner_status=planner_status,
planner_diagnostics=planner_diagnostics,
summary=summary,
registry_result=registry_result,
source_payload=registry_result,
openclaw_result=openclaw_result,
worker_results=worker_results,
pending_workers=[],
status="completed",
run_status="completed",
)
await AIOrchestrationRunService(db).update_run(
tenant_id=int(kwargs["tenant_id"]),
trace_id=trace_id,
status="completed",
planner_status=planner_status,
openclaw_status=str(openclaw_result.get("status") or ""),
worker_total=len(worker_role_keys),
worker_completed=len(worker_role_keys),
worker_failed=0,
summary=summary,
planner_diagnostics=planner_diagnostics if isinstance(planner_diagnostics, dict) else {},
result_payload=final_result,
completed_at=_utc_now_naive(),
)
await db.commit()
return final_result
class _ConcurrentWorkerSignalService(SignalSourceOrchestrationService):
@classmethod
async def _execute_single_worker_role_isolated(cls, **kwargs: object):
await asyncio.sleep(0.2)
role = dict(kwargs["role"])
return {
"worker_role_key": str(role["role_key"]),
"worker_role_name": str(role["role_name"]),
"status": "completed",
"summary": f"{role['role_key']} done",
}
class AIOrchestrationRunServiceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-orchestration-test-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
_FastModeSignalService.session_factory = self.session_factory
_ConcurrentWorkerSignalService.session_factory = self.session_factory
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
async def test_fast_mode_returns_before_background_workers_finish_and_run_is_queryable(self) -> None:
async with self.session_factory() as db:
service = _FastModeSignalService(db)
started = time.perf_counter()
result = await service.orchestrate_from_signal(
tenant_id=1,
project_id=3,
signal_id=9,
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
operator_user_id=1,
dry_run=False,
response_mode="fast",
)
elapsed = time.perf_counter() - started
self.assertLess(elapsed, 0.15)
self.assertEqual(result["run_status"], "running")
self.assertEqual(len(result["pending_workers"]), 3)
async with self.session_factory() as db:
row = await AIOrchestrationRunService(db).get_run(tenant_id=1, trace_id=result["trace_id"])
self.assertIsNotNone(row)
self.assertEqual(row.status, "running")
await asyncio.sleep(0.3)
async with self.session_factory() as db:
row = await AIOrchestrationRunService(db).get_run(tenant_id=1, trace_id=result["trace_id"])
snapshot = AIOrchestrationRunService.serialize_run(row)
self.assertEqual(snapshot["run_status"], "completed")
self.assertEqual(snapshot["worker_completed"], 3)
self.assertEqual(snapshot["worker_pending"], 0)
async def test_complete_mode_worker_execution_is_concurrent(self) -> None:
async with self.session_factory() as db:
service = _ConcurrentWorkerSignalService(db)
initial_result = service._build_orchestration_result(
trace_id="trace-concurrent",
response_mode="complete",
planner_status="planned",
planner_diagnostics={"used_fallback": False},
summary="接入 GitHub 仓库信号源。",
registry_result={"registry_id": 11, "status": "created"},
source_payload={
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
},
openclaw_result={"status": "completed", "result": {"trace_id": "openclaw-123"}},
worker_results=[],
pending_workers=["pm_dispatcher", "tech_architect", "delivery_coach"],
status="partial",
run_status="running",
)
await AIOrchestrationRunService(db).create_run(
tenant_id=1,
trace_id="trace-concurrent",
run_type="signal_source_onboarding",
project_id=3,
signal_id=9,
response_mode="complete",
status="running",
planner_status="planned",
openclaw_status="completed",
worker_total=3,
worker_completed=0,
worker_failed=0,
summary="接入 GitHub 仓库信号源。",
planner_diagnostics={"used_fallback": False},
result_payload=initial_result,
created_by_user_id=1,
)
await db.commit()
fake_roles = {
"roles": [
{"role_key": "pm_dispatcher", "role_name": "PM"},
{"role_key": "tech_architect", "role_name": "Arch"},
{"role_key": "delivery_coach", "role_name": "Coach"},
]
}
with patch("app.services.signal_source_orchestration_service.prompt_profile_service.get_profiles", AsyncMock(return_value=fake_roles)):
started = time.perf_counter()
final_result = await _ConcurrentWorkerSignalService._execute_workers_and_finalize(
tenant_id=1,
trace_id="trace-concurrent",
project_payload={"id": 3, "title": "项目A", "name": "项目A", "description": "desc", "owner_id": 1},
signal_payload={"id": 9, "title": "新增 GitHub 信号源", "summary": "监控 issue", "signal_type": "新需求", "recommended_action": "enter_only", "content": "content"},
registry_result={
"registry_id": 11,
"status": "created",
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
},
openclaw_result={"status": "completed", "result": {"trace_id": "openclaw-123"}},
worker_role_keys=["pm_dispatcher", "tech_architect", "delivery_coach"],
operator_user_payload={"id": 1, "name": "Boss", "username": "boss", "role": "BOSS"},
planner_status="planned",
planner_diagnostics={"used_fallback": False},
summary="接入 GitHub 仓库信号源。",
signal_id=None,
)
elapsed = time.perf_counter() - started
self.assertLess(elapsed, 0.45)
self.assertEqual(final_result["status"], "completed")
self.assertEqual(len(final_result["worker_executions"]), 3)
async def test_mark_stale_runs_as_interrupted(self) -> None:
async with self.session_factory() as db:
service = AIOrchestrationRunService(db)
await service.create_run(
tenant_id=1,
trace_id="trace-stale",
run_type="signal_source_onboarding",
project_id=3,
signal_id=9,
response_mode="fast",
status="running",
planner_status="planned",
openclaw_status="completed",
worker_total=3,
worker_completed=1,
worker_failed=0,
summary="stale run",
planner_diagnostics={"used_fallback": False},
result_payload={"pending_workers": ["pm_dispatcher"], "run_status": "running"},
created_by_user_id=1,
started_at=_utc_now_naive() - timedelta(minutes=11),
)
await db.commit()
updated = await service.mark_stale_runs_interrupted(older_than_minutes=10)
await db.commit()
row = await service.get_run(tenant_id=1, trace_id="trace-stale")
self.assertEqual(updated, 1)
self.assertEqual(row.status, "interrupted")
if __name__ == "__main__":
unittest.main()