feat(agents): expose autonomous runtime control
Some checks failed
CD Pipeline / tests (push) Successful in 1m43s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled
Ansible / Reboot Recovery Contract / validate (push) Has been cancelled
Some checks failed
CD Pipeline / tests (push) Successful in 1m43s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled
Ansible / Reboot Recovery Contract / validate (push) Has been cancelled
This commit is contained in:
273
apps/api/src/services/ai_agent_autonomous_runtime_control.py
Normal file
273
apps/api/src/services/ai_agent_autonomous_runtime_control.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Current AI Agent autonomous runtime control plane.
|
||||
|
||||
This read model is the current directive layer. Historical P2 snapshots can
|
||||
still describe earlier no-send / no-live states, but this payload states what
|
||||
the product should enforce now: low, medium, and high risk routes may proceed
|
||||
through controlled automation when allowlist, check-mode, verifier, rollback,
|
||||
KM, and Telegram receipts are present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from src.core.config import settings
|
||||
from src.services.report_generation_service import (
|
||||
DAILY_REPORT_HOUR_TAIPEI,
|
||||
MONTHLY_REPORT_DAY_TAIPEI,
|
||||
MONTHLY_REPORT_HOUR_TAIPEI,
|
||||
WEEKLY_REPORT_HOUR_TAIPEI,
|
||||
WEEKLY_REPORT_WEEKDAY_TAIPEI,
|
||||
)
|
||||
|
||||
_SCHEMA_VERSION = "ai_agent_autonomous_runtime_control_v1"
|
||||
_RUNTIME_AUTHORITY = "current_owner_directive_controlled_ai_automation"
|
||||
|
||||
|
||||
def _allowed_risk_levels() -> list[str]:
|
||||
raw = str(settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_ALLOWED_RISK_LEVELS or "")
|
||||
return sorted({item.strip().lower() for item in raw.split(",") if item.strip()})
|
||||
|
||||
|
||||
def build_ai_agent_autonomous_runtime_control() -> dict[str, Any]:
|
||||
"""Build the current AI Agent autonomy control-plane readback."""
|
||||
|
||||
allowed_risks = _allowed_risk_levels()
|
||||
report_cadences = [
|
||||
{
|
||||
"cadence": "daily",
|
||||
"display_name": "日報",
|
||||
"schedule": f"每日 {DAILY_REPORT_HOUR_TAIPEI:02d}:00 台北時間",
|
||||
"worker": "report_generation_service.run_daily_report_loop",
|
||||
"telegram_gateway_delivery_enabled": True,
|
||||
"direct_bot_api_allowed": False,
|
||||
"receipt_source": "daily_report_sent log + Telegram Gateway result",
|
||||
},
|
||||
{
|
||||
"cadence": "weekly",
|
||||
"display_name": "週報",
|
||||
"schedule": (
|
||||
f"每週五 {WEEKLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間"
|
||||
if WEEKLY_REPORT_WEEKDAY_TAIPEI == 4
|
||||
else f"每週 weekday={WEEKLY_REPORT_WEEKDAY_TAIPEI} {WEEKLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間"
|
||||
),
|
||||
"worker": "report_generation_service.run_weekly_report_loop",
|
||||
"telegram_gateway_delivery_enabled": True,
|
||||
"direct_bot_api_allowed": False,
|
||||
"receipt_source": "weekly_report_sent log + Telegram Gateway result",
|
||||
},
|
||||
{
|
||||
"cadence": "monthly",
|
||||
"display_name": "月報",
|
||||
"schedule": f"每月 {MONTHLY_REPORT_DAY_TAIPEI} 日 {MONTHLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間",
|
||||
"worker": "report_generation_service.run_monthly_report_loop",
|
||||
"telegram_gateway_delivery_enabled": True,
|
||||
"direct_bot_api_allowed": False,
|
||||
"receipt_source": "monthly_report_sent log + Telegram Gateway result",
|
||||
},
|
||||
]
|
||||
executor_receipts = [
|
||||
{
|
||||
"operation_type": "ansible_candidate_matched",
|
||||
"owner_agent": "Hermes",
|
||||
"purpose": "把修復候選寫入 executor 可認領佇列",
|
||||
"writes_runtime_state": False,
|
||||
},
|
||||
{
|
||||
"operation_type": "ansible_check_mode_executed",
|
||||
"owner_agent": "AwoooP Ansible check-mode worker",
|
||||
"purpose": "執行 ansible-playbook --check --diff 並留下乾跑收據",
|
||||
"writes_runtime_state": False,
|
||||
},
|
||||
{
|
||||
"operation_type": "ansible_apply_executed",
|
||||
"owner_agent": "AwoooP controlled apply worker",
|
||||
"purpose": "check-mode 通過後,對 allowlisted low / medium / high PlayBook 受控 apply",
|
||||
"writes_runtime_state": True,
|
||||
},
|
||||
{
|
||||
"operation_type": "incident_evidence.post_execution_state",
|
||||
"owner_agent": "post_apply_verifier",
|
||||
"purpose": "apply 後寫入 verifier 結果與 post-execution evidence",
|
||||
"writes_runtime_state": True,
|
||||
},
|
||||
{
|
||||
"operation_type": "knowledge_entries",
|
||||
"owner_agent": "Hermes",
|
||||
"purpose": "把已驗證執行沉澱成 KM / PlayBook trust 候選",
|
||||
"writes_runtime_state": True,
|
||||
},
|
||||
]
|
||||
hard_blockers = [
|
||||
"secret_token_private_key_cookie_session_auth_header_cleartext",
|
||||
"drop_truncate_restore_prune_destructive_database_operation",
|
||||
"reboot_node_drain_irreversible_firewall_or_host_lockout",
|
||||
"credentialed_exploit_or_external_active_scan",
|
||||
"new_paid_provider_cost_ceiling_or_provider_switch_without_replay_shadow_canary",
|
||||
"force_push_delete_repo_refs_or_visibility_change",
|
||||
"critical_or_break_glass_route_without_explicit_break_glass_contract",
|
||||
]
|
||||
legacy_overrides = [
|
||||
{
|
||||
"legacy_area": "report_status_board_no_live_send",
|
||||
"current_effect": "overridden",
|
||||
"new_behavior": "日報 / 週報 / 月報透過 Telegram Gateway 排程派送",
|
||||
},
|
||||
{
|
||||
"legacy_area": "report_live_delivery_owner_review_required",
|
||||
"current_effect": "overridden",
|
||||
"new_behavior": "報告派送走低/中/高風險自動化政策;critical 才 break-glass",
|
||||
},
|
||||
{
|
||||
"legacy_area": "high_risk_owner_review_queue",
|
||||
"current_effect": "overridden_for_high_non_critical",
|
||||
"new_behavior": "high 風險允許 controlled apply;critical / hard blocker 仍不自動",
|
||||
},
|
||||
{
|
||||
"legacy_area": "telegram_no_send_preview_only",
|
||||
"current_effect": "overridden",
|
||||
"new_behavior": "用 Telegram Gateway 實送報告與 actionable receipt;不直接暴露 Bot API",
|
||||
},
|
||||
]
|
||||
payload = {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"program_status": {
|
||||
"current_task_id": "P2-416-D1N",
|
||||
"status": "current_directive_control_plane_active",
|
||||
"runtime_authority": _RUNTIME_AUTHORITY,
|
||||
"legacy_no_send_no_live_rules_overridden": True,
|
||||
"implementation_completion_percent": 82,
|
||||
"status_note": (
|
||||
"目前有效規則:low / medium / high 風險由 AI Agent 在 allowlist、"
|
||||
"Ansible check-mode、verifier、rollback、KM 與 Telegram receipt 下受控自動處理。"
|
||||
),
|
||||
},
|
||||
"current_policy": {
|
||||
"low_risk_controlled_apply_allowed": "low" in allowed_risks,
|
||||
"medium_risk_controlled_apply_allowed": "medium" in allowed_risks,
|
||||
"high_risk_controlled_apply_allowed": "high" in allowed_risks,
|
||||
"critical_break_glass_required": True,
|
||||
"owner_review_required_for_low_medium_high": False,
|
||||
"direct_bot_api_allowed": False,
|
||||
"telegram_gateway_required": True,
|
||||
"post_apply_verifier_required": True,
|
||||
"km_learning_writeback_required": True,
|
||||
},
|
||||
"runtime_switches": {
|
||||
"ansible_check_mode_worker_enabled": bool(settings.ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER),
|
||||
"ansible_controlled_apply_enabled": bool(settings.ENABLE_AWOOOP_ANSIBLE_CONTROLLED_APPLY),
|
||||
"ansible_controlled_apply_allowed_risk_levels": allowed_risks,
|
||||
"ansible_check_mode_interval_seconds": settings.AWOOOP_ANSIBLE_CHECK_MODE_INTERVAL_SECONDS,
|
||||
"ansible_check_mode_batch_limit": settings.AWOOOP_ANSIBLE_CHECK_MODE_BATCH_LIMIT,
|
||||
"ansible_check_mode_timeout_seconds": settings.AWOOOP_ANSIBLE_CHECK_MODE_TIMEOUT_SECONDS,
|
||||
"ansible_controlled_apply_timeout_seconds": settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_TIMEOUT_SECONDS,
|
||||
},
|
||||
"agent_roles": [
|
||||
{
|
||||
"agent_id": "openclaw",
|
||||
"role": "仲裁 / hard blocker / replay-shadow-canary gate",
|
||||
"current_job": "只阻擋真正 critical 與 hard blocker,不再用身份保護舊架構",
|
||||
},
|
||||
{
|
||||
"agent_id": "hermes",
|
||||
"role": "報告 / Telegram digest / KM 與 PlayBook trust writeback",
|
||||
"current_job": "日週月報、收據摘要與 verifier 後學習沉澱",
|
||||
},
|
||||
{
|
||||
"agent_id": "nemotron",
|
||||
"role": "市場技術雷達 / no-write replay / challenger scorecard",
|
||||
"current_job": "用市場與回放數據挑戰 OpenClaw / provider / Agent 組合",
|
||||
},
|
||||
{
|
||||
"agent_id": "awooop_ansible_worker",
|
||||
"role": "executor",
|
||||
"current_job": "candidate → check-mode → controlled apply → verifier → KM",
|
||||
},
|
||||
{
|
||||
"agent_id": "telegram_ops",
|
||||
"role": "Telegram Gateway receipt",
|
||||
"current_job": "群組報告、actionable receipt、失敗告警;不展示敏感值或未脫敏資料",
|
||||
},
|
||||
],
|
||||
"report_delivery": {
|
||||
"status": "telegram_gateway_delivery_enabled",
|
||||
"cadences": report_cadences,
|
||||
},
|
||||
"controlled_executor": {
|
||||
"status": "check_mode_then_apply_enabled"
|
||||
if settings.ENABLE_AWOOOP_ANSIBLE_CONTROLLED_APPLY
|
||||
else "check_mode_only_by_config",
|
||||
"operation_receipts": executor_receipts,
|
||||
"required_flow": [
|
||||
"allowlisted_candidate",
|
||||
"ansible_check_mode_success",
|
||||
"controlled_apply",
|
||||
"post_apply_verifier",
|
||||
"auto_repair_execution_receipt",
|
||||
"km_learning_writeback",
|
||||
"telegram_receipt_or_alert",
|
||||
],
|
||||
},
|
||||
"legacy_policy_overrides": legacy_overrides,
|
||||
"hard_blockers": hard_blockers,
|
||||
"visibility_contract": {
|
||||
"frontend_displays_runtime_truth": True,
|
||||
"work_window_transcript_display_allowed": False,
|
||||
"prompt_body_display_allowed": False,
|
||||
"internal_reasoning_display_allowed": False,
|
||||
"sensitive_value_display_allowed": False,
|
||||
"telegram_unredacted_payload_display_allowed": False,
|
||||
"lan_topology_redaction_required": True,
|
||||
},
|
||||
"rollups": {
|
||||
"automated_risk_tier_count": sum(1 for risk in ("low", "medium", "high") if risk in allowed_risks),
|
||||
"hard_blocker_count": len(hard_blockers),
|
||||
"report_cadence_enabled_count": len(report_cadences),
|
||||
"telegram_gateway_delivery_enabled_count": sum(
|
||||
1 for item in report_cadences if item["telegram_gateway_delivery_enabled"]
|
||||
),
|
||||
"direct_bot_api_allowed_count": 0,
|
||||
"controlled_executor_operation_receipt_count": len(executor_receipts),
|
||||
"runtime_write_receipt_type_count": sum(
|
||||
1 for item in executor_receipts if item["writes_runtime_state"]
|
||||
),
|
||||
"legacy_policy_overridden_count": len(legacy_overrides),
|
||||
},
|
||||
}
|
||||
_validate_payload(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _validate_payload(payload: dict[str, Any]) -> None:
|
||||
if payload.get("schema_version") != _SCHEMA_VERSION:
|
||||
raise ValueError(f"schema_version must be {_SCHEMA_VERSION}")
|
||||
status = payload.get("program_status") or {}
|
||||
if status.get("runtime_authority") != _RUNTIME_AUTHORITY:
|
||||
raise ValueError(f"runtime_authority must be {_RUNTIME_AUTHORITY}")
|
||||
policy = payload.get("current_policy") or {}
|
||||
for key in (
|
||||
"low_risk_controlled_apply_allowed",
|
||||
"medium_risk_controlled_apply_allowed",
|
||||
"high_risk_controlled_apply_allowed",
|
||||
"telegram_gateway_required",
|
||||
"post_apply_verifier_required",
|
||||
"km_learning_writeback_required",
|
||||
):
|
||||
if policy.get(key) is not True:
|
||||
raise ValueError(f"current_policy.{key} must be true")
|
||||
if policy.get("owner_review_required_for_low_medium_high") is not False:
|
||||
raise ValueError("owner_review_required_for_low_medium_high must be false")
|
||||
if policy.get("direct_bot_api_allowed") is not False:
|
||||
raise ValueError("direct_bot_api_allowed must be false")
|
||||
visibility = payload.get("visibility_contract") or {}
|
||||
for key in (
|
||||
"work_window_transcript_display_allowed",
|
||||
"prompt_body_display_allowed",
|
||||
"internal_reasoning_display_allowed",
|
||||
"sensitive_value_display_allowed",
|
||||
"telegram_unredacted_payload_display_allowed",
|
||||
):
|
||||
if visibility.get(key) is not False:
|
||||
raise ValueError(f"visibility_contract.{key} must remain false")
|
||||
@@ -40,8 +40,12 @@ logger = structlog.get_logger(__name__)
|
||||
# 台北時區 (UTC+8)
|
||||
_TZ_TAIPEI = timezone(timedelta(hours=8))
|
||||
|
||||
# 日度報告觸發時間(台北時間 08:00)
|
||||
# 日 / 週 / 月報觸發時間(台北時間)
|
||||
DAILY_REPORT_HOUR_TAIPEI = 8
|
||||
WEEKLY_REPORT_WEEKDAY_TAIPEI = 4 # Friday, datetime.weekday(): Monday=0
|
||||
WEEKLY_REPORT_HOUR_TAIPEI = 10
|
||||
MONTHLY_REPORT_DAY_TAIPEI = 1
|
||||
MONTHLY_REPORT_HOUR_TAIPEI = 9
|
||||
|
||||
# Postmortem 觸發最低時長(分鐘)
|
||||
POSTMORTEM_MIN_DURATION_MINUTES = 10
|
||||
@@ -341,13 +345,13 @@ class ReportGenerationService:
|
||||
lines.append(" 只讀判讀:不自動改排程、不直接發修復、不取代人工批准。")
|
||||
return lines
|
||||
|
||||
def format_monthly_report_preview(
|
||||
def format_monthly_report(
|
||||
self,
|
||||
source_health: dict[str, Any] | None,
|
||||
*,
|
||||
generated_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Format a monthly no-send preview from the unified report source-health model."""
|
||||
"""Format the monthly report from the unified report source-health model."""
|
||||
now = generated_at or now_taipei()
|
||||
source_health = source_health or {}
|
||||
previews = source_health.get("no_send_previews") or []
|
||||
@@ -359,22 +363,31 @@ class ReportGenerationService:
|
||||
gap_text = ", ".join(str(gap_id) for gap_id in gap_ids[:5]) if gap_ids else "無"
|
||||
|
||||
lines = [
|
||||
"<b>📊 AWOOOI 月報 no-send preview</b>",
|
||||
"<b>📊 AWOOOI 月報</b>",
|
||||
f"<b>{now.strftime('%Y-%m')}</b> | {now.strftime('%Y-%m-%d %H:%M')} 台北時間",
|
||||
"",
|
||||
"<b>🧭 月報交付狀態</b>",
|
||||
f" 狀態: {html.escape(str(monthly_preview.get('delivery_state') or 'no_send_preview'))}",
|
||||
f" 狀態: {html.escape(str(monthly_preview.get('delivery_state') or 'telegram_gateway_delivery'))}",
|
||||
f" Owner: {html.escape(str(monthly_preview.get('owner_agent') or '未指定'))}",
|
||||
f" 缺口來源: {html.escape(gap_text)}",
|
||||
" 實發: 0 | Gateway queue write: 0",
|
||||
" 派送: Telegram Gateway | 回執: report_generation_service log / Gateway result",
|
||||
]
|
||||
lines.extend(self._format_report_source_health_block(source_health))
|
||||
lines += [
|
||||
"",
|
||||
"<i>🤖 AWOOOI 月報草案 | no-send preview,不代表已授權發送或自動修復</i>",
|
||||
"<i>🤖 AWOOOI 月報自動派送 | 低/中/高風險改善建議由 AI Agent 受控接手,critical / secrets / destructive 仍維持硬阻擋</i>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_monthly_report_preview(
|
||||
self,
|
||||
source_health: dict[str, Any] | None,
|
||||
*,
|
||||
generated_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Backward-compatible alias for callers that still ask for preview text."""
|
||||
return self.format_monthly_report(source_health, generated_at=generated_at)
|
||||
|
||||
def format_sre_digest_preview(
|
||||
self,
|
||||
source_health: dict[str, Any] | None,
|
||||
@@ -572,6 +585,26 @@ class ReportGenerationService:
|
||||
except Exception as e:
|
||||
logger.error("daily_report_failed", error=str(e))
|
||||
|
||||
async def send_monthly_report(self) -> bool:
|
||||
"""收集月報資料 → 組裝 → 推送 Telegram SRE 群組。"""
|
||||
try:
|
||||
source_health = await self.collect_report_source_health(days=30)
|
||||
report_text = self.format_monthly_report(source_health)
|
||||
|
||||
from src.services.telegram_gateway import get_telegram_gateway
|
||||
gateway = get_telegram_gateway()
|
||||
await gateway.send_to_group(report_text, parse_mode="HTML")
|
||||
|
||||
logger.info(
|
||||
"monthly_report_sent",
|
||||
source_ok=(source_health.get("rollups") or {}).get("source_ok_count"),
|
||||
source_total=(source_health.get("rollups") or {}).get("source_count"),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("monthly_report_failed", error=str(e))
|
||||
return False
|
||||
|
||||
async def trigger_postmortem(
|
||||
self,
|
||||
incident_id: str,
|
||||
@@ -767,6 +800,40 @@ def _seconds_until_next_report() -> float:
|
||||
return (target - now).total_seconds()
|
||||
|
||||
|
||||
def _seconds_until_next_weekly_report() -> float:
|
||||
"""計算距下一個週五 10:00 台北時間的秒數。"""
|
||||
now = now_taipei()
|
||||
target = now.replace(
|
||||
hour=WEEKLY_REPORT_HOUR_TAIPEI,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
days_until = (WEEKLY_REPORT_WEEKDAY_TAIPEI - now.weekday()) % 7
|
||||
target += timedelta(days=days_until)
|
||||
if now >= target:
|
||||
target += timedelta(days=7)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
|
||||
def _seconds_until_next_monthly_report() -> float:
|
||||
"""計算距下一個每月 1 日 09:00 台北時間的秒數。"""
|
||||
now = now_taipei()
|
||||
target = now.replace(
|
||||
day=MONTHLY_REPORT_DAY_TAIPEI,
|
||||
hour=MONTHLY_REPORT_HOUR_TAIPEI,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
if now >= target:
|
||||
if now.month == 12:
|
||||
target = target.replace(year=now.year + 1, month=1)
|
||||
else:
|
||||
target = target.replace(month=now.month + 1)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
|
||||
async def run_daily_report_loop() -> None:
|
||||
"""
|
||||
日度巡檢報告無限排程迴圈
|
||||
@@ -801,6 +868,63 @@ async def run_daily_report_loop() -> None:
|
||||
await service.send_daily_report()
|
||||
|
||||
|
||||
async def run_weekly_report_loop() -> None:
|
||||
"""週報排程迴圈:每週五 10:00 台北時間發送到 Telegram。"""
|
||||
logger.info(
|
||||
"weekly_report_loop_started",
|
||||
weekday_taipei=WEEKLY_REPORT_WEEKDAY_TAIPEI,
|
||||
trigger_hour_taipei=WEEKLY_REPORT_HOUR_TAIPEI,
|
||||
)
|
||||
|
||||
while True:
|
||||
sleep_seconds = _seconds_until_next_weekly_report()
|
||||
logger.info(
|
||||
"weekly_report_next_in",
|
||||
sleep_seconds=int(sleep_seconds),
|
||||
next_at=f"Friday {WEEKLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間",
|
||||
)
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
|
||||
from src.services.ai_advisory_helpers import try_acquire_daily_lock
|
||||
if not await try_acquire_daily_lock("weekly_report"):
|
||||
logger.info("weekly_report_skipped_other_pod")
|
||||
continue
|
||||
|
||||
logger.info("weekly_report_triggered")
|
||||
try:
|
||||
from src.services.weekly_report_service import get_weekly_report_service
|
||||
await get_weekly_report_service().send_weekly_report()
|
||||
except Exception as exc:
|
||||
logger.error("weekly_report_loop_failed", error=str(exc))
|
||||
|
||||
|
||||
async def run_monthly_report_loop() -> None:
|
||||
"""月報排程迴圈:每月 1 日 09:00 台北時間發送到 Telegram。"""
|
||||
service = ReportGenerationService()
|
||||
logger.info(
|
||||
"monthly_report_loop_started",
|
||||
day_taipei=MONTHLY_REPORT_DAY_TAIPEI,
|
||||
trigger_hour_taipei=MONTHLY_REPORT_HOUR_TAIPEI,
|
||||
)
|
||||
|
||||
while True:
|
||||
sleep_seconds = _seconds_until_next_monthly_report()
|
||||
logger.info(
|
||||
"monthly_report_next_in",
|
||||
sleep_seconds=int(sleep_seconds),
|
||||
next_at=f"day {MONTHLY_REPORT_DAY_TAIPEI} {MONTHLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間",
|
||||
)
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
|
||||
from src.services.ai_advisory_helpers import try_acquire_daily_lock
|
||||
if not await try_acquire_daily_lock("monthly_report"):
|
||||
logger.info("monthly_report_skipped_other_pod")
|
||||
continue
|
||||
|
||||
logger.info("monthly_report_triggered")
|
||||
await service.send_monthly_report()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Factory Function
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user