fix: schedule timezone bug + missing notifications + celery beat startup
- Timezone: compute_next_run now correctly interprets cron in the schedule's configured timezone (e.g., "0 8 * * *" with Asia/Shanghai = 8AM Beijing, not UTC) - Notifications: agent_tasks now reuses pre-created execution records and calls notify_schedule_result on completion, so non-workflow agent schedules get DB notifications + Feishu webhook + Feishu app messages - Duplicate execution: execute_agent_task accepts optional execution_id to reuse the record created by schedule_service instead of creating a second one - Celery Beat: added to restart_backend_celery.ps1, stop_aiagent.ps1, and docker-compose.dev.yml; fixed repo-root path resolution in all PS1 scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -95,7 +95,7 @@ async def create_schedule(
|
||||
|
||||
# 验证 cron 表达式
|
||||
try:
|
||||
next_run = compute_next_run(data.cron_expression)
|
||||
next_run = compute_next_run(data.cron_expression, tz=data.timezone or "Asia/Shanghai")
|
||||
except (ValueError, KeyError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"cron 表达式无效: {e}")
|
||||
|
||||
@@ -138,7 +138,7 @@ async def update_schedule(
|
||||
schedule.name = data.name
|
||||
if data.cron_expression is not None:
|
||||
try:
|
||||
schedule.next_run_at = compute_next_run(data.cron_expression, after=datetime.utcnow())
|
||||
schedule.next_run_at = compute_next_run(data.cron_expression, after=datetime.utcnow(), tz=data.timezone or schedule.timezone or "Asia/Shanghai")
|
||||
schedule.cron_expression = data.cron_expression
|
||||
except (ValueError, KeyError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"cron 表达式无效: {e}")
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import pytz
|
||||
from croniter import croniter
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -12,22 +13,33 @@ from app.core.database import SessionLocal
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def compute_next_run(cron_expression: str, after: Optional[datetime] = None) -> datetime:
|
||||
def compute_next_run(cron_expression: str, after: Optional[datetime] = None, tz: str = "UTC") -> datetime:
|
||||
"""根据 cron 表达式计算下一次执行时间。
|
||||
|
||||
Args:
|
||||
cron_expression: 标准 5 位 cron 表达式,如 "0 9 * * *"
|
||||
after: 从哪个时间点开始计算,默认为当前 UTC 时间
|
||||
tz: 时区名称,如 "Asia/Shanghai",cron 表达式在该时区下解释
|
||||
|
||||
Returns:
|
||||
下次执行的 UTC datetime
|
||||
下次执行的 UTC datetime(naive,存储时与 datetime.utcnow() 对齐)
|
||||
"""
|
||||
base = after or datetime.now(timezone.utc)
|
||||
# croniter 需要以 datetime 对象作为基准
|
||||
base_naive = base.replace(tzinfo=None) if base.tzinfo else base
|
||||
# 将 UTC 时间转换为目标时区,cron 表达式在目标时区下解释
|
||||
try:
|
||||
target_tz = pytz.timezone(tz)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
target_tz = pytz.UTC
|
||||
base_in_tz = base.astimezone(target_tz)
|
||||
# croniter 需要 naive datetime,在目标时区下计算
|
||||
base_naive = base_in_tz.replace(tzinfo=None)
|
||||
cron = croniter(cron_expression, base_naive)
|
||||
next_dt = cron.get_next(datetime)
|
||||
return next_dt
|
||||
next_naive = cron.get_next(datetime)
|
||||
# 将结果 localize 回目标时区,再转 UTC
|
||||
next_in_tz = target_tz.localize(next_naive)
|
||||
next_utc = next_in_tz.astimezone(pytz.UTC)
|
||||
# 返回 naive UTC(与 datetime.utcnow() 对齐)
|
||||
return next_utc.replace(tzinfo=None)
|
||||
|
||||
|
||||
def create_execution_for_schedule(db: Session, schedule) -> Optional[str]:
|
||||
@@ -69,11 +81,12 @@ def create_execution_for_schedule(db: Session, schedule) -> Optional[str]:
|
||||
{"message": schedule.input_message},
|
||||
)
|
||||
else:
|
||||
# 无工作流配置:走简单 Agent 异步执行
|
||||
# 无工作流配置:走简单 Agent 异步执行(传入已创建的 execution_id)
|
||||
from app.tasks.agent_tasks import execute_agent_task
|
||||
task = execute_agent_task.delay(
|
||||
str(schedule.agent_id),
|
||||
{"message": schedule.input_message},
|
||||
execution_id=str(execution.id),
|
||||
)
|
||||
execution.task_id = task.id
|
||||
execution.status = "running"
|
||||
@@ -124,8 +137,8 @@ def check_and_run_due_schedules() -> int:
|
||||
# 更新定时任务状态
|
||||
sched.last_run_at = now
|
||||
sched.last_run_status = "success"
|
||||
# 计算下次执行时间
|
||||
sched.next_run_at = compute_next_run(sched.cron_expression, after=now)
|
||||
# 计算下次执行时间(使用定时任务配置的时区)
|
||||
sched.next_run_at = compute_next_run(sched.cron_expression, after=now, tz=sched.timezone or "UTC")
|
||||
db.commit()
|
||||
triggered += 1
|
||||
logger.info(
|
||||
@@ -145,3 +158,91 @@ def check_and_run_due_schedules() -> int:
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
|
||||
def notify_schedule_result(db: Session, execution, status: str, error_message: Optional[str] = None) -> None:
|
||||
"""如果 execution 关联了定时任务,创建通知并推送飞书消息。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
execution: Execution ORM 对象
|
||||
status: "completed" 或 "failed"
|
||||
error_message: 失败时的错误信息
|
||||
"""
|
||||
if not execution or not execution.schedule_id:
|
||||
return
|
||||
try:
|
||||
from app.models.agent_schedule import AgentSchedule
|
||||
from app.services.notification_service import create_notification
|
||||
|
||||
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == execution.schedule_id).first()
|
||||
if not schedule:
|
||||
return
|
||||
|
||||
if status == "completed":
|
||||
title = f"定时任务「{schedule.name}」执行成功"
|
||||
content = f"Agent 已按计划执行完成。"
|
||||
else:
|
||||
title = f"定时任务「{schedule.name}」执行失败"
|
||||
content = f"错误信息: {error_message or '未知错误'}"
|
||||
|
||||
create_notification(
|
||||
db,
|
||||
user_id=schedule.user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
category="schedule",
|
||||
ref_type="execution",
|
||||
ref_id=str(execution.id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 如果配置了飞书 webhook,发送飞书通知(非阻塞,失败不影响主流程)
|
||||
if schedule.webhook_url:
|
||||
try:
|
||||
from app.services.feishu_notifier import send_feishu_card
|
||||
|
||||
detail_link = None
|
||||
try:
|
||||
from app.core.config import settings
|
||||
if settings.EXTERNAL_URL:
|
||||
detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
send_feishu_card(
|
||||
webhook_url=schedule.webhook_url,
|
||||
title=title,
|
||||
body=content,
|
||||
status=status,
|
||||
detail_link=detail_link,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("飞书 webhook 通知发送失败: %s", e)
|
||||
|
||||
# 如果用户绑定了飞书账号,通过飞书应用发送通知
|
||||
try:
|
||||
from app.models.user import User
|
||||
from app.services.feishu_app_service import send_message_to_user
|
||||
|
||||
schedule_user = db.query(User).filter(User.id == schedule.user_id).first()
|
||||
if schedule_user and schedule_user.feishu_open_id:
|
||||
detail_link = None
|
||||
try:
|
||||
from app.core.config import settings
|
||||
if settings.EXTERNAL_URL:
|
||||
detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
send_message_to_user(
|
||||
open_id=schedule_user.feishu_open_id,
|
||||
title=title,
|
||||
content=content,
|
||||
status=status,
|
||||
detail_link=detail_link,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("飞书应用通知发送失败: %s", e)
|
||||
except Exception as e:
|
||||
logger.warning("创建定时任务通知失败: %s", e)
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.models.execution import Execution
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,16 +78,20 @@ def _build_agent_config_from_db(agent: Agent) -> AgentConfig:
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def execute_agent_task(self, agent_id: str, input_data: dict):
|
||||
def execute_agent_task(self, agent_id: str, input_data: dict, execution_id: Optional[str] = None):
|
||||
"""异步执行 Agent 任务。
|
||||
|
||||
由定时调度 (check_agent_schedules_task) 或手动 API 触发。
|
||||
创建 Execution 记录,运行 Agent,更新结果。
|
||||
如果传入 execution_id(由 schedule_service 预创建),则复用该记录;
|
||||
否则创建新的 Execution 记录。运行 Agent 后更新结果并发送通知。
|
||||
|
||||
Args:
|
||||
agent_id: Agent ID
|
||||
input_data: 输入数据,至少包含 "message" 字段
|
||||
execution_id: 可选,预创建的 execution 记录 ID(定时任务场景)
|
||||
"""
|
||||
from app.services.agent_schedule_service import notify_schedule_result
|
||||
|
||||
db = SessionLocal()
|
||||
start_time = time.time()
|
||||
|
||||
@@ -99,14 +104,21 @@ def execute_agent_task(self, agent_id: str, input_data: dict):
|
||||
if not user_message:
|
||||
return {"status": "error", "detail": "缺少 message 输入"}
|
||||
|
||||
# 创建执行记录
|
||||
execution = Execution(
|
||||
agent_id=agent_id,
|
||||
input_data=input_data,
|
||||
status="running",
|
||||
)
|
||||
db.add(execution)
|
||||
db.flush()
|
||||
# 复用预创建的 execution 记录,或新建
|
||||
if execution_id:
|
||||
execution = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if not execution:
|
||||
return {"status": "error", "detail": f"Execution {execution_id} 不存在"}
|
||||
execution.status = "running"
|
||||
db.commit()
|
||||
else:
|
||||
execution = Execution(
|
||||
agent_id=agent_id,
|
||||
input_data=input_data,
|
||||
status="running",
|
||||
)
|
||||
db.add(execution)
|
||||
db.flush()
|
||||
|
||||
# 更新 Celery 任务状态
|
||||
self.update_state(
|
||||
@@ -137,6 +149,9 @@ def execute_agent_task(self, agent_id: str, input_data: dict):
|
||||
execution.execution_time = execution_time
|
||||
db.commit()
|
||||
|
||||
# 定时任务结果通知
|
||||
notify_schedule_result(db, execution, "completed")
|
||||
|
||||
logger.info(
|
||||
"Agent 异步执行完成: agent=%s execution=%s time=%dms",
|
||||
agent_id, execution.id, execution_time,
|
||||
@@ -154,6 +169,10 @@ def execute_agent_task(self, agent_id: str, input_data: dict):
|
||||
execution.error_message = result.error or "Agent 执行返回失败"
|
||||
execution.execution_time = int((time.time() - start_time) * 1000)
|
||||
db.commit()
|
||||
|
||||
# 定时任务失败通知
|
||||
notify_schedule_result(db, execution, "failed", error_message=execution.error_message)
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
"execution_id": str(execution.id),
|
||||
@@ -165,6 +184,10 @@ def execute_agent_task(self, agent_id: str, input_data: dict):
|
||||
execution.error_message = f"Agent 执行异常: {run_e!s}"
|
||||
execution.execution_time = execution_time
|
||||
db.commit()
|
||||
|
||||
# 定时任务失败通知
|
||||
notify_schedule_result(db, execution, "failed", error_message=execution.error_message)
|
||||
|
||||
logger.error("Agent 异步执行异常: agent=%s error=%s", agent_id, run_e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.models.agent import Agent
|
||||
from app.models.workflow import Workflow
|
||||
from app.services.execution_budget import merge_budget_for_execution
|
||||
from app.services.agent_workspace_chat_log import try_append_agent_dialogue_after_success
|
||||
from app.services.notification_service import create_notification
|
||||
from app.services.agent_schedule_service import notify_schedule_result
|
||||
from app.websocket.manager import websocket_manager
|
||||
import asyncio
|
||||
import json
|
||||
@@ -80,92 +80,6 @@ def _trusted_user_for_execution(db, execution: Optional[Execution]) -> Optional[
|
||||
return None
|
||||
|
||||
|
||||
def _notify_schedule_result(
|
||||
db,
|
||||
execution,
|
||||
status: str,
|
||||
error_message: Optional[str] = None,
|
||||
):
|
||||
"""如果 execution 关联了定时任务,创建通知推送结果给用户。"""
|
||||
if not execution or not execution.schedule_id:
|
||||
return
|
||||
try:
|
||||
from app.models.agent_schedule import AgentSchedule
|
||||
|
||||
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == execution.schedule_id).first()
|
||||
if not schedule:
|
||||
return
|
||||
|
||||
if status == "completed":
|
||||
title = f"定时任务「{schedule.name}」执行成功"
|
||||
content = f"Agent 已按计划执行完成。"
|
||||
else:
|
||||
title = f"定时任务「{schedule.name}」执行失败"
|
||||
content = f"错误信息: {error_message or '未知错误'}"
|
||||
|
||||
create_notification(
|
||||
db,
|
||||
user_id=schedule.user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
category="schedule",
|
||||
ref_type="execution",
|
||||
ref_id=str(execution.id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 如果配置了飞书 webhook,发送飞书通知(非阻塞,失败不影响主流程)
|
||||
if schedule.webhook_url:
|
||||
try:
|
||||
from app.services.feishu_notifier import send_feishu_card
|
||||
|
||||
detail_link = None
|
||||
# 如果系统配置了外部访问地址,拼接 execution 详情链接
|
||||
try:
|
||||
from app.core.config import settings
|
||||
if settings.EXTERNAL_URL:
|
||||
detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
send_feishu_card(
|
||||
webhook_url=schedule.webhook_url,
|
||||
title=title,
|
||||
body=content,
|
||||
status=status,
|
||||
detail_link=detail_link,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("飞书 webhook 通知发送失败: %s", e)
|
||||
|
||||
# 如果用户绑定了飞书账号,通过飞书应用发送通知
|
||||
try:
|
||||
from app.models.user import User
|
||||
from app.services.feishu_app_service import send_message_to_user
|
||||
|
||||
schedule_user = db.query(User).filter(User.id == schedule.user_id).first()
|
||||
if schedule_user and schedule_user.feishu_open_id:
|
||||
detail_link = None
|
||||
try:
|
||||
from app.core.config import settings
|
||||
if settings.EXTERNAL_URL:
|
||||
detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
send_message_to_user(
|
||||
open_id=schedule_user.feishu_open_id,
|
||||
title=title,
|
||||
content=content,
|
||||
status=status,
|
||||
detail_link=detail_link,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("飞书应用通知发送失败: %s", e)
|
||||
except Exception as e:
|
||||
logger.warning("创建定时任务通知失败: %s", e)
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def execute_workflow_task(
|
||||
self,
|
||||
@@ -287,7 +201,7 @@ def execute_workflow_task(
|
||||
execution_logger.warn(f"告警检测失败: {str(e)}")
|
||||
|
||||
# 定时任务结果通知
|
||||
_notify_schedule_result(db, execution, "completed")
|
||||
notify_schedule_result(db, execution, "completed")
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
@@ -331,7 +245,7 @@ def execute_workflow_task(
|
||||
execution_logger.warn(f"告警检测失败: {str(e2)}")
|
||||
|
||||
# 定时任务失败通知
|
||||
_notify_schedule_result(db, execution, "failed", error_message=err_text)
|
||||
notify_schedule_result(db, execution, "failed", error_message=err_text)
|
||||
|
||||
raise
|
||||
|
||||
|
||||
Reference in New Issue
Block a user