上一篇解决了「Agent 是谁」——身份体系让浏览器和 Agent 走同一扇门。但 Agent 平台还有一个和请求处理完全不同的维度:时间。
会话式交互只覆盖「用户在场」的场景:用户说「记一下今天的工作」,Agent 记了,会话结束。可用户真正想要的往往是「每天下班前帮我记一遍」「每周五把周报生成好」——这些需求里,触发时刻用户根本不在场。没有这半个维度,Agent 只是一个更聪明的问答框;有了它,才是平台。
从两个烟囱说起
重构前的代码里有两个并存的调度模块:
NotifySchedule:定时发通知。模型里「何时触发」和「发什么、发给谁」长在同一行上;WorkLogSchedule:定时生成日报周报。同样的 cron 解析、同样的领取执行逻辑,又写了一遍。
两个烟囱各自能跑,问题在第三个需求出现时暴露:我们想让 Agent 也能定时执行别的动作(跑一个工作流、调一个 webhook),按既有结构就得写第三个烟囱——第三份 cron 解析、第三份领取逻辑、第三套 MCP 工具。
烟囱的病因是触发和动作耦合:NotifySchedule 既是「什么时间做什么」的调度器,又是「怎么发通知」的执行器。解法因此清晰——把两者拆开,中间放一个通用的运行队列:
MCP / REST / tRPC(三个平级入口)
│
▼
Automation Application Service(统一用例与校验)
│
├── Trigger Registry ── ONCE / CRON(未来 EVENT)
├── Action Registry ── notification.send / work-log.generate-report / …
│
├── Scheduler ── 扫描到期 Automation,只创建 Run
└── Worker ── 领取 Run,调 Action 执行,处理续租与重试
用户侧产品概念统一为一条记录:Automation = 触发器 + 动作 + 策略。「工作日 18:30 生成日报」和「每周五 17:00 给我发条提醒」在数据模型里是同一种东西,只是 Trigger 和 Action 的组合不同。
双注册表:谁都不知道对方的全部
整个引擎最重要的设计决定写在依赖规则里,一共六条硬约束,核心是这几条:
- Scheduler 不调用任何 Action 执行器。它只做一件事:找到到期的 Automation,创建一条 Run 记录,推进下一次触发时间。事务提交,结束。
- Worker 不解析 cron。它拿到的 Run 里已经带着完整快照,照着执行就行。
- Action 执行器不更新 Run 状态。它返回一个标准化的
ActionExecutionResult(status + summary + retryable),由 Worker 统一推进状态机。 - Trigger/Action 注册表是 schema 和元数据的单一可信源。新增一种 Action 不需要改 Scheduler 一行代码——注册表长这样:
type AutomationActionDefinition = {
type: string; // "notification.send"
displayName: string;
configDescription: string; // 给 Agent 读的配置说明
idempotency: "STRONG" | "BEST_EFFORT" | "NONE";
requiredMcpScopes: readonly string[];
validateConfig: (config: unknown) => Record<string, unknown>;
execute: (config, ctx) => Promise<AutomationActionResult>;
};
这个设计的检验标准很朴素:新增 workflow.run Action 时,Scheduler 和 Worker 的代码 diff 应该是零。触发器同理——第一阶段只有 ONCE 和 CRON,模型为 EVENT 预留了枚举值和 dedupeKey 规则(event:<eventId>),但事件总线一行代码都没写。预留扩展点而不提前实现,是这套设计里反复出现的克制。
Run 是快照,不是引用
AutomationRun 表里最值得注意的三个字段:actionTypeSnapshot、actionConfigSnapshot、policySnapshot。
一条 Run 被创建的瞬间,它把当时的 Action 配置和策略整体拷贝进自己。之后用户改了 Automation 的配置、换了收件人、调了重试策略——已经排队和正在执行的 Run 不受影响,执行的永远是生成时刻的意图。
为什么这么设计?因为「定时任务」的本质是用户在过去某个时刻表达的意愿,在未来某个时刻执行。如果 Run 引用 Automation 的当前配置,就会出现「周五 17:00 排队的周报,在 Worker 执行前两分钟被用户改成发日报」的语义混乱——用户停用一个任务前,得先搞清楚有没有「半成品任务」正在引用它。快照语义把这个问题消掉了:改配置只影响未来的 Run,想阻止已排队的 Run,显式停用(未执行的 Run 会被批量标记 CANCELED)。
顺带一个防重细节:每条计划 Run 带唯一键 (automationId, dedupeKey),调度触发的 dedupeKey 是 schedule:<scheduledFor ISO>。多实例同时扫描到同一条到期 Automation,各自都想创建 Run,数据库唯一约束保证只有一条能插入成功——这是比应用层判断更硬的防线。
Redis 锁只是性能优化
多副本部署下,每个实例都会起调度心跳,怎么保证不重复触发?
答案分两层,而且层与层的职责被刻意分开了:
- 正确性层:数据库。Scheduler 推进触发游标用 CAS(
id + version + 旧 nextRunAt条件更新),Run 防重用唯一约束,Worker 领取用条件 UPDATE(只有count=1的实例获得执行权)。 - 性能层:Redis 分布式锁。同一时刻只有一个副本真正执行 tick,其余拿不到锁直接跳过。
关键在陈述顺序:Redis 挂掉,系统照样正确——每个副本都去扫表,CAS 和唯一约束保证不会产生重复的 Run,代价只是多了几次无效扫描。反过来就不成立:如果正确性依赖 Redis 锁,锁服务抖动的那几秒就是重复触发或漏触发的窗口。
「锁是优化不是保障」这句话很多人会背,落地时的检验是问自己:把 Redis 拔了,系统会发生什么? 如果答案是「出错」,设计就是错的;如果答案是「慢一点」,才算把这句话真正落了地。
心跳本身还有几个工程细节值得一提:它是进程内 setInterval 而不是外部 K8s CronJob——少一个部署依赖;用 globalThis 标记防 dev 热重载起出多个定时器;定时器 unref(),不阻止进程正常退出;实例 ID 取 HOSTNAME 回落到 pid-<pid>,让 lease 归属可查。
幂等三档,和一个叫 UNKNOWN 的诚实状态
崩溃恢复是这个领域躲不掉的问题:Worker 执行一条「发通知」的 Run,请求发出去了,进程在写结果之前死了。重启后看到这条 Run 处于 RUNNING 且 lease 过期——重试还是不重试?
重试,用户可能收到两条一样的消息;不重试标记失败,用户以为没发其实发了。两头都不对。我们的答案是把「能不能重试」的判断权交给 Action 自己声明,注册表里那个 idempotency 字段分三档:
| 档位 | 含义 | 崩溃恢复策略 |
|---|---|---|
| STRONG | 下游有幂等键,重试绝对安全 | 自动转重试队列 |
| BEST_EFFORT | 能确认副作用未发生则重试 | 不能确认时标 UNKNOWN |
| NONE | 下游无幂等协议 | 一律标 UNKNOWN,禁止自动重试 |
notification.send 是 NONE——企业 IM 和邮件的下游没有统一幂等协议,老实承认。work-log.generate-report 是 BEST_EFFORT——报告服务按 owner + 日期做版本控制,重试大概率幂等命中。
于是状态机里有了一个大多数任务系统没有的状态:UNKNOWN——副作用可能已发生,但无法确认。它不进重试队列,Agent 拿到这个状态的解释规则也写死在协议里:「不得自动重试,必须向用户提示重复风险」。UI 上它是独立的警告色。
UNKNOWN 的价值不在功能而在诚实:与其假装失败,不如承认不知道。定时任务系统最大的信任杀手,是用户在「到底发没发出去」的疑问里失去对它的信赖。
权限在时间维度上的两道弯
上一篇的身份体系到这里遇到了新问题:权限是「请求时刻」的概念,而 Automation 是「持久存在」的配置。两道弯:
第一道:双重 scope。 mcp:automation:write 只代表「你能管理自动化这个对象」,不代表「你能做它配的动作」。创建一个发通知的 Automation,还要有 mcp:message:write。而且 automation_list_action_types 只返回当前凭据两项都满足的 Action——权限被收紧的 Agent 看到的动作列表会自动缩短,和能力目录是权限的投影是同一个思想。
这里有个刻意的非对称:停用(set_enabled(false))只要求 automation scope,不要业务 scope。想象 Agent 的消息权限被收回了,它创建的通知自动化还在每小时发消息——这时你希望 Agent 还能干什么?至少还能把这个任务停掉。权限收紧的路径上,「关掉」永远要比「打开」容易。
第二道:凭据死了,任务还在。 Automation 创建后归属于用户,不属于创建它的那把 API Key。撤销那把 Key 不会自动停用任务——用户授权的是一个长期配置,不是给某把 Key 的永久代理权。那封禁呢?每次执行前检查 owner 仍然存在且未封禁,被封禁的用户的 Run 记为 SKIPPED(owner_inactive)。这呼应上一篇的结论:封禁是人的事件,不是凭据的事件——但和凭据不同,任务跟着人走。
Agent 怎么用这套东西
MCP 暴露给 Agent 的工具刻意做成「用户意图级」:预览触发时间、创建、更新、启停、手动跑一次、查执行历史。而引擎的内部控制面——tick、领取、续租、强制改状态——一个都不暴露。Agent 不需要知道 Worker 的存在,就像用户不需要知道数据库连接池的存在。
协议里写死的交互规则(这部分与薄壳系列的发现一脉相承——流程知识放服务端协议,不放 Skill):
- 创建或改时间规则前,先调 preview 拿未来触发时间;
- 向用户复述:名称、明确的触发时间、时区、动作、目标——自然语言日期必须转成明确时间,禁止静默猜时区;
run_now会立即产生真实副作用,必须获得本轮确认;- 拿到 UNKNOWN 不自动重试,向用户说明重复风险。
第 1 条尤其值得说:预览是时间类系统对 Agent 最重要的工具。人看 cron 表达式会算错,Agent 也会。「每周五 17:00」被写成 0 17 * * 5 还是 0 17 * * 4,预览三次触发时间就能验证——让 Agent 学会先看效果再提交,比教它 cron 语法可靠得多。
设计文档里写了、落地时砍掉的东西
这个引擎有一份相当完整的设计文档(分层、不变量、必测场景都写全了),但落地版做了几处明显的裁剪,每一处都是有意的:
DDD 分层被压平。 设计里是 domain/application/repository/presentation 四层,落地后压成一个 service.ts 加几个协作模块。不是设计错了,是这个规模(两张表、两种触发器、两三个 Action)撑不起四层的间接成本。分层是为变化频率服务的——等 Action 多到注册表需要独立演进时,再拆不迟。
Secret Store 没做完,Webhook 渠道就显式不可用。 定时发 IM 群机器人消息需要存 webhook URL,而 Secret Store(加密存储 + 引用 + 脱敏展示)还没实现。Action 注册表里的处理是:配置校验直接抛「暂不可用,等待 Secret Store 完成」。不是偷偷支持明文 URL,不是注释掉入口,是一个显式的、可搜索的失败。半成品能力的正确形态是「大声地不可用」。
EVENT 触发器预留未实现。 枚举、dedupeKey 规则、文档都留了位,事件总线一行没写。没有真实需求驱动的事件总线,写出来一定是错的——和上一篇薄壳系列里「版本协商等第一个不兼容案例再设计」是同一条纪律。
小结

- 定时能力的关键解耦是触发和动作分离,双注册表让「新增一种动作」的引擎侧 diff 为零;
- Run 是快照不是引用,「过去表达的意愿按过去的意图执行」,改配置不影响已排队的任务;
- 数据库 CAS + 唯一约束是正确性,Redis 锁只是性能优化——拔掉 Redis 系统应该只是变慢,不是出错;
- 幂等三档 + UNKNOWN,让「不知道发没发出去」成为一个诚实的状态而不是被掩盖的失败;
- 权限在时间维度上要过两道弯:双重 scope 且「关掉比打开容易」;任务属于用户不属于凭据,但跟着封禁走。
可靠性的底层机制——CAS 领取、lease 续租、崩溃恢复、受控并发——我在任务编排那篇里展开过,这篇刻意不重复。
下一篇离开时间维度,讲 LLM 出口:内部所有要调模型的地方,怎么收口成一个客户端——以及那条被我们亲手标记为 deprecated 的对外代理,教了什么。
本系列下一篇:《收口内部 LLM 调用:一个客户端、错误脱敏、和一条被废弃的代理》