fix(alerts): 補齊處置結果與人工通知契約
Some checks failed
CD Pipeline / tests (push) Failing after 45s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Code Review / ai-code-review (push) Successful in 12s

This commit is contained in:
Your Name
2026-05-31 15:41:09 +08:00
parent 03f2abf576
commit 3d8b395032
14 changed files with 888 additions and 75 deletions

View File

@@ -24,8 +24,10 @@ Approval Execution Service - Phase 16 R4.2 瘦身 Router 抽取
"""
import asyncio
import html
import time
from typing import TYPE_CHECKING, Any
from urllib.parse import quote
from uuid import UUID
import structlog
@@ -39,6 +41,7 @@ from src.plugins.mcp.interfaces import MCPToolResult
from src.services.approval_action_classifier import is_no_action_approval_action
from src.services.approval_db import get_approval_service, get_timeline_service
from src.services.executor import ExecutionResult, OperationType, get_executor
from src.services.operator_outcome import build_operator_outcome
from src.services.operation_parser import parse_operation_from_action
if TYPE_CHECKING:
@@ -112,6 +115,12 @@ class ApprovalExecutionService:
"destructive",
"blocked",
)
_NON_REPAIR_EXECUTION_KINDS: tuple[str, ...] = (
"no_action",
"diagnostic",
"parse_failed",
"unsupported_action",
)
@classmethod
def _is_transient_error(cls, error_message: str | None) -> bool:
@@ -136,6 +145,24 @@ class ApprovalExecutionService:
# 瞬態錯誤 → 可重試
return any(kw in lower for kw in cls._TRANSIENT_ERROR_KEYWORDS)
@classmethod
def _execution_kind_for_result(
cls,
operation_type: OperationType,
result: ExecutionResult | None = None,
) -> str:
if operation_type == OperationType.INVESTIGATE:
return "diagnostic"
if operation_type == OperationType.SSH_HOST:
message = str(getattr(result, "message", "") or "").lower()
if "ssh_mcp:ssh_diagnose" in message:
return "diagnostic"
return operation_type.value
@classmethod
def _is_repair_kind(cls, execution_kind: str | None) -> bool:
return str(execution_kind or "").lower() not in cls._NON_REPAIR_EXECUTION_KINDS
async def execute_approved_action(self, approval: ApprovalRequest) -> bool:
"""
背景執行已批准的操作
@@ -245,6 +272,8 @@ class ApprovalExecutionService:
approval.id,
success=True,
execution_kind="no_action",
repair_executed=False,
repair_attempted=False,
)
await timeline.add_event(
event_type="exec",
@@ -256,12 +285,6 @@ class ApprovalExecutionService:
approval_id=str(approval.id),
incident_id=approval.incident_id,
)
# 執行結果 reply 原告警卡片
asyncio.create_task(
self._push_execution_result_to_alert(
approval, success=True, error=None,
)
)
# ADR-090 § aol completed (NO_ACTION 視為成功)
await self._log_aol_completed(
op_id=_aol_op_id,
@@ -307,6 +330,14 @@ class ApprovalExecutionService:
approval_id=str(approval.id),
error=str(_resolve_e),
)
await self._push_execution_result_to_alert(
approval,
success=True,
error=None,
execution_kind="no_action",
repair_executed=False,
repair_attempted=False,
)
return True # NO_ACTION 視為成功完成
# 真解析失敗 (非 NO_ACTION)
@@ -320,6 +351,9 @@ class ApprovalExecutionService:
await service.update_execution_status(
approval.id, success=False,
error_message=f"Could not parse operation type from action: {approval.action[:150]}",
execution_kind="parse_failed",
repair_executed=False,
repair_attempted=False,
)
await timeline.add_event(
event_type="exec",
@@ -356,6 +390,14 @@ class ApprovalExecutionService:
duration_ms=int((time.time() - _aol_started_ms) * 1000),
error_message=f"Could not parse operation type from action: {approval.action[:150]}",
)
await self._push_execution_result_to_alert(
approval,
success=False,
error=f"Could not parse operation type from action: {approval.action[:150]}",
execution_kind="parse_failed",
repair_executed=False,
repair_attempted=False,
)
return False # 解析失敗 → 執行未發生
executor = get_executor()
@@ -441,12 +483,20 @@ class ApprovalExecutionService:
)
attempt += 1
execution_kind = self._execution_kind_for_result(operation_type, result)
repair_kind = self._is_repair_kind(execution_kind)
repair_executed = bool(result.success and repair_kind)
repair_attempted = bool(repair_kind)
# Phase 5: 更新資料庫狀態
# 2026-04-18 ADR-090 L5 P0.2: 失敗時帶上 error_message,寫進 rejection_reason
await service.update_execution_status(
approval.id,
success=result.success,
error_message=None if result.success else (result.error or "(executor 未回傳錯誤)"),
execution_kind=execution_kind,
repair_executed=repair_executed,
repair_attempted=repair_attempted,
)
# Update approval status based on result
@@ -484,12 +534,6 @@ class ApprovalExecutionService:
)
)
# 2026-04-14 Claude Sonnet 4.6: reply_to 原告警卡片顯示執行結果
# auto_approve 路徑由 _push_auto_repair_result 處理,此處僅處理人工批准
asyncio.create_task(
self._push_execution_result_to_alert(approval, success=True, error=None)
)
# Phase 7.6: 觸發 Playbook 自動萃取 (fire-and-forget)
asyncio.create_task(
self._trigger_playbook_extraction(approval)
@@ -576,7 +620,7 @@ class ApprovalExecutionService:
await self._log_alert_execution_completed(
approval,
success=True,
execution_kind=operation_type.value,
execution_kind=execution_kind,
duration_ms=int((time.time() - _aol_started_ms) * 1000),
output={
"operation_type": operation_type.value,
@@ -584,9 +628,18 @@ class ApprovalExecutionService:
"namespace": namespace,
"executor_duration_ms": result.duration_ms,
"total_attempts": total_attempts,
"repair_executed": True,
"repair_attempted": repair_attempted,
"repair_executed": repair_executed,
},
)
await self._push_execution_result_to_alert(
approval,
success=True,
error=None,
execution_kind=execution_kind,
repair_executed=repair_executed,
repair_attempted=repair_attempted,
)
return True # K8s 執行成功
else:
@@ -626,13 +679,6 @@ class ApprovalExecutionService:
)
)
# 2026-04-14 Claude Sonnet 4.6: reply_to 原告警卡片顯示失敗結果
asyncio.create_task(
self._push_execution_result_to_alert(
approval, success=False, error=result.error
)
)
# ADR-030 Phase 5 / ADR-083 Phase 3: 觸發學習服務(失敗案例)
# Phase 3 修復fire-and-forget → await + 30s 熔斷
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3 fire-and-forget 修復
@@ -691,7 +737,7 @@ class ApprovalExecutionService:
await self._log_alert_execution_completed(
approval,
success=False,
execution_kind=operation_type.value,
execution_kind=execution_kind,
duration_ms=int((time.time() - _aol_started_ms) * 1000),
output={
"operation_type": operation_type.value,
@@ -699,11 +745,19 @@ class ApprovalExecutionService:
"namespace": namespace,
"executor_duration_ms": result.duration_ms,
"total_attempts": total_attempts,
"repair_attempted": True,
"repair_attempted": repair_attempted,
"repair_executed": False,
},
error_message=result.error,
)
await self._push_execution_result_to_alert(
approval,
success=False,
error=result.error,
execution_kind=execution_kind,
repair_executed=False,
repair_attempted=repair_attempted,
)
return False # K8s 執行失敗
async def _execute_ssh_host_action(
@@ -896,6 +950,10 @@ class ApprovalExecutionService:
approval: ApprovalRequest,
success: bool,
error: str | None,
*,
execution_kind: str | None = None,
repair_executed: bool | None = None,
repair_attempted: bool | None = None,
) -> None:
"""
執行結果回覆到原告警 Telegram 卡片reply_to_message_id
@@ -904,7 +962,8 @@ class ApprovalExecutionService:
- 人工路徑:人類在 Telegram 點批准後,等執行完成,在原告警下 reply 執行結果
- 自動路徑 (requested_by=auto_approve) 由 _push_auto_repair_result 處理,此處 skip
透過 Redis tg_msg:{incident_id} 查原告警 message_id找不到則靜默不發。
透過 Redis tg_msg:{incident_id} 查原告警 message_id找不到原卡片時,
仍必須發獨立結果通知,避免批准後沒有可追蹤結論。
"""
try:
# 自動執行路徑 skip避免與 _push_auto_repair_result 重複發訊息)
@@ -917,36 +976,44 @@ class ApprovalExecutionService:
from src.core.redis_client import get_redis
redis = get_redis()
msg_id_raw = await redis.get(f"tg_msg:{approval.incident_id}")
if not msg_id_raw:
orig_msg_id: int | None = None
if msg_id_raw:
try:
orig_msg_id = int(msg_id_raw)
except (TypeError, ValueError):
orig_msg_id = None
if orig_msg_id is None:
logger.debug(
"push_execution_result_no_msg_id",
"push_execution_result_no_msg_id_standalone",
incident_id=approval.incident_id,
approval_id=str(approval.id),
)
return
try:
orig_msg_id = int(msg_id_raw)
except (TypeError, ValueError):
return
from src.core.config import get_settings
from src.services.telegram_gateway import get_telegram_gateway
settings = get_settings()
gateway = get_telegram_gateway()
target_chat_id = settings.SRE_GROUP_CHAT_ID or settings.OPENCLAW_TG_CHAT_ID
if not target_chat_id:
logger.warning(
"push_execution_result_no_target_chat",
incident_id=approval.incident_id,
approval_id=str(approval.id),
)
return
# 2026-04-19 ogt + Claude Opus 4.7 修 AP-2: 除了 reply 外,
# 也 edit 原卡片移除按鈕 + 更新狀態戳記(避免卡片永遠停在「執行中」)
try:
await gateway._send_request("editMessageReplyMarkup", {
"chat_id": target_chat_id,
"message_id": orig_msg_id,
"reply_markup": {"inline_keyboard": []},
})
except Exception as _edit_e:
logger.debug("push_execution_edit_buttons_failed",
approval_id=str(approval.id), error=str(_edit_e))
if orig_msg_id is not None:
try:
await gateway._send_request("editMessageReplyMarkup", {
"chat_id": target_chat_id,
"message_id": orig_msg_id,
"reply_markup": {"inline_keyboard": []},
})
except Exception as _edit_e:
logger.debug("push_execution_edit_buttons_failed",
approval_id=str(approval.id), error=str(_edit_e))
# 附加 KM/Playbook 增量(查最近該 incident 的 KM + playbook 使用)
km_info = ""
@@ -969,36 +1036,45 @@ class ApprovalExecutionService:
except Exception:
pass
outcome = await self._fetch_operator_outcome_for_result(approval)
no_action = success and is_no_action_approval_action(approval.action)
if no_action:
text = (
f" <b>已記錄觀察,未執行修復</b>\n"
f"<code>{(approval.action or '')[:180]}</code>"
f"{km_info}"
)
elif success:
text = (
f"✅ <b>執行成功</b>\n"
f"<code>{(approval.action or '')[:180]}</code>"
f"{km_info}"
)
else:
err_short = (error or "未知錯誤")[:150]
text = (
f"❌ <b>執行失敗</b>\n"
f"<code>{(approval.action or '')[:180]}</code>\n"
f"原因: {err_short}"
f"{km_info}"
)
text = self._format_execution_result_message(
approval=approval,
success=success,
error=error,
no_action=no_action,
km_info=km_info,
outcome=outcome,
)
reply_markup = {
"inline_keyboard": [[
{
"text": "🧭 AwoooP",
"url": (
"https://awoooi.wooo.work/zh-TW/awooop/runs"
f"?project_id=awoooi&incident_id={quote(approval.incident_id or '')}"
),
}
]]
}
payload: dict[str, Any] = {
"chat_id": target_chat_id,
"text": text,
"parse_mode": "HTML",
"reply_markup": reply_markup,
"disable_web_page_preview": True,
}
if orig_msg_id is not None:
payload["reply_to_message_id"] = orig_msg_id
await gateway._send_request(
"sendMessage",
{
"chat_id": target_chat_id,
"text": text,
"parse_mode": "HTML",
"reply_to_message_id": orig_msg_id,
},
await gateway._send_request("sendMessage", payload)
context_execution_kind = execution_kind or (
"no_action" if no_action else "execution"
)
context_repair_executed = (
bool(repair_executed)
if repair_executed is not None
else bool(success and not no_action)
)
logger.info(
"push_execution_result_sent",
@@ -1007,6 +1083,10 @@ class ApprovalExecutionService:
success=success,
no_action=no_action,
orig_msg_id=orig_msg_id,
execution_kind=context_execution_kind,
repair_executed=context_repair_executed,
operator_outcome_state=outcome.get("state"),
needs_human=outcome.get("needs_human"),
)
try:
from src.repositories.alert_operation_log_repository import (
@@ -1023,8 +1103,11 @@ class ApprovalExecutionService:
error_message=error,
context={
"reply_to_message_id": orig_msg_id,
"execution_kind": "no_action" if no_action else "execution",
"repair_executed": not no_action and success,
"execution_kind": context_execution_kind,
"repair_executed": context_repair_executed,
"repair_attempted": repair_attempted,
"operator_outcome": outcome,
"delivery": "reply" if orig_msg_id is not None else "standalone",
},
)
except Exception as _log_e:
@@ -1040,6 +1123,108 @@ class ApprovalExecutionService:
error=str(e),
)
async def _fetch_operator_outcome_for_result(
self,
approval: ApprovalRequest,
) -> dict[str, Any]:
incident_id = approval.incident_id
if not incident_id:
return build_operator_outcome(
truth_status=None,
automation_quality=None,
fetch_error="approval_missing_incident_id",
source_id=None,
)
try:
from src.services.awooop_truth_chain_service import fetch_truth_chain
truth_chain = await asyncio.wait_for(
fetch_truth_chain(source_id=incident_id, project_id="awoooi"),
timeout=3.0,
)
outcome = truth_chain.get("operator_outcome")
if isinstance(outcome, dict):
return outcome
quality = truth_chain.get("automation_quality")
if isinstance(quality, dict) and isinstance(quality.get("operator_outcome"), dict):
return quality["operator_outcome"]
return build_operator_outcome(
truth_status=truth_chain.get("truth_status")
if isinstance(truth_chain.get("truth_status"), dict)
else {},
automation_quality=quality if isinstance(quality, dict) else {},
source_id=incident_id,
)
except Exception as exc:
logger.warning(
"execution_result_outcome_fetch_failed",
approval_id=str(approval.id),
incident_id=incident_id,
error=str(exc),
)
return build_operator_outcome(
truth_status=None,
automation_quality=None,
fetch_error=str(exc),
source_id=incident_id,
)
def _format_execution_result_message(
self,
*,
approval: ApprovalRequest,
success: bool,
error: str | None,
no_action: bool,
km_info: str,
outcome: dict[str, Any],
) -> str:
state = str(outcome.get("state") or "unknown")
needs_human = bool(outcome.get("needs_human"))
if no_action:
icon = ""
title = "已記錄觀察,未執行修復"
elif not success:
icon = ""
title = "執行失敗"
elif state == "completed_verified":
icon = ""
title = "修復完成並已驗證"
elif state == "diagnostic_only_manual_review":
icon = "⚠️"
title = "已記錄診斷,尚未證明修復"
elif needs_human:
icon = "⚠️"
title = "已執行,但需人工確認"
else:
icon = ""
title = "執行完成"
notification = (
outcome.get("notification")
if isinstance(outcome.get("notification"), dict)
else {}
)
channels = notification.get("channels") if isinstance(notification.get("channels"), list) else []
lines = [
f"{icon} <b>處置結果</b> | <b>{html.escape(title)}</b>",
f"📋 <code>{html.escape(str(approval.incident_id or '--'))}</code>",
f"🧾 Approval: <code>{html.escape(str(approval.id))}</code>",
f"🧭 結論: <b>{html.escape(str(outcome.get('summary_zh') or '尚未形成明確結論'))}</b>",
f"├ 狀態: <code>{html.escape(str(outcome.get('state') or 'unknown'))}</code>",
f"├ 人工: <code>{'yes' if outcome.get('needs_human') else 'no'}</code>",
f"├ 通知: <code>{html.escape(','.join(str(item) for item in channels) or 'none')}</code>",
f"└ 下一步: <code>{html.escape(str(outcome.get('next_action') or '--'))}</code>",
f"⚙️ Action: <code>{html.escape((approval.action or '')[:220])}</code>",
]
if error:
lines.append(f"原因: {html.escape(str(error)[:240])}")
reason = outcome.get("human_action_reason")
if reason:
lines.append(f"卡點: <code>{html.escape(str(reason)[:240])}</code>")
if km_info:
lines.append(html.escape(km_info.strip()))
return "\n".join(lines)[:3900]
async def _get_anomaly_key_from_approval(self, approval: ApprovalRequest) -> str | None:
"""
從 approval → incident → anomaly_key。