fix(aiops): fallback and escalate automation blockers
Some checks failed
CD Pipeline / tests (push) Successful in 2m41s
Code Review / ai-code-review (push) Successful in 24s
CD Pipeline / build-and-deploy (push) Successful in 7m51s
CD Pipeline / post-deploy-checks (push) Failing after 2m15s

This commit is contained in:
Your Name
2026-04-30 14:13:57 +08:00
parent 82649c2cbb
commit 80defbed7c
10 changed files with 311 additions and 10 deletions

View File

@@ -54,6 +54,26 @@ def _fire_and_forget(coro) -> asyncio.Task:
return task
def _phase2_fallback_reason(package: Any) -> str | None:
"""Return why a Phase 2 package should continue to Playbook/LLM fallback.
Phase 2 is a deliberation layer, not the final brake. If the debate times
out or produces no actionable recommendation, the flywheel must still try
deterministic Playbook/LLM/expert paths before opening a manual gate.
"""
status = getattr(package, "session_status", None)
status_value = status.value if status and hasattr(status, "value") else str(status or "")
if status_value in {"timeout", "failed"}:
return f"session_{status_value}"
if getattr(package, "all_agents_degraded", False):
return "all_agents_degraded"
action = str(getattr(package, "recommended_action", "") or "").strip()
if not action and getattr(package, "requires_human_approval", False):
return "empty_action_requires_human"
return None
# =============================================================================
# Phase 31 (ADR-067 2026-04-10): Log 異常摘要 — NemoTron deepseek-r1:14b
# =============================================================================
@@ -2455,13 +2475,26 @@ class DecisionManager:
# evidence_snapshot 攜帶進 proposal_data避免 singleton 並發污染
_p2_result = _package_to_proposal_data(package)
_p2_result["_evidence_snapshot_ref"] = p2_snapshot
return _p2_result
_p2_fallback = _phase2_fallback_reason(package)
if not _p2_fallback:
return _p2_result
logger.warning(
"p2_degraded_fallback_to_playbook_llm",
incident_id=incident.incident_id,
reason=_p2_fallback,
confidence=_p2_result.get("confidence"),
blocked_reason=_p2_result.get("blocked_reason", ""),
)
# snapshot 仍為 None → 降級繼續走原路徑(不阻塞)
logger.warning("p2_no_snapshot_fallback", incident_id=incident.incident_id)
if p2_snapshot is None:
logger.warning("p2_no_snapshot_fallback", incident_id=incident.incident_id)
# Phase 7.5: 先嘗試 Playbook 匹配
playbook_result = await self._try_playbook_match(incident)
if playbook_result:
if evidence_snapshot is not None:
playbook_result["_evidence_snapshot_ref"] = evidence_snapshot
return playbook_result
# MCP Phase 4c: Playbook 無命中 → 非同步產生 AI 草稿 Playbook (2026-04-11 Claude Sonnet 4.6)

View File

