diff --git a/apps/api/src/services/approval_execution.py b/apps/api/src/services/approval_execution.py
index aa97417d..13320a9b 100644
--- a/apps/api/src/services/approval_execution.py
+++ b/apps/api/src/services/approval_execution.py
@@ -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"✅ 執行成功\n{(approval.action or '')[:180]}"
+ else:
+ err_short = (error or "未知錯誤")[:150]
+ text = f"❌ 執行失敗\n{(approval.action or '')[:180]}\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。
diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py
index d987e926..e63820ca 100644
--- a/apps/api/src/services/telegram_gateway.py
+++ b/apps/api/src/services/telegram_gateway.py
@@ -4769,7 +4769,9 @@ class TelegramGateway:
if action == "approve":
status_emoji = "✅"
status_text = f"已批准 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"已拒絕 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_triggered,race 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(