deer-flow/backend/docs/SCHEDULE_DESIGN_zh.md
rayhpeng 9d0b09558e feat(schedule): add the schedule domain model
First step of the scheduled-task context's hexagonal migration: the
inner-ring model, with zero infrastructure dependencies.

- ScheduleSpec / SchedulePolicy value objects
- ScheduledTask aggregate root
- ScheduledRun aggregate
- 9 domain errors, 5 enums

Rules are migrated verbatim from their current homes, each method's
docstring citing the source line: timezone/cron/next-run calculation
from deerflow/scheduler/schedules.py, context-mode and re-arm rules from
routers/scheduled_tasks.py, and the four status-derivation rules from
app/scheduler/service.py.

Two things previously held by convention are now enforced by
construction. Validation and normalization live in __post_init__, so
building a ScheduleSpec field-by-field cannot bypass them. The skipped
tombstone is a separate factory, so it can never be written as the
transient queued row that would collide with uq_scheduled_task_run_active.

The domain does not serialize itself: mapping the stored schedule_spec
JSON in and out stays with the adapter layer, keeping Mapping[str, Any]
out of every domain signature.

Production code still runs through app/scheduler/service.py -- this
commit adds no call sites and changes no behavior.
2026-07-28 11:16:16 +08:00

23 KiB
Raw Blame History

定时任务Schedule模块设计

面向想理解或扩展定时任务模块的人。读完你将能回答:一条定时规则从创建到执行经历了什么、它的每条业务规则住在哪个文件、以及你要改它时该动哪里。

配套文档:HEXAGONAL_ARCHITECTURE_zh.md(本文遵循的架构分层)、backend/AGENTS.md(编码规约与调度相关的运行时约定)。

本文当前只覆盖领域模型层domain/schedule/model/)——六边形迁移的第一步。端口、应用服务与适配器仍在旧位置,见 §2。


1. 这个模块做什么

一句话:

让用户注册一条「到点、或按 cron 用这段 prompt 起一次 agent run」的规则由后台轮询器按时把它派发进既有的 Gateway run 生命周期。

一条最简单的使用路径:

用户在 /workspace/scheduled-tasks 建一条任务
  "每天 09:00Asia/Shanghai帮我总结昨天的 GitHub issue"
       ↓
后台轮询器每 5 秒扫一次,发现它到点了
       ↓
起一个 agent run新 thread、非交互模式
       ↓
run 结束后回写执行记录,并算出下一次的时间

一条硬约束(写在 backend/AGENTS.md 里):调度器只决定 when,不得引入第二套执行栈。它最终只做一件事——在正确的时刻调用一次现有的 run 启动入口,然后记账。模块的全部复杂度都在"正确的时刻"和"记账"的并发与崩溃语义上,而不在执行本身。

两个核心概念,对应两张表、两个聚合:

概念 是什么 聚合
任务task 用户注册的规则,长期存在 ScheduledTask scheduled_tasks
执行run 规则的一次触发,只读历史 ScheduledRun scheduled_task_runs

2. 当前状态:新旧并存

这一节请先读,否则你会在代码库里迷路。

模块正处于六边形重构的中途。领域模型已经落地,但生产代码路径还没有切过来

flowchart LR
    subgraph NEW["✅ 已落地 · 内圈"]
        M["deerflow/domain/schedule/model/<br/>ScheduleSpec · ScheduledTask · ScheduledRun"]
    end
    subgraph OLD["⏳ 仍是旧形态 · 生产路径"]
        R["app/gateway/routers/scheduled_tasks.py<br/>入参校验 + 业务判断混在一起"]
        S["app/scheduler/service.py<br/>轮询 + 派发编排 + 状态推导"]
        P["deerflow/persistence/scheduled_task*/sql.py<br/>仓储返回裸 dict"]
        C["deerflow/scheduler/schedules.py<br/>时区 / cron 计算"]
    end
    subgraph TODO["🚧 待建"]
        PO["domain/schedule/ports.py"]
        SV["domain/schedule/service.py"]
        AD["app/infra/persistence/ + app/infra/schedule/"]
    end

    R -.->|尚未调用| M
    S -.->|尚未调用| M
    M --> PO --> SV --> AD

    style M fill:#d8ecff
    style OLD fill:#f5f5f5

