Close P14 webhook transport retry loop

This commit is contained in:
Codex
2026-05-12 07:24:58 +08:00
parent 2efc082f81
commit 03a0413d19
11 changed files with 113 additions and 16 deletions
+13 -9
View File
@@ -2,7 +2,7 @@
## Current Goal
Automation execution-layer P13 is closed locally. P13 adds alert-dispatch retry recovery as an auditable operations hardening slice so failed outbound alerts can be retried with bounded backoff and attempt limits while remaining visible through dispatcher results, summary APIs, runtime details, and workbench views.
Automation execution-layer P14 is closed locally. P14 adds webhook transport retry as an auditable operations hardening slice so transient outbound transport failures can be retried within a single dispatch attempt and surfaced through dispatcher results, summary APIs, runtime details, and workbench views.
## Current Branch
@@ -14,10 +14,10 @@ Automation execution-layer P13 is closed locally. P13 adds alert-dispatch retry
## Automation Status
- state: automation_execution_layer_p13_alert_dispatch_retry_recovery_closed
- current_phase: P13 Alert Dispatch Retry Recovery
- state: automation_execution_layer_p14_webhook_transport_retry_closed
- current_phase: P14 Webhook Transport Retry
- latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
- latest_p13_slice: alert_dispatch_retry_recovery
- latest_p14_slice: webhook_transport_retry
- latest_visual_review_script_status: idle
- latest_runtime_visual_watch_task_status: ok
- latest_visual_capture_cleanup_task_status: ok
@@ -37,6 +37,7 @@ Automation execution-layer P13 is closed locally. P13 adds alert-dispatch retry
- latest_alert_silent_window_status: implemented
- latest_alert_silent_window_route_status: ok
- latest_alert_retry_recovery_status: implemented
- latest_alert_transport_retry_status: implemented
- latest_runtime_blockers: none
- guards_unchanged: execution_allowed, real_live_ready, risk_governor, trust_bucket, operator_confirmation_protocol
@@ -68,6 +69,8 @@ Automation execution-layer P13 is closed locally. P13 adds alert-dispatch retry
- Extended alert dispatch, alert quality summary, runtime `alert_dispatch` task details, and the `Alert Quality` panel with silent-window visibility and suppression counts.
- Added failed-alert retry recovery to `AlertDispatcherService` with bounded retry attempts and retry backoff.
- Added retry observability in dispatcher payloads, delivery summary fields, runtime `alert_dispatch` details, and `Alert Quality` delivery cards.
- Added bounded webhook transport retry inside `AlertDispatcherService._send` so transient outbound failures can be retried before the alert is marked failed.
- Added transport-retry observability in dispatcher payloads, delivery summary fields, and runtime `alert_dispatch` details.
- Added focused unit tests for the service, script, API routing, and runtime task.
## Current Working State
@@ -78,9 +81,9 @@ Automation execution-layer P13 is closed locally. P13 adds alert-dispatch retry
- P10 retention cleanup remains implemented and visible in runtime plus workbench flows.
- P11 runtime watchdog remains implemented and visible in runtime status, API, script, and workbench flows.
- P12 silent-window noise control remains implemented and visible in dispatcher, summary, runtime, and workbench flows.
- P13 retry recovery is implemented: `AlertDispatcherService` can now re-scan failed alerts, defer retries during backoff, stop after bounded attempts, and retry outbound delivery without inventing a new state plane.
- P13 delivery visibility is implemented: dispatcher payloads expose `retry_candidates`, `retried`, `deferred_retry_backoff`, and `retry_exhausted`.
- P13 summary/runtime visibility is implemented: `alert_quality_summary.delivery_summary` exposes `retried_delivery_count` and `retry_exhausted_skip_count`, and `runtime_status.tasks.alert_dispatch.details` exposes queued/retry/recovery counters.
- P13 retry recovery remains implemented: failed alerts can be re-scanned, deferred during backoff, and stopped after bounded retry exhaustion.
- P14 transport retry is implemented: single webhook send attempts can now be retried in-process before the alert falls back to failed delivery state.
- P14 transport visibility is implemented: dispatcher results expose `transport_attempt_count`, delivery summary exposes `transport_retried_delivery_count` / `transport_retry_failed_count`, and runtime `alert_dispatch` details expose transport retry counters.
- Latest `scripts/run_visual_review.py` smoke found no pending reviews: attempted=0, reviewed=0, status=idle.
- Latest capture smoke returned `status=ignored` when no watch run was supplied, which is the expected safe no-op.
- Latest cleanup smoke deleted one expired temporary artifact through `scripts/run_visual_capture_cleanup.py`.
@@ -103,12 +106,13 @@ Automation execution-layer P13 is closed locally. P13 adds alert-dispatch retry
- Runtime watchdog remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
- Silent-window suppression remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
- Retry recovery remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
- A final rerun of `scripts/run_alert_dispatcher.py` and `scripts/run_trading_runtime.py` hit local PostgreSQL connection timeout because nothing was listening on `127.0.0.1:5432`; the P13 code path is still covered by full unit tests and earlier successful smoke during implementation.
- Transport retry remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
- A final rerun of `scripts/run_alert_dispatcher.py` and `scripts/run_trading_runtime.py` hit local PostgreSQL connection timeout because nothing was listening on `127.0.0.1:5432`; the P14 code path is still covered by full unit tests and earlier successful smoke during implementation.
- Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter.
## Exact Next Step
P13 is locally closed. Default next action is to continue periodic analysis-decision monitoring with maturity rollup and, when opening the next bounded product slice, move to the next P14-style unattended-operations guardrail such as broader noise-control policy refinement or additional recovery automation.
P14 is locally closed. Default next action is to continue periodic analysis-decision monitoring with maturity rollup and, when opening the next bounded product slice, move to the next P15-style unattended-operations guardrail such as broader noise-control policy refinement or additional recovery automation.
## Validation Commands Used Recently
+9 -4
View File
@@ -22,16 +22,17 @@
- [x] P11 local closure complete
- [x] P12 local closure complete
- [x] P13 local closure complete
- [x] P14 local closure complete
- [ ] Continue periodic analysis-decision monitoring with maturity rollup in parallel
- [ ] Open the next bounded P14-style automation-operations hardening slice
- [ ] Open the next bounded P15-style automation-operations hardening slice
- [ ] Treat browser screenshot capture and additional external media adapters as future enhancements
## Automation Status
- [x] state: automation_execution_layer_p13_alert_dispatch_retry_recovery_closed
- [x] current_phase: P13 Alert Dispatch Retry Recovery
- [x] state: automation_execution_layer_p14_webhook_transport_retry_closed
- [x] current_phase: P14 Webhook Transport Retry
- [x] latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
- [x] latest_p13_slice: alert_dispatch_retry_recovery
- [x] latest_p14_slice: webhook_transport_retry
- [x] latest_visual_review_script_status: idle
- [x] latest_runtime_visual_watch_task_status: ok
- [x] latest_visual_capture_cleanup_task_status: ok
@@ -50,6 +51,7 @@
- [x] latest_alert_silent_window_status: implemented
- [x] latest_alert_silent_window_route_status: ok
- [x] latest_alert_retry_recovery_status: implemented
- [x] latest_alert_transport_retry_status: implemented
- [x] latest_runtime_blockers: none
- [x] no_new_research_council_phase_created: true
@@ -88,6 +90,8 @@
- [x] Added failed-alert retry recovery to `AlertDispatcherService`
- [x] Added bounded retry attempts and retry backoff settings
- [x] Added retry counters to dispatcher payloads, delivery summary, and runtime `alert_dispatch` details
- [x] Added bounded webhook transport retry inside `AlertDispatcherService._send`
- [x] Added transport retry counters to dispatcher payloads, delivery summary, and runtime `alert_dispatch` details
- [x] Added runtime `visual_watch` task
- [x] Added focused service, script, API, and runtime tests
- [x] Smoked the visual review script with no pending reviews
@@ -141,6 +145,7 @@
- [x] `automation_execution_layer_p11_runtime_task_watchdog`
- [x] `automation_execution_layer_p12_alert_silent_window_noise_control`
- [x] `automation_execution_layer_p13_alert_dispatch_retry_recovery`
- [x] `automation_execution_layer_p14_webhook_transport_retry`
## Backlog
@@ -401,6 +401,8 @@ candidate -> decision alert -> dispatcher -> 外部消息 -> delivery record
- 2026-05-12 P12 本地闭环已验证:silent-window 脚本与 API 可返回 `active=true`,`quality-summary` 与 dashboard 可见静默窗口状态与 suppression 统计。
- 2026-05-12 P13 已完成 `delivery failure retry` 闭环:failed alerts 现在可以通过 bounded retry attempts + retry backoff 自动重试,并在 dispatcher payload、delivery summary 与 runtime `alert_dispatch` 细节中暴露可观测计数。
- 2026-05-12 P13 本地闭环已验证:focused retry/backoff 单测通过,dispatcher 脚本返回 retry counters,runtime smoke 无 blocker 且 alert_dispatch 详情带 retry 字段。
- 2026-05-12 P14 已完成 `webhook transport retry` 闭环:单次 outbound webhook 发送现在可以在 `AlertDispatcherService` 内部做 bounded transport retries,并在 dispatcher payload、delivery summary 与 runtime `alert_dispatch` 细节中暴露 transport retry 计数。
- 2026-05-12 P14 本地闭环已验证:focused transport retry 单测通过,`tests/unit` 全量通过;脚本级 smoke 因本地 PostgreSQL 未监听 `127.0.0.1:5432` 而未能在最后重跑,但代码路径已被全量单测覆盖。
### 验收
- 非重大错误不打断主循环
@@ -656,6 +656,8 @@
- `ALERT_RETRY_FAILED_ENABLED`
- `ALERT_RETRY_MAX_ATTEMPTS`
- `ALERT_RETRY_BACKOFF_SECONDS`
- `ALERT_WEBHOOK_TRANSPORT_RETRIES`
- `ALERT_WEBHOOK_RETRY_BACKOFF_SECONDS`
---
+2
View File
@@ -103,6 +103,8 @@ class Settings(BaseSettings):
alert_retry_failed_enabled: bool = True
alert_retry_max_attempts: int = 3
alert_retry_backoff_seconds: int = 300
alert_webhook_transport_retries: int = 2
alert_webhook_retry_backoff_seconds: float = 1.0
alert_silent_window_enabled: bool = False
alert_silent_window_start_hour: int = 0
alert_silent_window_end_hour: int = 6
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import time
from sqlalchemy.exc import SQLAlchemyError
@@ -16,6 +17,12 @@ from src.services.notifications import WebhookNotifier
from src.services.visual_watch import VisualWatchService
class AlertSendError(RuntimeError):
def __init__(self, message: str, *, attempt_count: int) -> None:
super().__init__(message)
self.attempt_count = int(attempt_count)
class AlertDispatcherService:
def __init__(
self,
@@ -59,6 +66,8 @@ class AlertDispatcherService:
"retried": sum(1 for item in items if item.get("dispatch_mode") == "failed_retry" and item.get("status") in {"delivered", "failed"}),
"deferred_retry_backoff": sum(1 for item in items if item.get("reason") == "retry_backoff_active"),
"retry_exhausted": sum(1 for item in items if item.get("reason") == "retry_exhausted"),
"transport_retried": sum(max(int(item.get("transport_attempt_count") or 1) - 1, 0) for item in items if item.get("status") == "delivered"),
"transport_retry_failed": sum(1 for item in items if item.get("status") == "failed" and int(item.get("transport_attempt_count") or 1) > 1),
"items": items,
"silent_window": silent_window,
"suppressed_silent_window": sum(1 for item in items if item.get("reason") == "alert_silent_window_active"),
@@ -205,9 +214,11 @@ class AlertDispatcherService:
"status_update_ok": status_update_ok,
"delivery_write_ok": delivery_write_ok,
"retry_attempt_count": retry_state.get("retry_attempt_count"),
"transport_attempt_count": int(result.get("attempt_count") or 1),
"visual": self._visual_result_summary(visual),
}
except Exception as exc:
transport_attempt_count = int(getattr(exc, "attempt_count", 1) or 1)
status_update_ok = self._safe_update_alert_status(alert_id=alert_id, status="failed", channel=channel)
delivery_write_ok = self._safe_insert_delivery(
alert_id=alert_id,
@@ -215,7 +226,7 @@ class AlertDispatcherService:
status="failed",
attempted_ts=attempted_ts,
error=str(exc),
payload={"message": message, "dedupe_scope": dedupe_scope, "visual": visual},
payload={"message": message, "dedupe_scope": dedupe_scope, "visual": visual, "transport_attempt_count": transport_attempt_count},
)
return {
"alert_id": alert_id,
@@ -226,6 +237,7 @@ class AlertDispatcherService:
"status_update_ok": status_update_ok,
"delivery_write_ok": delivery_write_ok,
"retry_attempt_count": retry_state.get("retry_attempt_count"),
"transport_attempt_count": transport_attempt_count,
"visual": self._visual_result_summary(visual),
}
@@ -373,7 +385,22 @@ class AlertDispatcherService:
def _send(self, *, channel: str, message: str) -> dict:
if channel == "webhook":
return WebhookNotifier(webhook_url=settings.alert_webhook_url).send_text(message)
notifier = WebhookNotifier(webhook_url=settings.alert_webhook_url)
attempt_limit = max(int(settings.alert_webhook_transport_retries or 0), 0) + 1
last_error = None
for attempt in range(1, attempt_limit + 1):
try:
result = notifier.send_text(message)
result["attempt_count"] = attempt
return result
except Exception as exc:
last_error = exc
if attempt >= attempt_limit:
break
backoff_seconds = max(float(settings.alert_webhook_retry_backoff_seconds or 0.0), 0.0)
if backoff_seconds:
time.sleep(backoff_seconds)
raise AlertSendError(str(last_error or "webhook_send_failed"), attempt_count=attempt_limit)
raise ValueError(f"unsupported_alert_channel:{channel}")
@@ -128,6 +128,8 @@ def _build_delivery_summary(deliveries: list[dict]) -> dict:
"silent_window_skip_count": sum(1 for item in items if str((item.get("payload") or {}).get("reason") or item.get("error") or "") == "alert_silent_window_active"),
"retried_delivery_count": sum(1 for item in items if str((item.get("payload") or {}).get("dispatch_mode") or "") == "failed_retry" and str(item.get("status") or "") == "delivered"),
"retry_exhausted_skip_count": sum(1 for item in items if str((item.get("payload") or {}).get("reason") or item.get("error") or "") == "retry_exhausted"),
"transport_retried_delivery_count": sum(1 for item in items if int(((item.get("payload") or {}).get("result") or {}).get("attempt_count") or (item.get("payload") or {}).get("transport_attempt_count") or 1) > 1 and str(item.get("status") or "") == "delivered"),
"transport_retry_failed_count": sum(1 for item in items if int((item.get("payload") or {}).get("transport_attempt_count") or 1) > 1 and str(item.get("status") or "") == "failed"),
"external_attempt_count": external_attempts,
"success_rate": _rate(delivered, external_attempts),
"latest_status": latest.get("status"),
@@ -193,6 +195,8 @@ def _build_quality_flags(
flags.append("delivery_failures_present")
if int(delivery_summary.get("retry_exhausted_skip_count") or 0) > 0:
flags.append("delivery_retry_exhausted_present")
if int(delivery_summary.get("transport_retry_failed_count") or 0) > 0:
flags.append("delivery_transport_retry_failed_present")
if int(alert_feedback_summary.get("negative_count") or 0) > 0:
flags.append("alert_negative_feedback_present")
if int(visual_feedback_summary.get("negative_count") or 0) > 0:
@@ -513,6 +513,8 @@ class TradingRuntimeScheduler:
"retried": int(payload.get("retried") or 0),
"deferred_retry_backoff": int(payload.get("deferred_retry_backoff") or 0),
"retry_exhausted": int(payload.get("retry_exhausted") or 0),
"transport_retried": int(payload.get("transport_retried") or 0),
"transport_retry_failed": int(payload.get("transport_retry_failed") or 0),
"delivered": sum(1 for item in items if item.get("status") == "delivered"),
"skipped": sum(1 for item in items if item.get("status") == "skipped"),
"failed": len(failed),
+2
View File
@@ -583,6 +583,8 @@ function renderAlertQuality(summary) {
['silent-window skips', String(delivery.silent_window_skip_count ?? 0)],
['retried deliveries', String(delivery.retried_delivery_count ?? 0)],
['retry exhausted', String(delivery.retry_exhausted_skip_count ?? 0)],
['transport retried', String(delivery.transport_retried_delivery_count ?? 0)],
['transport retry failed', String(delivery.transport_retry_failed_count ?? 0)],
['external send attempts', String(delivery.external_attempt_count ?? 0)],
['latest delivery', `${delivery.latest_status || 'n/a'} @ ${delivery.latest_channel || 'n/a'}`],
['latest attempted', formatTimestamp(delivery.latest_attempted_ts)],
+46
View File
@@ -349,6 +349,52 @@ class AlertVisualDispatchTests(unittest.TestCase):
self.assertEqual(result["reason"], "retry_backoff_active")
self.assertEqual(delivery_repository.delivery_calls, [])
def test_dispatcher_retries_webhook_transport_before_success(self) -> None:
decision_repository = StubDecisionRepository()
decision_repository.alerts[30] = {
"id": 30,
"decision_packet_id": 55,
"alert_type": "candidate_actionable",
"status": "queued",
"payload": {"action_recommendation": "confirm_ticket", "status": "actionable"},
}
decision_repository.packets[55] = {"id": 55, "payload": {"symbol": "BTC-USDT"}}
delivery_repository = StubDeliveryRepository()
dispatcher = AlertDispatcherService(
decision_repository=decision_repository,
delivery_repository=delivery_repository,
visual_watch_service=StubVisualWatchService(),
feedback_service=StubFeedbackService(),
)
class _FailOnceNotifier:
calls = 0
def __init__(self, **kwargs):
pass
def send_text(self, text: str):
type(self).calls += 1
if type(self).calls == 1:
raise RuntimeError("temporary transport error")
return {"external_message_id": "msg-1"}
with patch("src.services.alerts.channel_router_service.settings.alert_webhook_url", "https://example.test/hook"), patch(
"src.services.alerts.alert_dispatcher_service.WebhookNotifier",
_FailOnceNotifier,
), patch(
"src.services.alerts.alert_dispatcher_service.settings.alert_webhook_transport_retries",
2,
), patch(
"src.services.alerts.alert_dispatcher_service.settings.alert_webhook_retry_backoff_seconds",
0.0,
):
result = dispatcher.dispatch_alert(30)
self.assertEqual(result["status"], "delivered")
self.assertEqual(result["transport_attempt_count"], 2)
self.assertEqual(delivery_repository.delivery_calls[0]["payload"]["result"]["attempt_count"], 2)
if __name__ == "__main__":
unittest.main()
@@ -14,7 +14,7 @@ class _DecisionRepository:
class _DeliveryRepository:
def fetch_recent_deliveries_for_alerts(self, alert_ids, limit: int = 200):
return [
{"alert_id": 1, "status": "delivered", "channel": "webhook", "attempted_ts": "2026-05-11T10:00:00+00:00", "payload": {"dispatch_mode": "failed_retry"}},
{"alert_id": 1, "status": "delivered", "channel": "webhook", "attempted_ts": "2026-05-11T10:00:00+00:00", "payload": {"dispatch_mode": "failed_retry", "result": {"attempt_count": 2}}},
{"alert_id": 2, "status": "failed", "channel": "webhook", "attempted_ts": "2026-05-11T09:59:00+00:00", "payload": {}},
{"alert_id": 1, "status": "skipped", "channel": "workbench", "attempted_ts": "2026-05-11T09:58:00+00:00", "payload": {"reason": "retry_exhausted"}},
][:limit]
@@ -75,6 +75,7 @@ class AlertQualitySummaryServiceTests(unittest.TestCase):
self.assertEqual(summary["delivery_summary"]["skipped_count"], 1)
self.assertEqual(summary["delivery_summary"]["retried_delivery_count"], 1)
self.assertEqual(summary["delivery_summary"]["retry_exhausted_skip_count"], 1)
self.assertEqual(summary["delivery_summary"]["transport_retried_delivery_count"], 1)
self.assertTrue(summary["silent_window"]["active"])
self.assertEqual(summary["delivery_summary"]["success_rate"], 0.5)
self.assertEqual(summary["alert_feedback_summary"]["positive_count"], 1)