# F1 規劃 — Escalate 路徑同步 close incident（24h gate 後 deploy）

> 2026-05-07 ogt + Claude Sonnet 4.6
>
> 對應 INC-20260507-99ADF2 飛輪斷流根因 #3：emergency_escalation 兩條路徑（dedup hit / 一般 escalate）都不 close incident，導致 stuck 線性增長。

---

## 0. 部署節奏（統帥決議）

```
F1 規劃完成（now）
    ↓
F2 觀察 24h（gate）
    ↓
4 條驗收條件全過 → F1 落 patch + commit + push → CD deploy
    └─ 任一條不過 → 暫不 deploy F1，先做 Minor #3 方案 B
```

---

## 1. 為什麼 F1 不擴大範圍

### 不改的東西（避免擴散）

- ❌ **不動 `IncidentOutcome` 模型**：加 `outcome_type` 欄位會擴散到 DB schema + repository + 所有讀取方
- ❌ **不動 `resolve_incident` 簽名**：line 1078 已有 `resolution_type: str = "manual"` 參數，直接擴展 string 值即可
- ❌ **不動 `webhooks.py`**：debugger 報告 #B7 已確認 `webhooks.py:1862-1891` GUARDRAIL_BLOCKED 走的是 `escalate_auto_repair_unavailable`，會自動受惠 F1
- ❌ **不動 Codex 5/6 設計區**：`flywheel_stats_service.py` / `heartbeat_report_service.py` / `auto_repair_service.record_auto_repair()` / `metrics_repository.UPPER(status)`

### 動的東西（最小集合）

- ✅ `apps/api/src/services/emergency_escalation_service.py`：2 條路徑（dedup hit + 一般 escalate）+ 1 個 helper
- ✅ `apps/api/tests/test_emergency_escalation_close_incident.py`：新檔，3 個 test case

範圍：**1 服務檔 + 1 測試檔（新建）**

---

## 2. F1 Patch 清單（按行號）

### Patch A：`emergency_escalation_service.py` 加 close helper

**插入位置**：line 224（檔案末尾，`_dedup_first_send` 之後）

```python
async def _close_incident_with_resolution_type(
    incident_id: str,
    *,
    resolution_type: str,
    reason: str,
) -> None:
    """F1 (2026-05-07 ogt + Claude Sonnet 4.6) — 補 escalate 路徑的 close 鏈。

    INC-20260507-99ADF2 飛輪斷流根因 #3：emergency_escalation 兩條路徑都不
    close incident → 同 fingerprint 重複觸發 → stuck 線性增長（30s 漲 1）。

    Why timeline-based outcome 而非 IncidentOutcome 欄位：
    - IncidentOutcome 是「AI 學習的關鍵回饋」schema，加 outcome_type 會擴散
      到 DB / repository / 所有讀取方
    - resolve_incident 的 resolution_type 字串已是現成擴展點（已有 "manual"
      / "timeout"），加 "auto_repair_unavailable" / "..._dedup_suppressed"
      即可
    - timeline event 是 SRE 觀察渠道，標記「為何結案」最直接
    - Codex 5/6 source of truth 是 auto_repair_executions，不會被 close 鏈
      污染（resolve_incident 不寫此表）

    fail-safe：close 失敗只 warning log，不讓 escalate 主流程失敗。
    """
    try:
        from src.services.approval_db import get_timeline_service
        from src.services.incident_service import get_incident_service

        # 先寫 timeline event 標記結案原因（給 SRE 觀察 / incident report 用）
        try:
            await get_timeline_service().add_event(
                event_type="exec",
                status="skipped",
                title=f"Incident closed: {resolution_type}",
                description=reason[:500],
                actor="auto_repair",
                actor_role="emergency_escalation",
                incident_id=incident_id,
            )
        except Exception as timeline_exc:
            logger.warning(
                "incident_close_timeline_event_failed",
                incident_id=incident_id,
                resolution_type=resolution_type,
                error=str(timeline_exc),
            )

        # 再 resolve incident（F2 已加 RESOLVED 冪等 guard，重複呼叫 idempotent）
        await get_incident_service().resolve_incident(
            incident_id,
            resolution_type=resolution_type,
        )
        logger.info(
            "incident_closed_after_escalation",
            incident_id=incident_id,
            resolution_type=resolution_type,
        )
    except Exception as exc:
        logger.warning(
            "incident_close_after_escalation_failed",
            incident_id=incident_id,
            resolution_type=resolution_type,
            error=str(exc),
        )
```

### Patch B：dedup hit 仍 close（line 38-45）

**改之前**：
```python
if not await _dedup_first_send(dedup_key, ttl=86400, event="auto_repair"):
    logger.info(
        "auto_repair_escalation_dedup_skipped",
        incident_id=incident_id,
        approval_id=approval_id,
        fingerprint=f"{_alertname_fp}:{_target_fp}",
    )
    return
```