含义很具体:

  • domain/schedule/model/ 是唯一的真相声明处,但目前只有域测试在用它。运行中的定时任务走的仍是 app/scheduler/service.py 那套。
  • 两边规则内容一致(模型是逐条从旧代码搬迁的,每个方法的 docstring 都标了来源行号),但代码是重复的。这是迁移中间态的正常代价。
  • 你现在改一条业务规则,要两边都改,直到迁移完成。旧位置见 §10 的索引。
  • 新增业务规则请只写在领域模型里,然后在旧位置调用它——不要再往 router / service 里加新的判断。

迁移完成后 app/scheduler/service.pydeerflow/scheduler/ 整包、两个 sql.py 都会消失,本文会补上端口与服务层章节。


3. 领域模型全景

packages/harness/deerflow/domain/schedule/model/ 是一个包而非单文件,因为这个上下文有两个聚合加一个值对象:

model/
├── errors.py    9 个领域错误,零依赖
├── enums.py     5 个枚举,零依赖
├── spec.py      ScheduleSpec值对象· SchedulePolicy值对象
├── task.py      ScheduledTask聚合根· TERMINAL_TASK_STATUSES
└── run.py       ScheduledRun聚合· ACTIVE/TERMINAL_RUN_STATUSES

纪律:这一层零基础设施依赖——没有 SQL、没有 HTTP、没有配置读取、不看时钟now 一律由调用方显式传入。CI 的 tests/test_harness_domain_purity.py 会 AST 扫描整个 domain/ 目录执法。唯一的第三方依赖是 croniter,它是确定性纯计算库(无 IO、无全局状态与标准库 zoneinfo 同性质——日历计算本身就是定时任务的领域知识。

classDiagram
    class ScheduledTask {
        <<聚合根 · frozen>>
        +ScheduleSpec schedule
        +TaskStatus status
        +ContextMode context_mode
        +create(...)$
        +resolve_execution_thread() str
        +ensure_mutable()
        +status_after_launch(trigger)
        +status_after_failure(trigger)
        +status_after_skip()
        +status_after_completion(outcome)
        +with_schedule(...) ScheduledTask
        +with_context(...) ScheduledTask
        +paused() / resumed()
    }
    class ScheduleSpec {
        <<值对象 · frozen>>
        +ScheduleType schedule_type
        +str timezone
        +str|None cron
        +datetime|None run_at
        +next_after(now) datetime|None
        +ensure_launchable(now, policy)
    }
    class SchedulePolicy {
        <<值对象 · frozen>>
        +int min_once_delay_seconds
    }
    class ScheduledRun {
        <<聚合 · frozen>>
        +RunStatus status
        +TriggerKind trigger
        +queued(...)$
        +skipped_tombstone(...)$
        +is_active bool
    }

    ScheduledTask *-- ScheduleSpec : 持有
    ScheduledTask ..> SchedulePolicy : 方法入参
    ScheduleSpec ..> SchedulePolicy : 方法入参

三个关系值得注意:

  • 两个聚合之间只有 task_id 字符串引用,没有对象引用。 一次派发要写两张表,且现状就不在同一个事务里——它们是各自独立的一致性边界。
  • SchedulePolicy 是"传入"而非"持有"。 它承载运营可调阈值(当前只有 min_once_delay_seconds),由组合根从 config.scheduler 构造。聚合若持有它,同一个任务对象在不同部署配置下语义就不同了。它的领域默认值是 0(不施加约束),真正的业务阈值只能由外圈注入。
  • ScheduledTask 持有的是解析后的 ScheduleSpec,不是原始 dict。 dict ↔ 值对象的映射属于适配器层。

4. ScheduleSpec:调度在什么时候

它把三个存储字段(schedule_type / schedule_spec JSON / timezone)解析成一个校验过的值对象。

4.1 两种调度类型

类型 依据字段 语义
cron cron5 段) 周期性,永远有下一次
once run_at 一次性,用完即止

