Files
awoooi/docs/superpowers/specs/2026-05-07-F1-escalate-close-plan.md
Your Name cfb866d055
Some checks failed
Ansible Lint / lint (push) Successful in 35s
CD Pipeline / tests (push) Failing after 13s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Code Review / ai-code-review (push) Failing after 11s
feat(governance): add agent market automation surfaces
2026-06-04 21:50:55 +08:00

13 KiB
Raw Blame History

F1 規劃 — Escalate 路徑同步 close incident24h gate 後 deploy

2026-05-07 ogt + Claude Sonnet 4.6

對應 INC-20260507-99ADF2 飛輪斷流根因 #3emergency_escalation 兩條路徑dedup hit / 一般 escalate都不 close incident導致 stuck 線性增長。


0. 部署節奏(統帥決議)

F1 規劃完成now
    ↓
F2 觀察 24hgate
    ↓
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.pydebugger 報告 #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.py2 條路徑dedup hit + 一般 escalate+ 1 個 helper
  • apps/api/tests/test_emergency_escalation_close_incident.py新檔3 個 test case

範圍:1 服務檔 + 1 測試檔(新建)


2. F1 Patch 清單(按行號)

Patch Aemergency_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 飛輪斷流根因 #3emergency_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-safeclose 失敗只 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 incidentF2 已加 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 Bdedup hit 仍 closeline 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 incident566+ 增長根因 #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 完成後 closeline 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 Ddrift 路徑同步處理(可選,建議納入)

escalate_drift_auto_adopt_blockedline 115-207也有相同模式但目前 drift 用的是 report_id 不是 incident_iddrift 不存在 IncidentRecordF1 範圍內不動 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 必須被 closeresolution_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 方案 Bresolve_incidentresolution_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 已上線冪等 guardline 1106test_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 失敗仍繼續 closefail-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
  • F3webhooks.py LLM 全失敗 fallback pathdebugger 報告 鏈 A #2
  • F4extract_affected_services 空集合 fallbackdebugger 報告 鏈 B #4
  • resolution_type 字串值常數化到 incident_service.py,避免後續 typo 漂移
  • drift escalate 路徑(escalate_drift_auto_adopt_blocked)類似處理 — 但 drift 用 report_id 不是 incident_id要另案評估