fix(alerts): 補齊處置結果與人工通知契約
This commit is contained in:
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user