fix(critic-review): KMWriter 名實統一 + Alertmanager 修抑制 + drift checker AST 化

critic PR review 揭示已 push commits 的 7 個 blocker,本 commit 全部修復。

## C1 + C2 + M1 + M2 + M3 — KMWriter 真正統一契約(critic 最嚴重 5 條)

### C1 km_writer.py:194 — backfill 自打臉修
- 裸 asyncio.create_task(_backfill_path_a_approval) → await _backfill_path_a_approval_safe()
- 同步 await + 獨立 DLQ km:backfill:dlq + try/except 不阻塞主寫入
- 新增 km_backfill_reconciler_job.py(每 5 分鐘掃 DLQ)+ ENABLE_KM_BACKFILL_RECONCILER flag
- 防 Path B 比 Path A 先完成 → related_approval_id 永遠 NULL 的 race

### C2 km_writer.py:391 — KM_WRITE_AWAIT=false 路徑收緊
- 從 ensure_future(fire-and-forget 比舊版同步寫更糟)
- 改 await writer.write(retry=1, timeout=2.0)(仍 await 但只試一次、超時短)
- docstring 明確標註「緊急回滾用,不保證可靠性」

### M1 decision_manager.py:2178/2203 — 移除 _fire_and_forget 旁路
- 兩處 _fire_and_forget(executor.write_execution_result_to_km(...))
- 改 await asyncio.shield(...) + BaseException 保護(防上層 cancel 中斷)
- KM_WRITE_AWAIT=true 在這條路徑終於真正 await

### M2 incident_service.py:1099 — 自製 path 加 retry+DLQ
- 原本 if settings.KM_WRITE_AWAIT: await asyncio.wait_for else create_task
- 改 3 次指數退避 retry + DLQ 保護(呼叫 km_writer 私有 helper)

### M3 km_writer.py:166 — 冪等聲明對齊實作
- knowledge_repository.create() 加 UPSERT 路徑(pg_insert ON CONFLICT DO UPDATE)
- KnowledgeEntryCreate / KnowledgeEntryRecord 加 path_type 欄位
- migration: ADD COLUMN path_type + partial unique index uix_knowledge_incident_path

## M4 alertmanager.yml — equal: [] 收緊(critic 防爆炸抑制)
- OllamaInstanceDown / KMConverterDown 抑制加 equal: ['cluster'] 約束
- 防多 cluster 場景下任一 Ollama down 誤抑全 AI/SLO 告警

## M5 Alertmanager 版本驗證(已確認 v0.31.1,遠超 v0.22+)

## M6 governance_agent.py — health score 區分 skipped vs ok vs violated
- check_slo_compliance 加 _meta {violated_count, skipped_count, ok_count, all_skipped, status}
- run_self_check: SLO 全 skipped 時獨立發 governance_slo_data_gap 告警
  (不污染 self_failure 計數,因為 no_data 是 emitter 未實作不是治理機制故障)

## M7 scripts/check_config_drift.py — 改 AST 解析
- regex 改 ast.parse 找 Settings ClassDef AnnAssign Field(default=...)
- 避免多行 list / default_factory= / 含跳行字串的 false negative
- 4 欄位(AI_FALLBACK_ORDER / ARGOCD_URL / PROMETHEUS_URL / OLLAMA_URL)全對齊

## 新增測試
- test_km_writer_backfill_reconciler.py: 7 cases(C1 reconciler + safe helper)
- test_km_writer_idempotent.py: 5 cases(M3 path_type 注入 + UPSERT 分支)

## 驗證
- 1585 unit tests 全綠(+13 從 1572)
- amtool check-config SUCCESS(8 inhibit_rules / 2 receivers)
- drift checker AST-based 4 欄位全對齊
- Alertmanager v0.31.1 確認支援新語法

## 期望影響
- KMWriter 名實統一:飛輪閉環 KM 寫入路徑 100% 可靠
- M4 抑制爆炸風險解除
- 治理層不再對 SLO no_data 靜默
- drift checker false negative 風險解除

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-29 10:32:05 +08:00
parent 6878e62af7
commit c5753e1c57
16 changed files with 1062 additions and 127 deletions