**改之後**：
```python
if not await _dedup_first_send(dedup_key, ttl=86400, event="auto_repair"):
    logger.info(
        "auto_repair_escalation_dedup_skipped",
        incident_id=incident_id,
        approval_id=approval_id,
        fingerprint=f"{_alertname_fp}:{_target_fp}",
    )
    # F1 (2026-05-07): dedup 跳過 Telegram 但仍 close incident
    # 否則同 fingerprint 重複觸發都會新增 stuck incident（566+ 增長根因 #3）
    await _close_incident_with_resolution_type(
        incident_id,
        resolution_type="auto_repair_unavailable_dedup_suppressed",
        reason=f"dedup window: {_alertname_fp}:{_target_fp} | reason: {failure_reason}",
    )
    return
```

### Patch C：一般 escalate 完成後 close（line 100-105 之後）

**改之前**：
```python
        logger.warning(
            "auto_repair_emergency_escalated",
            incident_id=incident_id,
            approval_id=approval_id,
            reason=failure_reason,
        )
    except Exception as exc:
        logger.warning(
            "auto_repair_emergency_escalation_failed",
            incident_id=incident_id,
            approval_id=approval_id,
            error=str(exc),
        )
```

**改之後**（在 `logger.warning("auto_repair_emergency_escalated", ...)` 後、`except` 前加）：
```python
        logger.warning(
            "auto_repair_emergency_escalated",
            incident_id=incident_id,
            approval_id=approval_id,
            reason=failure_reason,
        )
        # F1 (2026-05-07): escalate 完成後 close incident（已通知 SRE，不該再卡 INVESTIGATING）
        await _close_incident_with_resolution_type(
            incident_id,
            resolution_type="auto_repair_unavailable",
            reason=failure_reason,
        )
    except Exception as exc:
        logger.warning(
            "auto_repair_emergency_escalation_failed",
            incident_id=incident_id,
            approval_id=approval_id,
            error=str(exc),
        )
```

### Patch D：drift 路徑同步處理（可選，建議納入）

`escalate_drift_auto_adopt_blocked`（line 115-207）也有相同模式但目前 drift 用的是 report_id 不是 incident_id（drift 不存在 IncidentRecord）。**F1 範圍內不動 drift 路徑**，列入 follow-up 評估。

---

## 3. Test 規劃

### 新建 `apps/api/tests/test_emergency_escalation_close_incident.py`

```python
"""
F1 回歸測試 — escalate 兩條路徑都 close incident。

對應 INC-20260507-99ADF2 飛輪斷流根因 #3。
"""

from unittest.mock import AsyncMock, patch

import pytest

from src.services.emergency_escalation_service import escalate_auto_repair_unavailable


@pytest.fixture
def mock_dependencies(monkeypatch):
    """Mock 所有外部依賴（Redis / Telegram / DB），只測 close 鏈是否觸發。"""
    mocks = {
        "redis_set": AsyncMock(return_value=True),  # dedup pass
        "telegram_send": AsyncMock(),
        "op_log_append": AsyncMock(),
        "timeline_add_event": AsyncMock(),
        "resolve_incident": AsyncMock(),
    }
    # 依實際 DI 結構 monkeypatch
    return mocks


@pytest.mark.asyncio
async def test_escalate_resolves_incident_after_telegram_sent(mock_dependencies):
    """一般 escalate 完成後，incident 必須被 close（resolution_type=auto_repair_unavailable）。"""
    # ... setup mock ...
    await escalate_auto_repair_unavailable(
        incident_id="INC-F1-001",
        approval_id=None,
        alert_type="HostDiskUsageHigh",
        target_resource="node-exporter-110",
        namespace="monitoring",
        failure_reason="LLM timeout",
        attempted_actions="ssh_diagnose -> blocked",
    )
    mock_dependencies["resolve_incident"].assert_awaited_once_with(
        "INC-F1-001",
        resolution_type="auto_repair_unavailable",
    )


@pytest.mark.asyncio
async def test_escalate_dedup_hit_still_closes_incident(mock_dependencies):
    """dedup hit 跳過 Telegram，但 incident 仍須 close（避免 stuck 累積）。"""
    mock_dependencies["redis_set"] = AsyncMock(return_value=False)  # dedup hit
    # ... setup ...
    await escalate_auto_repair_unavailable(
        incident_id="INC-F1-002",
        approval_id=None,
        alert_type="HostDiskUsageHigh",
        target_resource="node-exporter-110",
        namespace="monitoring",
        failure_reason="dup",
        attempted_actions="dup",
    )
    mock_dependencies["resolve_incident"].assert_awaited_once_with(
        "INC-F1-002",
        resolution_type="auto_repair_unavailable_dedup_suppressed",
    )
    # Telegram 不應被呼叫
    mock_dependencies["telegram_send"].assert_not_called()


@pytest.mark.asyncio
async def test_escalate_close_failure_does_not_break_main_flow(mock_dependencies):
    """close incident 失敗時，escalate 主流程仍應 return None（不 raise）。"""
    mock_dependencies["resolve_incident"].side_effect = RuntimeError("redis down")
    # 驗證 escalate 不會 raise
    result = await escalate_auto_repair_unavailable(
        incident_id="INC-F1-003",
        approval_id=None,
        alert_type="HostDiskUsageHigh",
        target_resource="node-exporter-110",
        namespace="monitoring",
        failure_reason="x",
        attempted_actions="y",
    )
    assert result is None  # 主流程 return None
```

