ForcePilot/backend/package/yuxi/channel/runtime/manager.py
Kris d52e9b518d feat(channel/runtime): 新增链路追踪与启动运行时工具集
新增trace_ctx实现链路ID上下文管理与日志注入,添加boot.py启动管理器、startup.py启动任务执行器、backoff.py退避策略工具以及trace.py启动链路追踪模块,完善channel运行时基础能力
2026-05-21 10:28:46 +08:00

1049 lines
40 KiB
Python

from __future__ import annotations
import asyncio
import contextvars
import logging
import time
from collections import defaultdict
from contextlib import suppress
from dataclasses import dataclass, field
from enum import Enum, StrEnum
from typing import Any, TYPE_CHECKING
from collections.abc import Callable
from pathlib import Path
from yuxi.channel.context import ChannelContext
from yuxi.channel.protocols import (
ConfigProtocol,
GatewayProtocol,
HealthProtocol,
LifecycleProtocol,
MessageProtocol,
StartupProtocol,
TransportProtocol,
)
from .boot import BootManager
from .trace import GatewayReadyState, StartupTrace
if TYPE_CHECKING:
from yuxi.channel.config.diff import ConfigDiff
from yuxi.channel.config.reloader import ConfigReloader
from yuxi.channel.events.bus import ChannelEventBus
logger = logging.getLogger(__name__)
_gateway_ctx: contextvars.ContextVar[ChannelGateway | None] = contextvars.ContextVar("channel_gateway", default=None)
class ChannelState(Enum):
STOPPED = "stopped"
STARTING = "starting"
RUNNING = "running"
STOPPING = "stopping"
ERROR = "error"
RESTARTING = "restarting"
class HealthState(StrEnum):
HEALTHY = "healthy"
DEGRADED = "degraded"
UNHEALTHY = "unhealthy"
UNKNOWN = "unknown"
MAINTENANCE = "maintenance"
@dataclass
class ChannelSnapshot:
channel_type: str
account_id: str
state: ChannelState
connected: bool = False
health: str = "unknown"
last_event_at: float = 0.0
last_error: str | None = None
active_runs: int = 0
uptime_seconds: float = 0.0
restart_count: int = 0
last_start_at: float = 0.0
last_stop_at: float = 0.0
name: str | None = None
enabled: bool | None = None
configured: bool | None = None
last_message_at: float = 0.0
dm_policy: str | None = None
allow_from: list[str] | None = None
mode: str | None = None
last_disconnect: DisconnectInfo | None = None
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {
"channel_type": self.channel_type,
"account_id": self.account_id,
"name": self.name,
"state": self.state.value,
"connected": self.connected,
"health": self.health,
"health_state": self.health,
"last_event_at": self.last_event_at,
"last_error": self.last_error,
"active_runs": self.active_runs,
"busy": self.active_runs > 0,
"uptime_seconds": self.uptime_seconds,
"restart_count": self.restart_count,
"last_start_at": self.last_start_at,
"last_stop_at": self.last_stop_at,
"last_connected_at": self.last_start_at,
"last_message_at": self.last_message_at if self.last_message_at > 0 else None,
"last_disconnect": self.last_disconnect.to_dict() if self.last_disconnect else None,
"configured": self.configured,
"enabled": self.enabled,
"dm_policy": self.dm_policy,
"allow_from": self.allow_from,
"mode": self.mode,
}
return result
@dataclass
class DisconnectInfo:
at: float
status: int | None = None
error: str | None = None
logged_out: bool = False
def to_dict(self) -> dict[str, Any]:
return {
"at": self.at,
"status": self.status,
"error": self.error,
"logged_out": self.logged_out,
}
@dataclass
class HealthPolicy:
stale_transport_timeout_ms: int = 300_000
startup_grace_ms: int = 10_000
busy_activity_ms: int = 60_000
max_reconnect_attempts: int = 10
def apply(self, snap: ChannelSnapshot, gateway_started_at: float) -> HealthState:
now = time.time()
if snap.last_start_at > 0 and (now - snap.last_start_at) * 1000 < self.startup_grace_ms:
return HealthState.UNKNOWN
if snap.state == ChannelState.STOPPED and snap.last_disconnect and snap.last_disconnect.logged_out:
return HealthState.MAINTENANCE
if snap.state in (ChannelState.STOPPED, ChannelState.ERROR) and snap.active_runs == 0:
return HealthState.UNHEALTHY
if snap.state in (ChannelState.STOPPED, ChannelState.ERROR) and snap.active_runs > 0:
return HealthState.DEGRADED
if snap.state == ChannelState.RUNNING:
if snap.connected and snap.last_event_at > 0:
stale_ms = (now - snap.last_event_at) * 1000
if stale_ms > self.stale_transport_timeout_ms:
return HealthState.DEGRADED
if snap.last_event_at > 0 and snap.active_runs > 0:
busy_ms = (now - snap.last_event_at) * 1000
if busy_ms > self.busy_activity_ms:
return HealthState.DEGRADED
return HealthState.HEALTHY
if snap.state == ChannelState.STARTING:
return HealthState.UNKNOWN
return HealthState.UNKNOWN
@dataclass
class HealthSummary:
total: int = 0
running: int = 0
stopped: int = 0
degraded: int = 0
unhealthy: int = 0
unknown: int = 0
connected: int = 0
total_active_runs: int = 0
@dataclass
class GatewayHealthReport:
status: str = "unknown"
summary: HealthSummary = field(default_factory=HealthSummary)
channels: dict[str, dict[str, Any]] = field(default_factory=dict)
@dataclass
class GatewayState:
channels: dict[str, ChannelSnapshot] = field(default_factory=dict)
global_config: dict[str, Any] = field(default_factory=dict)
route_bindings: dict[str, str] = field(default_factory=dict)
running: bool = False
started_at: float = 0.0
last_health_check: float = 0.0
class ChannelGateway(GatewayProtocol):
"""渠道网关 — 统一管理所有渠道实例的生命周期、健康检查与消息路由。"""
def __init__(
self,
global_config: dict[str, Any] | None = None,
event_bus: ChannelEventBus | None = None,
state_dir: str | Path | None = None,
config_path: str | Path | None = None,
):
self.global_config = global_config or {}
self._event_bus = event_bus
self.state_dir = Path(state_dir) if state_dir else None
self.config_path = Path(config_path) if config_path else None
self._channels: dict[str, Any] = {}
self._states: dict[str, ChannelState] = {}
self._snapshots: dict[str, ChannelSnapshot] = {}
self._tasks: dict[str, asyncio.Task] = {}
self._health_checks: dict[str, asyncio.Task] = {}
self._message_handlers: list[Callable] = []
self._event_handlers: list[Callable] = []
self._running = False
self._started_at = 0.0
self._lock = asyncio.Lock()
self._boot_manager = BootManager()
self._startup_trace = StartupTrace()
self._restart_counts: dict[str, int] = defaultdict(int)
self._max_restarts_per_hour = self.global_config.get("gateway", {}).get("channel_max_restarts_per_hour", 10)
self._restart_windows: dict[str, list[float]] = defaultdict(list)
self._manually_stopped: set[str] = set()
from .backoff import BackoffConfig, ErrorBackoff
self._error_backoff = ErrorBackoff(BackoffConfig(base_delay=5.0, max_delay=300.0, max_retries=10))
self._reloader: ConfigReloader | None = None
self._register_boot_phases()
# ── GatewayProtocol ─────────────────────────────────────
async def start(self) -> None:
async with self._lock:
if self._running:
logger.warning("Gateway already running")
return
self._running = True
self._started_at = time.time()
logger.info("ChannelGateway starting...")
if self.state_dir or self.config_path:
from yuxi.channel.security.fix import fix_security
results = await fix_security(
config=self.global_config,
state_dir=self.state_dir,
config_path=self.config_path,
)
for r in results:
if r.status == "error":
logger.warning("Security fix error during startup: %s", r.errors)
from yuxi.channel.plugins.registry import ChannelPluginRegistry
max_concurrent = self.global_config.get("gateway", {}).get("channel_start_concurrency", 10)
semaphore = asyncio.Semaphore(max_concurrent)
async def _start_one(plugin: Any, account_id: str) -> None:
async with semaphore:
async def _do_start():
await self.start_channel(plugin.id, account_id, self.global_config)
result = await _runner.run(
_do_start,
name=f"{plugin.id}:{account_id}",
timeout=30.0,
)
if not result.success:
logger.warning("Channel %s:%s startup failed: %s", plugin.id, account_id, result.error)
self._startup_trace.add_phase(
f"channel:{plugin.id}:{account_id}",
result.duration_ms,
success=False,
error=result.error,
)
from .startup import StartupTaskRunner
_runner = StartupTaskRunner(default_timeout=30.0, max_retries=1)
plugins = ChannelPluginRegistry.all()
tasks: list[asyncio.Task] = []
for plugin in plugins:
if not isinstance(plugin, ConfigProtocol):
continue
try:
account_ids = await plugin.list_account_ids()
except Exception:
logger.exception("Failed to list accounts for %s", getattr(plugin, "id", "unknown"))
continue
for account_id in account_ids:
try:
account = await plugin.resolve_account(account_id)
if not plugin.is_configured(account):
continue
if hasattr(plugin, "is_enabled") and not plugin.is_enabled(account, self.global_config):
logger.debug("Channel %s:%s is disabled, skipping", plugin.id, account_id)
continue
tasks.append(asyncio.create_task(_start_one(plugin, account_id)))
except Exception:
logger.exception("Failed to prepare channel %s:%s", plugin.id, account_id)
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
for plugin in plugins:
if not isinstance(plugin, ConfigProtocol):
continue
try:
account_ids = await plugin.list_account_ids()
except Exception:
continue
for account_id in account_ids:
key = f"{plugin.id}:{account_id}"
snap = self._snapshots.get(key)
if snap is None:
continue
try:
account = await plugin.resolve_account(account_id)
snap.configured = plugin.is_configured(account)
snap.enabled = (
plugin.is_enabled(account, self.global_config)
if hasattr(plugin, 'is_enabled')
else True
)
snap.name = (
plugin.describe_account(account, self.global_config).get('name')
if hasattr(plugin, 'describe_account')
else account.get('name')
)
snap.dm_policy = account.get('dm_policy', 'pairing')
snap.allow_from = account.get('allow_from', [])
snap.mode = account.get('mode', 'socket')
except Exception:
logger.debug("Failed to backfill snapshot metadata for %s", key)
logger.info("ChannelGateway started with %d channels", len(self._channels))
await self._publish_event("gateway.start", channel_count=len(self._channels))
from yuxi.channel.config.reloader import ConfigReloader
from yuxi.channel.config.reload_plan import ReloadMode
self._reloader = ConfigReloader(
poll_interval_seconds=self.global_config.get("gateway", {})
.get("reload", {})
.get("poll_interval_seconds", 5.0),
debounce_seconds=self.global_config.get("gateway", {}).get("reload", {}).get("debounce_seconds", 0.3),
reload_mode=ReloadMode(self.global_config.get("gateway", {}).get("reload", {}).get("mode", "hybrid")),
event_bus=self._event_bus,
)
async def _get_snapshot():
from yuxi.channel.config.snapshot import AccountConfigEntry, ConfigSnapshot, hash_config_value
accounts: list[AccountConfigEntry] = []
for plugin_item in ChannelPluginRegistry.all():
if not isinstance(plugin_item, ConfigProtocol):
continue
try:
account_ids = await plugin_item.list_account_ids()
except Exception:
logger.exception(
"ConfigReloader: list_account_ids failed for %s", getattr(plugin_item, "id", "unknown")
)
continue
for account_id in account_ids:
try:
account = await plugin_item.resolve_account(account_id)
configured = plugin_item.is_configured(account)
enabled = False
if hasattr(plugin_item, "is_enabled"):
enabled = plugin_item.is_enabled(account, self.global_config)
config_hash = hash_config_value(account) if isinstance(account, dict) else ""
accounts.append(
AccountConfigEntry(
channel_type=plugin_item.id,
account_id=account_id,
enabled=enabled,
configured=configured,
config_hash=config_hash,
)
)
except Exception:
logger.exception("ConfigReloader: resolve_account failed for %s:%s", plugin_item.id, account_id)
return ConfigSnapshot(
version=f"{int(time.time())}-{len(accounts)}",
accounts=accounts,
route_bindings_hash="",
)
def _on_diff(diff: ConfigDiff):
if not diff.has_changes:
return
logger.info("ChannelGateway: applying config diff %s", diff.summary)
for entry in diff.added:
if entry.enabled and entry.configured:
logger.info("ChannelGateway: hot-starting new channel %s:%s", entry.channel_type, entry.account_id)
asyncio.create_task(self.start_channel(entry.channel_type, entry.account_id, self.global_config))
for entry in diff.removed:
logger.info("ChannelGateway: stopping removed channel %s:%s", entry.channel_type, entry.account_id)
asyncio.create_task(self.stop_channel(entry.channel_type, entry.account_id))
for entry in diff.changed:
if not entry.enabled:
logger.info("ChannelGateway: stopping disabled channel %s:%s", entry.channel_type, entry.account_id)
asyncio.create_task(self.stop_channel(entry.channel_type, entry.account_id))
else:
logger.info(
"ChannelGateway: restarting changed channel %s:%s", entry.channel_type, entry.account_id
)
asyncio.create_task(self.restart_channel(entry.channel_type, entry.account_id))
if diff.routes_changed:
logger.info("ChannelGateway: route bindings changed, rebuilding index")
if hasattr(self, "_reload_bindings"):
asyncio.create_task(self._reload_bindings())
self._reloader.on_diff(_on_diff)
await self._reloader.start(_get_snapshot)
logger.info("ChannelGateway: ConfigReloader started")
async def stop(self) -> None:
async with self._lock:
if not self._running:
return
self._running = False
logger.info("ChannelGateway stopping...")
if self._reloader:
await self._reloader.stop()
self._reloader = None
for key in list(self._channels.keys()):
await self.stop_channel(*key.split(":", 1))
for task in list(self._tasks.values()):
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
for task in list(self._health_checks.values()):
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
logger.info("ChannelGateway stopped")
await self._publish_event("gateway.stop")
async def restart(self) -> None:
await self.stop()
await asyncio.sleep(0.5)
await self.start()
def is_running(self) -> bool:
return self._running
def get_state(self) -> GatewayState:
return GatewayState(
channels=dict(self._snapshots),
global_config=dict(self.global_config),
running=self._running,
started_at=self._started_at,
last_health_check=time.time(),
)
def get_snapshot(self, channel_type: str, account_id: str) -> ChannelSnapshot | None:
return self._snapshots.get(f"{channel_type}:{account_id}")
def list_channels(self) -> list[str]:
return list(self._channels.keys())
def inc_active_runs(self, channel_type: str, account_id: str) -> None:
key = f"{channel_type}:{account_id}"
snap = self._snapshots.get(key)
if snap is not None:
snap.active_runs += 1
def dec_active_runs(self, channel_type: str, account_id: str) -> None:
key = f"{channel_type}:{account_id}"
snap = self._snapshots.get(key)
if snap is not None:
snap.active_runs = max(0, snap.active_runs - 1)
def update_last_message_at(self, channel_type: str, account_id: str) -> None:
key = f"{channel_type}:{account_id}"
snap = self._snapshots.get(key)
if snap is not None:
snap.last_message_at = time.time()
def get_health(self) -> GatewayHealthReport:
policy = HealthPolicy(
stale_transport_timeout_ms=self.global_config.get("gateway", {}).get(
"stale_transport_timeout_ms", 300_000
),
startup_grace_ms=self.global_config.get("gateway", {}).get(
"startup_grace_ms", 10_000
),
)
channels_data: dict[str, dict[str, Any]] = {}
running_count = 0
stopped_count = 0
connected_count = 0
total_active_runs = 0
healthy_count = 0
degraded_count = 0
unhealthy_count = 0
unknown_count = 0
for key, snap in self._snapshots.items():
parts = key.split(":", 1)
if len(parts) != 2:
continue
state_val = snap.state.value if isinstance(snap.state, ChannelState) else str(snap.state)
is_running = snap.state == ChannelState.RUNNING
health_state = policy.apply(snap, self._started_at)
data: dict[str, Any] = {
"name": snap.name,
"enabled": snap.enabled,
"configured": snap.configured,
"running": is_running,
"connected": snap.connected,
"busy": snap.active_runs > 0,
"active_runs": snap.active_runs,
"last_run_activity_at": snap.last_event_at if snap.last_event_at > 0 else None,
"last_transport_activity_at": snap.last_event_at if snap.last_event_at > 0 else None,
"last_message_at": snap.last_message_at if snap.last_message_at > 0 else None,
"last_start_at": snap.last_start_at if snap.last_start_at > 0 else None,
"reconnect_attempts": 0,
"restart_pending": self._states.get(key) == ChannelState.RESTARTING,
"state": state_val,
"health_state": health_state.value,
"last_error": snap.last_error,
"dm_policy": snap.dm_policy,
"allow_from": snap.allow_from,
"mode": snap.mode,
}
channels_data[key] = data
if health_state == HealthState.HEALTHY:
healthy_count += 1
elif health_state == HealthState.DEGRADED:
degraded_count += 1
elif health_state == HealthState.UNHEALTHY:
unhealthy_count += 1
else:
unknown_count += 1
if is_running:
running_count += 1
else:
stopped_count += 1
if snap.connected:
connected_count += 1
total_active_runs += snap.active_runs
total = len(channels_data)
summary = HealthSummary(
total=total,
running=running_count,
stopped=stopped_count,
degraded=degraded_count,
unhealthy=unhealthy_count,
unknown=unknown_count,
connected=connected_count,
total_active_runs=total_active_runs,
)
if unhealthy_count == 0 and total > 0:
status = HealthState.HEALTHY.value
elif degraded_count > 0:
status = HealthState.DEGRADED.value
elif running_count > 0:
status = HealthState.DEGRADED.value
else:
status = HealthState.UNHEALTHY.value
return GatewayHealthReport(status=status, summary=summary, channels=channels_data)
def get_all_snapshots(self) -> dict[str, ChannelSnapshot]:
return dict(self._snapshots)
async def start_all(self) -> None:
await self.start()
async def stop_all(self) -> None:
await self.stop()
# ── Channel Lifecycle ───────────────────────────────────
async def start_channel(
self,
channel_type: str,
account_id: str,
config: dict[str, Any] | None = None,
) -> bool:
if config is None:
config = self.global_config
key = f"{channel_type}:{account_id}"
async with self._lock:
if key in self._channels:
logger.warning("Channel %s already running", key)
return False
current_state = self._states.get(key)
if current_state in (ChannelState.STARTING, ChannelState.RESTARTING):
logger.warning("Channel %s is already %s", key, current_state.value)
return False
if not self._check_restart_rate(key):
logger.warning("Channel %s restart rate limit exceeded", key)
return False
self._states[key] = ChannelState.STARTING
self._snapshots[key] = ChannelSnapshot(
channel_type=channel_type,
account_id=account_id,
state=ChannelState.STARTING,
last_start_at=time.time(),
)
from yuxi.channel.plugins.registry import ChannelPluginRegistry
plugin = ChannelPluginRegistry.get(channel_type)
if plugin is None:
logger.error("Plugin not found for channel type: %s", channel_type)
await self._apply_start_error(key, "Plugin not found")
return False
instance_error: str | None = None
try:
instance = plugin()
except Exception as e:
await self._apply_start_error(key, str(e))
return False
try:
if isinstance(instance, StartupProtocol):
await instance.on_startup(config)
if isinstance(instance, TransportProtocol):
await instance.connect()
async with self._lock:
if self._states.get(key) != ChannelState.STARTING:
raise RuntimeError(f"Channel {key} state changed to {self._states.get(key)} during startup")
self._channels[key] = instance
self._states[key] = ChannelState.RUNNING
self._manually_stopped.discard(key)
if key in self._snapshots:
self._snapshots[key].state = ChannelState.RUNNING
self._snapshots[key].connected = isinstance(instance, TransportProtocol)
self._snapshots[key].health = "healthy"
if isinstance(instance, LifecycleProtocol):
self._tasks[key] = asyncio.create_task(self._channel_loop(key, instance))
if isinstance(instance, HealthProtocol):
self._health_checks[key] = asyncio.create_task(self._health_check_loop(key, instance))
logger.info("Channel %s started", key)
await self._publish_event("channel.running", channel_type=channel_type, account_id=account_id)
return True
except Exception as e:
logger.exception("Failed to start channel %s", key)
instance_error = str(e)
await self._apply_start_error(key, instance_error)
await self._publish_event(
"channel.error", channel_type=channel_type, account_id=account_id, error=instance_error
)
return False
async def stop_channel(
self,
channel_type: str,
account_id: str,
*,
cleanup_restart_data: bool = True,
manual: bool = False,
) -> bool:
key = f"{channel_type}:{account_id}"
if manual:
self._manually_stopped.add(key)
async with self._lock:
instance = self._channels.pop(key, None)
if instance is None:
return False
self._states[key] = ChannelState.STOPPING
self._snapshots[key].state = ChannelState.STOPPING
await self._publish_event("channel.stopping", channel_type=channel_type, account_id=account_id)
try:
if isinstance(instance, TransportProtocol):
await instance.disconnect()
if isinstance(instance, LifecycleProtocol):
await instance.on_shutdown()
task = self._tasks.pop(key, None)
if task and not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
health_task = self._health_checks.pop(key, None)
if health_task and not health_task.done():
health_task.cancel()
with suppress(asyncio.CancelledError):
await health_task
async with self._lock:
self._states[key] = ChannelState.STOPPED
if key in self._snapshots:
snap = self._snapshots[key]
snap.state = ChannelState.STOPPED
snap.connected = False
snap.last_stop_at = time.time()
snap.last_disconnect = DisconnectInfo(
at=snap.last_stop_at,
error=snap.last_error,
logged_out=key in self._manually_stopped,
)
logger.info("Channel %s stopped", key)
return True
except Exception as e:
logger.exception("Failed to stop channel %s", key)
async with self._lock:
self._states[key] = ChannelState.ERROR
if key in self._snapshots:
snap = self._snapshots[key]
snap.state = ChannelState.ERROR
snap.last_error = str(e)
snap.last_stop_at = time.time()
snap.last_disconnect = DisconnectInfo(
at=snap.last_stop_at,
error=str(e),
logged_out=False,
)
return False
finally:
if cleanup_restart_data:
self._restart_counts.pop(key, None)
self._restart_windows.pop(key, None)
def is_manually_stopped(self, channel_type: str, account_id: str) -> bool:
return f"{channel_type}:{account_id}" in self._manually_stopped
async def restart_channel(self, channel_type: str, account_id: str) -> bool:
key = f"{channel_type}:{account_id}"
self._restart_counts[key] += 1
self._restart_windows[key].append(time.time())
await self.stop_channel(channel_type, account_id, cleanup_restart_data=False)
attempt = self._error_backoff.get_attempts(key)
delay = self._error_backoff.config.compute_delay(attempt)
logger.info("Channel %s restart attempt %d, waiting %.2fs", key, attempt + 1, delay)
await asyncio.sleep(max(delay, 0.1))
success = await self.start_channel(channel_type, account_id, self.global_config)
if success:
self._error_backoff.reset(key)
return success
def _check_restart_rate(self, key: str) -> bool:
window = self._restart_windows.get(key, [])
now = time.time()
hour_ago = now - 3600
recent = [t for t in window if t > hour_ago]
self._restart_windows[key] = recent
return len(recent) < self._max_restarts_per_hour
async def _publish_event(self, topic: str, **kwargs) -> None:
if self._event_bus is None:
return
try:
await self._event_bus.publish(topic, **kwargs)
except Exception:
logger.exception("EventBus publish failed: topic=%s", topic)
async def _apply_start_error(self, key: str, error: str) -> None:
async with self._lock:
if self._states.get(key) != ChannelState.STARTING:
return
self._states[key] = ChannelState.ERROR
if key in self._snapshots:
self._snapshots[key].state = ChannelState.ERROR
self._snapshots[key].last_error = error
# ── Message & Event Routing ─────────────────────────────
def on_message(self, handler: Callable) -> Callable:
self._message_handlers.append(handler)
return handler
def on_event(self, handler: Callable) -> Callable:
self._event_handlers.append(handler)
return handler
async def send_message(
self,
channel_type: str,
account_id: str,
message: dict[str, Any],
) -> bool:
key = f"{channel_type}:{account_id}"
instance = self._channels.get(key)
if instance is None:
logger.warning("Cannot send message: channel %s not found", key)
return False
if not isinstance(instance, MessageProtocol):
logger.warning("Channel %s does not support MessageProtocol", key)
return False
try:
await instance.send_message(message)
return True
except Exception:
logger.exception("Failed to send message to %s", key)
return False
async def route_message(self, context: ChannelContext) -> bool:
for handler in self._message_handlers:
try:
result = await handler(context)
if result is True:
return True
except Exception:
logger.exception("Message handler failed")
return False
# ── Internal Loops ──────────────────────────────────────
async def _channel_loop(self, key: str, instance: LifecycleProtocol) -> None:
try:
while self._running and self._states.get(key) == ChannelState.RUNNING:
try:
await instance.on_tick()
except Exception as e:
logger.exception("Channel loop error for %s", key)
self._snapshots[key].last_error = str(e)
await asyncio.sleep(1.0)
except asyncio.CancelledError:
pass
except Exception as e:
logger.exception("Channel loop crashed for %s", key)
async with self._lock:
self._snapshots[key].last_error = str(e)
self._states[key] = ChannelState.ERROR
self._snapshots[key].state = ChannelState.ERROR
parts = key.split(":", 1)
if len(parts) == 2:
asyncio.create_task(self.restart_channel(parts[0], parts[1]))
async def _health_check_loop(self, key: str, instance: HealthProtocol) -> None:
interval = self.global_config.get("gateway", {}).get("channel_health_check_minutes", 5)
interval_seconds = max(interval * 60, 30)
try:
while self._running and self._states.get(key) in (
ChannelState.RUNNING,
ChannelState.STARTING,
):
try:
healthy = await instance.health_check()
self._snapshots[key].health = "healthy" if healthy else "unhealthy"
if not healthy:
logger.warning("Health check failed for %s", key)
except Exception as e:
logger.exception("Health check error for %s", key)
self._snapshots[key].health = "error"
self._snapshots[key].last_error = str(e)
await asyncio.sleep(interval_seconds)
except asyncio.CancelledError:
pass
# ── Boot & Startup ──────────────────────────────────────
def _register_boot_phases(self) -> None:
self._boot_manager.register("early", self._preflight_check, priority=100)
self._boot_manager.register("plugins", self._registry_check, priority=100)
self._boot_manager.register("channels", self._run_bootstrap_hooks, priority=50)
self._boot_manager.register("channels", self._warmup_channels_wrapper, priority=100)
self._boot_manager.register("late", self._readiness_check, priority=100)
self._boot_manager.register("late", self._mark_running, priority=50)
async def _preflight_check(self) -> None:
self._startup_trace.start_phase("preflight")
try:
from yuxi.storage.postgres.manager import pg_manager
async with pg_manager.get_async_session_context() as session:
from sqlalchemy import text
await session.execute(text("SELECT 1"))
self._startup_trace.complete_phase("preflight", ok=True)
except Exception as e:
self._startup_trace.complete_phase("preflight", ok=False, error=str(e))
self._startup_trace.finalize(GatewayReadyState.FAILED)
raise
async def _registry_check(self) -> None:
self._startup_trace.start_phase("registry")
from yuxi.channel.plugins.registry import ChannelPluginRegistry
plugins = ChannelPluginRegistry.all()
if not plugins:
self._startup_trace.complete_phase("registry", ok=False, error="No registered plugins")
self._startup_trace.finalize(GatewayReadyState.FAILED)
raise RuntimeError("No plugins registered in ChannelPluginRegistry")
self._startup_trace.complete_phase("registry", ok=True)
async def _run_bootstrap_hooks(self) -> None:
self._startup_trace.start_phase("bootstrap")
from yuxi.channel.plugins.registry import ChannelPluginRegistry
errors: list[str] = []
for plugin in ChannelPluginRegistry.all():
if not isinstance(plugin, LifecycleProtocol):
continue
try:
await plugin.run_startup_maintenance(self.global_config)
except Exception as e:
logger.exception("Bootstrap hook failed for plugin: %s", getattr(plugin, "id", "unknown"))
errors.append(str(e))
if errors:
self._startup_trace.complete_phase("bootstrap", ok=False, error="; ".join(errors))
else:
self._startup_trace.complete_phase("bootstrap", ok=True)
async def _warmup_channels_wrapper(self) -> None:
self._startup_trace.start_phase("channel-warmup")
async def _readiness_check(self) -> None:
self._startup_trace.start_phase("readiness")
running = sum(1 for s in self._snapshots.values() if s.state == ChannelState.RUNNING)
total = len(self._snapshots)
if total == 0:
ready_state = GatewayReadyState.NOT_READY
error = "No channels configured"
elif running == total:
ready_state = GatewayReadyState.READY
error = None
elif running > 0:
ready_state = GatewayReadyState.DEGRADED
error = None
else:
ready_state = GatewayReadyState.FAILED
error = "All channels failed to start"
self._startup_trace.complete_phase("readiness", ok=ready_state != GatewayReadyState.FAILED, error=error)
warmup_ok = running > 0
if warmup_ok:
self._startup_trace.complete_phase("channel-warmup", ok=True)
else:
self._startup_trace.complete_phase("channel-warmup", ok=False, error="No channels running")
self._startup_trace.finalize(ready_state)
async def _mark_running(self) -> None:
self._startup_trace.start_phase("running")
await self._publish_event("gateway.ready", ready_state=self._startup_trace.ready_state.value)
self._startup_trace.complete_phase("running", ok=True)
async def boot(self, overall_timeout: float = 120.0) -> None:
logger.info("ChannelGateway boot sequence starting...")
self._startup_trace.start()
try:
await self._boot_manager.run_startup_tasks()
try:
await asyncio.wait_for(self.start(), timeout=overall_timeout)
except TimeoutError:
logger.error("ChannelGateway start timed out after %.0fs", overall_timeout)
self._startup_trace.add_phase("channel-warmup", overall_timeout * 1000, success=False,
error=f"Overall startup timeout ({overall_timeout}s)")
self._startup_trace.complete()
logger.info("ChannelGateway boot sequence completed in %.2fs", self._startup_trace.duration)
except Exception as e:
self._startup_trace.fail(str(e))
logger.exception("ChannelGateway boot sequence failed")
raise
def get_startup_trace(self) -> dict[str, Any]:
return self._startup_trace.to_dict()
def get_boot_status(self) -> dict[str, Any]:
return self._boot_manager.to_dict()
# ── Plugin Dependency Check ─────────────────────────────
def _check_plugin_dependencies(self) -> None:
from yuxi.channel.plugins.dependency import DependencyGraph
from yuxi.channel.plugins.discovery import ChannelPluginDiscovery
graph = DependencyGraph()
manifests = ChannelPluginDiscovery.discover_manifests("manifests/channels")
for manifest in manifests:
graph.add(manifest)
has_cycle, cycle = graph.has_cycle()
if has_cycle:
logger.error("Plugin dependency cycle detected: %s", " -> ".join(cycle))
raise RuntimeError(f"Plugin dependency cycle: {' -> '.join(cycle)}")
def get_gateway() -> ChannelGateway:
gw = _gateway_ctx.get()
if gw is None:
gw = ChannelGateway()
_gateway_ctx.set(gw)
return gw
def set_gateway(gw: ChannelGateway) -> None:
_gateway_ctx.set(gw)
def reset_gateway() -> None:
_gateway_ctx.set(None)
def __getattr__(name: str):
if name == "gateway":
return get_gateway()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")