495 lines
20 KiB
Python
495 lines
20 KiB
Python
"""AI 编排运行账本服务。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Optional
|
|
|
|
from sqlalchemy import and_, or_, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ...core.database import AsyncSessionLocal
|
|
from ...models import AIOrchestrationRun
|
|
|
|
|
|
from ...utils.timezone import CN_TZ, now_cn_naive
|
|
|
|
|
|
def _utc_now_naive() -> datetime:
|
|
return now_cn_naive()
|
|
|
|
|
|
def _normalize_naive_datetime(value: Optional[datetime]) -> Optional[datetime]:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, datetime):
|
|
return None
|
|
if value.tzinfo is None:
|
|
return value
|
|
return value.astimezone(CN_TZ).replace(tzinfo=None)
|
|
|
|
|
|
INGRESS_RUN_TYPE = "ingress_feishu_message"
|
|
INGRESS_RETRY_SCHEDULE_SECONDS: tuple[int, ...] = (10, 30, 90)
|
|
INGRESS_RUNNING_STALE_MINUTES = 30
|
|
|
|
|
|
class AIOrchestrationRunService:
|
|
def __init__(self, db: AsyncSession) -> None:
|
|
self.db = db
|
|
|
|
async def create_run(
|
|
self,
|
|
*,
|
|
tenant_id: int,
|
|
trace_id: str,
|
|
run_type: str,
|
|
project_id: Optional[int],
|
|
signal_id: Optional[int],
|
|
response_mode: str,
|
|
status: str,
|
|
planner_status: Optional[str],
|
|
execution_status: Optional[str],
|
|
worker_total: int,
|
|
worker_completed: int,
|
|
worker_failed: int,
|
|
summary: str,
|
|
planner_diagnostics: Optional[dict[str, Any]],
|
|
result_payload: Optional[dict[str, Any]],
|
|
created_by_user_id: Optional[int],
|
|
started_at: Optional[datetime] = None,
|
|
completed_at: Optional[datetime] = None,
|
|
) -> AIOrchestrationRun:
|
|
row = AIOrchestrationRun(
|
|
tenant_id=tenant_id,
|
|
trace_id=trace_id,
|
|
run_type=run_type,
|
|
project_id=project_id,
|
|
signal_id=signal_id,
|
|
response_mode=response_mode,
|
|
status=status,
|
|
planner_status=planner_status,
|
|
openclaw_status=execution_status,
|
|
worker_total=max(0, int(worker_total)),
|
|
worker_completed=max(0, int(worker_completed)),
|
|
worker_failed=max(0, int(worker_failed)),
|
|
summary=str(summary or "").strip()[:1000] or None,
|
|
planner_diagnostics=planner_diagnostics if isinstance(planner_diagnostics, dict) else None,
|
|
result_payload=result_payload if isinstance(result_payload, dict) else None,
|
|
created_by_user_id=created_by_user_id,
|
|
started_at=started_at or _utc_now_naive(),
|
|
completed_at=completed_at,
|
|
)
|
|
self.db.add(row)
|
|
await self.db.flush()
|
|
return row
|
|
|
|
@staticmethod
|
|
def build_ingress_trace_id(
|
|
*,
|
|
source_system: str,
|
|
source_message_id: str,
|
|
) -> str:
|
|
normalized_source = str(source_system or "").strip().lower()[:24] or "unknown"
|
|
normalized_message_id = str(source_message_id or "").strip()[:96]
|
|
if normalized_message_id:
|
|
raw = f"{normalized_source}:{normalized_message_id}"
|
|
else:
|
|
digest = hashlib.sha1(f"{normalized_source}:{_utc_now_naive().isoformat()}".encode("utf-8"), usedforsecurity=False).hexdigest()
|
|
raw = f"{normalized_source}:{digest[:40]}"
|
|
return raw[:64]
|
|
|
|
async def get_run_by_id(self, *, run_id: int) -> Optional[AIOrchestrationRun]:
|
|
return (
|
|
await self.db.execute(
|
|
select(AIOrchestrationRun).where(AIOrchestrationRun.id == int(run_id))
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
async def enqueue_ingress_run(
|
|
self,
|
|
*,
|
|
tenant_id: int,
|
|
source_system: str,
|
|
source_message_id: str,
|
|
sender: str,
|
|
message_text: str,
|
|
channel_ref: Optional[str],
|
|
source_sender_open_id: Optional[str],
|
|
chat_type: Optional[str],
|
|
captured_at: Optional[datetime],
|
|
project_hint_id: Optional[int],
|
|
max_retries: int = 3,
|
|
) -> tuple[AIOrchestrationRun, bool]:
|
|
trace_id = self.build_ingress_trace_id(
|
|
source_system=source_system,
|
|
source_message_id=source_message_id,
|
|
)
|
|
existing = await self.get_run(tenant_id=tenant_id, trace_id=trace_id)
|
|
if existing is not None:
|
|
return existing, False
|
|
|
|
result_payload = {
|
|
"queue_mode": "async_ingress",
|
|
"source_system": str(source_system or "").strip()[:32],
|
|
"source_message_id": str(source_message_id or "").strip()[:128],
|
|
"queued_at": _utc_now_naive().isoformat(),
|
|
}
|
|
row = AIOrchestrationRun(
|
|
tenant_id=int(tenant_id),
|
|
trace_id=trace_id,
|
|
run_type=INGRESS_RUN_TYPE,
|
|
project_id=int(project_hint_id) if project_hint_id is not None else None,
|
|
signal_id=None,
|
|
response_mode="fast",
|
|
status="queued",
|
|
planner_status=None,
|
|
openclaw_status=None,
|
|
worker_total=1,
|
|
worker_completed=0,
|
|
worker_failed=0,
|
|
summary="消息已入异步处理队列。",
|
|
planner_diagnostics=None,
|
|
result_payload=result_payload,
|
|
created_by_user_id=None,
|
|
started_at=None,
|
|
completed_at=None,
|
|
source_system=str(source_system or "").strip()[:32] or None,
|
|
source_message_id=str(source_message_id or "").strip()[:128] or None,
|
|
sender=str(sender or "").strip()[:128] or "unknown",
|
|
source_sender_open_id=str(source_sender_open_id or "").strip()[:128] or None,
|
|
channel_ref=str(channel_ref or "").strip()[:128] or None,
|
|
chat_type=str(chat_type or "").strip()[:32] or None,
|
|
captured_at=_normalize_naive_datetime(captured_at),
|
|
message_text=str(message_text or "").strip()[:8000] or "",
|
|
project_hint_id=int(project_hint_id) if project_hint_id is not None else None,
|
|
retry_count=0,
|
|
max_retries=max(0, min(int(max_retries), 5)),
|
|
next_retry_at=_utc_now_naive(),
|
|
last_error=None,
|
|
last_error_at=None,
|
|
ack_sent_at=None,
|
|
result_notified_at=None,
|
|
work_item_id=None,
|
|
)
|
|
self.db.add(row)
|
|
try:
|
|
await self.db.flush()
|
|
except IntegrityError:
|
|
await self.db.rollback()
|
|
existing = await self.get_run(tenant_id=tenant_id, trace_id=trace_id)
|
|
if existing is not None:
|
|
return existing, False
|
|
raise
|
|
return row, True
|
|
|
|
async def claim_next_ingress_run(self, *, now: Optional[datetime] = None) -> Optional[AIOrchestrationRun]:
|
|
now_at = now or _utc_now_naive()
|
|
stale_before = now_at - timedelta(minutes=INGRESS_RUNNING_STALE_MINUTES)
|
|
stale_rows = (
|
|
await self.db.execute(
|
|
select(AIOrchestrationRun)
|
|
.where(
|
|
and_(
|
|
AIOrchestrationRun.run_type == INGRESS_RUN_TYPE,
|
|
AIOrchestrationRun.status == "running",
|
|
AIOrchestrationRun.updated_at <= stale_before,
|
|
)
|
|
)
|
|
.with_for_update(skip_locked=True)
|
|
)
|
|
).scalars().all()
|
|
for stale in stale_rows:
|
|
if int(stale.retry_count or 0) < int(stale.max_retries or 0):
|
|
delay = INGRESS_RETRY_SCHEDULE_SECONDS[min(int(stale.retry_count or 0), len(INGRESS_RETRY_SCHEDULE_SECONDS) - 1)]
|
|
stale.retry_count = int(stale.retry_count or 0) + 1
|
|
stale.status = "retrying"
|
|
stale.next_retry_at = now_at + timedelta(seconds=int(delay))
|
|
else:
|
|
stale.status = "failed"
|
|
stale.completed_at = now_at
|
|
stale.next_retry_at = None
|
|
stale.last_error = "stale_running_recovered"
|
|
stale.last_error_at = now_at
|
|
|
|
candidate_id = (
|
|
await self.db.execute(
|
|
select(AIOrchestrationRun.id)
|
|
.where(
|
|
and_(
|
|
AIOrchestrationRun.run_type == INGRESS_RUN_TYPE,
|
|
or_(
|
|
and_(
|
|
AIOrchestrationRun.status.in_(("queued", "retrying")),
|
|
or_(
|
|
AIOrchestrationRun.next_retry_at.is_(None),
|
|
AIOrchestrationRun.next_retry_at <= now_at,
|
|
),
|
|
),
|
|
and_(
|
|
AIOrchestrationRun.status == "failed",
|
|
AIOrchestrationRun.last_error == "greenlet_spawn_error",
|
|
AIOrchestrationRun.retry_count < AIOrchestrationRun.max_retries,
|
|
or_(
|
|
AIOrchestrationRun.next_retry_at.is_(None),
|
|
AIOrchestrationRun.next_retry_at <= now_at,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
)
|
|
.order_by(AIOrchestrationRun.id.asc())
|
|
.with_for_update(skip_locked=True)
|
|
.limit(1)
|
|
)
|
|
).scalar_one_or_none()
|
|
if candidate_id is None:
|
|
return None
|
|
|
|
row = await self.get_run_by_id(run_id=int(candidate_id))
|
|
if row is None:
|
|
return None
|
|
if str(row.status or "") == "failed" and str(row.last_error or "") == "greenlet_spawn_error":
|
|
row.retry_count = min(
|
|
int(row.max_retries or 0),
|
|
int(row.retry_count or 0) + 1,
|
|
)
|
|
row.status = "running"
|
|
row.openclaw_status = "running"
|
|
row.started_at = row.started_at or now_at
|
|
row.next_retry_at = None
|
|
row.last_error = None
|
|
row.last_error_at = None
|
|
await self.db.flush()
|
|
return row
|
|
|
|
async def mark_ingress_ack_sent(
|
|
self,
|
|
*,
|
|
tenant_id: int,
|
|
trace_id: str,
|
|
) -> Optional[AIOrchestrationRun]:
|
|
row = await self.get_run(tenant_id=tenant_id, trace_id=trace_id)
|
|
if row is None:
|
|
return None
|
|
row.ack_sent_at = _utc_now_naive()
|
|
await self.db.flush()
|
|
return row
|
|
|
|
async def mark_ingress_succeeded(
|
|
self,
|
|
*,
|
|
run_id: int,
|
|
result_payload: dict[str, Any],
|
|
work_item_id: Optional[int],
|
|
summary: Optional[str] = None,
|
|
) -> Optional[AIOrchestrationRun]:
|
|
row = await self.get_run_by_id(run_id=run_id)
|
|
if row is None:
|
|
return None
|
|
now_at = _utc_now_naive()
|
|
row.status = "succeeded"
|
|
row.openclaw_status = "succeeded"
|
|
row.worker_total = max(1, int(row.worker_total or 1))
|
|
row.worker_completed = max(1, int(row.worker_completed or 0))
|
|
row.worker_failed = max(0, int(row.worker_failed or 0))
|
|
row.result_payload = result_payload if isinstance(result_payload, dict) else {}
|
|
if work_item_id is not None:
|
|
row.work_item_id = int(work_item_id)
|
|
row.summary = (str(summary or "").strip()[:1000] or "异步处理完成。")
|
|
row.completed_at = now_at
|
|
row.next_retry_at = None
|
|
row.last_error = None
|
|
row.last_error_at = None
|
|
await self.db.flush()
|
|
return row
|
|
|
|
async def mark_ingress_failed_or_retrying(
|
|
self,
|
|
*,
|
|
run_id: int,
|
|
error_message: str,
|
|
) -> tuple[Optional[AIOrchestrationRun], bool]:
|
|
# Failure path must not reuse a possibly committed/invalid caller session.
|
|
async with AsyncSessionLocal() as new_db:
|
|
row = (
|
|
await new_db.execute(
|
|
select(AIOrchestrationRun)
|
|
.where(AIOrchestrationRun.id == int(run_id))
|
|
.with_for_update(skip_locked=True)
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
await new_db.commit()
|
|
return None, False
|
|
|
|
now_at = _utc_now_naive()
|
|
normalized_error = str(error_message or "").strip()[:1000] or "unknown_error"
|
|
retry_count = int(row.retry_count or 0)
|
|
max_retries = max(0, int(row.max_retries or 0))
|
|
should_retry = retry_count < max_retries
|
|
if should_retry:
|
|
delay = INGRESS_RETRY_SCHEDULE_SECONDS[min(retry_count, len(INGRESS_RETRY_SCHEDULE_SECONDS) - 1)]
|
|
row.retry_count = retry_count + 1
|
|
row.status = "retrying"
|
|
row.openclaw_status = "retrying"
|
|
row.next_retry_at = now_at + timedelta(seconds=int(delay))
|
|
row.completed_at = None
|
|
row.worker_failed = max(0, int(row.worker_failed or 0))
|
|
else:
|
|
row.status = "failed"
|
|
row.openclaw_status = "failed"
|
|
row.next_retry_at = None
|
|
row.completed_at = now_at
|
|
row.worker_failed = max(1, int(row.worker_failed or 0) + 1)
|
|
row.last_error = normalized_error
|
|
row.last_error_at = now_at
|
|
row.summary = (
|
|
f"异步处理失败(已重试{int(row.retry_count or 0)}次)"
|
|
if should_retry
|
|
else f"异步处理失败(自动重试{max_retries}次后终止)"
|
|
)
|
|
await new_db.flush()
|
|
await new_db.commit()
|
|
return row, (not should_retry)
|
|
|
|
async def mark_ingress_result_notified(
|
|
self,
|
|
*,
|
|
run_id: int,
|
|
) -> Optional[AIOrchestrationRun]:
|
|
row = await self.get_run_by_id(run_id=run_id)
|
|
if row is None:
|
|
return None
|
|
row.result_notified_at = _utc_now_naive()
|
|
await self.db.flush()
|
|
return row
|
|
|
|
async def get_run(self, *, tenant_id: int, trace_id: str) -> Optional[AIOrchestrationRun]:
|
|
return (
|
|
await self.db.execute(
|
|
select(AIOrchestrationRun).where(
|
|
and_(
|
|
AIOrchestrationRun.tenant_id == int(tenant_id),
|
|
AIOrchestrationRun.trace_id == str(trace_id),
|
|
)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
async def update_run(
|
|
self,
|
|
*,
|
|
tenant_id: int,
|
|
trace_id: str,
|
|
status: Optional[str] = None,
|
|
planner_status: Optional[str] = None,
|
|
execution_status: Optional[str] = None,
|
|
worker_total: Optional[int] = None,
|
|
worker_completed: Optional[int] = None,
|
|
worker_failed: Optional[int] = None,
|
|
summary: Optional[str] = None,
|
|
planner_diagnostics: Optional[dict[str, Any]] = None,
|
|
result_payload: Optional[dict[str, Any]] = None,
|
|
completed_at: Optional[datetime] = None,
|
|
) -> Optional[AIOrchestrationRun]:
|
|
row = await self.get_run(tenant_id=tenant_id, trace_id=trace_id)
|
|
if row is None:
|
|
return None
|
|
if status is not None:
|
|
row.status = str(status or "").strip()[:20]
|
|
if planner_status is not None:
|
|
row.planner_status = str(planner_status or "").strip()[:20] or None
|
|
if execution_status is not None:
|
|
row.openclaw_status = str(execution_status or "").strip()[:20] or None
|
|
if worker_total is not None:
|
|
row.worker_total = max(0, int(worker_total))
|
|
if worker_completed is not None:
|
|
row.worker_completed = max(0, int(worker_completed))
|
|
if worker_failed is not None:
|
|
row.worker_failed = max(0, int(worker_failed))
|
|
if summary is not None:
|
|
row.summary = str(summary or "").strip()[:1000] or None
|
|
if planner_diagnostics is not None:
|
|
row.planner_diagnostics = planner_diagnostics
|
|
if result_payload is not None:
|
|
row.result_payload = result_payload
|
|
if completed_at is not None:
|
|
row.completed_at = completed_at
|
|
return row
|
|
|
|
async def mark_stale_runs_interrupted(
|
|
self,
|
|
*,
|
|
older_than_minutes: int = 10,
|
|
) -> int:
|
|
threshold = _utc_now_naive() - timedelta(minutes=max(1, int(older_than_minutes)))
|
|
rows = (
|
|
await self.db.execute(
|
|
select(AIOrchestrationRun).where(
|
|
and_(
|
|
AIOrchestrationRun.status.in_(("queued", "running")),
|
|
AIOrchestrationRun.run_type != INGRESS_RUN_TYPE,
|
|
AIOrchestrationRun.started_at.is_not(None),
|
|
AIOrchestrationRun.started_at < threshold,
|
|
)
|
|
)
|
|
)
|
|
).scalars().all()
|
|
count = 0
|
|
for row in rows:
|
|
payload = row.result_payload if isinstance(row.result_payload, dict) else {}
|
|
payload["run_status"] = "interrupted"
|
|
payload["pending_workers"] = payload.get("pending_workers") or []
|
|
row.status = "interrupted"
|
|
row.completed_at = _utc_now_naive()
|
|
row.result_payload = payload
|
|
count += 1
|
|
return count
|
|
|
|
@staticmethod
|
|
def serialize_run(row: AIOrchestrationRun) -> dict[str, Any]:
|
|
payload = row.result_payload if isinstance(row.result_payload, dict) else {}
|
|
pending_workers = payload.get("pending_workers") if isinstance(payload.get("pending_workers"), list) else []
|
|
worker_total = int(row.worker_total or 0)
|
|
worker_completed = int(row.worker_completed or 0)
|
|
worker_failed = int(row.worker_failed or 0)
|
|
worker_pending = max(0, worker_total - worker_completed - worker_failed)
|
|
if not pending_workers and worker_pending > 0:
|
|
pending_workers = [f"pending_{idx}" for idx in range(1, worker_pending + 1)]
|
|
return {
|
|
"trace_id": str(row.trace_id or ""),
|
|
"run_type": str(row.run_type or ""),
|
|
"project_id": int(row.project_id) if row.project_id is not None else None,
|
|
"signal_id": int(row.signal_id) if row.signal_id is not None else None,
|
|
"response_mode": str(row.response_mode or ""),
|
|
"run_status": str(row.status or ""),
|
|
"planner_status": str(row.planner_status or ""),
|
|
"execution_status": str(row.openclaw_status or ""),
|
|
"worker_total": worker_total,
|
|
"worker_completed": worker_completed,
|
|
"worker_failed": worker_failed,
|
|
"worker_pending": worker_pending,
|
|
"pending_workers": pending_workers,
|
|
"summary": str(row.summary or payload.get("summary") or "").strip(),
|
|
"planner_diagnostics": row.planner_diagnostics if isinstance(row.planner_diagnostics, dict) else None,
|
|
"result_snapshot": payload,
|
|
"source_system": str(row.source_system or ""),
|
|
"source_message_id": str(row.source_message_id or ""),
|
|
"sender": str(row.sender or ""),
|
|
"channel_ref": str(row.channel_ref or ""),
|
|
"project_hint_id": int(row.project_hint_id) if row.project_hint_id is not None else None,
|
|
"retry_count": int(row.retry_count or 0),
|
|
"max_retries": int(row.max_retries or 0),
|
|
"next_retry_at": row.next_retry_at.isoformat() if row.next_retry_at else None,
|
|
"last_error": str(row.last_error or ""),
|
|
"work_item_id": int(row.work_item_id) if row.work_item_id is not None else None,
|
|
"ack_sent_at": row.ack_sent_at.isoformat() if row.ack_sent_at else None,
|
|
"result_notified_at": row.result_notified_at.isoformat() if row.result_notified_at else None,
|
|
"created_by_user_id": int(row.created_by_user_id) if row.created_by_user_id is not None else None,
|
|
"started_at": row.started_at.isoformat() if row.started_at else None,
|
|
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
|
|
}
|