diff --git a/backend/docs/SCHEDULE_DESIGN_zh.md b/backend/docs/SCHEDULE_DESIGN_zh.md new file mode 100644 index 000000000..3c7308fed --- /dev/null +++ b/backend/docs/SCHEDULE_DESIGN_zh.md @@ -0,0 +1,468 @@ +# 定时任务(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:00(Asia/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/
ScheduleSpec · ScheduledTask · ScheduledRun"] + end + subgraph OLD["⏳ 仍是旧形态 · 生产路径"] + R["app/gateway/routers/scheduled_tasks.py
入参校验 + 业务判断混在一起"] + S["app/scheduler/service.py
轮询 + 派发编排 + 状态推导"] + P["deerflow/persistence/scheduled_task*/sql.py
仓储返回裸 dict"] + C["deerflow/scheduler/schedules.py
时区 / 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
必须是未来时间"] + A3 -->|否| A5{"距今 < min_once_delay?"} + A5 -->|是| A6["InvalidScheduleError
至少提前 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 在**任务时区**里算,返回 UTC;once 只在还没过期时返回。 +- `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-07(EST, UTC-5)→ 14:00 UTC + 2026-03-09(EDT, 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)
启动成功后"] --> L1{"ONCE?"} + L1 -->|是| LR["RUNNING"] + L1 -->|否| L2{"MANUAL 且当前 PAUSED?"} + L2 -->|是| LP["PAUSED"] + L2 -->|否| LE["ENABLED"] + + F0["status_after_failure(trigger)
启动失败后"] --> F1{"MANUAL?"} + F1 -->|是| FS["保持原状态"] + F1 -->|否| F2{"ONCE?"} + F2 -->|是| FF["FAILED"] + F2 -->|否| FE["ENABLED"] + + K0["status_after_skip()
因重叠被跳过"] --> K1{"ONCE?"} + K1 -->|是| KF["FAILED"] + K1 -->|否| KE["ENABLED"] + + C0["status_after_completion(outcome)
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`。 diff --git a/backend/packages/harness/deerflow/domain/schedule/__init__.py b/backend/packages/harness/deerflow/domain/schedule/__init__.py new file mode 100644 index 000000000..38497879a --- /dev/null +++ b/backend/packages/harness/deerflow/domain/schedule/__init__.py @@ -0,0 +1,58 @@ +"""Schedule bounded context: standing instructions to run a prompt on time. + +Public API of the context. Import domain objects from here; ports will live in +`deerflow.domain.schedule.ports` -- they are contracts consumed by adapters and +tests, not everyday call-site symbols. + +`ScheduleService` is not exported yet: this commit lands the model only. +""" + +from deerflow.domain.schedule.model import ( + ACTIVE_RUN_STATUSES, + CRON_FIELD_COUNT, + TERMINAL_RUN_STATUSES, + TERMINAL_TASK_STATUSES, + ActiveRunConflictError, + ContextMode, + InvalidContextModeError, + InvalidScheduleError, + LaunchFailedError, + RunStatus, + ScheduledRun, + ScheduledTask, + ScheduleError, + SchedulePolicy, + ScheduleSpec, + ScheduleType, + TaskNotFoundError, + TaskNotMutableError, + TaskStatus, + ThreadBusyError, + ThreadNotFoundError, + TriggerKind, +) + +__all__ = [ + "ACTIVE_RUN_STATUSES", + "CRON_FIELD_COUNT", + "TERMINAL_RUN_STATUSES", + "TERMINAL_TASK_STATUSES", + "ActiveRunConflictError", + "ContextMode", + "InvalidContextModeError", + "InvalidScheduleError", + "LaunchFailedError", + "RunStatus", + "ScheduleError", + "SchedulePolicy", + "ScheduleSpec", + "ScheduleType", + "ScheduledRun", + "ScheduledTask", + "TaskNotFoundError", + "TaskNotMutableError", + "TaskStatus", + "ThreadBusyError", + "ThreadNotFoundError", + "TriggerKind", +] diff --git a/backend/packages/harness/deerflow/domain/schedule/model/__init__.py b/backend/packages/harness/deerflow/domain/schedule/model/__init__.py new file mode 100644 index 000000000..fbc9edbcc --- /dev/null +++ b/backend/packages/harness/deerflow/domain/schedule/model/__init__.py @@ -0,0 +1,67 @@ +"""Domain model of the schedule context. + +A package rather than a single module because this context has two +aggregates plus a value object (Feedback, the first slice, needed only +92 lines and stayed a module). Re-exports below make the split +invisible to callers: `deerflow.domain.schedule.model` exposes exactly +what a single `model.py` would have. + +The re-exports below are ordered alphabetically because ruff's isort rule +owns that block; alphabetical happens to satisfy the real dependency order +too (enums and errors depend on nothing, run depends on enums, spec depends +on enums and errors, task depends on all three), so it needs no override. + +What actually keeps the package acyclic is a rule isort cannot express: +**a submodule imports its siblings directly, never this package.** Reaching +back through `deerflow.domain.schedule.model` makes a submodule depend on a +partially initialized package — it only works while the symbol happens to +sit above that submodule's own line here, and reordering this block silently +breaks it. Keep it that way. +""" + +from deerflow.domain.schedule.model.enums import ( + ContextMode, + RunStatus, + ScheduleType, + TaskStatus, + TriggerKind, +) +from deerflow.domain.schedule.model.errors import ( + ActiveRunConflictError, + InvalidContextModeError, + InvalidScheduleError, + LaunchFailedError, + ScheduleError, + TaskNotFoundError, + TaskNotMutableError, + ThreadBusyError, + ThreadNotFoundError, +) +from deerflow.domain.schedule.model.run import ACTIVE_RUN_STATUSES, TERMINAL_RUN_STATUSES, ScheduledRun +from deerflow.domain.schedule.model.spec import CRON_FIELD_COUNT, SchedulePolicy, ScheduleSpec +from deerflow.domain.schedule.model.task import TERMINAL_TASK_STATUSES, ScheduledTask + +__all__ = [ + "ACTIVE_RUN_STATUSES", + "CRON_FIELD_COUNT", + "TERMINAL_RUN_STATUSES", + "TERMINAL_TASK_STATUSES", + "ActiveRunConflictError", + "ContextMode", + "InvalidContextModeError", + "InvalidScheduleError", + "LaunchFailedError", + "RunStatus", + "ScheduleError", + "SchedulePolicy", + "ScheduleSpec", + "ScheduleType", + "ScheduledRun", + "ScheduledTask", + "TaskNotFoundError", + "TaskNotMutableError", + "TaskStatus", + "ThreadBusyError", + "ThreadNotFoundError", + "TriggerKind", +] diff --git a/backend/packages/harness/deerflow/domain/schedule/model/enums.py b/backend/packages/harness/deerflow/domain/schedule/model/enums.py new file mode 100644 index 000000000..f112fafed --- /dev/null +++ b/backend/packages/harness/deerflow/domain/schedule/model/enums.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from enum import StrEnum + + +class TaskStatus(StrEnum): + ENABLED = "enabled" + PAUSED = "paused" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ContextMode(StrEnum): + FRESH_THREAD_PER_RUN = "fresh_thread_per_run" + REUSE_THREAD = "reuse_thread" + + +class ScheduleType(StrEnum): + ONCE = "once" + CRON = "cron" + + +class RunStatus(StrEnum): + QUEUED = "queued" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + INTERRUPTED = "interrupted" + SKIPPED = "skipped" + + +class TriggerKind(StrEnum): + """What caused a dispatch. + + The two kinds diverge in almost every decision the domain makes — how an + overlap is handled, what status survives a failed launch, whether a paused + task stays paused — so this is a first-class concept rather than the raw + string the old service compared in four places. + """ + + SCHEDULED = "scheduled" + MANUAL = "manual" diff --git a/backend/packages/harness/deerflow/domain/schedule/model/errors.py b/backend/packages/harness/deerflow/domain/schedule/model/errors.py new file mode 100644 index 000000000..d222e5bae --- /dev/null +++ b/backend/packages/harness/deerflow/domain/schedule/model/errors.py @@ -0,0 +1,48 @@ +from __future__ import annotations + + +class ScheduleError(Exception): + """Base error for the schedule domain.""" + + +class InvalidScheduleError(ScheduleError): + """Timezone, cron expression, or run_at is not usable.""" + + +class InvalidContextModeError(ScheduleError): + """context_mode is unknown, or reuse_thread is missing its thread_id.""" + + +class TaskNotFoundError(ScheduleError): + """The task does not exist or does not belong to the user.""" + + +class TaskNotMutableError(ScheduleError): + """The task is currently running and cannot be edited.""" + + +class ThreadNotFoundError(ScheduleError): + """reuse_thread points at a thread the user cannot access.""" + + +class ActiveRunConflictError(ScheduleError): + """The task already holds its single active run slot. + + Raised by the run repository when the partial unique index + ``uq_scheduled_task_run_active`` rejects a second active row. Moved here + from ``persistence/scheduled_task_runs/sql.py`` (was + ``ActiveScheduledRunConflict``) so the domain owns its own vocabulary. + """ + + +class ThreadBusyError(ScheduleError): + """The execution thread already has an in-flight run. + + Translated by the RunLauncher adapter from ConflictError / HTTP 409. + This is what removes `from fastapi import HTTPException` from the + orchestration layer. + """ + + +class LaunchFailedError(ScheduleError): + """The run could not be launched for any non-conflict reason.""" diff --git a/backend/packages/harness/deerflow/domain/schedule/model/run.py b/backend/packages/harness/deerflow/domain/schedule/model/run.py new file mode 100644 index 000000000..373387f6e --- /dev/null +++ b/backend/packages/harness/deerflow/domain/schedule/model/run.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from deerflow.domain.schedule.model.enums import RunStatus + +ACTIVE_RUN_STATUSES: tuple[RunStatus, ...] = (RunStatus.QUEUED, RunStatus.RUNNING) +"""The statuses that occupy a task's single active-run slot. + +Must stay in lockstep with the predicate of the partial unique index +``uq_scheduled_task_run_active`` (``status IN ('queued','running')``, declared +in ``persistence/scheduled_task_runs/model.py``). Drift here silently +decouples the overlap fast path from its atomic arbiter — the consistency +assertion lives in a separate test module rather than the domain tests, which +stay dependency-free. +""" + +TERMINAL_RUN_STATUSES: frozenset[RunStatus] = frozenset( + { + RunStatus.SUCCESS, + RunStatus.FAILED, + RunStatus.SKIPPED, + RunStatus.INTERRUPTED, + } +) +"""Statuses a run row can no longer leave. + +Used by the repository's ``protect_terminal`` compare-and-set: a fast-failing +run can reach the completion hook before the launch path's own write lands. +""" + + +@dataclass(frozen=True) +class ScheduledRun: + """One execution record of a scheduled task — the history row. + + A separate aggregate from ``ScheduledTask``: the two are written in + independent transactions and reference each other only by ``task_id``. + """ + + record_id: str + task_id: str + thread_id: str + scheduled_for: datetime + trigger: str + status: RunStatus + run_id: str | None = None + error: str | None = None + started_at: datetime | None = None + finished_at: datetime | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + @classmethod + def queued(cls, *, task_id: str, thread_id: str, scheduled_for: datetime, trigger: str) -> ScheduledRun: + """The active row of a normal dispatch. + + Inserting this is what the unique index arbitrates: the loser of a + concurrent insert surfaces as ``ActiveRunConflictError``. + + The ``task-run-{hex}`` id shape is depended on by existing rows and by + the run metadata that links a Gateway run back to this record — do not + change it. + """ + return cls( + record_id=f"task-run-{uuid.uuid4().hex}", + task_id=task_id, + thread_id=thread_id, + scheduled_for=scheduled_for, + trigger=trigger, + status=RunStatus.QUEUED, + ) + + @classmethod + def skipped_tombstone(cls, *, task_id: str, thread_id: str, scheduled_for: datetime, trigger: str) -> ScheduledRun: + """A dropped occurrence, created directly as terminal ``SKIPPED``. + + Deliberately a second factory rather than ``queued()`` followed by a + status change: ``QUEUED`` falls inside ``uq_scheduled_task_run_active``'s + predicate and would collide with the pre-existing run that still holds + the task's single active slot. ``SKIPPED`` is outside the predicate and + can never conflict (service.py:249-256). + """ + return cls( + record_id=f"task-run-{uuid.uuid4().hex}", + task_id=task_id, + thread_id=thread_id, + scheduled_for=scheduled_for, + trigger=trigger, + status=RunStatus.SKIPPED, + ) + + @property + def is_active(self) -> bool: + """Whether this row occupies the task's single active-run slot.""" + return self.status in ACTIVE_RUN_STATUSES diff --git a/backend/packages/harness/deerflow/domain/schedule/model/spec.py b/backend/packages/harness/deerflow/domain/schedule/model/spec.py new file mode 100644 index 000000000..3176d2dcb --- /dev/null +++ b/backend/packages/harness/deerflow/domain/schedule/model/spec.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from croniter import croniter + +from deerflow.domain.schedule.model.enums import ScheduleType +from deerflow.domain.schedule.model.errors import InvalidScheduleError + +CRON_FIELD_COUNT = 5 + + +@dataclass(frozen=True) +class SchedulePolicy: + """Operator-tunable thresholds the domain needs but must not read itself.""" + + min_once_delay_seconds: int = 0 + + +@dataclass(frozen=True) +class ScheduleSpec: + """Parsed, validated view of (schedule_type, schedule_spec, timezone). + + The stored JSON spec is mapped in and out by the adapter layer, never here: + a `Mapping[str, Any]` in a domain signature would mean the domain is + handling a persistence/transport format. The two halves of that parsing + split cleanly — structural checks (is the key present? is it a str?) belong + to the boundary, value rules (5-field cron, resolvable timezone, run_at + present) belong to __post_init__ below. Storage keeps the same raw JSON, so + this needs no migration. + + Normalization happens in __post_init__ rather than in the factories below, + so direct construction cannot bypass it: a frozen dataclass is still + constructible field-by-field, and "valid on construction" has to hold for + that path too. + """ + + schedule_type: ScheduleType + timezone: str + cron: str | None = None + run_at: datetime | None = None + + def __post_init__(self) -> None: + # The timezone is checked first because normalizing a naive run_at + # below needs it to already be known-good. + try: + zone = ZoneInfo(self.timezone) + except ZoneInfoNotFoundError as exc: + raise InvalidScheduleError(f"Unknown timezone: {self.timezone}") from exc + + if self.schedule_type is ScheduleType.CRON: + if not self.cron: + raise InvalidScheduleError("cron schedule requires schedule_spec.cron") + fields = [part for part in self.cron.split() if part] + if len(fields) != CRON_FIELD_COUNT: + raise InvalidScheduleError(f"Cron expression must contain exactly {CRON_FIELD_COUNT} fields") + object.__setattr__(self, "cron", " ".join(fields)) + + if self.schedule_type is ScheduleType.ONCE: + if self.run_at is None: + raise InvalidScheduleError("once schedule requires run_at") + if self.run_at.tzinfo is None: + # A naive run_at means wall-clock time in this schedule's own + # timezone (schedules.py:40-43). Localizing here rather than at + # every read site keeps the rest of this class tz-aware only. + object.__setattr__(self, "run_at", self.run_at.replace(tzinfo=zone)) + + @classmethod + def cron_schedule(cls, expr: str, timezone: str) -> ScheduleSpec: + """Readability sugar — all validation lives in __post_init__.""" + return cls(ScheduleType.CRON, timezone, cron=expr) + + @classmethod + def once_at(cls, run_at: datetime, timezone: str) -> ScheduleSpec: + """Readability sugar — all validation lives in __post_init__.""" + return cls(ScheduleType.ONCE, timezone, run_at=run_at) + + def next_after(self, now: datetime) -> datetime | None: + """Next fire time in UTC, or None when there is no future occurrence. + + The dispatch-path calculation (was `next_run_at` in schedules.py:24-55). + It applies no submission-time policy — see ensure_launchable for that, + and do not swap the two: re-arming a cron task through the stricter one + would reject it right after a perfectly normal launch. + + ONCE returns run_at while it is still ahead of `now`, else None (the + single occurrence is in the past). CRON is evaluated in this schedule's + timezone and returned as UTC. A naive `now` is read as UTC + (schedules.py:32-33). + """ + if now.tzinfo is None: + now = now.replace(tzinfo=UTC) + + if self.schedule_type is ScheduleType.ONCE: + return self.run_at if self.run_at > now else None + + zone = ZoneInfo(self.timezone) + next_local = croniter(self.cron, now.astimezone(zone)).get_next(datetime) + if next_local.tzinfo is None: + next_local = next_local.replace(tzinfo=zone) + return next_local.astimezone(UTC) + + def ensure_launchable(self, now: datetime, policy: SchedulePolicy) -> datetime | None: + """Next fire time, with the constraints that only apply at submission. + + Used by create/update; the dispatch path must use next_after instead. + + Raises: + InvalidScheduleError: a ONCE schedule with no future occurrence, or + one closer than policy.min_once_delay_seconds. CRON is never + subject to the delay floor (router:105, router:196). + """ + if now.tzinfo is None: + now = now.replace(tzinfo=UTC) + + next_at = self.next_after(now) + if self.schedule_type is not ScheduleType.ONCE: + return next_at + if next_at is None: + raise InvalidScheduleError("once schedule must be in the future") + if (next_at - now).total_seconds() < policy.min_once_delay_seconds: + raise InvalidScheduleError(f"once schedule must be at least {policy.min_once_delay_seconds} seconds in the future") + return next_at diff --git a/backend/packages/harness/deerflow/domain/schedule/model/task.py b/backend/packages/harness/deerflow/domain/schedule/model/task.py new file mode 100644 index 000000000..0f72bc919 --- /dev/null +++ b/backend/packages/harness/deerflow/domain/schedule/model/task.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime + +from deerflow.domain.schedule.model.enums import ContextMode, RunStatus, ScheduleType, TaskStatus, TriggerKind +from deerflow.domain.schedule.model.errors import InvalidContextModeError, TaskNotMutableError +from deerflow.domain.schedule.model.spec import SchedulePolicy, ScheduleSpec + +TERMINAL_TASK_STATUSES = frozenset({TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED}) +"""The statuses in which a task's current schedule is done. + +Lives here rather than in enums.py: it is a business subset of TaskStatus, so +it belongs beside the aggregate that reasons about it. + +Two rules read it, and they share one constant rather than drifting into two +same-valued ones: + +- `with_schedule` re-arms them to ENABLED once the schedule moves into the + future — claiming only admits ENABLED rows, so leaving a terminal status + there would hand back a next_run_at that silently never fires. +- The repository's `protect_terminal` compare-and-set refuses to overwrite + them, because a fast-failing run's completion hook can land before the launch + path's own write (scheduled_tasks/sql.py:12). + +The two coincide because terminal *is* the definition of re-armable. The name +states what the statuses are; what each rule does with them belongs to that +rule. +""" + + +def _parse_context_mode(value: str | ContextMode) -> ContextMode: + """Coerce a wire-level context_mode into the enum. + + Reported as InvalidContextModeError rather than letting ValueError + escape, so the router can map the whole ScheduleError family uniformly + — an unknown mode was a 422 at router:76-77. + """ + try: + return ContextMode(value) + except ValueError as exc: + raise InvalidContextModeError(f"Unsupported context_mode: {value}") from exc + + +@dataclass(frozen=True) +class ScheduledTask: + """A user's standing instruction to run one prompt on a schedule. + + Aggregate root of the schedule context. Holds every invariant the old + router/service pair scattered across three files; nothing here knows about + HTTP, SQL, or the run runtime. + + The `with_*` / `paused` / `resumed` transitions return a new aggregate and + deliberately leave `updated_at` alone: the repository stamps it on write + (scheduled_tasks/sql.py:97), and a second clock read here would make the + domain both impure and a competing source of truth for the same column. + """ + + task_id: str + user_id: str + title: str + prompt: str + schedule: ScheduleSpec + context_mode: ContextMode = ContextMode.FRESH_THREAD_PER_RUN + thread_id: str | None = None + assistant_id: str | None = "lead_agent" + status: TaskStatus = TaskStatus.ENABLED + # The MVP fixes this to "skip"; a single-valued enum would be noise. The + # comparison is confined to `skips_on_overlap` so a second policy only has + # to change one place. + overlap_policy: str = "skip" + next_run_at: datetime | None = None + last_run_at: datetime | None = None + last_run_id: str | None = None + last_thread_id: str | None = None + last_error: str | None = None + run_count: int = 0 + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + def __post_init__(self) -> None: + if self.context_mode is ContextMode.REUSE_THREAD and not self.thread_id: + raise InvalidContextModeError("reuse_thread requires thread_id") + + @classmethod + def create( + cls, + *, + user_id: str, + title: str, + prompt: str, + schedule: ScheduleSpec, + context_mode: str | ContextMode, + thread_id: str | None, + now: datetime, + policy: SchedulePolicy, + ) -> ScheduledTask: + """Factory: generate identity, normalize context, validate invariants. + + FRESH_THREAD_PER_RUN drops any supplied thread_id (router:164-165 does + the same on update) — carrying a thread the task will never use would + make `resolve_execution_thread` ambiguous. + + Thread existence/ownership is NOT checked here: that needs the + ThreadLookup port and belongs to the service. + """ + mode = _parse_context_mode(context_mode) + effective_thread = thread_id if mode is ContextMode.REUSE_THREAD else None + return cls( + task_id=f"task-{uuid.uuid4().hex}", + user_id=user_id, + title=title, + prompt=prompt, + schedule=schedule, + context_mode=mode, + thread_id=effective_thread, + next_run_at=schedule.ensure_launchable(now, policy), + ) + + @property + def skips_on_overlap(self) -> bool: + """Whether an overlapping dispatch is dropped rather than queued.""" + return self.overlap_policy == "skip" + + def resolve_execution_thread(self) -> str: + """Pick the thread this dispatch executes in (service.py:94-96). + + NOT idempotent: FRESH_THREAD_PER_RUN mints a new uuid4 on every call, + so a dispatch must call this exactly once and reuse the value for the + run row, the launch, and the result. + + The empty-thread_id fallback is kept even though __post_init__ makes it + unreachable for new aggregates — rows predating that invariant can + still carry REUSE_THREAD with no thread. + """ + if self.context_mode is ContextMode.FRESH_THREAD_PER_RUN or not self.thread_id: + return str(uuid.uuid4()) + return self.thread_id + + def ensure_mutable(self) -> None: + """Reject edits while this dispatch round's lease is held. + + Was `_ensure_task_mutable` in the router (409). Called by every + state-changing method here rather than by the caller, so the rule + cannot be forgotten at a new call site. The router historically applied + it to update/pause/resume but not trigger/delete; that asymmetry is + intentional and is preserved by keeping those two off this path. + + Raises: + TaskNotMutableError: the task is currently RUNNING. + """ + if self.status is TaskStatus.RUNNING: + raise TaskNotMutableError("Scheduled task is currently running; retry after the active execution finishes") + + def status_after_launch(self, *, trigger: TriggerKind) -> TaskStatus: + """Status to persist after a successful launch (service.py:152-161). + + Precondition on `self.status`: for a SCHEDULED trigger it is already + RUNNING (written by claim_due); for a MANUAL trigger it is the + user-facing status. The PAUSED rule below depends on that. + + Decision order is load-bearing — ONCE is tested first, so manually + triggering a paused ONCE task yields RUNNING, not PAUSED: + + ONCE -> RUNNING (await the completion + hook; declaring COMPLETED at + launch would stick if the run + fails or the process dies) + MANUAL and self.status PAUSED -> PAUSED (a manual run must not + silently resume the schedule) + otherwise -> ENABLED + """ + if self.schedule.schedule_type is ScheduleType.ONCE: + return TaskStatus.RUNNING + if trigger is TriggerKind.MANUAL and self.status is TaskStatus.PAUSED: + return TaskStatus.PAUSED + return TaskStatus.ENABLED + + def status_after_failure(self, *, trigger: TriggerKind) -> TaskStatus: + """Status to persist after a failed launch (was _task_status_for_failure). + + Decision order is load-bearing — MANUAL is tested first, so a failed + manual trigger of a ONCE task keeps its status instead of burning the + occurrence: + + MANUAL -> self.status (unchanged; the old dict-based code + needed an `or "enabled"` fallback, which the + field default makes unnecessary here) + SCHEDULED, ONCE -> FAILED + SCHEDULED, CRON -> ENABLED (the next occurrence still stands) + """ + if trigger is TriggerKind.MANUAL: + return self.status + if self.schedule.schedule_type is ScheduleType.ONCE: + return TaskStatus.FAILED + return TaskStatus.ENABLED + + def status_after_skip(self) -> TaskStatus: + """Status to persist after an overlap skip (was _task_status_for_skip). + + No `trigger` parameter: a skip only happens on the SCHEDULED path — a + manual trigger that overlaps is rejected outright and records no run + row at all, so a parameter here would imply a branch that cannot exist. + + ONCE -> FAILED (the single occurrence was lost; COMPLETED would + claim an execution that never happened) + CRON -> ENABLED + """ + if self.schedule.schedule_type is ScheduleType.ONCE: + return TaskStatus.FAILED + return TaskStatus.ENABLED + + def status_after_completion(self, outcome: RunStatus) -> TaskStatus | None: + """Status to persist when a launched run reaches a terminal state. + + Returns None when the status must not change — every CRON task, whose + schedule outlives any single run. + + CRON -> None + ONCE, SUCCESS -> COMPLETED + ONCE, INTERRUPTED -> CANCELLED (a cancel or same-thread takeover is + not an execution failure) + ONCE, otherwise -> FAILED + + The occurrence is consumed either way: the run did launch, so re-arming + would risk duplicate side effects. Note the caller writes `last_error` + unconditionally, whether or not this returns None (service.py:346). + """ + if self.schedule.schedule_type is not ScheduleType.ONCE: + return None + if outcome is RunStatus.SUCCESS: + return TaskStatus.COMPLETED + if outcome is RunStatus.INTERRUPTED: + return TaskStatus.CANCELLED + return TaskStatus.FAILED + + def with_schedule(self, schedule: ScheduleSpec, *, now: datetime, policy: SchedulePolicy) -> ScheduledTask: + """Replace the schedule and recompute next_run_at (router:172-208). + + A terminal task whose schedule just moved into the future must be + re-armed: claim_due only admits ENABLED rows, so leaving the terminal + status would return 200 with a next_run_at that silently never fires. + + Raises: + TaskNotMutableError: via ensure_mutable. + InvalidScheduleError: via ensure_launchable. + """ + self.ensure_mutable() + next_at = schedule.ensure_launchable(now, policy) + status = TaskStatus.ENABLED if next_at is not None and self.status in TERMINAL_TASK_STATUSES else self.status + return replace(self, schedule=schedule, next_run_at=next_at, status=status) + + def with_context(self, context_mode: str | ContextMode, thread_id: str | None) -> ScheduledTask: + """Replace execution context (router:153-165). + + FRESH_THREAD_PER_RUN forces thread_id to None. Thread existence is the + service's job (ThreadLookup port), not this aggregate's. + + Raises: + TaskNotMutableError: via ensure_mutable. + InvalidContextModeError: unknown mode, or REUSE_THREAD with no + thread (raised by __post_init__ on the replacement). + """ + self.ensure_mutable() + mode = _parse_context_mode(context_mode) + effective_thread = thread_id if mode is ContextMode.REUSE_THREAD else None + return replace(self, context_mode=mode, thread_id=effective_thread) + + def paused(self) -> ScheduledTask: + """Stop claiming this task until resumed.""" + self.ensure_mutable() + return replace(self, status=TaskStatus.PAUSED) + + def resumed(self) -> ScheduledTask: + """Re-admit this task to claim_due.""" + self.ensure_mutable() + return replace(self, status=TaskStatus.ENABLED) diff --git a/backend/tests/test_schedule_domain.py b/backend/tests/test_schedule_domain.py new file mode 100644 index 000000000..96c808d1e --- /dev/null +++ b/backend/tests/test_schedule_domain.py @@ -0,0 +1,484 @@ +"""Domain tests for the schedule bounded context. + +Deliberately synchronous and dependency-free: no pytest-asyncio, no fakes, no +IO. Everything under `deerflow.domain.schedule.model` is pure, and this file is +the proof — if a rule here ever needs a stub, the rule has leaked out of the +inner ring. + +The three `status_after_*` truth tables (TestStatusAfter*) are the reason this +commit exists: those rules used to be static methods on +`app/scheduler/service.py` with no direct coverage at all, reachable only +through the service's integration tests. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest + +from deerflow.domain.schedule.model import ( + ACTIVE_RUN_STATUSES, + ContextMode, + InvalidContextModeError, + InvalidScheduleError, + RunStatus, + ScheduledRun, + ScheduledTask, + SchedulePolicy, + ScheduleSpec, + ScheduleType, + TaskNotMutableError, + TaskStatus, + TriggerKind, +) + +NOW = datetime(2026, 7, 27, 12, 0, tzinfo=UTC) +NO_DELAY = SchedulePolicy(min_once_delay_seconds=0) +MIN_60 = SchedulePolicy(min_once_delay_seconds=60) + + +def cron_spec(expr: str = "0 9 * * *", timezone: str = "UTC") -> ScheduleSpec: + return ScheduleSpec.cron_schedule(expr, timezone) + + +def once_spec(*, after_seconds: int = 3600, timezone: str = "UTC") -> ScheduleSpec: + return ScheduleSpec.once_at(NOW + timedelta(seconds=after_seconds), timezone) + + +def make_task( + *, + schedule: ScheduleSpec | None = None, + status: TaskStatus = TaskStatus.ENABLED, + context_mode: ContextMode = ContextMode.FRESH_THREAD_PER_RUN, + thread_id: str | None = None, +) -> ScheduledTask: + return ScheduledTask( + task_id="task-1", + user_id="user-1", + title="Daily summary", + prompt="Summarize", + schedule=schedule if schedule is not None else cron_spec(), + status=status, + context_mode=context_mode, + thread_id=thread_id, + ) + + +# ---------------------------------------------------------------- A. ScheduleSpec invariants + + +class TestScheduleSpecInvariants: + def test_unknown_timezone_is_rejected(self): + with pytest.raises(InvalidScheduleError, match="Unknown timezone"): + ScheduleSpec.cron_schedule("0 9 * * *", "Mars/Olympus_Mons") + + def test_cron_without_expression_is_rejected(self): + with pytest.raises(InvalidScheduleError, match="requires schedule_spec.cron"): + ScheduleSpec(ScheduleType.CRON, "UTC") + + @pytest.mark.parametrize("expr", ["0 9 * *", "0 9 * * * *", "0"]) + def test_cron_must_have_exactly_five_fields(self, expr): + with pytest.raises(InvalidScheduleError, match="exactly 5 fields"): + ScheduleSpec.cron_schedule(expr, "UTC") + + def test_once_without_run_at_is_rejected(self): + with pytest.raises(InvalidScheduleError, match="requires run_at"): + ScheduleSpec(ScheduleType.ONCE, "UTC") + + def test_direct_construction_cannot_bypass_validation(self): + """A frozen dataclass is still constructible field-by-field, so the + rules have to live in __post_init__ rather than in the factories.""" + with pytest.raises(InvalidScheduleError, match="exactly 5 fields"): + ScheduleSpec(ScheduleType.CRON, "UTC", cron="not-a-cron") + + def test_cron_whitespace_is_normalized_on_construction(self): + assert ScheduleSpec(ScheduleType.CRON, "UTC", cron=" 0 9 * * * ").cron == "0 9 * * *" + + def test_naive_run_at_is_localized_to_the_schedule_timezone(self): + spec = ScheduleSpec.once_at(datetime(2026, 8, 1, 9, 0), "Asia/Shanghai") + assert spec.run_at.utcoffset() == timedelta(hours=8) + assert spec.run_at.astimezone(UTC) == datetime(2026, 8, 1, 1, 0, tzinfo=UTC) + + def test_aware_run_at_is_left_alone(self): + run_at = datetime(2026, 8, 1, 9, 0, tzinfo=UTC) + assert ScheduleSpec.once_at(run_at, "Asia/Shanghai").run_at == run_at + + def test_zulu_iso_input_is_accepted_as_the_same_instant(self): + """The shape the frontend actually submits: `zonedLocalToUtcIso` output, + which carries a trailing Z. It is already aware, so localization must + leave it alone rather than reinterpreting it in the task's timezone.""" + spec = ScheduleSpec.once_at(datetime.fromisoformat("2026-08-01T01:00:00Z"), "Asia/Shanghai") + assert spec.run_at == datetime(2026, 8, 1, 1, 0, tzinfo=UTC) + + +# ---------------------------------------------------------------- B. next_after + + +class TestNextAfter: + def test_once_in_the_future_returns_run_at(self): + spec = once_spec(after_seconds=3600) + assert spec.next_after(NOW) == NOW + timedelta(seconds=3600) + + def test_once_in_the_past_returns_none(self): + spec = ScheduleSpec.once_at(NOW - timedelta(seconds=1), "UTC") + assert spec.next_after(NOW) is None + + def test_once_exactly_now_returns_none(self): + """`run_at > now`, not `>=` (schedules.py:44).""" + assert ScheduleSpec.once_at(NOW, "UTC").next_after(NOW) is None + + def test_cron_returns_the_next_occurrence_in_utc(self): + # 09:00 UTC daily; NOW is 12:00 UTC, so the next one is tomorrow. + assert cron_spec("0 9 * * *", "UTC").next_after(NOW) == datetime(2026, 7, 28, 9, 0, tzinfo=UTC) + + def test_cron_is_evaluated_in_the_schedule_timezone(self): + # 09:00 Shanghai == 01:00 UTC. NOW (12:00 UTC) is past today's, so the + # answer is tomorrow 01:00 UTC -- not 09:00 UTC. + assert cron_spec("0 9 * * *", "Asia/Shanghai").next_after(NOW) == datetime(2026, 7, 28, 1, 0, tzinfo=UTC) + + def test_cron_absorbs_a_dst_transition(self): + """US DST starts 2026-03-08, so the same wall-clock cron maps to a + different UTC instant on either side of it. This is what evaluating in + the schedule's timezone buys.""" + spec = cron_spec("0 9 * * *", "America/New_York") + before = spec.next_after(datetime(2026, 3, 7, 0, 0, tzinfo=UTC)) + after = spec.next_after(datetime(2026, 3, 9, 0, 0, tzinfo=UTC)) + assert before == datetime(2026, 3, 7, 14, 0, tzinfo=UTC) # EST, UTC-5 + assert after == datetime(2026, 3, 9, 13, 0, tzinfo=UTC) # EDT, UTC-4 + + def test_naive_now_is_read_as_utc(self): + spec = cron_spec("0 9 * * *", "UTC") + assert spec.next_after(NOW.replace(tzinfo=None)) == spec.next_after(NOW) + + +# ---------------------------------------------------------------- C. ensure_launchable + + +class TestEnsureLaunchable: + def test_once_in_the_past_is_rejected(self): + spec = ScheduleSpec.once_at(NOW - timedelta(hours=1), "UTC") + with pytest.raises(InvalidScheduleError, match="must be in the future"): + spec.ensure_launchable(NOW, NO_DELAY) + + def test_once_closer_than_the_minimum_delay_is_rejected(self): + with pytest.raises(InvalidScheduleError, match="at least 60 seconds"): + once_spec(after_seconds=59).ensure_launchable(NOW, MIN_60) + + def test_once_exactly_at_the_minimum_delay_is_accepted(self): + assert once_spec(after_seconds=60).ensure_launchable(NOW, MIN_60) == NOW + timedelta(seconds=60) + + def test_cron_is_never_subject_to_the_delay_floor(self): + """router:105 / router:196 apply the floor only to `once`; a cron + schedule whose next fire is seconds away is perfectly legal.""" + spec = cron_spec("* * * * *", "UTC") + assert spec.ensure_launchable(NOW, MIN_60) is not None + + def test_matches_next_after_for_a_valid_once_schedule(self): + spec = once_spec(after_seconds=3600) + assert spec.ensure_launchable(NOW, MIN_60) == spec.next_after(NOW) + + +# ---------------------------------------------------------------- D. value semantics + + +class TestScheduleSpecEquality: + """A value object is its values -- two specs built from equivalent inputs + must compare equal, because the repository relies on that to decide whether + a schedule actually changed. + + Serialization shape (the JSON written to `scheduled_tasks.schedule_spec`) + is deliberately NOT tested here: that mapping lives in the adapter layer, + and so do its tests. + """ + + def test_equivalent_cron_inputs_converge(self): + assert ScheduleSpec.cron_schedule(" 0 9 * * * ", "UTC") == ScheduleSpec.cron_schedule("0 9 * * *", "UTC") + + def test_equivalent_once_inputs_converge(self): + naive = ScheduleSpec.once_at(datetime(2026, 8, 1, 9, 0), "Asia/Shanghai") + aware = ScheduleSpec.once_at(datetime.fromisoformat("2026-08-01T01:00:00Z"), "Asia/Shanghai") + assert naive == aware + + def test_timezone_is_part_of_identity(self): + at = datetime(2026, 8, 1, 9, 0, tzinfo=UTC) + assert ScheduleSpec.once_at(at, "UTC") != ScheduleSpec.once_at(at, "Asia/Shanghai") + + +# ---------------------------------------------------------------- E. ScheduledTask invariants + + +class TestScheduledTaskInvariants: + def test_reuse_thread_requires_a_thread(self): + with pytest.raises(InvalidContextModeError, match="reuse_thread requires thread_id"): + make_task(context_mode=ContextMode.REUSE_THREAD, thread_id=None) + + def test_unknown_context_mode_is_a_domain_error(self): + """Not a bare ValueError -- the router maps the ScheduleError family + uniformly (router:76-77 was a 422).""" + with pytest.raises(InvalidContextModeError, match="Unsupported context_mode"): + ScheduledTask.create( + user_id="user-1", + title="t", + prompt="p", + schedule=cron_spec(), + context_mode="teleport", + thread_id=None, + now=NOW, + policy=NO_DELAY, + ) + + def test_create_drops_thread_id_for_fresh_thread_mode(self): + task = ScheduledTask.create( + user_id="user-1", + title="t", + prompt="p", + schedule=cron_spec(), + context_mode=ContextMode.FRESH_THREAD_PER_RUN, + thread_id="thread-9", + now=NOW, + policy=NO_DELAY, + ) + assert task.thread_id is None + + def test_create_computes_next_run_at_and_defaults(self): + task = ScheduledTask.create( + user_id="user-1", + title="t", + prompt="p", + schedule=cron_spec("0 9 * * *", "UTC"), + context_mode=ContextMode.FRESH_THREAD_PER_RUN, + thread_id=None, + now=NOW, + policy=NO_DELAY, + ) + assert task.task_id.startswith("task-") + assert task.status is TaskStatus.ENABLED + assert task.run_count == 0 + assert task.assistant_id == "lead_agent" + assert task.next_run_at == datetime(2026, 7, 28, 9, 0, tzinfo=UTC) + + def test_create_rejects_a_once_schedule_inside_the_delay_floor(self): + with pytest.raises(InvalidScheduleError, match="at least 60 seconds"): + ScheduledTask.create( + user_id="user-1", + title="t", + prompt="p", + schedule=once_spec(after_seconds=10), + context_mode=ContextMode.FRESH_THREAD_PER_RUN, + thread_id=None, + now=NOW, + policy=MIN_60, + ) + + def test_skips_on_overlap_reflects_the_policy(self): + assert make_task().skips_on_overlap is True + + +# ---------------------------------------------------------------- F. the three truth tables + + +class TestStatusAfterLaunch: + @pytest.mark.parametrize( + ("schedule", "trigger", "status", "expected"), + [ + (once_spec(), TriggerKind.SCHEDULED, TaskStatus.RUNNING, TaskStatus.RUNNING), + (once_spec(), TriggerKind.MANUAL, TaskStatus.ENABLED, TaskStatus.RUNNING), + (cron_spec(), TriggerKind.MANUAL, TaskStatus.PAUSED, TaskStatus.PAUSED), + (cron_spec(), TriggerKind.MANUAL, TaskStatus.ENABLED, TaskStatus.ENABLED), + (cron_spec(), TriggerKind.SCHEDULED, TaskStatus.RUNNING, TaskStatus.ENABLED), + ], + ) + def test_truth_table(self, schedule, trigger, status, expected): + assert make_task(schedule=schedule, status=status).status_after_launch(trigger=trigger) is expected + + def test_manual_trigger_of_paused_once_task_yields_running(self): + """Decision-order trap: ONCE is tested before the MANUAL+PAUSED rule, + so the paused branch never sees a once task.""" + task = make_task(schedule=once_spec(), status=TaskStatus.PAUSED) + assert task.status_after_launch(trigger=TriggerKind.MANUAL) is TaskStatus.RUNNING + + +class TestStatusAfterFailure: + @pytest.mark.parametrize( + ("schedule", "trigger", "status", "expected"), + [ + (once_spec(), TriggerKind.SCHEDULED, TaskStatus.RUNNING, TaskStatus.FAILED), + (cron_spec(), TriggerKind.SCHEDULED, TaskStatus.RUNNING, TaskStatus.ENABLED), + (cron_spec(), TriggerKind.MANUAL, TaskStatus.ENABLED, TaskStatus.ENABLED), + (cron_spec(), TriggerKind.MANUAL, TaskStatus.PAUSED, TaskStatus.PAUSED), + ], + ) + def test_truth_table(self, schedule, trigger, status, expected): + assert make_task(schedule=schedule, status=status).status_after_failure(trigger=trigger) is expected + + @pytest.mark.parametrize("status", [TaskStatus.ENABLED, TaskStatus.PAUSED]) + def test_failed_manual_trigger_of_once_task_keeps_status(self, status): + """Decision-order trap: MANUAL is tested before ONCE, so a failed + manual trigger cannot burn the task's single scheduled occurrence.""" + task = make_task(schedule=once_spec(), status=status) + assert task.status_after_failure(trigger=TriggerKind.MANUAL) is status + + +class TestStatusAfterSkip: + def test_once_is_failed_not_completed(self): + assert make_task(schedule=once_spec()).status_after_skip() is TaskStatus.FAILED + + def test_cron_stays_enabled(self): + assert make_task(schedule=cron_spec()).status_after_skip() is TaskStatus.ENABLED + + +class TestStatusAfterCompletion: + @pytest.mark.parametrize("outcome", list(RunStatus)) + def test_cron_never_changes_status(self, outcome): + assert make_task(schedule=cron_spec()).status_after_completion(outcome) is None + + @pytest.mark.parametrize( + ("outcome", "expected"), + [ + (RunStatus.SUCCESS, TaskStatus.COMPLETED), + (RunStatus.INTERRUPTED, TaskStatus.CANCELLED), + (RunStatus.FAILED, TaskStatus.FAILED), + ], + ) + def test_once_maps_each_terminal_outcome(self, outcome, expected): + assert make_task(schedule=once_spec()).status_after_completion(outcome) is expected + + def test_interrupted_is_cancelled_not_failed(self): + """A user cancel or same-thread takeover carries no execution failure.""" + task = make_task(schedule=once_spec()) + assert task.status_after_completion(RunStatus.INTERRUPTED) is TaskStatus.CANCELLED + + +# ---------------------------------------------------------------- G. mutability gate + + +class TestMutabilityGate: + def test_ensure_mutable_rejects_a_running_task(self): + with pytest.raises(TaskNotMutableError, match="currently running"): + make_task(status=TaskStatus.RUNNING).ensure_mutable() + + @pytest.mark.parametrize("status", [s for s in TaskStatus if s is not TaskStatus.RUNNING]) + def test_ensure_mutable_allows_every_other_status(self, status): + make_task(status=status).ensure_mutable() + + def test_pause_and_resume_are_gated(self): + running = make_task(status=TaskStatus.RUNNING) + with pytest.raises(TaskNotMutableError): + running.paused() + with pytest.raises(TaskNotMutableError): + running.resumed() + + def test_pause_then_resume_round_trips(self): + task = make_task(status=TaskStatus.ENABLED) + assert task.paused().status is TaskStatus.PAUSED + assert task.paused().resumed().status is TaskStatus.ENABLED + + def test_transitions_do_not_mutate_the_original(self): + task = make_task(status=TaskStatus.ENABLED) + task.paused() + assert task.status is TaskStatus.ENABLED + + +# ---------------------------------------------------------------- H. with_schedule + + +class TestWithSchedule: + @pytest.mark.parametrize("status", [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]) + def test_terminal_task_is_rearmed_by_a_future_schedule(self, status): + """claim_due only admits ENABLED rows, so leaving the terminal status + would return a next_run_at that silently never fires (router:203-208).""" + task = make_task(schedule=once_spec(), status=status) + updated = task.with_schedule(once_spec(after_seconds=7200), now=NOW, policy=NO_DELAY) + assert updated.status is TaskStatus.ENABLED + assert updated.next_run_at == NOW + timedelta(seconds=7200) + + @pytest.mark.parametrize("status", [TaskStatus.ENABLED, TaskStatus.PAUSED]) + def test_non_terminal_status_is_preserved(self, status): + updated = make_task(status=status).with_schedule(cron_spec("0 10 * * *"), now=NOW, policy=NO_DELAY) + assert updated.status is status + + def test_running_task_cannot_be_rescheduled(self): + task = make_task(status=TaskStatus.RUNNING) + with pytest.raises(TaskNotMutableError): + task.with_schedule(cron_spec("0 10 * * *"), now=NOW, policy=NO_DELAY) + + def test_invalid_once_schedule_propagates(self): + task = make_task(schedule=once_spec(), status=TaskStatus.COMPLETED) + with pytest.raises(InvalidScheduleError, match="must be in the future"): + task.with_schedule(ScheduleSpec.once_at(NOW - timedelta(hours=1), "UTC"), now=NOW, policy=NO_DELAY) + + +# ---------------------------------------------------------------- I. with_context + + +class TestWithContext: + def test_switching_to_fresh_thread_clears_the_thread(self): + task = make_task(context_mode=ContextMode.REUSE_THREAD, thread_id="thread-1") + updated = task.with_context(ContextMode.FRESH_THREAD_PER_RUN, "thread-1") + assert updated.context_mode is ContextMode.FRESH_THREAD_PER_RUN + assert updated.thread_id is None + + def test_switching_to_reuse_thread_without_a_thread_is_rejected(self): + with pytest.raises(InvalidContextModeError, match="reuse_thread requires thread_id"): + make_task().with_context(ContextMode.REUSE_THREAD, None) + + def test_switching_to_reuse_thread_keeps_the_thread(self): + updated = make_task().with_context("reuse_thread", "thread-7") + assert updated.context_mode is ContextMode.REUSE_THREAD + assert updated.thread_id == "thread-7" + + def test_running_task_cannot_change_context(self): + with pytest.raises(TaskNotMutableError): + make_task(status=TaskStatus.RUNNING).with_context(ContextMode.FRESH_THREAD_PER_RUN, None) + + +# ---------------------------------------------------------------- J. execution thread + + +class TestResolveExecutionThread: + def test_fresh_thread_mode_mints_a_new_thread_every_call(self): + """Non-idempotent by design -- a dispatch must call this once and reuse + the value for the run row, the launch, and the result.""" + task = make_task(context_mode=ContextMode.FRESH_THREAD_PER_RUN) + assert task.resolve_execution_thread() != task.resolve_execution_thread() + + def test_reuse_thread_mode_returns_the_bound_thread(self): + task = make_task(context_mode=ContextMode.REUSE_THREAD, thread_id="thread-1") + assert task.resolve_execution_thread() == "thread-1" + assert task.resolve_execution_thread() == "thread-1" + + def test_reuse_thread_with_an_empty_thread_falls_back_to_a_fresh_one(self): + """Unreachable for new aggregates (__post_init__ forbids it) but rows + predating that invariant can still carry this shape.""" + task = make_task() + legacy = ScheduledTask.__new__(ScheduledTask) + object.__setattr__(legacy, "context_mode", ContextMode.REUSE_THREAD) + object.__setattr__(legacy, "thread_id", None) + assert ScheduledTask.resolve_execution_thread(legacy) != task.thread_id + + +# ---------------------------------------------------------------- K. ScheduledRun + + +class TestScheduledRun: + def test_queued_run_occupies_the_active_slot(self): + run = ScheduledRun.queued(task_id="task-1", thread_id="thread-1", scheduled_for=NOW, trigger=TriggerKind.SCHEDULED) + assert run.status is RunStatus.QUEUED + assert run.is_active is True + assert run.record_id.startswith("task-run-") + + def test_skipped_tombstone_is_terminal_and_never_queued(self): + """QUEUED falls inside uq_scheduled_task_run_active's predicate and + would collide with the pre-existing run still holding the slot.""" + run = ScheduledRun.skipped_tombstone(task_id="task-1", thread_id="thread-1", scheduled_for=NOW, trigger=TriggerKind.SCHEDULED) + assert run.status is RunStatus.SKIPPED + assert run.is_active is False + + def test_each_run_gets_a_distinct_identity(self): + first = ScheduledRun.queued(task_id="task-1", thread_id="t", scheduled_for=NOW, trigger=TriggerKind.MANUAL) + second = ScheduledRun.queued(task_id="task-1", thread_id="t", scheduled_for=NOW, trigger=TriggerKind.MANUAL) + assert first.record_id != second.record_id + + def test_active_statuses_are_exactly_queued_and_running(self): + assert ACTIVE_RUN_STATUSES == (RunStatus.QUEUED, RunStatus.RUNNING)