4.2 创建即一致

所有校验与规范化都在 __post_init__ 里,不在工厂方法里。原因很实际frozen dataclass 仍然可以逐字段直接构造,把规则放在 cron_schedule() / once_at() 里等于留了一条绕过通道。

构造期做四件事:

  1. 时区必须是合法 IANA 名(先做,因为下面本地化 run_at 要用它)
  2. cron 必须存在且恰好 5 段,空白折叠后写回
  3. once 必须带 run_at
  4. naive 的 run_at任务自己的时区本地化——2026-08-01T09:00Asia/Shanghai 意为"上海时间早上九点",不是 UTC 九点

非法输入在任何 IO 发生之前就抛 InvalidScheduleError

4.3 两个算时间的方法,别用反

这是本模块最容易写错的一处:

flowchart TD
    subgraph CU["创建 / 更新路径(用户提交)"]
        A1["ensure_launchable(now, policy)"] --> A2{"ONCE?"}
        A2 -->|是| A3{"没有未来的一次?"}
        A3 -->|是| A4["InvalidScheduleError<br/>必须是未来时间"]
        A3 -->|否| A5{"距今 < min_once_delay?"}
        A5 -->|是| A6["InvalidScheduleError<br/>至少提前 N 秒"]
        A5 -->|否| A7["返回时间"]
        A2 -->|否| A8["CRON 不受提前量约束"]
    end
    subgraph DP["派发路径(重排下一次)"]
        B1["next_after(now)"] --> B2["纯计算,不做任何提交期校验"]
    end
    style A4 fill:#ffe0e0
    style A6 fill:#ffe0e0
  • next_after(now) —— 算下一次是什么时候。cron 在任务时区里算,返回 UTConce 只在还没过期时返回。
  • ensure_launchable(now, policy) —— 同样的计算,外加只在用户提交时才成立的约束once 必须在未来,且至少提前 min_once_delay_seconds(默认配置 60 秒防止用户建一个立刻就要跑的任务。cron 从不受这个下限约束。

用反的后果:拿 ensure_launchable 做派发后重排,会让一个正常执行完的 cron 任务被"提前量不足"拒绝。

4.4 时区是真的按时区算

cron 表达式在任务声明的时区里求值,然后转成 UTC 存储。这意味着夏令时切换会被正确吸收:

America/New_York 的 "0 9 * * *"
  2026-03-07EST, UTC-5→ 14:00 UTC
  2026-03-09EDT, UTC-4→ 13:00 UTC

用户看到的始终是"每天早上九点"。

4.5 它不知道自己怎么被存储

ScheduleSpec没有序列化方法。数据库里那个 schedule_spec JSON 列(以及 HTTP 请求/响应里的同名字段)与值对象之间的双向映射,属于适配器层:

{"cron": "0 9 * * *"}  ←──→  ScheduleSpec(CRON, "Asia/Shanghai", cron="0 9 * * *")
        JSON 列 / HTTP 字段)        (值对象)
                    ↑
        app/infra/schedule/spec_mapping.py

这不是洁癖,是一条可执行的判据:一旦领域方法的签名里出现 Mapping[str, Any],就说明领域在处理持久化/传输格式了。解析这件事天然可以切成两半——结构校验键在不在值是不是字符串属于边界值校验cron 是不是 5 段、时区认不认识、run_at 有没有)属于 __post_init__。切开之后领域完全不需要看见 dict签名全部强类型。

同样的形状在 Feedback 上下文里也成立:Feedback 聚合对 ORM 行一无所知,转换全在 app/infra/persistence/feedback.py


5. ScheduledTask:规则本体

聚合根,持有全部不变量。

5.1 字段分组

