13 KiB
13 KiB
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-1891GUARDRAIL_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 之後)
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)
改之前:
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
改之後:
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 之後)
改之前:
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 前加):
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
"""
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 驗證腳本
# 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,要另案評估