From f68fb0c4cfbae172c071486805bf228edb83b8c9 Mon Sep 17 00:00:00 2001 From: rayhpeng Date: Fri, 31 Jul 2026 10:18:46 +0800 Subject: [PATCH] docs(schedule): lead the walkthrough with a behavioural spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure per review: state what the system promises before how the code delivers it. The thin product-decision table becomes a full §1 behavioural spec written in zero implementation vocabulary -- user operations, scheduling semantics, the skip-not-queue overlap decision, the busy-wait guarantees (delayed, never lost; ordered by due time; no catch-up of missed cron periods), the failure/recovery table, and an explicit non-goals list (no queue, no punctuality guarantee, no task credentials, single scheduler instance) -- each promise cross-referenced to the section whose mechanism delivers it. The mechanism chapters absorb what the spec now promises: §6.2 explains where waiting goes (the table as an implicit priority queue via the claim query's ordering), §6.3 and a new pitfall entry name the budget-leak boundary when a completion callback is lost, §8's defence table gains the race-window column and a per-layer error-translation table as the journey's unhappy half, and the HTTP endpoint block moves beside the commands it carries. --- backend/docs/SCHEDULE_DESIGN_zh.md | 125 ++++++++++++++++++++++------- 1 file changed, 96 insertions(+), 29 deletions(-) diff --git a/backend/docs/SCHEDULE_DESIGN_zh.md b/backend/docs/SCHEDULE_DESIGN_zh.md index 36c5a7724..c2aae82a9 100644 --- a/backend/docs/SCHEDULE_DESIGN_zh.md +++ b/backend/docs/SCHEDULE_DESIGN_zh.md @@ -1,40 +1,74 @@ # 定时任务(Schedule)模块设计 -> [六边形设计规范](HEXAGONAL_ARCHITECTURE_zh.md) 的**参考实现走读**。本文假定你已读过规范:术语(端口、适配器、聚合、command、防腐层)与通用规则不再重复解释,只讲三件事——schedule 特有的产品决定、每条规范规则落在哪个文件、以及 schedule 特有的陷阱。读完你将能回答:一条定时规则从创建到执行经历了什么、它的每条并发防线由谁守卫、想加一种调度类型该改哪几个文件。 +> [六边形设计规范](HEXAGONAL_ARCHITECTURE_zh.md) 的**参考实现走读**。本文假定你已读过规范:术语(端口、适配器、聚合、command、防腐层)与通用规则不再重复解释。结构上业务先行:§1 是零实现词汇的行为规格(系统承诺了什么),§2 起才讲每条承诺由哪个文件的什么机制兑现、以及 schedule 特有的陷阱。读完你将能回答:一条定时规则从创建到执行经历了什么、它的每条并发防线由谁守卫、想加一种调度类型该改哪几个文件。 > > 姊妹文档:[FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md)——首个切片、规范的最小实例。schedule 是同样的结构,复杂度高一个量级:两个聚合、两个状态机、三种驱动源、四道并发防线。 --- -## 1. 这个模块做什么 +## 1. 业务需求与行为规格 -让用户注册一条「到点、或按 cron 用这段 prompt 起一次 agent run」的规则,由后台轮询器按时把它派发进**既有的** Gateway run 生命周期。 +让用户注册一条「到点、或按 cron 用这段 prompt 起一次 agent run」的规则,由系统按时执行并记录每次执行的结果。本章只讲系统**承诺的行为**;每条承诺由什么机制兑现,见括号里指向的章节。 -产品层面的四条决定,解释了后面所有设计: +### 1.1 用户能做什么 -| 决定 | 后果 | +| 操作 | 语义要点 | |---|---| -| **调度器只决定 when,不建第二套执行栈**(`backend/AGENTS.md` 的硬约束) | 派发最终只做一件事:在正确的时刻调用一次现有的 run 启动入口,然后记账。全部复杂度在"正确的时刻"与"记账"的并发与崩溃语义上 | -| **规则与执行是两回事** | 两个聚合、两张表:`ScheduledTask`(规则,长期存在)与 `ScheduledRun`(一次触发的历史记录)。互相只有 `task_id` 字符串引用,各自独立事务 | -| **重叠就跳过**(MVP 固定 `overlap_policy="skip"`) | 一个任务同时最多一条活跃执行;到点时上一次还没结束,这一次被跳过并留终态墓碑 | -| **定时运行不可交互** | 派发走 `non_interactive=True` 的内部启动路径,lead-agent 工具集不含 `ask_clarification` | +| 创建任务 | 两种调度:cron(周期)或 once(一次);两种执行上下文:每次新会话,或固定在某个已有会话里(后者要求该会话存在且归本人) | +| 部分更新 | 只改提交的字段;任务正在派发中时拒绝编辑;把一个已结束的任务改到未来时间会自动重新启用它 | +| 暂停 / 恢复 | 暂停的任务不被自动调度,但仍可手动触发,触发后保持暂停 | +| 立即执行 | 无视调度时间跑一次;撞上该任务还有执行在跑时直接拒绝(409),不排队 | +| 删除 | 任何状态都可删,包括执行中——飞行中的 run 不受影响,只是结果无处写回 | +| 查执行历史 | 每次触发(包括被跳过的)都留一条记录 | -对应的 HTTP 面(`app/gateway/routers/schedule/`): +所有读写按用户隔离;别人的任务表现为"不存在",不是"无权限"。 -``` -GET /api/scheduled-tasks 列表 -POST /api/scheduled-tasks 创建 -GET /api/scheduled-tasks/{id} 详情 -PATCH /api/scheduled-tasks/{id} 部分更新 -POST /api/scheduled-tasks/{id}/pause 暂停 -POST /api/scheduled-tasks/{id}/resume 恢复 -POST /api/scheduled-tasks/{id}/trigger 立即执行(409/502/200 三种码) -DELETE /api/scheduled-tasks/{id} 删除 -GET /api/scheduled-tasks/{id}/runs 执行历史 -GET /api/threads/{tid}/scheduled-tasks 某 thread 绑定的任务 -``` +### 1.2 调度语义:什么时候跑 -但 HTTP 只是三种驱动源之一——轮询器与运行完成回调走同样的用例,见 §8。 +- cron 表达式在**任务声明的时区**里求值。夏令时切换被正确吸收:纽约的"每天 9 点"在冬夏令时对应不同的 UTC 时刻,用户视角始终是"每天 9 点"。 +- once 任务的时刻必须在未来,且距提交至少一个运营下限(默认 60 秒),防止"立刻就要跑"的任务;cron 不受此限制。 +- 定时触发的 run 是**非交互的**:执行中的 agent 不能停下来向用户提问——后台没有人会回答。 +- 执行走普通的 agent run 生命周期,调度器只决定 when(§8)。 + +### 1.3 重叠:跳过,不排队 + +一个任务同时最多一次执行在跑。到点时上一次还没结束: + +- **自动调度**:这一次被**跳过**,留一条终态记录作审计;cron 任务等下一个自然周期,once 任务记为**失败**——唯一的机会丢了,说"完成"等于谎称执行过。 +- **手动触发**:直接拒绝,让用户自己决定,不替他排队。 + +选择跳过而非排队是产品决定:对"每天 9 点总结昨天"这类任务,一个迟到两小时才补跑的 occurrence 产出的往往已经不是用户要的东西。(机制:§8 的四道防线之一;将来若需要排队策略,改动面见 §10.4。) + +### 1.4 忙时等待:延迟,不丢失 + +系统对同时执行的定时 run 总数设全局上限(运营配置)。到期任务多于余量时: + +- 多出来的任务**等待**,按到期时间先后依次放行——不会丢失,也不会被随机跳过; +- 等待没有超时:预算被长任务占满多久,就等多久。预算的回收依赖每个 run 终将结束(§1.5 的恢复语义为此兜底); +- 等待期间错过的 cron 周期**不补跑**:轮到它时跑一次,然后从当前时刻算下一个周期。宕机三天的每日任务恢复后也只跑一次,不连跑三次。 + +推论:长期预算不足的表现不是积压爆炸(系统里每任务最多一条"欠账"),而是低频任务被静默降频——这是容量报警该盯的信号。 + +### 1.5 失败与恢复 + +| 出了什么事 | 用户看到什么 | +|---|---| +| 提交的规则非法(坏 cron、过近的 once…) | 立即拒绝,任何数据都不写 | +| run 启动失败 | 执行记录记失败;once 任务失败,cron 任务照常排下一次并展示上次的错误 | +| 目标会话正忙(固定会话模式) | 自动调度视同一次重叠跳过;手动触发拒绝 | +| run 执行中失败 / 超时 | once 记失败;cron 保住调度,展示错误 | +| run 被用户取消 | once 记**取消**(不是失败——用户主动行为);cron 不受影响 | +| 服务进程崩溃 | 重启后自动清理:跑到一半的执行记录标"中断",等一个永远不会来的完成信号的 once 任务标"取消";调度立即恢复,不需要人工干预 | + +已成功启动的执行**绝不**因系统内部的记账失败被追认为失败(§6.1)。 + +### 1.6 明确的非目标 + +- **没有执行队列**——重叠跳过、忙时靠下轮重试,见 1.3/1.4; +- **不补跑错过的周期**(no catch-up); +- **不保证准点**——执行时刻 = 到期后的下一个轮询拍点再加预算等待,轮询间隔是运营配置; +- **任务不携带凭据**——定时 run 拿不到请求级 secrets(它没有"每次请求重新提供"的人); +- **单调度器实例**——多实例下互斥机制仍正确,但预算各算各的(§9 的测试边界)。 ## 2. 内圈全景 @@ -218,6 +252,21 @@ flowchart TD | `DeleteTask` | `ScheduleService.delete_task` | 同上 | | `TriggerTask` | `ScheduleService.trigger_task` | 同上 | +对应的 wire 契约(`app/gateway/routers/schedule/`;HTTP 只是三种驱动源之一,另两种见 §8): + +``` +GET /api/scheduled-tasks 列表 +POST /api/scheduled-tasks 创建 +GET /api/scheduled-tasks/{id} 详情 +PATCH /api/scheduled-tasks/{id} 部分更新 +POST /api/scheduled-tasks/{id}/pause 暂停 +POST /api/scheduled-tasks/{id}/resume 恢复 +POST /api/scheduled-tasks/{id}/trigger 立即执行(409/502/200 三种码) +DELETE /api/scheduled-tasks/{id} 删除 +GET /api/scheduled-tasks/{id}/runs 执行历史 +GET /api/threads/{tid}/scheduled-tasks 某 thread 绑定的任务 +``` + 命名链三拼写互为变体(规范 §2.1),grep 任何一个就能找到用例全部三层。command 是哑数据(`context_mode="not-a-mode"` 能构造出来——校验归聚合,错误归因顺序归 handler 的构造顺序);身份不进请求模型(`user_id` 服务端解析后经 `to_command` 参数注入,测试钉住两个请求模型上都没有该字段)。 schedule 在 feedback 之上多出三个设计点: @@ -322,12 +371,16 @@ claimed = await tasks.claim_due(now=..., lease_seconds=..., limit=budget) `max_concurrent_runs` 限制**同时活跃的执行总数**,不是每轮批量。长任务跨轮次累积,每轮只能认领进剩余额度——当成"每轮最多 N 个"会让长任务压垮系统。 +**等待去哪了(§1.4 承诺的兑现处)**:没轮到的到期任务不进任何等待结构——它就留在表里,`next_run_at` 停在过去。`claim_due` 的 `ORDER BY next_run_at ASC, id ASC` + `LIMIT budget` 让表本身成为隐式优先队列:出队 = 认领,重新入队 = `record_launch` 重排 `next_run_at`;预算释放后最早到期的先被放行,轮询间隔就是消费节拍。已跑过的 cron 任务 `next_run_at` 立即排到未来、退出到期集合,所以高频任务不可能把低频任务永远压在队尾。这是"不建第二套执行栈"的直接推论——一张有索引的表加一条排序查询,比一个要考虑持久化、崩溃恢复、重复消费的队列组件便宜得多。 + ### 6.3 完成回调与启动清扫 `handle_run_completion(outcome, now)` 先写执行记录终态,再问聚合这个结局意味着什么、只写那个答案:once 推向终态,cron 保持不变。**但 `last_error` 无条件写入**——cron 保住了调度,仍要报告上次出了什么问题。任务在 run 飞行途中被删掉不是错误,静默返回。 `reconcile_on_startup(error)` 清扫崩溃留下的两种残骸:永远不会结束的活跃执行记录、和停在 `running` 等一个已死回调的 once 任务(后者不被过期认领覆盖——已启动的任务释放了租约,认领查询永远看不见它)。它**不吞异常**——部分清扫失败要不要阻塞启动,是调用方的策略。 +这个回调同时是 §1.4 全局预算的回收信号:`count_active` 数的活跃执行记录靠它写成终态。回调丢失(run 已结束但钩子没落库)会让那格预算一直被占——泄漏到上限时调度整体停摆,直到下一次进程重启的清扫把它收回。清扫刻意只在启动时跑:进程存活时"活跃记录多老算僵死"无法安全判定(run 真的可能跑很久),运行中的周期性清扫需要先有 run 表侧的对账依据(§11 有对应陷阱条目)。 + ## 7. 适配器与组合根 四个从适配器 + 一个入站适配器住在 `app/adapters/schedule/`,一个端口一个文件;两形态判据见规范 §2。 @@ -406,14 +459,27 @@ sequenceDiagram S->>TR: record_completion(status_after_completion, error) ``` -四道并发防线在这条时序上各守一段: +四道并发防线在这条时序上各守一段——每条对应一个真实的竞态窗口: -| # | 防线 | 守什么 | +| # | 竞态场景 | 窗口在哪 | 防线 | +|---|---|---|---| +| 1 | 双击 / 客户端重试 / 手动撞轮询,同一任务两个派发并发 | `has_active` 快检与插入之间隔着 await 点,两边都能通过 | 部分唯一索引 `uq_scheduled_task_run_active` 仲裁;败者收敛到与快路径**字节一致**的结局(调用方分不出被哪种机制拦下,重试行为才不分叉) | +| 2 | 两个轮询进程抢同一批到期任务;认领者崩溃后任务卡在 `running` | `claim_due` 的选取与更新之间;租约期内无人能动 | `FOR UPDATE SKIP LOCKED` + 租约过期接管分支 | +| 3 | 快速失败 run 的回调先于派发记账落库;`record_launch` 与 `record_completion` 并发写同一任务行 | launch 返回与两笔回写之间;两个独立事务 | 双向 `protect_terminal` CAS + 分字段所有权(§7.1 ②)——任何一方读-改-写整聚合都会重放过期快照 | +| 4 | 进程死亡留下活跃记录与卡住的 once 任务 | 崩溃点到重启之间 | 启动清扫 `reconcile_on_startup` | + +时序讲完 happy path,异常是它的另一半——每层失败的翻译点与最终去向: + +| 异常来源 | 翻译点 | 最终去向 | |---|---|---| -| 1 | 部分唯一索引 `uq_scheduled_task_run_active` | 两个并发派发(双击、重试、手动撞轮询)最多一个拿到活跃槽位 | -| 2 | `claim_due` 的 `FOR UPDATE SKIP LOCKED` + 租约 | 认领本身原子;崩溃的认领者过期后可被接管 | -| 3 | 双向 `protect_terminal` CAS | 快速失败 run 的回调 与 启动路径的迟到写,谁先谁后都不丢裁决 | -| 4 | 启动清扫 `reconcile_on_startup` | 进程死亡留下的活跃记录与卡住的 once 任务 | +| 客户端提交非法(坏 cron、未知 mode、改 running 中的任务) | 聚合构造期 / 转换期抛领域错误 | router 单表映射:404 / 422 / 409 | +| 手动触发撞活跃执行 | `DispatchOutcome.CONFLICT` | 409,不留 run 记录 | +| 执行 thread 忙(reuse_thread 撞用户会话) | launcher 译成 `ThreadBusyError` | 自动路径 = 一次正常跳过(墓碑);手动 = 409 | +| 启动失败(其余一切,launcher 兜底 `except Exception`;返回值缺 run_id 同罪) | → `LaunchFailedError` | 执行记录 `FAILED`;once 失败、cron 重排并记 `last_error`;手动映 502(故障在下游,任务完好)。兜底刻意不捕 `CancelledError`——关机是控制流,不是启动结果 | +| 记账写入失败(launch 成功之后) | **不捕获**,如实冒泡 | 只有 launch 被 try 包住——已成功启动的执行绝不因记账失败被标成 failed(§6.1) | +| 存储坏行 | 适配器 `_to_domain` → `CorruptStoredScheduleError` | 单行读 500(服务端故障),列表读跳过该行并记日志(§7.1 ④) | +| 一整轮 poll 失败(如 database is locked) | poller 循环捕获并记日志 | 下个周期重试——瞬时故障不能让所有定时任务静默停摆 | +| 启动清扫失败 | poller 记日志后照常开始调度 | 因遗留脏行拒绝启动比带着脏行运行更糟 | ## 9. 测试分层 @@ -502,6 +568,7 @@ sequenceDiagram | 坏行错误进了 router 的 422 映射 | 存储故障被报成客户端错误,PATCH 修复路径自身 422 | | 把契约测试全绿当成可以多实例 | fake 没有原子性;并发由真数据库的 race 测试负责 | | 显式继承 Protocol 拼错方法名 | 静默继承 `...` 体返回 `None`,`isinstance` 抓不到——契约用例必须断言返回值 | +| 完成回调丢失(run 结束但钩子没落库) | 执行记录停在 `running`,占着一格全局预算;泄漏到上限时调度停摆,直到进程重启的清扫收回(§6.3)。想加运行中的周期清扫,先回答"多老算僵死"——那需要 run 表侧的对账依据,不宜在 schedule 侧拍超时数字 | ## 12. 代码索引