字段 说明
身份 task_id user_id 一切读写按 user_id 隔离
内容 title prompt
调度 scheduleScheduleSpec
执行上下文 context_mode thread_id assistant_id 见 5.2
状态 status overlap_policy 见 5.3、5.5
调度游标 next_run_at 认领的唯一依据:为空或在未来 ⇒ 不会被派发
回执 last_run_at last_run_id last_thread_id last_error run_count 仅供展示

5.2 执行上下文:每次新会话,还是复用同一个

context_mode 语义
fresh_thread_per_run(默认) 每次派发新建一个 thread各次执行互不干扰
reuse_thread 所有执行都落在同一个 thread 里agent 能看到历史

不变量:reuse_thread 必须thread_id,构造期强制。

resolve_execution_thread() 回答"这次派发用哪个 thread"。注意它不是幂等的——fresh_thread_per_run 每次调用都生成新 UUID。调用方必须每次派发只调一次把结果存进局部变量供 run 记录、启动调用、返回结果三处复用。

5.3 状态机

stateDiagram-v2
    [*] --> enabled: create()
    enabled --> paused: paused()
    paused --> enabled: resumed()
    enabled --> running: 被轮询器认领
    running --> enabled: 派发完成cron
    running --> running: 派发完成once等回调
    running --> completed: run 成功once
    running --> failed: run 失败once
    running --> cancelled: run 被中断once
    completed --> enabled: with_schedule 改到未来
    failed --> enabled: with_schedule 改到未来
    cancelled --> enabled: with_schedule 改到未来

    note right of running
        RUNNING ≠ "agent 正在跑"
        而是"这一轮的调度所有权被持有"
    end note

三件反直觉的事,理解了它们就理解了这个状态机:

running 不表示 agent 在执行。 轮询器认领任务的那一刻就写 running,此时 run 还没创建。它真正的含义是"某个轮询进程持有这一轮的调度所有权(租约)"。ensure_mutable() 因此在这个状态拒绝编辑——正在被派发的任务改不得。

② cron 任务几乎从不停在 running 派发一完成立刻回 enabled。只有 once 会停在 running 等待完成回调——因为在启动那一刻宣布 completed 会在 run 失败或进程崩溃时永久说谎。

③ 终态可以被重新武装。 把一个 completed / failed / cancelled 的任务的调度改到未来时间,状态会被强制拉回 enabledTERMINAL_TASK_STATUSES)。不这么做的话,接口会返回 200、next_run_at 有值、但永远不触发——静默死亡。

5.4 四条状态推导规则

派发流程的每个出口都要回答"任务接下来是什么状态"。这四个方法就是答案,判定顺序即语义(现状是 if/elif/else,写成并列的 if 会静默改变行为):

flowchart TD
    L0["status_after_launch(trigger)<br/>启动成功后"] --> L1{"ONCE?"}
    L1 -->|是| LR["RUNNING"]
    L1 -->|否| L2{"MANUAL 且当前 PAUSED?"}
    L2 -->|是| LP["PAUSED"]
    L2 -->|否| LE["ENABLED"]

    F0["status_after_failure(trigger)<br/>启动失败后"] --> F1{"MANUAL?"}
    F1 -->|是| FS["保持原状态"]
    F1 -->|否| F2{"ONCE?"}
    F2 -->|是| FF["FAILED"]
    F2 -->|否| FE["ENABLED"]

    K0["status_after_skip()<br/>因重叠被跳过"] --> K1{"ONCE?"}
    K1 -->|是| KF["FAILED"]
    K1 -->|否| KE["ENABLED"]

    C0["status_after_completion(outcome)<br/>run 到达终态"] --> C1{"ONCE?"}
    C1 -->|否| CN["None不改"]
    C1 -->|是| C2{"outcome"}
    C2 -->|SUCCESS| CC["COMPLETED"]
    C2 -->|INTERRUPTED| CX["CANCELLED"]
    C2 -->|FAILED| CF["FAILED"]

    style L1 fill:#ffd9d9
    style F1 fill:#ffd9d9

