474 lines
16 KiB
Python
474 lines
16 KiB
Python
|
|
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()
|