# ADR-038: OpenClaw 推理引擎併發治理架構

**狀態**: 已批准
**日期**: 2026-03-29 14:05 (台北時間)
**決策者**: 統帥 + Antigravity (首席架構師)
**觸發事件**: 沙盤推演發現 Thundering Herd 可導致 Ollama OOM 崩潰

---

## 問題陳述

### 場景：網路閃斷後的告警雪崩

```
.188 網路閃斷 3 分鐘 → 恢復後：
  Alertmanager 積壓 N 個告警同時倒入 AWOOOI Webhook
  Sentry 積壓 N 個錯誤同時倒入 AWOOOI Webhook
  Signal Worker 拉起 N 個 asyncio.Task 同時呼叫 OpenClaw
  Ollama/GPU 收到 N 個並發 LLM 推理請求
  → VRAM/RAM OOM → Ollama 進程崩潰
  → Circuit Breaker 觸發，但系統大腦已死
```

**核心問題**：Circuit Breaker 只防「失敗」，不防「超載」。

---

## 決策：Semaphore + Circuit Breaker 雙層保護

### 架構設計

```
告警事件
  │
  ▼
Signal Worker (asyncio.Task)
  │
  ▼
┌─────────────────────────────────────────┐
│  Layer 1: Circuit Breaker               │
│  - 5 次連續失敗 → OPEN（60 秒冷卻）     │
│  - OPEN 狀態：立即返回 None，不等待      │
└──────────────────┬──────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────┐
│  Layer 2: Concurrency Semaphore         │
│  - 全域最多 3 個並發 LLM 推理           │
│  - 超過限制的請求：排隊等待（非拒絕）   │
└──────────────────┬──────────────────────┘
                   │
                   ▼
             OpenClaw / Ollama
```

### 為何 max_concurrent = 3？

| 考量 | 說明 |
|------|------|
| Ollama CPU 模式 | .188 純 CPU（無 GPU），每次推理佔 1-2 CPU Core |
| .188 主機規格 | 共享 CPU，同時跑 PostgreSQL + Redis + SigNoz |
| 安全邊界 | 3 = 不超過 .188 可用 CPU 的 60%（保留 40% 給其他服務） |
| 排隊不拒絕 | 超出的請求排隊等待，確保不遺失重要告警 |

---

## 實作規範

### 核心實作（`apps/api/src/core/circuit_breaker.py`）

```python
"""
OpenClaw 推理引擎保護機制
=========================
ADR-038: 雙層保護策略
- Layer 1: Circuit Breaker（防失敗傳播）
- Layer 2: Concurrency Semaphore（防 Thundering Herd）

遵循 leWOOOgo 積木化鐵律：
- 此模組屬於 core/ 基礎設施層
- 不依賴任何 Service 層
- 透過 Singleton 提供全域狀態
"""
import asyncio
import time
from enum import Enum
from dataclasses import dataclass, field

import structlog

logger = structlog.get_logger(__name__)


class CircuitState(Enum):
    CLOSED = "closed"       # 正常運作
    OPEN = "open"           # 斷路（快速失敗）
    HALF_OPEN = "half_open" # 試探性恢復


@dataclass
class CircuitBreakerConfig:
    failure_threshold: int = 5       # 連續失敗次數觸發斷路
    timeout_s: float = 60.0          # 斷路後冷卻時間（秒）
    max_concurrent: int = 3          # 最大並發 LLM 推理數


class OpenClawGuard:
    """
    OpenClaw 雙層推理保護門衛

    使用方式：
        guard = get_openclaw_guard()

        if guard.is_circuit_open():
            return None  # 快速失敗

        async with guard.semaphore:  # 排隊等待
            try:
                result = await call_openclaw(...)
                guard.record_success()
                return result
            except Exception:
                guard.record_failure()
                raise
    """

    def __init__(self, config: CircuitBreakerConfig | None = None):
        self.config = config or CircuitBreakerConfig()
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self._opened_at: float | None = None
        # Semaphore 必須在 event loop 中建立
        self._semaphore: asyncio.Semaphore | None = None

    @property
    def semaphore(self) -> asyncio.Semaphore:
        if self._semaphore is None:
            self._semaphore = asyncio.Semaphore(self.config.max_concurrent)
        return self._semaphore

    def is_circuit_open(self) -> bool:
        if self.state == CircuitState.OPEN:
            if time.time() - self._opened_at > self.config.timeout_s:
                self.state = CircuitState.HALF_OPEN
                logger.info("circuit_breaker_half_open")
                return False
            return True
        return False

    def record_success(self) -> None:
        self.failure_count = 0
        if self.state != CircuitState.CLOSED:
            logger.info("circuit_breaker_closed")
        self.state = CircuitState.CLOSED

    def record_failure(self) -> None:
        self.failure_count += 1
        if self.failure_count >= self.config.failure_threshold:
            self.state = CircuitState.OPEN
            self._opened_at = time.time()
            logger.warning(
                "circuit_breaker_opened",
                failure_count=self.failure_count,
                cooldown_s=self.config.timeout_s,
            )

    def get_metrics(self) -> dict:
        return {
            "state": self.state.value,
            "failure_count": self.failure_count,
            "max_concurrent": self.config.max_concurrent,
        }


# 全域 Singleton
_guard: OpenClawGuard | None = None


def get_openclaw_guard() -> OpenClawGuard:
    """取得全域 OpenClaw 保護門衛"""
    global _guard
    if _guard is None:
        _guard = OpenClawGuard()
    return _guard
```