两个判定顺序陷阱(红色节点),都有专门的测试锁住:

  • status_after_launch 先判 ONCE:一个 paused 的 once 任务被手动触发,结果是 RUNNING 而不是 PAUSED
  • status_after_failure 先判 MANUAL:一个 once 任务被手动触发且失败,保持原状态而不是 FAILED——失败的手动触发不能吃掉这个任务本来的调度未来。

另外三处设计意图:

  • status_after_skip 没有 trigger 参数。跳过只发生在自动调度路径上——手动触发遇到重叠是直接拒绝、不留记录的,所以那个分支根本不存在,加参数会暗示一个虚构的可能性。
  • once 被跳过是 FAILED 而非 COMPLETED:唯一的那次机会丢了,说"完成"等于谎称执行过。
  • 完成回调里 INTERRUPTED 映射到 CANCELLED 而非 FAILED:用户主动取消、或同 thread 被新 run 抢占,都不是执行失败。

5.5 重叠策略

overlap_policy 目前固定为 "skip":一个任务同时最多有一个活跃执行,到点时若上一次还没结束,这一次就被跳过。

聚合上用 skips_on_overlap 属性封装这个判断,字符串比较只存在于这一处——将来加第二种策略(比如 queue)只改这里。之所以不做成枚举:目前只有一个取值,单值枚举是噪音。


6. ScheduledRun:一次执行的记账

字段几乎是 scheduled_task_runs 表的镜像,业务逻辑很少。它存在的核心理由是两个具名工厂

工厂 产出状态 用在哪
ScheduledRun.queued(...) QUEUED(活跃) 正常派发
ScheduledRun.skipped_tombstone(...) SKIPPED直接终态 因重叠被跳过

为什么墓碑必须是第二个工厂,而不是"先 queued 再改状态"——这是全模块最容易写错的一处:

数据库上有一个部分唯一索引 uq_scheduled_task_run_active,谓词是 status IN ('queued','running'),保证一个任务最多一条活跃执行。跳过发生时,上一次执行还占着那个槽位。如果墓碑先建成 queued,它自己就会撞上这个索引。skipped 落在谓词之外,永不冲突。

用两个具名工厂而不是一个可变状态的构造器,就是用类型系统把这条规则焊死,让错误实现写不出来。

ACTIVE_RUN_STATUSES 这个常量必须与上述索引谓词保持逐字一致——它是"跳过"判断的快路径依据,而索引是并发下的最终仲裁者,两者漂移会让它们对不上。

执行状态共六个:queued → running → success | failed | interrupted,外加旁路的 skipped


7. 二次开发指引

7.1 给任务加一个字段

例:加一个 notify_on_failure: bool

  1. model/task.pyScheduledTask 加字段(带默认值)
  2. 若有约束,写进 __post_init__
  3. persistence/scheduled_tasks/model.py 的 ORM 行加列
  4. 新增一个 alembic revisioncd backend && make migrate-rev MSG="..."),用 _helpers.py 的幂等 helper
  5. router 的请求/响应模型加字段
  6. 前端 frontend/src/core/scheduled-tasks/types.ts 同步
  7. 域测试补一条

7.2 加一种调度类型

例:加 interval(每 N 分钟)。

  1. model/enums.pyScheduleType 加成员
  2. model/spec.py:加承载参数的字段(如 interval_seconds)、在 __post_init__ 加校验、在 next_after 加一个分支
  3. 适配器的 spec_mapping(见 §4.5):进出两个方向各加一个分支
  4. 逐个检查 task.py 里四个 status_after_*——它们目前都在问"是不是 ONCE",新类型会落进 else 分支。确认那是你要的语义大概率是interval 与 cron 同属周期性)
  5. 域测试:新类型在 §5.4 四张表里各补一行

不需要改数据库——schedule_spec 是 JSON 列。

7.3 改一条状态推导规则

