第一次提交
This commit is contained in:
6
app-instance/backend/nanobot/cron/__init__.py
Normal file
6
app-instance/backend/nanobot/cron/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Cron service for scheduled agent tasks."""
|
||||
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob, CronSchedule
|
||||
|
||||
__all__ = ["CronService", "CronJob", "CronSchedule"]
|
||||
116
app-instance/backend/nanobot/cron/runtime.py
Normal file
116
app-instance/backend/nanobot/cron/runtime.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""cron 任务运行时辅助逻辑。
|
||||
|
||||
这里负责把已经到点的 `CronJob` 真正翻译成一次可执行动作:
|
||||
1. 纯提醒型任务:直接向目标会话投递消息;
|
||||
2. agent task 型任务:构造自动执行上下文,再交给 `AgentLoop.process_direct()`;
|
||||
3. 额外注入 `cron_action` 工具,让模型可以反向控制后续调度。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.cron_action import CronActionTool
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.cron.types import CronExecutionResult, CronJob
|
||||
|
||||
|
||||
async def _deliver_response(
|
||||
bus: MessageBus,
|
||||
*,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
content: str | None,
|
||||
) -> None:
|
||||
# cron 统一通过 outbound 消息回到现有渠道层,避免绕开原有发送链路。
|
||||
await bus.publish_outbound(OutboundMessage(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
content=content or "",
|
||||
))
|
||||
|
||||
|
||||
def _describe_schedule(job: CronJob) -> str:
|
||||
"""把调度对象转成面向模型的简短文本。"""
|
||||
if job.schedule.kind == "every":
|
||||
every_ms = job.schedule.every_ms or 0
|
||||
return f"every {every_ms // 1000}s"
|
||||
if job.schedule.kind == "cron":
|
||||
return job.schedule.expr or "cron"
|
||||
return "one-time"
|
||||
|
||||
|
||||
def _resolve_session_key(job: CronJob) -> str:
|
||||
"""为 cron task 选择一个应复用的会话 key。"""
|
||||
# 优先使用显式记录的 session_key,这样任务型 cron 可以延续原短期上下文。
|
||||
if job.payload.session_key:
|
||||
return job.payload.session_key
|
||||
# 如果老数据没有 session_key,但有 channel/to,则退化为路由键。
|
||||
if job.payload.channel and job.payload.to:
|
||||
return f"{job.payload.channel}:{job.payload.to}"
|
||||
# 再兜底到 cron 自己的命名空间,保证始终能生成稳定 key。
|
||||
return f"cron:{job.id}"
|
||||
|
||||
|
||||
def _build_execution_context(job: CronJob, session_key: str) -> str:
|
||||
"""构造注入给 agent 的自动执行上下文说明。"""
|
||||
schedule = _describe_schedule(job)
|
||||
return f"""This turn was triggered automatically by a scheduled cron job.
|
||||
|
||||
Job ID: {job.id}
|
||||
Job Name: {job.name}
|
||||
Schedule: {schedule}
|
||||
Origin Session: {session_key}
|
||||
|
||||
You are in autonomous scheduled-task mode:
|
||||
- This is not an interactive user turn.
|
||||
- Do not ask the user what to do next.
|
||||
- Execute the task, make the necessary tool calls, and report the concrete outcome.
|
||||
- If the task has reached a terminal condition, natural stopping point, or no longer needs future runs, emit a structured cron_action tool call instead of only describing it in text.
|
||||
- Use cron_action(action="complete_today", reason="...") when today's batch is complete and the job should resume next cycle.
|
||||
- Use cron_action(action="remove", reason="...") to delete the current job permanently.
|
||||
- Use cron_action(action="disable", reason="...") to stop the current job without deleting it.
|
||||
- Use cron_action(action="reschedule", ...) to change the current job's schedule deterministically.
|
||||
- Use the regular cron tool only if you truly need to inspect or manage additional jobs beyond the current one.
|
||||
"""
|
||||
|
||||
|
||||
async def run_cron_job(
|
||||
job: CronJob,
|
||||
*,
|
||||
agent: Any,
|
||||
bus: MessageBus,
|
||||
default_channel: str,
|
||||
default_chat_id: str,
|
||||
) -> CronExecutionResult:
|
||||
"""Execute one cron job according to its payload kind."""
|
||||
# deliver 目标允许任务使用自己的渠道配置,否则落回默认 web 会话。
|
||||
channel = job.payload.channel or default_channel
|
||||
chat_id = job.payload.to or default_chat_id
|
||||
|
||||
if job.payload.kind == "system_event":
|
||||
# 提醒模式不需要再过一层 agent 推理,直接把原消息投递给目标会话。
|
||||
message = job.payload.message
|
||||
if job.payload.deliver and job.payload.to:
|
||||
await _deliver_response(bus, channel=channel, chat_id=job.payload.to, content=message)
|
||||
return CronExecutionResult(response=message)
|
||||
|
||||
# task 模式会进入 agent 主循环,因此要准备复用的 session key 和运行说明。
|
||||
session_key = _resolve_session_key(job)
|
||||
execution_context = _build_execution_context(job, session_key)
|
||||
# 把 cron_action 作为“附加工具”注入,仅对当前这次 cron 执行生效。
|
||||
action_tool = CronActionTool(job.id)
|
||||
response = await agent.process_direct(
|
||||
content=job.payload.message,
|
||||
session_key=session_key,
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
execution_context=execution_context,
|
||||
extra_tools=[action_tool],
|
||||
)
|
||||
# 若任务要求把最终结果投递出去,则沿用正常 outbound 消息链路。
|
||||
if job.payload.deliver and job.payload.to:
|
||||
await _deliver_response(bus, channel=channel, chat_id=job.payload.to, content=response)
|
||||
# runtime 同时返回文本结果和结构化动作,供 CronService 后续处理。
|
||||
return CronExecutionResult(response=response, action=action_tool.decision)
|
||||
583
app-instance/backend/nanobot/cron/service.py
Normal file
583
app-instance/backend/nanobot/cron/service.py
Normal file
@ -0,0 +1,583 @@
|
||||
"""Cron 调度服务(持久化 + 计算下一次触发 + 定时执行)。
|
||||
|
||||
这个模块是 nanobot 的“计划任务内核”,职责边界如下:
|
||||
1. 数据层:把任务状态持久化到 `jobs.json`,并在内存维护一个 `CronStore` 缓存;
|
||||
2. 调度层:根据 `at / every / cron` 规则计算每个任务的下一次触发时间;
|
||||
3. 执行层:在任务到点时调用 `on_job` 回调(通常由 gateway 注入,转到 agent 执行);
|
||||
4. 管理层:提供增删改查、启停、手动触发等公共 API。
|
||||
|
||||
关键设计点:
|
||||
- 单计时器模型:始终只保留“最近一次触发点”的 `asyncio.Task`,
|
||||
避免“每个任务一个 sleep 协程”导致的资源膨胀;
|
||||
- 懒加载存储:首次访问才读盘,后续以内存对象为准,写操作再落盘;
|
||||
- 容错优先:配置/解析异常尽量降级为空任务或不可调度,不让主服务崩溃。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Coroutine, Literal
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.cron.types import (
|
||||
CronAction,
|
||||
CronExecutionResult,
|
||||
CronJob,
|
||||
CronJobState,
|
||||
CronPayload,
|
||||
CronSchedule,
|
||||
CronStore,
|
||||
)
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
"""返回当前 Unix 时间戳(毫秒,基于系统墙钟时间)。"""
|
||||
# 这里使用 wall-clock(time.time),因为 cron 语义本身就是“现实时间点”。
|
||||
# 若改用 monotonic,则无法直接表达“今天 9:00”这种绝对时刻。
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
|
||||
"""计算下一次运行时间(毫秒时间戳)。
|
||||
|
||||
返回 None 表示该任务当前不可运行(如参数非法、时间已过或 cron 解析失败)。
|
||||
"""
|
||||
if schedule.kind == "at":
|
||||
# 一次性定时:仅当目标时间晚于“现在”才有效。
|
||||
return schedule.at_ms if schedule.at_ms and schedule.at_ms > now_ms else None
|
||||
|
||||
if schedule.kind == "every":
|
||||
if not schedule.every_ms or schedule.every_ms <= 0:
|
||||
return None
|
||||
# 固定间隔任务:以“当前时刻 + 间隔”作为下一次触发点。
|
||||
# 注意这里不做“对齐”计算(例如每分钟整点),仅做相对延迟:
|
||||
# - 优点:实现简单、行为稳定;
|
||||
# - 代价:若执行耗时较长,长期看会有“相位漂移”(不保证卡在固定秒位)。
|
||||
return now_ms + schedule.every_ms
|
||||
|
||||
if schedule.kind == "cron" and schedule.expr:
|
||||
try:
|
||||
from croniter import croniter
|
||||
from zoneinfo import ZoneInfo
|
||||
# 使用调用方传入的 now_ms 作为基准,保证在同一输入下行为可预测。
|
||||
base_time = now_ms / 1000
|
||||
# 未指定 tz 时,退回到当前系统本地时区。
|
||||
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
|
||||
base_dt = datetime.fromtimestamp(base_time, tz=tz)
|
||||
cron = croniter(schedule.expr, base_dt)
|
||||
next_dt = cron.get_next(datetime)
|
||||
return int(next_dt.timestamp() * 1000)
|
||||
except Exception:
|
||||
# 调度表达式或时区非法时,返回 None 让上层把任务视为不可调度。
|
||||
# 这里吞掉异常是有意设计:单个坏任务不应拖垮整个调度器。
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_schedule_for_add(schedule: CronSchedule) -> None:
|
||||
"""在创建任务前做必要校验,避免写入明显不可执行的调度。"""
|
||||
# 只有 cron 表达式支持时区字段,at/every 传 tz 视为配置错误。
|
||||
if schedule.tz and schedule.kind != "cron":
|
||||
raise ValueError("tz can only be used with cron schedules")
|
||||
|
||||
if schedule.kind == "cron" and schedule.tz:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ZoneInfo(schedule.tz)
|
||||
except Exception:
|
||||
raise ValueError(f"unknown timezone '{schedule.tz}'") from None
|
||||
|
||||
|
||||
_DAILY_LIMIT_PATTERNS = [
|
||||
re.compile(r"今日.*已达.*上限"),
|
||||
re.compile(r"已达\d+支上限"),
|
||||
re.compile(r"停止介绍"),
|
||||
re.compile(r"daily (?:cap|limit).*(?:reached|hit)", re.IGNORECASE),
|
||||
re.compile(r"today.*(?:reached|hit).*(?:cap|limit)", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def _looks_like_daily_limit_reached(response: str | None) -> bool:
|
||||
if not response:
|
||||
return False
|
||||
probe = response.strip()
|
||||
if not probe:
|
||||
return False
|
||||
return any(pattern.search(probe) for pattern in _DAILY_LIMIT_PATTERNS)
|
||||
|
||||
|
||||
def _next_daily_cycle_start_ms(job: CronJob, now_ms: int) -> int:
|
||||
"""Pick the next local-day anchor time for finite daily batch jobs."""
|
||||
tz = datetime.now().astimezone().tzinfo
|
||||
now_dt = datetime.fromtimestamp(now_ms / 1000, tz=tz)
|
||||
anchor_source_ms = job.created_at_ms or now_ms
|
||||
anchor_dt = datetime.fromtimestamp(anchor_source_ms / 1000, tz=tz)
|
||||
candidate = now_dt.replace(
|
||||
hour=anchor_dt.hour,
|
||||
minute=anchor_dt.minute,
|
||||
second=anchor_dt.second,
|
||||
microsecond=anchor_dt.microsecond,
|
||||
) + timedelta(days=1)
|
||||
return int(candidate.timestamp() * 1000)
|
||||
|
||||
|
||||
def _schedule_from_action(action: CronAction) -> CronSchedule:
|
||||
if action.every_seconds is not None:
|
||||
return CronSchedule(kind="every", every_ms=action.every_seconds * 1000)
|
||||
if action.cron_expr:
|
||||
return CronSchedule(kind="cron", expr=action.cron_expr, tz=action.tz)
|
||||
if action.at:
|
||||
dt = datetime.fromisoformat(action.at)
|
||||
return CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
|
||||
raise ValueError("reschedule action requires exactly one schedule field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ActionOutcome:
|
||||
removed: bool = False
|
||||
explicit_next_run: bool = False
|
||||
managed_next_run_at_ms: int | None = None
|
||||
|
||||
|
||||
_CronCallbackResult = str | CronExecutionResult | None
|
||||
|
||||
|
||||
class CronService:
|
||||
"""管理并执行定时任务的服务对象。
|
||||
|
||||
运行模型(事件循环内):
|
||||
1. `start()` 时加载 store、重算 next_run、挂载单计时器;
|
||||
2. 计时器唤醒后 `_on_timer()` 找到到期任务并顺序执行;
|
||||
3. 每次状态变化后都 `_save_store()` + `_arm_timer()`,保持数据与调度一致。
|
||||
|
||||
并发假设:
|
||||
- 默认在同一个 asyncio 事件循环线程内被调用;
|
||||
- 代码未显式加锁,不保证跨线程并发安全;
|
||||
- 若要跨线程/多进程共享,应加文件锁或迁移到数据库事务模型。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store_path: Path,
|
||||
on_job: Callable[[CronJob], Coroutine[Any, Any, _CronCallbackResult]] | None = None,
|
||||
):
|
||||
# 任务持久化文件(默认:~/.nanobot/data/cron/jobs.json)。
|
||||
self.store_path = store_path
|
||||
# 任务执行回调:由 gateway 注入,用于真正触发 agent 处理。
|
||||
# CLI 仅做任务管理时可以不传(保持 None)。
|
||||
self.on_job = on_job
|
||||
# `_store` 采用懒加载;首次访问时才读盘。
|
||||
self._store: CronStore | None = None
|
||||
# 全局只维护一个“最近唤醒点”的计时任务,减少无效 wake-up。
|
||||
self._timer_task: asyncio.Task | None = None
|
||||
# 服务开关:只要 stop() 把它置 False,计时器回调会自然短路退出。
|
||||
self._running = False
|
||||
|
||||
def _load_store(self) -> CronStore:
|
||||
"""从磁盘加载任务到内存(懒加载 + 内存缓存)。"""
|
||||
if self._store:
|
||||
# 已加载过直接返回内存对象,避免频繁磁盘 IO。
|
||||
return self._store
|
||||
|
||||
if self.store_path.exists():
|
||||
try:
|
||||
data = json.loads(self.store_path.read_text(encoding="utf-8"))
|
||||
jobs = []
|
||||
for j in data.get("jobs", []):
|
||||
# 反序列化时字段采用“宽松读取”:
|
||||
# - 新老版本缺失字段尽量给默认值;
|
||||
# - 以最大兼容性优先,减少升级时配置爆炸。
|
||||
jobs.append(CronJob(
|
||||
id=j["id"],
|
||||
name=j["name"],
|
||||
enabled=j.get("enabled", True),
|
||||
schedule=CronSchedule(
|
||||
kind=j["schedule"]["kind"],
|
||||
at_ms=j["schedule"].get("atMs"),
|
||||
every_ms=j["schedule"].get("everyMs"),
|
||||
expr=j["schedule"].get("expr"),
|
||||
tz=j["schedule"].get("tz"),
|
||||
),
|
||||
payload=CronPayload(
|
||||
kind=j["payload"].get("kind", "agent_turn"),
|
||||
message=j["payload"].get("message", ""),
|
||||
session_key=j["payload"].get("sessionKey"),
|
||||
deliver=j["payload"].get("deliver", False),
|
||||
channel=j["payload"].get("channel"),
|
||||
to=j["payload"].get("to"),
|
||||
),
|
||||
state=CronJobState(
|
||||
next_run_at_ms=j.get("state", {}).get("nextRunAtMs"),
|
||||
last_run_at_ms=j.get("state", {}).get("lastRunAtMs"),
|
||||
last_status=j.get("state", {}).get("lastStatus"),
|
||||
last_error=j.get("state", {}).get("lastError"),
|
||||
),
|
||||
created_at_ms=j.get("createdAtMs", 0),
|
||||
updated_at_ms=j.get("updatedAtMs", 0),
|
||||
delete_after_run=j.get("deleteAfterRun", False),
|
||||
))
|
||||
self._store = CronStore(jobs=jobs)
|
||||
except Exception as e:
|
||||
# 文件损坏或结构异常时,不让服务崩溃,回退为空 store。
|
||||
logger.warning("Failed to load cron store: {}", e)
|
||||
self._store = CronStore()
|
||||
else:
|
||||
# 首次运行尚无文件时,初始化为空 store。
|
||||
self._store = CronStore()
|
||||
|
||||
return self._store
|
||||
|
||||
def _save_store(self) -> None:
|
||||
"""把内存中的任务快照写回磁盘。"""
|
||||
if not self._store:
|
||||
return
|
||||
|
||||
# 首次保存时自动创建上级目录。
|
||||
self.store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = {
|
||||
"version": self._store.version,
|
||||
"jobs": [
|
||||
{
|
||||
"id": j.id,
|
||||
"name": j.name,
|
||||
"enabled": j.enabled,
|
||||
"schedule": {
|
||||
"kind": j.schedule.kind,
|
||||
"atMs": j.schedule.at_ms,
|
||||
"everyMs": j.schedule.every_ms,
|
||||
"expr": j.schedule.expr,
|
||||
"tz": j.schedule.tz,
|
||||
},
|
||||
"payload": {
|
||||
"kind": j.payload.kind,
|
||||
"message": j.payload.message,
|
||||
"sessionKey": j.payload.session_key,
|
||||
"deliver": j.payload.deliver,
|
||||
"channel": j.payload.channel,
|
||||
"to": j.payload.to,
|
||||
},
|
||||
"state": {
|
||||
"nextRunAtMs": j.state.next_run_at_ms,
|
||||
"lastRunAtMs": j.state.last_run_at_ms,
|
||||
"lastStatus": j.state.last_status,
|
||||
"lastError": j.state.last_error,
|
||||
},
|
||||
"createdAtMs": j.created_at_ms,
|
||||
"updatedAtMs": j.updated_at_ms,
|
||||
"deleteAfterRun": j.delete_after_run,
|
||||
}
|
||||
for j in self._store.jobs
|
||||
]
|
||||
}
|
||||
|
||||
# 这里是“整文件覆盖写”模型,不是事务性写入。
|
||||
# 若未来需要更强一致性,可升级为“临时文件 + 原子 rename”。
|
||||
self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动服务并挂载下一次唤醒计时器。"""
|
||||
# 幂等启动语义:重复 start 不抛错,但会重算并重新挂载 timer。
|
||||
self._running = True
|
||||
self._load_store()
|
||||
# 每次启动都重算 next_run,避免沿用过期的历史状态。
|
||||
self._recompute_next_runs()
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else []))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止服务并取消当前计时器。"""
|
||||
self._running = False
|
||||
if self._timer_task:
|
||||
# 取消后不等待完成:让调用方快速返回,避免阻塞关停流程。
|
||||
self._timer_task.cancel()
|
||||
self._timer_task = None
|
||||
|
||||
def _recompute_next_runs(self) -> None:
|
||||
"""批量重算启用任务的下一次触发时间。"""
|
||||
if not self._store:
|
||||
return
|
||||
now = _now_ms()
|
||||
for job in self._store.jobs:
|
||||
if job.enabled:
|
||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, now)
|
||||
|
||||
def _get_next_wake_ms(self) -> int | None:
|
||||
"""返回所有启用任务中最早的触发时间。"""
|
||||
if not self._store:
|
||||
return None
|
||||
times = [j.state.next_run_at_ms for j in self._store.jobs
|
||||
if j.enabled and j.state.next_run_at_ms]
|
||||
# 没有任何可触发任务则返回 None,上层据此不挂 timer。
|
||||
return min(times) if times else None
|
||||
|
||||
def _arm_timer(self) -> None:
|
||||
"""按“最近触发点”重置单计时器。"""
|
||||
# 每次状态变化后都重置 timer,保证只等待当前最近的一次触发。
|
||||
if self._timer_task:
|
||||
self._timer_task.cancel()
|
||||
|
||||
next_wake = self._get_next_wake_ms()
|
||||
if not next_wake or not self._running:
|
||||
return
|
||||
|
||||
delay_ms = max(0, next_wake - _now_ms())
|
||||
delay_s = delay_ms / 1000
|
||||
|
||||
async def tick():
|
||||
# sleep 期间若 timer 被 cancel,会抛 CancelledError 并自然结束任务。
|
||||
await asyncio.sleep(delay_s)
|
||||
if self._running:
|
||||
await self._on_timer()
|
||||
|
||||
self._timer_task = asyncio.create_task(tick())
|
||||
|
||||
async def _on_timer(self) -> None:
|
||||
"""计时器触发后执行所有到期任务,并继续调度下一轮。"""
|
||||
if not self._store:
|
||||
return
|
||||
|
||||
now = _now_ms()
|
||||
due_jobs = [
|
||||
j for j in self._store.jobs
|
||||
if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
|
||||
]
|
||||
|
||||
# 顺序执行,便于日志可读性与状态一致性;若后续有并发需求可在此扩展。
|
||||
# 这里“顺序而非并发”的取舍:
|
||||
# - 优点:状态更新顺序可预测,诊断简单;
|
||||
# - 代价:单个慢任务会延后后续任务执行。
|
||||
for job in due_jobs:
|
||||
await self._execute_job(job)
|
||||
|
||||
# 无论是否有 due job,都保存一次状态并重挂 timer,
|
||||
# 保证 next_run 与磁盘快照一致。
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
|
||||
@staticmethod
|
||||
def _coerce_execution_result(
|
||||
callback_result: _CronCallbackResult,
|
||||
) -> CronExecutionResult:
|
||||
"""Normalize legacy string callbacks into the structured execution result."""
|
||||
if isinstance(callback_result, CronExecutionResult):
|
||||
return callback_result
|
||||
return CronExecutionResult(response=callback_result)
|
||||
|
||||
def _apply_structured_action(self, job: CronJob, action: CronAction) -> _ActionOutcome:
|
||||
"""Apply one structured cron control decision to the current job."""
|
||||
normalized = (action.action or "none").strip().lower()
|
||||
reason = action.reason or "no reason provided"
|
||||
if normalized == "none":
|
||||
return _ActionOutcome()
|
||||
if normalized == "remove":
|
||||
self._store.jobs = [item for item in self._store.jobs if item.id != job.id]
|
||||
logger.info("Cron: removed job '{}' via structured action ({})", job.name, reason)
|
||||
return _ActionOutcome(removed=True)
|
||||
if normalized == "disable":
|
||||
job.enabled = False
|
||||
job.state.next_run_at_ms = None
|
||||
logger.info("Cron: disabled job '{}' via structured action ({})", job.name, reason)
|
||||
return _ActionOutcome(explicit_next_run=True)
|
||||
if normalized == "complete_today":
|
||||
managed_next_run_at_ms = _next_daily_cycle_start_ms(job, _now_ms())
|
||||
logger.info(
|
||||
"Cron: job '{}' completed today's batch via structured action ({}), next cycle at {}",
|
||||
job.name,
|
||||
reason,
|
||||
managed_next_run_at_ms,
|
||||
)
|
||||
return _ActionOutcome(managed_next_run_at_ms=managed_next_run_at_ms)
|
||||
if normalized == "reschedule":
|
||||
schedule = _schedule_from_action(action)
|
||||
_validate_schedule_for_add(schedule)
|
||||
job.schedule = schedule
|
||||
job.enabled = True
|
||||
job.delete_after_run = schedule.kind == "at"
|
||||
job.state.next_run_at_ms = _compute_next_run(schedule, _now_ms())
|
||||
logger.info("Cron: rescheduled job '{}' via structured action ({})", job.name, reason)
|
||||
return _ActionOutcome(explicit_next_run=True)
|
||||
logger.warning("Cron: unknown structured action '{}' for job '{}'", normalized, job.name)
|
||||
return _ActionOutcome()
|
||||
|
||||
async def _execute_job(self, job: CronJob) -> None:
|
||||
"""执行单个任务并更新其运行状态。"""
|
||||
start_ms = _now_ms()
|
||||
logger.info("Cron: executing job '{}' ({})", job.name, job.id)
|
||||
managed_next_run_at_ms: int | None = None
|
||||
removed_by_action = False
|
||||
explicit_next_run = False
|
||||
|
||||
try:
|
||||
result = CronExecutionResult()
|
||||
if self.on_job:
|
||||
# on_job 是业务注入点(如 gateway 中调用 agent.process_direct)。
|
||||
result = self._coerce_execution_result(await self.on_job(job))
|
||||
if result.action is not None:
|
||||
action_outcome = self._apply_structured_action(job, result.action)
|
||||
removed_by_action = action_outcome.removed
|
||||
explicit_next_run = action_outcome.explicit_next_run
|
||||
managed_next_run_at_ms = action_outcome.managed_next_run_at_ms
|
||||
elif job.schedule.kind == "every" and _looks_like_daily_limit_reached(result.response):
|
||||
managed_next_run_at_ms = _next_daily_cycle_start_ms(job, _now_ms())
|
||||
logger.info(
|
||||
"Cron: job '{}' reached daily terminal state, snoozed until {}",
|
||||
job.name,
|
||||
managed_next_run_at_ms,
|
||||
)
|
||||
# 无论回调是否返回内容,只要没有抛异常都视为成功。
|
||||
job.state.last_status = "ok"
|
||||
job.state.last_error = None
|
||||
logger.info("Cron: job '{}' completed", job.name)
|
||||
|
||||
except Exception as e:
|
||||
# 执行失败仅影响当前任务,不中断调度器整体运行。
|
||||
job.state.last_status = "error"
|
||||
job.state.last_error = str(e)
|
||||
logger.error("Cron: job '{}' failed: {}", job.name, e)
|
||||
|
||||
job.state.last_run_at_ms = start_ms
|
||||
job.updated_at_ms = _now_ms()
|
||||
if removed_by_action:
|
||||
return
|
||||
if explicit_next_run:
|
||||
return
|
||||
if managed_next_run_at_ms is not None:
|
||||
# 终态任务:跳过本日剩余频繁触发,等到下一日周期起点再恢复。
|
||||
job.state.next_run_at_ms = managed_next_run_at_ms
|
||||
return
|
||||
|
||||
# 一次性任务:执行后按配置删除或停用,避免重复触发。
|
||||
if job.schedule.kind == "at":
|
||||
if job.delete_after_run:
|
||||
# 一次性且要求删除:直接从 store 移除,后续 list 不再显示。
|
||||
self._store.jobs = [j for j in self._store.jobs if j.id != job.id]
|
||||
else:
|
||||
# 一次性但不删除:仅禁用,便于事后审计/手动重启。
|
||||
job.enabled = False
|
||||
job.state.next_run_at_ms = None
|
||||
else:
|
||||
# 周期任务:立即计算下一次触发时间,供下轮 timer 使用。
|
||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
||||
|
||||
# ========== Public API ==========
|
||||
|
||||
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
|
||||
"""列出任务,默认仅返回已启用任务。"""
|
||||
store = self._load_store()
|
||||
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
|
||||
# 以 next_run 升序返回,便于直接展示“谁最先执行”。
|
||||
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float("inf"))
|
||||
|
||||
def add_job(
|
||||
self,
|
||||
name: str,
|
||||
schedule: CronSchedule,
|
||||
message: str,
|
||||
payload_kind: Literal["system_event", "agent_turn"] = "agent_turn",
|
||||
session_key: str | None = None,
|
||||
deliver: bool = False,
|
||||
channel: str | None = None,
|
||||
to: str | None = None,
|
||||
delete_after_run: bool = False,
|
||||
) -> CronJob:
|
||||
"""创建并持久化新任务。"""
|
||||
store = self._load_store()
|
||||
# 添加前做参数合法性校验,尽早失败并给上层明确异常。
|
||||
_validate_schedule_for_add(schedule)
|
||||
now = _now_ms()
|
||||
|
||||
job = CronJob(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
name=name,
|
||||
enabled=True,
|
||||
schedule=schedule,
|
||||
payload=CronPayload(
|
||||
kind=payload_kind,
|
||||
message=message,
|
||||
session_key=session_key,
|
||||
deliver=deliver,
|
||||
channel=channel,
|
||||
to=to,
|
||||
),
|
||||
state=CronJobState(next_run_at_ms=_compute_next_run(schedule, now)),
|
||||
created_at_ms=now,
|
||||
updated_at_ms=now,
|
||||
delete_after_run=delete_after_run,
|
||||
)
|
||||
|
||||
store.jobs.append(job)
|
||||
# 每次变更都立即落盘并重排 timer,避免“内存态/调度态”漂移。
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
|
||||
logger.info("Cron: added job '{}' ({})", name, job.id)
|
||||
return job
|
||||
|
||||
def remove_job(self, job_id: str) -> bool:
|
||||
"""按 ID 删除任务;存在并删除成功时返回 True。"""
|
||||
store = self._load_store()
|
||||
before = len(store.jobs)
|
||||
store.jobs = [j for j in store.jobs if j.id != job_id]
|
||||
removed = len(store.jobs) < before
|
||||
|
||||
if removed:
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
logger.info("Cron: removed job {}", job_id)
|
||||
|
||||
# 返回布尔值给上层决定提示文案(found/not found)。
|
||||
return removed
|
||||
|
||||
def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
|
||||
"""启用或停用任务,并同步更新 next_run。"""
|
||||
store = self._load_store()
|
||||
for job in store.jobs:
|
||||
if job.id == job_id:
|
||||
job.enabled = enabled
|
||||
job.updated_at_ms = _now_ms()
|
||||
if enabled:
|
||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
||||
else:
|
||||
job.state.next_run_at_ms = None
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
return job
|
||||
# 没找到任务时返回 None,调用方据此输出“not found”。
|
||||
return None
|
||||
|
||||
async def run_job(self, job_id: str, force: bool = False) -> bool:
|
||||
"""手动触发任务执行。
|
||||
|
||||
默认遵守启用状态;`force=True` 时即使任务被禁用也会执行一次。
|
||||
"""
|
||||
store = self._load_store()
|
||||
for job in store.jobs:
|
||||
if job.id == job_id:
|
||||
if not force and not job.enabled:
|
||||
# 遵守启用状态:禁用任务默认不执行。
|
||||
return False
|
||||
await self._execute_job(job)
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
return True
|
||||
return False
|
||||
|
||||
def status(self) -> dict:
|
||||
"""返回服务运行状态摘要。"""
|
||||
store = self._load_store()
|
||||
# 这个接口主要用于 status 面板,不暴露详细任务内容。
|
||||
return {
|
||||
"enabled": self._running,
|
||||
"jobs": len(store.jobs),
|
||||
"next_wake_at_ms": self._get_next_wake_ms(),
|
||||
}
|
||||
98
app-instance/backend/nanobot/cron/types.py
Normal file
98
app-instance/backend/nanobot/cron/types.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""cron 模型对象定义。
|
||||
|
||||
这些 dataclass 主要承担两类职责:
|
||||
1. 作为内存中的稳定结构,供 CronService / Web API / Agent 工具共用;
|
||||
2. 作为持久化 JSON 的逻辑模型,尽量保持字段语义直观、兼容性友好。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronSchedule:
|
||||
"""Schedule definition for a cron job."""
|
||||
# `kind` 决定其余字段哪一个生效。
|
||||
kind: Literal["at", "every", "cron"]
|
||||
# `at`:绝对触发时间,毫秒时间戳。
|
||||
at_ms: int | None = None
|
||||
# `every`:固定间隔,毫秒。
|
||||
every_ms: int | None = None
|
||||
# `cron`:标准 5 段 cron 表达式,例如 `0 9 * * *`。
|
||||
expr: str | None = None
|
||||
# cron 表达式使用的时区;其余 kind 不应设置。
|
||||
tz: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronPayload:
|
||||
"""What to do when the job runs."""
|
||||
# system_event: 直接向目标会话投递消息(典型:提醒)
|
||||
# agent_turn: 把 message 当作 prompt 再交给 agent 执行
|
||||
kind: Literal["system_event", "agent_turn"] = "agent_turn"
|
||||
message: str = ""
|
||||
# 任务型 cron 若希望复用原会话短期记忆,可在这里保存 session_key。
|
||||
session_key: str | None = None
|
||||
# 是否把执行结果发回渠道层。
|
||||
deliver: bool = False
|
||||
channel: str | None = None # e.g. "whatsapp"
|
||||
to: str | None = None # e.g. phone number
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronAction:
|
||||
"""Structured cron control decision emitted by the LLM."""
|
||||
# `action` 是唯一必填字段,其余字段只在特定动作下有意义。
|
||||
action: Literal["none", "remove", "disable", "complete_today", "reschedule"] = "none"
|
||||
reason: str | None = None
|
||||
every_seconds: int | None = None
|
||||
cron_expr: str | None = None
|
||||
tz: str | None = None
|
||||
at: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronExecutionResult:
|
||||
"""Structured result of one cron execution."""
|
||||
# 模型最终输出文本。
|
||||
response: str | None = None
|
||||
# 可选结构化调度动作,例如 complete_today / remove / reschedule。
|
||||
action: CronAction | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronJobState:
|
||||
"""Runtime state of a job."""
|
||||
# 调度器计算出的下次执行时间。
|
||||
next_run_at_ms: int | None = None
|
||||
# 最近一次实际执行时间。
|
||||
last_run_at_ms: int | None = None
|
||||
# 最近一次执行结果状态。
|
||||
last_status: Literal["ok", "error", "skipped"] | None = None
|
||||
# 最近一次错误详情,便于 UI 排查。
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronJob:
|
||||
"""A scheduled job."""
|
||||
# 稳定主键。
|
||||
id: str
|
||||
# 展示名,主要用于 UI 和日志。
|
||||
name: str
|
||||
enabled: bool = True
|
||||
schedule: CronSchedule = field(default_factory=lambda: CronSchedule(kind="every"))
|
||||
payload: CronPayload = field(default_factory=CronPayload)
|
||||
state: CronJobState = field(default_factory=CronJobState)
|
||||
# 创建 / 更新时间都使用毫秒时间戳,便于直接序列化。
|
||||
created_at_ms: int = 0
|
||||
updated_at_ms: int = 0
|
||||
# 一次性任务执行后是否自动删除。
|
||||
delete_after_run: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronStore:
|
||||
"""Persistent store for cron jobs."""
|
||||
version: int = 1
|
||||
jobs: list[CronJob] = field(default_factory=list)
|
||||
Reference in New Issue
Block a user