import asyncio import logging import time from dataclasses import dataclass, field from yuxi.channel.cron.types import ( CronAgentExecutionPhase, CronDeliveryReceipt, CronDeliveryTarget, CronJob, CronRunDiagnostic, DeliveryHandler, ) logger = logging.getLogger(__name__) DEFAULT_MAX_RETRIES = 3 DEFAULT_RETRY_BASE_MS = 500 DEFAULT_RETRY_MAX_MS = 10_000 DEFAULT_RETRY_BACKOFF = 2.0 @dataclass class DeliveryConfig: max_retries: int = DEFAULT_MAX_RETRIES retry_base_ms: int = DEFAULT_RETRY_BASE_MS retry_max_ms: int = DEFAULT_RETRY_MAX_MS retry_backoff: float = DEFAULT_RETRY_BACKOFF concurrency: int = 5 request_timeout_ms: int = 30_000 total_timeout_ms: int = 120_000 def resolve_delivery_targets(job: CronJob) -> list[CronDeliveryTarget]: return [t for t in job.delivery if t.enabled and t.channel and t.target_id] def format_delivery_content( job: CronJob, result: dict | None, phase: CronAgentExecutionPhase | None, error: str | None, ) -> str: job_label = job.name or job.id if error: return f"⛔ Cron 任务执行失败\n\n任务:{job_label}\n阶段:{phase.value if phase else '未知'}\n错误:{error}" if result is None: return f"✅ Cron 任务执行完成\n\n任务:{job_label}\n阶段:{phase.value if phase else '未知'}\n结果:无返回值" result_text = _format_result_value(result) return f"✅ Cron 任务执行完成\n\n任务:{job_label}\n阶段:{phase.value if phase else '未知'}\n结果:\n{result_text}" def _format_result_value(result: dict) -> str: if not result: return "(空)" lines = [] for k, v in result.items(): if isinstance(v, (list, dict)): v = str(v)[:500] elif isinstance(v, str) and len(v) > 500: v = v[:500] + "..." lines.append(f" {k}: {v}") return "\n".join(lines) def _backoff_delay(attempt: int, config: DeliveryConfig) -> int: base = config.retry_base_ms * (config.retry_backoff ** (attempt - 1)) return min(int(base), config.retry_max_ms) async def deliver_to_target( handler: DeliveryHandler, target: CronDeliveryTarget, content: str, config: DeliveryConfig, ) -> CronDeliveryReceipt: receipt = CronDeliveryReceipt(target=target) started_mono = time.monotonic() receipt.sent_at_ms = int(time.time() * 1000) total_started_mono = time.monotonic() for attempt in range(1, config.max_retries + 1): try: success = await asyncio.wait_for( handler(target.channel, target.target_id, content), timeout=config.request_timeout_ms / 1000.0, ) if success: receipt.success = True receipt.retries = attempt - 1 receipt.sent_at_ms = int(time.time() * 1000) receipt.duration_ms = int((time.monotonic() - started_mono) * 1000) return receipt receipt.error = "handler returned False" except asyncio.TimeoutError: receipt.error = f"delivery timeout ({config.request_timeout_ms}ms)" logger.warning( "Cron delivery attempt %d/%d to %s:%s timed out after %dms", attempt, config.max_retries, target.channel, target.target_id, config.request_timeout_ms, ) except Exception as e: receipt.error = str(e) logger.warning( "Cron delivery attempt %d/%d to %s:%s failed: %s", attempt, config.max_retries, target.channel, target.target_id, e, ) total_elapsed_ms = int((time.monotonic() - total_started_mono) * 1000) if total_elapsed_ms >= config.total_timeout_ms: receipt.error = f"total delivery timeout ({config.total_timeout_ms}ms)" logger.warning( "Cron delivery to %s:%s exceeded total timeout %dms", target.channel, target.target_id, config.total_timeout_ms, ) break if attempt < config.max_retries: delay = _backoff_delay(attempt, config) await asyncio.sleep(delay / 1000.0) receipt.success = False receipt.retries = config.max_retries receipt.duration_ms = int((time.monotonic() - started_mono) * 1000) return receipt async def batch_deliver( handler: DeliveryHandler, job: CronJob, result: dict | None, error: str | None, diagnostic: CronRunDiagnostic | None, config: DeliveryConfig | None = None, ) -> list[CronDeliveryReceipt]: targets = resolve_delivery_targets(job) if not targets: return [] config = config or DeliveryConfig() content = format_delivery_content(job, result, CronAgentExecutionPhase.DELIVERING, error) semaphore = asyncio.Semaphore(config.concurrency) async def _deliver_one(target: CronDeliveryTarget) -> CronDeliveryReceipt: async with semaphore: return await deliver_to_target(handler, target, content, config) receipts = await asyncio.gather(*[_deliver_one(t) for t in targets], return_exceptions=True) results: list[CronDeliveryReceipt] = [] for i, r in enumerate(receipts): if isinstance(r, CronDeliveryReceipt): results.append(r) elif isinstance(r, Exception): logger.exception("Cron delivery task crashed for target [%d]", i) target = targets[i] if i < len(targets) else CronDeliveryTarget(channel="unknown", target_id="unknown") results.append( CronDeliveryReceipt( target=target, success=False, error=str(r), ) ) if diagnostic is not None: diagnostic.delivery_receipts = results succeeded = sum(1 for r in results if r.success) failed = len(results) - succeeded if failed > 0: logger.warning( "Cron batch delivery for '%s': %d/%d succeeded, %d failed", job.id, succeeded, len(results), failed, ) return results async def deliver_result( handler: DeliveryHandler, job: CronJob, result: dict | None, error: str | None, diagnostic: CronRunDiagnostic | None, config: DeliveryConfig | None = None, ) -> list[CronDeliveryReceipt]: return await batch_deliver(handler, job, result, error, diagnostic, config)