@@ -0,0 +1,154 @@
"""Emergency escalation service for automation blockers.
Keeps Redis dedup, Telegram fanout, and operation-log writes out of API
routers while giving auto-repair / drift paths a single emergency channel.
"""
from __future__ import annotations
from typing import Any
import structlog
from src.core.config import settings
from src.core.redis_client import get_redis
logger = structlog.get_logger(__name__)
async def escalate_auto_repair_unavailable(
*,
incident_id: str,
approval_id: str,
alert_type: str,
target_resource: str,
namespace: str,
failure_reason: str,
attempted_actions: str,
) -> None:
"""Open an emergency channel when auto repair cannot safely continue."""
dedup_key = f"auto_repair:emergency_escalated:{incident_id}"
if not await _dedup_first_send(dedup_key, ttl=900, event="auto_repair"):
logger.info(
"auto_repair_escalation_dedup_skipped",
incident_id=incident_id,
approval_id=approval_id,
)
return
try:
from src.repositories.alert_operation_log_repository import (
get_alert_operation_log_repository,
)
from src.services.telegram_gateway import get_telegram_gateway
await get_telegram_gateway().send_escalation_card(
incident_id=incident_id,
original_alertname=alert_type or "AutoRepairBlocked",
duration_min=0,
priority=0,
attempted_actions=attempted_actions,
failure_reason=failure_reason,
current_impact=f"target={target_resource or 'unknown'} namespace={namespace or 'unknown'}",
group_chat_id=settings.SRE_GROUP_CHAT_ID or None,
)
await get_alert_operation_log_repository().append(
"EMERGENCY_ESCALATED",
incident_id=incident_id,
approval_id=approval_id,
actor="auto_repair",
action_detail="auto_repair_unavailable_emergency_channel",
success=True,
context={
"alert_type": alert_type,
"target_resource": target_resource,
"namespace": namespace,
"failure_reason": failure_reason,
"attempted_actions": attempted_actions,
},
)
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),
)
async def escalate_drift_auto_adopt_blocked(
*,
report: Any,
reason: str,
interpretation: Any,
) -> None:
"""Notify the emergency channel when drift cannot be auto-adopted safely."""
dedup_key = f"drift:auto_adopt_emergency:{report.report_id}"
if not await _dedup_first_send(dedup_key, ttl=3600, event="drift"):
logger.info("drift_emergency_escalation_dedup_skipped", report_id=report.report_id)
return
try:
from src.services.telegram_gateway import get_telegram_gateway
actionable_count = sum(
1 for item in report.items
if not getattr(item, "is_allowlisted", False)
)
intent = getattr(getattr(interpretation, "intent", None), "value", "unknown")
confidence = getattr(interpretation, "confidence", 0.0) if interpretation else 0.0
risk = getattr(interpretation, "risk", "unknown") if interpretation else "unknown"
await get_telegram_gateway().send_escalation_card(
incident_id=report.report_id,
original_alertname="ConfigDriftAutoAdoptBlocked",
duration_min=0,
priority=0 if report.high_count > 0 else 1,
attempted_actions="drift_interpreter -> auto_adopt_if_safe -> emergency_escalation",
failure_reason=reason,
current_impact=(
f"namespace={report.namespace} high={report.high_count} "
f"medium={report.medium_count} actionable={actionable_count} "
f"intent={intent} confidence={confidence:.0%} risk={risk}"
),
group_chat_id=settings.SRE_GROUP_CHAT_ID or None,
)
logger.warning(
"drift_auto_adopt_emergency_escalated",
report_id=report.report_id,
reason=reason,
high=report.high_count,
medium=report.medium_count,
actionable=actionable_count,
)
except Exception as exc:
logger.warning(
"drift_emergency_escalation_failed",
report_id=report.report_id,
error=str(exc),
)
async def _dedup_first_send(key: str, *, ttl: int, event: str) -> bool:
"""Return True when this is the first escalation in the dedup window."""
try:
redis = get_redis()
return bool(await redis.set(key, "1", ex=ttl, nx=True))
except Exception as exc:
logger.warning(
"emergency_escalation_dedup_failed_open",
key=key,
event=event,
error=str(exc),
)
return True

View File

@@ -131,9 +131,9 @@ class FailoverAlerter:
msg = (
f"*Gemini 每日配額耗盡*\n\n"
f"日期:{date_str}\n"
f"上限:{quota} calls/day\n"
f"當前用量:{current_count}\n"
f"日期:{_escape_md(date_str)}\n"
f"上限:{_escape_md(str(quota))} calls/day\n"
f"當前用量:{_escape_md(str(current_count))}\n"
f"降級目標Nemotron → Claude \\(Gemini 不可用\\)\n\n"
f"進入容災模式至明日 0:00\n"
f"建議檢查是否有異常流量,評估是否升級 Gemini 配額"