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

469 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 定时任务Schedule模块设计
> 面向想理解或扩展定时任务模块的人。读完你将能回答:一条定时规则从创建到执行经历了什么、它的每条业务规则住在哪个文件、以及你要改它时该动哪里。
>
> 配套文档:[`HEXAGONAL_ARCHITECTURE_zh.md`](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. 当前状态:新旧并存
**这一节请先读,否则你会在代码库里迷路。**
模块正处于六边形重构的中途。领域模型已经落地,但**生产代码路径还没有切过来**
```mermaid
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.py``deerflow/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` 同性质——日历计算本身就是定时任务的领域知识。
```mermaid
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` | `cron`5 段) | 周期性,永远有下一次 |
| `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:00``Asia/Shanghai` 意为"上海时间早上九点",不是 UTC 九点
非法输入在**任何 IO 发生之前**就抛 `InvalidScheduleError`
### 4.3 两个算时间的方法,别用反
这是本模块最容易写错的一处:
```mermaid
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` | |
| 调度 | `schedule``ScheduleSpec` | |
| 执行上下文 | `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 状态机
```mermaid
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` 的任务的调度改到未来时间,状态会被强制拉回 `enabled``TERMINAL_TASK_STATUSES`)。不这么做的话,接口会返回 200、`next_run_at` 有值、但**永远不触发**——静默死亡。
### 5.4 四条状态推导规则
派发流程的每个出口都要回答"任务接下来是什么状态"。这四个方法就是答案,**判定顺序即语义**(现状是 `if/elif/else`,写成并列的 `if` 会静默改变行为):
```mermaid
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.py``ScheduledTask` 加字段(带默认值)
2. 若有约束,写进 `__post_init__`
3. `persistence/scheduled_tasks/model.py` 的 ORM 行加列
4. 新增一个 alembic revision`cd 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.py``ScheduleType` 加成员
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.py``skips_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`](../packages/harness/deerflow/domain/schedule/model/enums.py) | `TaskStatus` `RunStatus` `ScheduleType` `ContextMode` `TriggerKind` |
| [`domain/schedule/model/errors.py`](../packages/harness/deerflow/domain/schedule/model/errors.py) | 9 个领域错误 |
| [`domain/schedule/model/spec.py`](../packages/harness/deerflow/domain/schedule/model/spec.py) | `ScheduleSpec` `SchedulePolicy` |
| [`domain/schedule/model/task.py`](../packages/harness/deerflow/domain/schedule/model/task.py) | `ScheduledTask` `TERMINAL_TASK_STATUSES` |
| [`domain/schedule/model/run.py`](../packages/harness/deerflow/domain/schedule/model/run.py) | `ScheduledRun` `ACTIVE_RUN_STATUSES` `TERMINAL_RUN_STATUSES` |
| [`tests/test_schedule_domain.py`](../tests/test_schedule_domain.py) | 域测试,全同步零 IO四张真值表逐格覆盖 |
**尚未迁移(生产路径,见 §2**
| 文件 | 内容 |
|---|---|
| [`app/gateway/routers/scheduled_tasks.py`](../app/gateway/routers/scheduled_tasks.py) | 10 个 HTTP 端点,含入参校验与业务判断 |
| [`app/scheduler/service.py`](../app/scheduler/service.py) | 轮询循环、派发编排、状态推导、完成回调、启动清扫 |
| [`deerflow/scheduler/schedules.py`](../packages/harness/deerflow/scheduler/schedules.py) | 时区 / cron / 下次时间计算 |
| [`persistence/scheduled_tasks/`](../packages/harness/deerflow/persistence/scheduled_tasks/) | 任务表 ORM + 仓储 |
| [`persistence/scheduled_task_runs/`](../packages/harness/deerflow/persistence/scheduled_task_runs/) | 执行表 ORM + 仓储,含唯一索引定义 |
| [`config/scheduler_config.py`](../packages/harness/deerflow/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`