fix: Telegram 簽核 gate + 執行結果 reply — 打通人工審核閉環
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 14m7s

3 處修復(統帥盤查發現):

1. telegram_gateway.py:4890 — gate 從 execution_triggered 改 approval.status==APPROVED
   - 原 gate 靠樂觀鎖旗標,race 時失效(REST+Telegram 同時簽核)
   - 與 REST API approvals.py:360 路徑對齊
   - 加 Redis lock exec:{approval_id} 60s TTL 防重入

2. telegram_gateway.py:4772 — 拿掉「👀 等待執行」誤導文案
   - 批准後一律顯示「 執行中...」,實際結果由 #3 reply 補上

3. approval_execution.py — 新增 _push_execution_result_to_alert()
   - 成功/失敗兩處 fire-and-forget 呼叫
   - requested_by=="auto_approve" skip(避免與 _push_auto_repair_result 衝突)
   - Redis tg_msg:{incident_id} 查原告警 message_id → reply_to
   - 找不到 message_id 靜默不發,不影響執行主流程

防破壞性檢查:
-  自動執行路徑不受影響(skip via requested_by)
-  Reject 路徑完全不動
-  Redis lock 防重入
-  132 回歸測試全過

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-14 19:03:38 +08:00
parent e7171a4ac8
commit 72dd0c5875
2 changed files with 121 additions and 13 deletions

View File

@@ -250,6 +250,12 @@ 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)
@@ -325,6 +331,13 @@ 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: 觸發學習服務 (失敗案例)
asyncio.create_task(
self._trigger_learning(
@@ -335,6 +348,79 @@ class ApprovalExecutionService:
)
)
async def _push_execution_result_to_alert(
self,
approval: ApprovalRequest,
success: bool,
error: str | None,
) -> None:
"""
執行結果回覆到原告警 Telegram 卡片reply_to_message_id
2026-04-14 Claude Sonnet 4.6 實裝:
- 人工路徑:人類在 Telegram 點批准後,等執行完成,在原告警下 reply 執行結果
- 自動路徑 (requested_by=auto_approve) 由 _push_auto_repair_result 處理,此處 skip
透過 Redis tg_msg:{incident_id} 查原告警 message_id找不到則靜默不發。
"""
try:
# 自動執行路徑 skip避免與 _push_auto_repair_result 重複發訊息)
if (approval.requested_by or "").lower() == "auto_approve":
return
if not approval.incident_id:
return
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:
logger.debug(
"push_execution_result_no_msg_id",
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()
if success:
text = f"✅ <b>執行成功</b>\n<code>{(approval.action or '')[:180]}</code>"
else:
err_short = (error or "未知錯誤")[:150]
text = f"❌ <b>執行失敗</b>\n<code>{(approval.action or '')[:180]}</code>\n原因: {err_short}"
await gateway._http_client.post(
f"https://api.telegram.org/bot{settings.OPENCLAW_TG_BOT_TOKEN}/sendMessage",
json={
"chat_id": settings.OPENCLAW_TG_CHAT_ID,
"text": text,
"parse_mode": "HTML",
"reply_to_message_id": orig_msg_id,
},
)
logger.info(
"push_execution_result_sent",
incident_id=approval.incident_id,
approval_id=str(approval.id),
success=success,
orig_msg_id=orig_msg_id,
)
except Exception as e:
logger.warning(
"push_execution_result_failed",
approval_id=str(approval.id),
error=str(e),
)
async def _get_anomaly_key_from_approval(self, approval: ApprovalRequest) -> str | None:
"""
從 approval → incident → anomaly_key。

View File

@@ -4769,7 +4769,9 @@ class TelegramGateway:
if action == "approve":
status_emoji = ""
status_text = f"<b>已批准</b> by {_html.escape(username)}"
suffix = "⚡ 執行中..." if execution_triggered else "👀 等待執行"
# 2026-04-14 Claude Sonnet 4.6: 原「等待執行」誤導(實際沒有 gate 會卡住路徑)
# 批准後一律顯示「執行中」,真實結果由 _push_execution_result_to_alert reply 補上
suffix = "⚡ 執行中..."
else:
status_emoji = ""
status_text = f"<b>已拒絕</b> by {_html.escape(username)}"
@@ -4885,20 +4887,40 @@ class TelegramGateway:
username=username,
execution_triggered=execution_triggered,
)
# ADR-073 修補: 觸發實際執行 (之前 sign_approval 只更新 DB 狀態,指令從未執行)
# execution_triggered=True 代表簽名數已達 required_signatures
if execution_triggered:
# ADR-073 修補 + 2026-04-14 Claude Sonnet 4.6 修復:
# 原本 gate 用 execution_triggeredrace condition 時失效(樂觀鎖失敗)
# 改用 approval.status == APPROVED與 REST API 路徑 approvals.py:360 對齊)
# 用 Redis lock exec:{approval_id} 防重入REST + Telegram 同時簽核)
from src.models.approval import ApprovalStatus
if approval.status == ApprovalStatus.APPROVED:
import asyncio
from src.core.redis_client import get_redis
from src.services.approval_execution import get_execution_service
_exec_task = asyncio.create_task(
get_execution_service().execute_approved_action(approval)
)
_exec_task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
logger.info(
"telegram_approval_execution_triggered",
approval_id=approval_id,
action=approval.action,
)
_redis = get_redis()
_lock_key = f"exec:{approval.id}"
# SET NX EX 60 — 60s 內同一 approval 只能執行一次
_acquired = await _redis.set(_lock_key, "1", nx=True, ex=60)
if _acquired:
_exec_task = asyncio.create_task(
get_execution_service().execute_approved_action(approval)
)
_exec_task.add_done_callback(
lambda t: t.exception() if not t.cancelled() else None
)
logger.info(
"telegram_approval_execution_triggered",
approval_id=approval_id,
action=approval.action,
gate="status=APPROVED",
)
else:
logger.info(
"telegram_approval_execution_skipped_lock_held",
approval_id=approval_id,
reason="另一路徑 (REST/自動) 已取得 exec lock",
)
elif action == "reject":
approval, message = await service.reject_approval(