### 呼叫端整合（`sentry_webhook.py` + `signoz_webhook.py`）

```python
from src.core.circuit_breaker import get_openclaw_guard

async def call_openclaw_analyzer(error_context: dict) -> ErrorAnalysisResult | None:
    guard = get_openclaw_guard()

    # Layer 1: Circuit Breaker 快速失敗
    if guard.is_circuit_open():
        logger.warning("openclaw_circuit_open_skip", metrics=guard.get_metrics())
        return None

    # Layer 2: Semaphore 排隊（最多 3 個並發，超出排隊等待）
    async with guard.semaphore:
        try:
            async with httpx.AsyncClient(timeout=60.0) as client:
                response = await client.post(
                    f"{settings.OPENCLAW_BASE_URL}/analyze",
                    json=error_context,
                )
                response.raise_for_status()
                guard.record_success()
                return ErrorAnalysisResult(**response.json())
        except Exception as e:
            guard.record_failure()
            logger.exception("openclaw_call_failed", error=str(e))
            return None
```

---

## 模組化合規驗證

| 項目 | 說明 | 合規狀態 |
|------|------|---------|
| 層次 | `core/` 基礎設施層 | ✅ 合規 |
| 依賴 | 只依賴 stdlib + structlog | ✅ 合規 |
| Singleton | `get_openclaw_guard()` 工廠函數 | ✅ 合規 |
| 協議介面 | `OpenClawGuard` 直接使用（無需 Protocol，因為非跨模組） | ✅ 合規 |
| 測試 | 可透過 `_guard = None` 重置 | ✅ 合規 |

---

## 驗收標準

| 項目 | 通過條件 |
|------|---------|
| Semaphore 排隊 | 第 4 個請求在前 3 個完成前不呼叫 OpenClaw |
| Circuit Breaker 觸發 | 5 次失敗後 `is_circuit_open()` 返回 True |
| 冷卻恢復 | 60 秒後切換到 HALF_OPEN，1 次成功恢復 CLOSED |
| Graceful Degrade | Circuit OPEN 時呼叫返回 None，不拋例外 |

---

## 相關文件

- `docs/proposals/ARCHITECTURAL_RISK_WAR_GAME.md`：風險沙盤推演
- `apps/api/src/api/v1/sentry_webhook.py`：Sentry Webhook 整合
- `apps/api/src/api/v1/signoz_webhook.py`：SignOz Webhook 整合
- ADR-028：Failure Auto-Repair Loop
- ADR-039：全域自動修復熔斷機制