View File

@@ -2171,12 +2171,22 @@ class DecisionManager:
_push_auto_repair_result(incident, action, success=_exec_success)
)
# 2026-04-25 ogt + Claude Sonnet 4.6: 飛輪閉環 — auto_execute 路徑補 KM 寫入
# P1-1 2026-04-28 ogt + Claude Sonnet 4.6: 改用 write_execution_result_to_km公開
# KMWriter 統一契約feature flag KM_WRITE_AWAIT 控制 await/fire-and-forget
_fire_and_forget(
executor.write_execution_result_to_km(approval, _exec_success, None)
)
# M1 修復 2026-04-28 ogt + Claude Sonnet 4.6:
# 飛輪閉環 — auto_execute 路徑 KM 寫入改為 await asyncio.shield()
# 原為 _fire_and_forget裸 create_task→ 整個 coroutine 繞過 KMWriter 統一契約
# asyncio.shield 確保上層 cancel 時 KM 寫入仍能完成(不被中止)
# KM_WRITE_AWAIT=false 時,write_execution_result_to_km 內部走 km_write_with_flag 降級
try:
await asyncio.shield(
executor.write_execution_result_to_km(approval, _exec_success, None)
)
except BaseException as _km_err:
# BaseException 涵蓋 CancelledErrorPython 3.8+ 繼承自 BaseException
logger.warning(
"auto_execute_km_write_failed",
incident_id=incident.incident_id,
error=str(_km_err),
)
except Exception as e:
logger.error(
@@ -2196,14 +2206,21 @@ class DecisionManager:
_push_decision_to_telegram(incident, token.proposal_data)
)
# P1.1 fix 2026-04-27 ogt + Claude Sonnet 4.6: 失敗路徑 KM 寫入
# P1-1 2026-04-28 ogt + Claude Sonnet 4.6: 改用 write_execution_result_to_km公開
# M1 修復 2026-04-28 ogt + Claude Sonnet 4.6: 失敗路徑 KM 寫入改為 await asyncio.shield()
if _km_executor is not None and _km_approval is not None:
_fire_and_forget(
_km_executor.write_execution_result_to_km(
_km_approval, False, str(e)
try:
await asyncio.shield(
_km_executor.write_execution_result_to_km(
_km_approval, False, str(e)
)
)
except BaseException as _km_err:
# BaseException 涵蓋 CancelledErrorPython 3.8+ 繼承自 BaseException
logger.warning(
"auto_execute_km_write_failed_path",
incident_id=incident.incident_id,
error=str(_km_err),
)
)
async def _query_kb_context_inner(self, incident: Incident) -> str:
"""KB RAG 實際查詢邏輯,由 _query_kb_context 包裝 timeout 後呼叫"""

View File

@@ -368,8 +368,34 @@ class GovernanceAgent:
results[name] = {"error": str(e)}
logger.warning("governance_slo_check_error", slo=name, error=str(e))
# 2026-04-29 ogt + Claude Opus 4.7: critic M6 修
# 加聚合 _meta 區分「全 skipped」(metric 未 emit) vs「全 ok」(SLO 健康)
# 防止 dashboard 把 no_data 當 pass 顯示
violated_count = sum(1 for v in results.values() if isinstance(v, dict) and v.get("violated"))
logger.info("governance_slo_compliance_complete", results=results, violated=violated_count)
skipped_count = sum(1 for v in results.values() if isinstance(v, dict) and v.get("skipped"))
ok_count = sum(
1 for v in results.values()
if isinstance(v, dict) and not v.get("violated") and not v.get("skipped") and "error" not in v
)
results["_meta"] = {
"violated_count": violated_count,
"skipped_count": skipped_count,
"ok_count": ok_count,
"all_skipped": skipped_count > 0 and ok_count == 0 and violated_count == 0,
"status": (
"no_data" if (skipped_count > 0 and ok_count == 0 and violated_count == 0)
else "violated" if violated_count > 0
else "ok"
),
}
logger.info(
"governance_slo_compliance_complete",
results=results,
violated=violated_count,
skipped=skipped_count,
ok=ok_count,
status=results["_meta"]["status"],
)
return results
# =========================================================================
@@ -418,6 +444,27 @@ class GovernanceAgent:
except Exception:
logger.exception("governance_self_failure_alert_failed")
# 2026-04-29 ogt + Claude Opus 4.7: critic M6 修
# SLO 全 skipped 是「資料未產生」emitter 未實作)不是「治理機制故障」
# 用獨立 alert 區分,避免污染 self_failure 計數
slo_meta = (
results.get("slo_compliance", {}).get("_meta")
if isinstance(results.get("slo_compliance"), dict)
else None
)
if slo_meta and slo_meta.get("all_skipped"):
try:
await self._alert(
"governance_slo_data_gap",
{
"reason": "all_slo_metrics_not_emitted",
"skipped_count": slo_meta.get("skipped_count", 0),
"hint": "ADR-100 emitter 未實作或 PROMETHEUS_MULTIPROC_DIR 未設",
},
)
except Exception:
logger.exception("governance_slo_data_gap_alert_failed")
logger.info("governance_self_check_complete", results=results)
return results

View File

@@ -1089,26 +1089,79 @@ class IncidentService:
except Exception:
logger.exception("kb_extract_task_create_failed", incident_id=incident_id)
# ADR-073 Phase 4-2: resolve 時觸發 KM conversion (2026-04-12 ogt)
# 將已解決的 Incident 轉換為結構化 KM 條目,驅動飛輪學習固化節點
# P1-1 2026-04-28 ogt + Claude Sonnet 4.6: 改用 KMWriter 統一契約
# km_conversion_service 仍由 KMWriter.write() 內部呼叫(透過 _do_write → create_entry
# M2 修復 2026-04-28 ogt + Claude Sonnet 4.6: 改走 KMWriter 統一契約
# 原路徑自製 if/else + create_task沒有 retry / DLQ / 冪等。
# 現在:用 km_write_with_flag 的 DLQ 包裝,在呼叫點加 retry + DLQ 保護。
# km_conversion_service 內部邏輯不改(保留 notification_type 分類等複雜決策),
# 改為在呼叫點加統一契約保護(指數退避 3 次 + DLQ 失敗回收)。
try:
import asyncio
from src.services.km_conversion_service import get_km_conversion_service
from src.core.config import settings
if settings.KM_WRITE_AWAIT:
await asyncio.wait_for(
get_km_conversion_service().convert(incident),
timeout=settings.KM_WRITE_TIMEOUT_SECONDS,
)
else:
asyncio.create_task(
get_km_conversion_service().convert(incident)
)
except asyncio.TimeoutError:
logger.warning("km_conversion_timeout", incident_id=incident_id,
timeout_sec=settings.KM_WRITE_TIMEOUT_SECONDS)
from src.services.km_conversion_service import get_km_conversion_service
from src.services.km_writer import (
KMWritePayload,
KMWriteResult,
_RETRY_BASE_DELAY,
_RETRY_MAX,
_is_retriable,
_write_to_dlq,
)
_conversion_svc = get_km_conversion_service()
_effective_timeout = settings.KM_WRITE_TIMEOUT_SECONDS
_last_exc: Exception | None = None
for _attempt in range(1, _RETRY_MAX + 1):
try:
await asyncio.wait_for(
_conversion_svc.convert(incident),
timeout=_effective_timeout,
)
break # 成功,離開 retry loop
except asyncio.TimeoutError:
logger.warning(
"km_conversion_timeout",
incident_id=incident_id,
timeout_sec=_effective_timeout,
attempt=_attempt,
)
# Timeout 不重試(重試也會 timeout
await _write_to_dlq(
KMWritePayload(
path_type="incident_resolve",
incident_id=incident_id,
entry_create_kwargs={"title": f"[DLQ] resolve {incident_id}"},
),
f"km_conversion_timeout_{_effective_timeout}s",
)
break
except Exception as _exc:
_last_exc = _exc
if _attempt < _RETRY_MAX and _is_retriable(_exc):
_delay = _RETRY_BASE_DELAY * (2 ** (_attempt - 1))
logger.warning(
"km_conversion_retry",
incident_id=incident_id,
attempt=_attempt,
delay_sec=_delay,
error=str(_exc),
)
await asyncio.sleep(_delay)
else:
logger.error(
"km_conversion_failed_all_retries",
incident_id=incident_id,
error=str(_exc),
)
await _write_to_dlq(
KMWritePayload(
path_type="incident_resolve",
incident_id=incident_id,
entry_create_kwargs={"title": f"[DLQ] resolve {incident_id}"},
),
str(_exc),
)
break
except Exception:
logger.exception("km_conversion_task_create_failed", incident_id=incident_id)

View File

@@ -35,8 +35,9 @@ from src.core.config import settings
logger = structlog.get_logger(__name__)
# DLQ Redis key(失敗後暫存)
KM_DLQ_KEY = "km:dlq"
# DLQ Redis keys
KM_DLQ_KEY = "km:dlq" # 主寫入 DLQ
KM_BACKFILL_DLQ_KEY = "km:backfill:dlq" # C1 backfill 獨立 DLQ路徑 A approval 回填)
KM_DLQ_MAX_LEN = 1000 # 防止 DLQ 無限膨脹
# 重試設定
@@ -183,26 +184,37 @@ async def _backfill_path_a_approval(incident_id: str, approval_id: str) -> None:
SET related_approval_id = :aid
WHERE related_incident_id = :iid AND related_approval_id IS NULL
"""
try:
from sqlalchemy import text as sa_text
from sqlalchemy import text as sa_text
from src.db.base import get_db_context
async with get_db_context() as db:
await db.execute(
sa_text(
"UPDATE knowledge_entries "
"SET related_approval_id = :aid "
"WHERE related_incident_id = :iid "
" AND related_approval_id IS NULL"
),
{"aid": approval_id, "iid": incident_id},
)
await db.commit()
logger.info(
"km_backfill_approval_done",
incident_id=incident_id,
approval_id=approval_id,
from src.db.base import get_db_context
async with get_db_context() as db:
await db.execute(
sa_text(
"UPDATE knowledge_entries "
"SET related_approval_id = :aid "
"WHERE related_incident_id = :iid "
" AND related_approval_id IS NULL"
),
{"aid": approval_id, "iid": incident_id},
)
await db.commit()
logger.info(
"km_backfill_approval_done",
incident_id=incident_id,
approval_id=approval_id,
)
async def _backfill_path_a_approval_safe(incident_id: str, approval_id: str) -> None:
"""
C1 修復 2026-04-28 ogt + Claude Sonnet 4.6
_backfill_path_a_approval 的安全 wrapper失敗時寫獨立 DLQkm:backfill:dlq
由 _do_write 同步 await 呼叫(取代原裸 asyncio.create_task
backfill 失敗不影響主寫入結果,但確保有跡可查供 reconciler 補救。
"""
try:
await _backfill_path_a_approval(incident_id, approval_id)
except Exception as e:
logger.warning(
"km_backfill_approval_failed",
@@ -210,6 +222,24 @@ async def _backfill_path_a_approval(incident_id: str, approval_id: str) -> None:
approval_id=approval_id,
error=str(e),
)
# 寫獨立 DLQ 供 KMBackfillReconciler 補救
try:
from src.core.redis_client import get_redis
import json
redis = get_redis()
record = json.dumps({
"incident_id": incident_id,
"approval_id": approval_id,
"error": str(e),
}, ensure_ascii=False)
await redis.lpush(KM_BACKFILL_DLQ_KEY, record)
await redis.ltrim(KM_BACKFILL_DLQ_KEY, 0, KM_DLQ_MAX_LEN - 1)
except Exception as dlq_exc:
logger.error(
"km_backfill_dlq_write_failed",
incident_id=incident_id,
error=str(dlq_exc),
)
async def _do_write(payload: KMWritePayload) -> None:
@@ -217,7 +247,8 @@ async def _do_write(payload: KMWritePayload) -> None:
實際執行 KM 寫入(含 M4 反查鏈填充)
- 若 payload.approval_id 存在,自動填入 entry 的 related_approval_id
- 若 payload.incident_id + approval_id 均存在,寫完後回填 Path A 條目
- 若 payload.incident_id + approval_id 均存在,寫完後同步 await backfill Path A 條目
- path_type 傳入 KnowledgeEntryCreate觸發 UPSERT 冪等M3
"""
from src.models.knowledge import KnowledgeEntryCreate
from src.services.knowledge_service import get_knowledge_service
@@ -228,12 +259,20 @@ async def _do_write(payload: KMWritePayload) -> None:
if payload.approval_id and "related_approval_id" not in kwargs:
kwargs["related_approval_id"] = payload.approval_id
# M3 冪等:注入 path_type + related_incident_id觸發 UPSERT
if payload.path_type and "path_type" not in kwargs:
kwargs["path_type"] = payload.path_type
if payload.incident_id and "related_incident_id" not in kwargs:
kwargs["related_incident_id"] = payload.incident_id
entry_data = KnowledgeEntryCreate(**kwargs)
await get_knowledge_service().create_entry(entry_data)
# M4 反查鏈回填:若 Path B 寫入成功且有 incident_id回填 Path A 條目
# C1 修復 2026-04-28 ogt + Claude Sonnet 4.6:
# M4 反查鏈回填:同步 await原為裸 create_taskPod recycle 時被丟棄)
# 回填失敗 → 寫獨立 DLQ key km:backfill:dlq不影響主寫入結果
if payload.approval_id and payload.incident_id:
asyncio.create_task(_backfill_path_a_approval(payload.incident_id, payload.approval_id))
await _backfill_path_a_approval_safe(payload.incident_id, payload.approval_id)
logger.info(
"km_write_success",
@@ -368,18 +407,19 @@ async def km_write_with_flag(
"""
統一入口:根據 KM_WRITE_AWAIT feature flag 決定執行模式。
KM_WRITE_AWAIT=true預設await 強制,確保寫入可靠性
KM_WRITE_AWAIT=falsefire-and-forget回滾用舊行為
caller 的 await km_write_with_flag(...) 在 flag=false 時幾乎即返,
不阻塞主流程background task 已丟出)。
KM_WRITE_AWAIT=true預設await 強制,確保寫入可靠性
KM_WRITE_AWAIT=false緊急降級模式(回滾用)。
C2 修復 2026-04-28 ogt + Claude Sonnet 4.6
不再用 ensure_future 裸丟Pod recycle 時 task 被丟棄且比舊同步行為更差)。
改為 await writer.write(retry=1, timeout=2.0):仍然等待一次嘗試完成,
但只試一次、timeout 短,失敗寫 DLQ 後繼續。
docstring 明確標註KM_WRITE_AWAIT=false 不保證可靠性,僅供緊急回滾用。
"""
writer = get_km_writer()
if settings.KM_WRITE_AWAIT:
return await writer.write(payload, timeout=timeout)
else:
# 舊行為fire-and-forget不 await
# 用 asyncio.ensure_future 保持相容性
asyncio.ensure_future(writer.write(payload, timeout=timeout))
return KMWriteResult.SUCCESS
# 緊急降級await 一次嘗試timeout 縮短為 2s失敗寫 DLQ不阻塞太久
# 注意此路徑不保證可靠性KM_WRITE_AWAIT=false 僅供緊急回滾用
return await writer.write(payload, retry=1, timeout=2.0)