import asyncio import gc import logging import time from dataclasses import dataclass, field logger = logging.getLogger(__name__) DEFAULT_HEARTBEAT_INTERVAL = 30 STUCK_SESSION_TIMEOUT = 600 MAX_CONSECUTIVE_FAILURES = 5 MB = 1024 * 1024 DEFAULT_RSS_WARNING_MB = 1536 DEFAULT_RSS_CRITICAL_MB = 3072 DEFAULT_DATA_WARNING_MB = 1024 DEFAULT_DATA_CRITICAL_MB = 2048 DEFAULT_GC_OBJECTS_WARNING = 500000 DEFAULT_GC_SAMPLE_INTERVAL = 5 DEFAULT_RSS_GROWTH_WARNING_MB = 512 DEFAULT_RSS_GROWTH_CRITICAL_MB = 1024 DEFAULT_GROWTH_WINDOW_S = 600 DEFAULT_PRESSURE_COOLDOWN_S = 300 @dataclass class MemorySnapshot: rss_mb: float = 0.0 vms_mb: float = 0.0 data_mb: float = 0.0 gc_objects: int = 0 gc_g0_count: int = 0 gc_g1_count: int = 0 gc_g2_count: int = 0 timestamp: float = 0.0 @dataclass class MemoryPressureEvent: level: str # "warning" | "critical" reason: str # "rss_threshold" | "data_threshold" | "rss_growth" rss_mb: float = 0.0 threshold_mb: float = 0.0 rss_growth_mb: float = 0.0 window_s: float = 0.0 timestamp: float = 0.0 @dataclass class HeartbeatReport: active_sessions: int = 0 stuck_sessions: int = 0 stuck_details: list[dict] = field(default_factory=list) tool_loop_sessions: int = 0 tool_loop_details: list[dict] = field(default_factory=list) memory: MemorySnapshot = field(default_factory=MemorySnapshot) active_tasks: int = 0 uptime_seconds: float = 0.0 process_start_timestamp: float = 0.0 timestamp: float = 0.0 warnings: list[str] = field(default_factory=list) memory_pressure: MemoryPressureEvent | None = None def to_dict(self) -> dict: result = { "active_sessions": self.active_sessions, "stuck_sessions": self.stuck_sessions, "stuck_details": self.stuck_details, "tool_loop_sessions": self.tool_loop_sessions, "tool_loop_details": self.tool_loop_details, "memory": { "rss_mb": self.memory.rss_mb, "vms_mb": self.memory.vms_mb, "data_mb": self.memory.data_mb, "gc_objects": self.memory.gc_objects, "gc_g0_count": self.memory.gc_g0_count, "gc_g1_count": self.memory.gc_g1_count, "gc_g2_count": self.memory.gc_g2_count, "timestamp": self.memory.timestamp, }, "active_tasks": self.active_tasks, "uptime_seconds": self.uptime_seconds, "process_start_timestamp": self.process_start_timestamp, "timestamp": self.timestamp, "warnings": self.warnings, } if self.memory_pressure: result["memory_pressure"] = { "level": self.memory_pressure.level, "reason": self.memory_pressure.reason, "rss_mb": self.memory_pressure.rss_mb, "threshold_mb": self.memory_pressure.threshold_mb, "rss_growth_mb": self.memory_pressure.rss_growth_mb, "window_s": self.memory_pressure.window_s, "timestamp": self.memory_pressure.timestamp, } return result class DiagnosticHeartbeat: """诊断心跳:30s 间隔检测卡住 session、内存快照 + 内存压力。 集成到 FastAPI lifespan 后启动,可被 health endpoint 消费。 """ def __init__( self, interval: int = DEFAULT_HEARTBEAT_INTERVAL, stuck_timeout: int = STUCK_SESSION_TIMEOUT, rss_warning_mb: float = DEFAULT_RSS_WARNING_MB, rss_critical_mb: float = DEFAULT_RSS_CRITICAL_MB, data_warning_mb: float = DEFAULT_DATA_WARNING_MB, data_critical_mb: float = DEFAULT_DATA_CRITICAL_MB, rss_growth_warning_mb: float = DEFAULT_RSS_GROWTH_WARNING_MB, rss_growth_critical_mb: float = DEFAULT_RSS_GROWTH_CRITICAL_MB, growth_window_s: float = DEFAULT_GROWTH_WINDOW_S, pressure_cooldown_s: float = DEFAULT_PRESSURE_COOLDOWN_S, gc_objects_warning: int = DEFAULT_GC_OBJECTS_WARNING, gc_sample_interval: int = DEFAULT_GC_SAMPLE_INTERVAL, ): self._interval = interval self._stuck_timeout = stuck_timeout self._rss_warning_mb = rss_warning_mb self._rss_critical_mb = rss_critical_mb self._data_warning_mb = data_warning_mb self._data_critical_mb = data_critical_mb self._rss_growth_warning_mb = rss_growth_warning_mb self._rss_growth_critical_mb = rss_growth_critical_mb self._growth_window_s = growth_window_s self._pressure_cooldown_s = pressure_cooldown_s self._gc_objects_warning = gc_objects_warning self._gc_sample_interval = gc_sample_interval self._running = False self._task: asyncio.Task | None = None self._last_report: HeartbeatReport | None = None self._last_memory: MemorySnapshot | None = None self._last_pressure_at: dict[str, float] = {} self._start_time: float | None = None self._start_monotonic: float | None = None self._consecutive_failures = 0 self._cycle_count = 0 @property def last_report(self) -> HeartbeatReport | None: return self._last_report @staticmethod def _resolve_start_time() -> float: try: import psutil return psutil.Process().create_time() except Exception: return time.time() async def start(self): self._running = True self._start_time = self._resolve_start_time() self._start_monotonic = time.monotonic() self._task = asyncio.create_task(self._loop()) logger.info( "DiagnosticHeartbeat started (interval=%ds, stuck_timeout=%ds)", self._interval, self._stuck_timeout, ) async def stop(self): self._running = False if self._task: self._task.cancel() try: await self._task except asyncio.CancelledError: pass self._task = None logger.info("DiagnosticHeartbeat stopped") async def _loop(self): while self._running: await asyncio.sleep(self._interval) try: self._last_report = await self._collect() self._consecutive_failures = 0 await self._push_metrics(self._last_report) if self._last_report.warnings: for w in self._last_report.warnings: logger.warning("DiagnosticHeartbeat: %s", w) if self._last_report.memory_pressure: mp = self._last_report.memory_pressure logger.warning( "DiagnosticHeartbeat: 内存压力 %s (reason=%s, rss=%.0fMB, threshold=%.0fMB)", mp.level, mp.reason, mp.rss_mb, mp.threshold_mb, ) except Exception: self._consecutive_failures += 1 logger.exception( "DiagnosticHeartbeat: collect failed (consecutive=%d/%d)", self._consecutive_failures, MAX_CONSECUTIVE_FAILURES, ) if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES: logger.critical( "DiagnosticHeartbeat: %d consecutive collect failures, heartbeat may be broken", self._consecutive_failures, ) async def _collect(self) -> HeartbeatReport: now = time.monotonic() self._cycle_count += 1 memory = await self._capture_memory(now) self._last_memory = memory snapshots = _get_all_snapshots() stuck_details, stuck_count = await self._detect_stuck_sessions(now, snapshots) active_tasks = len(asyncio.all_tasks()) active_sessions = _count_active_sessions_from(snapshots) uptime_seconds = now - self._start_monotonic if self._start_monotonic else 0.0 process_start_timestamp = self._start_time or 0.0 memory_pressure = self._detect_memory_pressure(memory, now) warnings: list[str] = [] if stuck_count > 0: warnings.append(f"检测到 {stuck_count} 个卡住 session") if memory.rss_mb > self._rss_warning_mb: warnings.append(f"内存 RSS {memory.rss_mb:.0f}MB > {self._rss_warning_mb:.0f}MB") if memory.data_mb > self._data_warning_mb: warnings.append(f"内存 DATA {memory.data_mb:.0f}MB > {self._data_warning_mb:.0f}MB") if memory.gc_objects > self._gc_objects_warning: warnings.append(f"GC 对象数 {memory.gc_objects} > {self._gc_objects_warning}") if memory_pressure: level_label = "严重" if memory_pressure.level == "critical" else "" warnings.append(f"内存压力{level_label}: {memory_pressure.reason} (RSS={memory_pressure.rss_mb:.0f}MB)") return HeartbeatReport( active_sessions=active_sessions, stuck_sessions=stuck_count, stuck_details=stuck_details, tool_loop_sessions=0, tool_loop_details=[], memory=memory, active_tasks=active_tasks, uptime_seconds=uptime_seconds, process_start_timestamp=process_start_timestamp, timestamp=now, warnings=warnings, memory_pressure=memory_pressure, ) async def _push_metrics(self, report: HeartbeatReport): try: from yuxi.channel.monitoring.metrics import ( active_sessions, active_tasks, memory_gc_objects, memory_rss_mb, stuck_sessions, ) active_sessions.set(report.active_sessions) stuck_sessions.set(report.stuck_sessions) memory_rss_mb.set(report.memory.rss_mb) memory_gc_objects.set(report.memory.gc_objects) active_tasks.set(report.active_tasks) except Exception: pass def _detect_memory_pressure(self, current: MemorySnapshot, now: float) -> MemoryPressureEvent | None: pressure = _check_threshold_pressure( current, rss_warning_mb=self._rss_warning_mb, rss_critical_mb=self._rss_critical_mb, ) if pressure is None: pressure = _check_data_threshold_pressure( current, data_warning_mb=self._data_warning_mb, data_critical_mb=self._data_critical_mb, ) if pressure is None: pressure = _check_growth_pressure( self._last_memory, current, now, growth_window_s=self._growth_window_s, rss_growth_warning_mb=self._rss_growth_warning_mb, rss_growth_critical_mb=self._rss_growth_critical_mb, ) if pressure is None: return None key = pressure.reason last_at = self._last_pressure_at.get(key, 0) if now - last_at < self._pressure_cooldown_s: return None self._last_pressure_at[key] = now return pressure async def _detect_stuck_sessions(self, now: float, snapshots: dict) -> tuple[list[dict], int]: stuck: list[dict] = [] for key, snap in snapshots.items(): if snap.last_event_at and (now - snap.last_event_at) > self._stuck_timeout: state_value = _get_state_value(snap.state) if snap.connected and state_value in ("running", "retrying"): stuck.append( { "key": key, "state": state_value, "last_event_at": snap.last_event_at, "idle_seconds": round(now - snap.last_event_at, 1), } ) return stuck, len(stuck) async def _capture_memory(self, now: float) -> MemorySnapshot: snapshot = MemorySnapshot(timestamp=now) try: import psutil proc = psutil.Process() mem = proc.memory_info() snapshot.rss_mb = round(mem.rss / 1024 / 1024, 2) snapshot.vms_mb = round(mem.vms / 1024 / 1024, 2) snapshot.data_mb = round(getattr(mem, "data", 0) / 1024 / 1024, 2) except Exception: pass gc_counts = gc.get_count() snapshot.gc_g0_count = gc_counts[0] snapshot.gc_g1_count = gc_counts[1] snapshot.gc_g2_count = gc_counts[2] if self._cycle_count % self._gc_sample_interval == 0: snapshot.gc_objects = len(gc.get_objects()) return snapshot def get_memory_trend(self) -> dict: if self._last_memory: return { "rss_mb": self._last_memory.rss_mb, "vms_mb": self._last_memory.vms_mb, "data_mb": self._last_memory.data_mb, "gc_objects": self._last_memory.gc_objects, "timestamp": self._last_memory.timestamp, } return {} def reset(self): self._last_memory = None self._last_pressure_at.clear() self._last_report = None self._cycle_count = 0 def _check_threshold_pressure( memory: MemorySnapshot, *, rss_warning_mb: float, rss_critical_mb: float, ) -> MemoryPressureEvent | None: if memory.rss_mb >= rss_critical_mb: return MemoryPressureEvent( level="critical", reason="rss_threshold", rss_mb=memory.rss_mb, threshold_mb=rss_critical_mb, timestamp=memory.timestamp, ) if memory.rss_mb >= rss_warning_mb: return MemoryPressureEvent( level="warning", reason="rss_threshold", rss_mb=memory.rss_mb, threshold_mb=rss_warning_mb, timestamp=memory.timestamp, ) return None def _check_data_threshold_pressure( memory: MemorySnapshot, *, data_warning_mb: float, data_critical_mb: float, ) -> MemoryPressureEvent | None: if memory.data_mb >= data_critical_mb: return MemoryPressureEvent( level="critical", reason="data_threshold", rss_mb=memory.rss_mb, threshold_mb=data_critical_mb, timestamp=memory.timestamp, ) if memory.data_mb >= data_warning_mb: return MemoryPressureEvent( level="warning", reason="data_threshold", rss_mb=memory.rss_mb, threshold_mb=data_warning_mb, timestamp=memory.timestamp, ) return None def _check_growth_pressure( previous: MemorySnapshot | None, current: MemorySnapshot, now: float, *, growth_window_s: float, rss_growth_warning_mb: float, rss_growth_critical_mb: float, ) -> MemoryPressureEvent | None: if previous is None or previous.rss_mb <= 0: return None window_s = now - previous.timestamp if window_s <= 0 or window_s > growth_window_s: return None rss_growth_mb = current.rss_mb - previous.rss_mb if rss_growth_mb <= 0: return None if rss_growth_mb >= rss_growth_critical_mb: return MemoryPressureEvent( level="critical", reason="rss_growth", rss_mb=current.rss_mb, threshold_mb=rss_growth_critical_mb, rss_growth_mb=round(rss_growth_mb, 2), window_s=round(window_s, 1), timestamp=current.timestamp, ) if rss_growth_mb >= rss_growth_warning_mb: return MemoryPressureEvent( level="warning", reason="rss_growth", rss_mb=current.rss_mb, threshold_mb=rss_growth_warning_mb, rss_growth_mb=round(rss_growth_mb, 2), window_s=round(window_s, 1), timestamp=current.timestamp, ) return None def _get_state_value(state) -> str: return state.value if hasattr(state, "value") else str(state) def _get_all_snapshots() -> dict: try: from yuxi.channel.runtime.manager import gateway # 延迟导入以避免循环依赖;Python import 缓存保证无重复加载开销 return gateway.get_all_snapshots() except Exception: logger.warning("DiagnosticHeartbeat: get_all_snapshots failed", exc_info=True) return {} def _count_active_sessions_from(snapshots: dict) -> int: return sum(1 for s in snapshots.values() if _get_state_value(s.state) == "running") diagnostic_heartbeat = DiagnosticHeartbeat()