只改 model/task.py 对应的那个方法,顺便改 app/scheduler/service.py 里的旧副本(见 §2。域测试里对应的真值表用例必须同步更新——那张表就是规则的规格说明。

7.4 加一种重叠策略

例:加 queue(排队而非跳过)。

这是改动面最大的一种,因为它触及数据库不变量:

  1. model/task.pyskips_on_overlap 拆成策略判断
  2. scheduled_task_runs 的部分唯一索引必须改成条件化的(... AND overlap_policy = 'skip')——现在它是纯状态谓词,会把排队的执行也挡掉。索引同时定义在 ORM __table_args__ 和迁移文件里,两处都要改(空库 bootstrap 走 create_all,不执行迁移)
  3. 跳过路径的墓碑逻辑要相应分叉

动手前先读 backend/AGENTS.md 里关于这个索引的整段说明。

7.5 不要做的事

  • 不要在 router 或 service 里新增业务判断——那是迁移前的旧形态,新规则一律进领域模型
  • 不要在领域层读配置或时钟——阈值通过 SchedulePolicy 注入,now 显式传参CI 的纯度测试会拦截基础设施导入
  • 不要为定时执行另建一套运行栈——必须复用现有的 run 生命周期

8. 常见陷阱速查

陷阱 后果
ensure_launchable 做派发后重排 正常执行完的 cron 任务被"提前量不足"拒绝
status_after_*if/elif 写成并列 if 两处判定顺序失效,静默改变行为
多次调用 resolve_execution_thread() 每次拿到不同的 thread记录与实际执行对不上
墓碑先建 queued 再改 skipped 撞上唯一索引,跳过流程直接报错
改了 ACTIVE_RUN_STATUSES 没改索引谓词 快路径与数据库仲裁者判断不一致
终态任务改了调度但没重新武装 接口返回 200任务永不触发
只改领域模型,忘了 app/scheduler/service.py 迁移完成前,生产行为不变

9. 术语表

术语 含义
任务task 用户注册的定时规则,长期存在
执行run 规则的一次触发,只读历史
派发dispatch 把一个到期任务变成一次执行的动作
触发方式trigger scheduled(轮询器)或 manual(用户点"立即执行"
认领claim 轮询器取得某任务这一轮调度所有权
租约lease 认领时盖的带过期时间的戳,用于崩溃恢复
重叠overlap 到点时上一次执行还没结束
墓碑tombstone 被跳过的那次执行留下的终态记录
重新武装re-arm 把终态任务拉回 enabled 使其可再次被认领

10. 代码索引

领域模型(已迁移,本文覆盖)

文件 内容
domain/schedule/model/enums.py TaskStatus RunStatus ScheduleType ContextMode TriggerKind
domain/schedule/model/errors.py 9 个领域错误
domain/schedule/model/spec.py ScheduleSpec SchedulePolicy
domain/schedule/model/task.py ScheduledTask TERMINAL_TASK_STATUSES
domain/schedule/model/run.py ScheduledRun ACTIVE_RUN_STATUSES TERMINAL_RUN_STATUSES
tests/test_schedule_domain.py 域测试,全同步零 IO四张真值表逐格覆盖

尚未迁移(生产路径,见 §2

文件 内容
app/gateway/routers/scheduled_tasks.py 10 个 HTTP 端点,含入参校验与业务判断
app/scheduler/service.py 轮询循环、派发编排、状态推导、完成回调、启动清扫
deerflow/scheduler/schedules.py 时区 / cron / 下次时间计算
persistence/scheduled_tasks/ 任务表 ORM + 仓储
persistence/scheduled_task_runs/ 执行表 ORM + 仓储,含唯一索引定义
config/scheduler_config.py enabled poll_interval_seconds lease_seconds max_concurrent_runs min_once_delay_seconds

前端

frontend/src/core/scheduled-tasks/类型、API、cron 解析、预设配方)与 frontend/src/app/workspace/scheduled-tasks/page.tsx