Ctrl K
ComunidadGuías…

butler/gateway/runner_ops.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Gateway runner best-effort helpers (P0-A)."""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from butler.core.best_effort import async_safe_best_effort, safe_best_effort
from butler.skills.similarity import _ensure_jieba
from butler.ops.degradation_registry import sync_all_startup_degradations
from butler.resilience.message_queue import restore_persisted_queue
from butler.mcp.async_runner import graceful_shutdown_mcp_stack
from butler.memory.semantic_index import close_all_semantic_indices
from butler.tools.reminder import poll_due_reminders
from butler.resilience.durable_outbox import (
    durable_outbox_enabled,
    enqueue_outbox_message,
    list_pending_outbox,
    mark_outbox_sent,
)

logger = logging.getLogger(__name__)


def warmup_gateway_runtime_safe(butler: Any) -> None:
    def _skills() -> None:

        _ensure_jieba()
        mgr = getattr(butler._orchestrator, "_skill_manager", None)
        if mgr is not None:
            mgr.list_skills()
        logger.info("Gateway runtime warmup complete (skills/jieba)")

    safe_best_effort(_skills, label="runner.warmup_skills", default=None)

    def _degradation() -> None:

        sync_all_startup_degradations()

    safe_best_effort(_degradation, label="runner.warmup_degradation", default=None)


def restore_persisted_queue_safe() -> int:
    def _run() -> int:

        return int(restore_persisted_queue() or 0)

    result = safe_best_effort(_run, label="runner.queue_restore", default=0)
    return int(result or 0)


async def disconnect_adapter_safe(adapter: Any) -> None:
    async def _run() -> None:
        await adapter.disconnect()

    await async_safe_best_effort(
        _run,
        label=f"runner.disconnect.{getattr(adapter, 'name', '?')}",
        default=None,
    )


def shutdown_mcp_stack_safe() -> None:
    def _run() -> None:

        graceful_shutdown_mcp_stack(timeout=5.0)

    safe_best_effort(_run, label="runner.mcp_shutdown", default=None)


def close_semantic_indices_safe() -> None:
    def _run() -> None:

        close_all_semantic_indices()

    safe_best_effort(_run, label="runner.semantic_index_shutdown", default=None)


def sync_send_via_adapter_loud(
    adapters: list[Any],
    chat_id: str,
    text: str,
) -> bool:
    """Send via first adapter; log warning and try next on failure."""
    if not chat_id or not text:
        return False
    for adapter in adapters:
        if not hasattr(adapter, "send"):
            continue
        try:
            loop = None
            try:
                loop = asyncio.get_running_loop()
            except RuntimeError:
                pass
            if loop and loop.is_running():
                asyncio.run_coroutine_threadsafe(
                    adapter.send(chat_id, text), loop
                ).result(timeout=30)
            else:
                asyncio.run(adapter.send(chat_id, text))
            return True
        except Exception as exc:
            logger.warning("_sync_send_via_adapter failed: %s", exc)
            continue
    logger.warning("No adapter with send() available")
    return False


def push_reminder_safe(adapters: list[Any], reminder: dict[str, Any]) -> None:
    def _run() -> None:
        import os
        from butler.defaults.env_defaults import OWNER_WECHAT_ID_DEFAULT


        text = f"⏰ 提醒:{reminder.get('message', '')}\n(设定时间:{reminder.get('due_human', '')})"
        chat_id = os.getenv("BUTLER_OWNER_WECHAT_ID", OWNER_WECHAT_ID_DEFAULT)
        if not chat_id:
            logger.warning(
                "BUTLER_OWNER_WECHAT_ID not set, reminder not pushed: %s",
                reminder.get("id"),
            )
            return
        if durable_outbox_enabled():
            entry_id = enqueue_outbox_message(chat_id, text, kind="reminder")
            if sync_send_via_adapter_loud(adapters, chat_id, text):
                mark_outbox_sent(entry_id)
                logger.info("Reminder sent+marked via outbox: %s", reminder.get("id"))
            else:
                logger.warning(
                    "Reminder enqueued but send failed (will retry on replay): %s",
                    reminder.get("id"),
                )
        elif sync_send_via_adapter_loud(adapters, chat_id, text):
            logger.info("Reminder fired (direct): %s", reminder.get("id"))
        else:
            logger.warning("Reminder direct send failed: %s", reminder.get("id"))

    safe_best_effort(_run, label="runner.reminder_push", default=None)


def poll_due_reminders_safe() -> list[dict[str, Any]]:
    def _run() -> list[dict[str, Any]]:

        fired = poll_due_reminders()
        return fired if isinstance(fired, list) else []

    result = safe_best_effort(_run, label="runner.reminder_poll", default=[])
    return result if isinstance(result, list) else []


def replay_outbox_entry_safe(
    adapters: list[Any],
    entry: dict[str, Any],
) -> bool:
    """Replay one outbox entry; return True if sent and marked."""

    chat_id = entry.get("chat_id", "")
    body = entry.get("body", "")
    entry_id = entry.get("entry_id", "")
    if not chat_id or not body:
        return False
    if not sync_send_via_adapter_loud(adapters, chat_id, body):
        logger.warning("Outbox replay send failed for entry %s", entry_id)
        return False
    mark_outbox_sent(entry_id)
    return True


def replay_pending_outbox_safe(adapters: list[Any]) -> tuple[int, int]:
    """Return (sent_count, total_pending)."""
    def _run() -> tuple[int, int]:

        if not durable_outbox_enabled():
            return 0, 0
        pending = list_pending_outbox()
        if not pending:
            return 0, 0
        sent = 0
        for entry in pending:
            if replay_outbox_entry_safe(adapters, entry):
                sent += 1
        return sent, len(pending)

    result = safe_best_effort(_run, label="runner.outbox_replay", default=(0, 0))
    if isinstance(result, tuple) and len(result) == 2:
        return int(result[0]), int(result[1])
    return 0, 0