---

## 4. 24h Gate 驗收條件（F2 部署 24h 後檢查）

| # | 驗收項 | 量化判定 | 通過 → F1 deploy |
|---|--------|---------|-----------------|
| **1** | NO_ACTION resolve 是否 1:1 接通 | grep prod log 計數 `incident_resolved_after_no_action_execution` ÷ `background_execution_noop` ∈ [0.95, 1.05] | ✅ |
| **2** | stuck 增長是否轉平 | `awoooi_flywheel_incidents_stuck` 24h 增長率從 30s/+1 → ≤ 5/hr | ✅ |
| **3** | SRE 群 NO_ACTION postmortem 量 | ≤ 20 份/24h | ✅ |
| **4** | 無 NEW regression | `incident_resolve_after_no_action_execution_failed` warning 量 ≤ NO_ACTION 總量的 1% | ✅ |

**任一條不過的處置**：
- 條件 1 不過：F2 沒生效，先排查 `path="no_action"` log 是否寫入 / monkeypatch 是否誤抓
- 條件 2 不過：除了 NO_ACTION 還有其他 stuck 來源（極可能是 F1 範圍的 escalate path），**反而支持立刻 deploy F1**
- 條件 3 不過（>20 postmortem）：先做 Minor #3 方案 B（給 `resolve_incident` 加 `resolution_type="no_action"` 跳過 postmortem），再評估 F1 時機
- 條件 4 不過：F2 有副作用，先 revert F2 再說

---

## 5. F1 Risk Matrix

| 風險 | 觸發條件 | 影響 | 緩解 |
|------|---------|------|------|
| close 失敗讓 escalate 主流程崩 | `resolve_incident` raise | 沒人通知 SRE | helper 內 try/except 全吞，只 warning log |
| `resolve_incident` 重觸發 postmortem | F2 冪等 guard 失效 | SRE 群被洗版 | F2 已上線冪等 guard（line 1106），test_incident_service_resolve_idempotency 覆蓋 |
| `resolution_type="auto_repair_unavailable_dedup_suppressed"` 字串值改動 | 後續有人改 string | metrics / log filter 失準 | 在 incident_service.py 加常數定義（follow-up） |
| dedup hit close 但 timeline 沒寫 | timeline_service raise | SRE 不知道 dedup 在做什麼 | helper 內 timeline 失敗仍繼續 close（fail-soft） |

整體風險：**Medium**（比 F2 高一階，因為 close 在「LLM 全失敗 + escalate 鏈」這個高風險路徑上）。

---

## 6. 部署後 1h 驗證腳本

```bash
# 1. 確認 image tag 含 F1 commit hash
kubectl -n awoooi-prod get deploy awoooi-api -o jsonpath='{.spec.template.spec.containers[0].image}'

# 2. close 鏈是否觸發
kubectl -n awoooi-prod logs -l app=awoooi-api --since=1h | grep -E "incident_closed_after_escalation|auto_repair_escalation_dedup_skipped" | wc -l

# 3. 驗證 stuck 趨緩
curl -sf https://awoooi.wooo.work/api/v1/stats/summary | jq .incidents_stuck

# 4. 110 Prom 確認 awoooi_flywheel_incidents_stuck 從增長變平
curl -sf 'http://192.168.0.110:9090/api/v1/query?query=delta(awoooi_flywheel_incidents_stuck[1h])'
```

---

## 7. Follow-up（不在 F1 commit 範圍）

- F2 NO_ACTION 路徑也帶 `resolution_type="no_action_observation"` 跟 F1 對齊（看 24h gate 驗收條件 #3）
- F3：webhooks.py LLM 全失敗 fallback path（debugger 報告 鏈 A #2）
- F4：`extract_affected_services` 空集合 fallback（debugger 報告 鏈 B #4）
- 把 `resolution_type` 字串值常數化到 `incident_service.py`，避免後續 typo 漂移
- drift escalate 路徑（`escalate_drift_auto_adopt_blocked`）類似處理 — 但 drift 用 report_id 不是 incident_id，要另案評估
