diff --git a/backend/docs/SCHEDULE_DESIGN_zh.md b/backend/docs/SCHEDULE_DESIGN_zh.md
index 3c7308fed..8d5c89f39 100644
--- a/backend/docs/SCHEDULE_DESIGN_zh.md
+++ b/backend/docs/SCHEDULE_DESIGN_zh.md
@@ -4,7 +4,7 @@
>
> 配套文档:[`HEXAGONAL_ARCHITECTURE_zh.md`](HEXAGONAL_ARCHITECTURE_zh.md)(本文遵循的架构分层)、`backend/AGENTS.md`(编码规约与调度相关的运行时约定)。
>
-> **本文当前只覆盖领域模型层**(`domain/schedule/model/`)——六边形迁移的第一步。端口、应用服务与适配器仍在旧位置,见 §2。
+> **本文覆盖整个内圈**(`domain/schedule/`:模型、端口、应用服务)。适配器与入口仍在旧位置,见 §2。
---
@@ -42,57 +42,69 @@ run 结束后回写执行记录,并算出下一次的时间
**这一节请先读,否则你会在代码库里迷路。**
-模块正处于六边形重构的中途。领域模型已经落地,但**生产代码路径还没有切过来**:
+模块正处于六边形重构的中途。**整个内圈已经落地**,但生产代码路径还没有切过来:
```mermaid
flowchart LR
subgraph NEW["✅ 已落地 · 内圈"]
- M["deerflow/domain/schedule/model/
ScheduleSpec · ScheduledTask · ScheduledRun"]
+ direction TB
+ SV["service.py
ScheduleService(用例编排)"]
+ PO["ports.py
4 个 Protocol + 2 个 DTO"]
+ M["model/
ScheduleSpec · ScheduledTask · ScheduledRun"]
+ SV --> PO
+ SV --> M
end
subgraph OLD["⏳ 仍是旧形态 · 生产路径"]
- R["app/gateway/routers/scheduled_tasks.py
入参校验 + 业务判断混在一起"]
+ R["gateway/routers/scheduled_tasks.py
入参校验 + 业务判断混在一起"]
S["app/scheduler/service.py
轮询 + 派发编排 + 状态推导"]
- P["deerflow/persistence/scheduled_task*/sql.py
仓储返回裸 dict"]
+ P["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/"]
+ subgraph TODO["🚧 待建 · 外圈"]
+ AD["app/infra/persistence/
app/infra/schedule/
app/scheduler/poller.py"]
end
- R -.->|尚未调用| M
- S -.->|尚未调用| M
- M --> PO --> SV --> AD
+ R -.->|尚未调用| SV
+ S -.->|尚未调用| SV
+ AD -.->|将实现| PO
- style M fill:#d8ecff
+ style NEW fill:#eef6ff
style OLD fill:#f5f5f5
```
含义很具体:
-- **`domain/schedule/model/` 是唯一的真相声明处**,但目前只有域测试在用它。运行中的定时任务走的仍是 `app/scheduler/service.py` 那套。
-- 两边**规则内容一致**(模型是逐条从旧代码搬迁的,每个方法的 docstring 都标了来源行号),但**代码是重复的**。这是迁移中间态的正常代价。
-- 你现在改一条业务规则,要**两边都改**,直到迁移完成。旧位置见 §10 的索引。
-- 新增业务规则请**只写在领域模型里**,然后在旧位置调用它——不要再往 router / service 里加新的判断。
+- **`domain/schedule/` 是唯一的真相声明处**,但目前只有测试在用它。运行中的定时任务走的仍是 `app/scheduler/service.py` 那套。
+- 两边**规则内容一致**(内圈是逐条从旧代码搬迁的,每个方法的 docstring 都标了来源行号),但**代码是重复的**。这是迁移中间态的正常代价。
+- 你现在改一条业务规则,要**两边都改**,直到迁移完成。旧位置见 §12 的索引。
+- 新增业务规则请**只写在内圈**,然后在旧位置调用它——不要再往 router / 旧 service 里加新的判断。
-迁移完成后 `app/scheduler/service.py`、`deerflow/scheduler/` 整包、两个 `sql.py` 都会消失,本文会补上端口与服务层章节。
+**还差什么**:四个端口都没有真实实现。适配器落地后,`app/scheduler/service.py`、`deerflow/scheduler/` 整包、两个 `sql.py` 都会消失,router 瘦身成协议转换。
----
-
-## 3. 领域模型全景
-
-`packages/harness/deerflow/domain/schedule/model/` 是一个包而非单文件,因为这个上下文有两个聚合加一个值对象:
+## 3. 内圈全景
```
-model/
-├── errors.py 9 个领域错误,零依赖
-├── enums.py 5 个枚举,零依赖
-├── spec.py ScheduleSpec(值对象)· SchedulePolicy(值对象)
-├── task.py ScheduledTask(聚合根)· TERMINAL_TASK_STATUSES
-└── run.py ScheduledRun(聚合)· ACTIVE/TERMINAL_RUN_STATUSES
+domain/schedule/
+├── service.py ScheduleService —— 用例编排(input port)
+├── ports.py 4 个 Protocol + LaunchedRun / RunOutcome(output ports)
+└── model/ 领域模型;是包而非单文件,因为这里有两个聚合加值对象
+ ├── errors.py 9 个领域错误,零依赖
+ ├── enums.py 6 个枚举,零依赖
+ ├── spec.py ScheduleSpec(值对象)· SchedulePolicy(值对象)
+ ├── task.py ScheduledTask(聚合根)· TERMINAL_TASK_STATUSES
+ └── run.py ScheduledRun(聚合)· ACTIVE/TERMINAL_RUN_STATUSES
```
+三层的纪律各不相同:
+
+| 文件 | 职责 | 纪律 |
+|---|---|---|
+| `model/` | 业务事实与不变量 | 零依赖;不知道存储和 HTTP 存在;不变量在构造期校验 |
+| `ports.py` | 领域声明的接口 | 技术中立——签名里不出现 SQL、表名、HTTP 状态码、运行时类型 |
+| `service.py` | 用例编排 | 内圈唯一调用 output port 的地方;`user_id` 显式传参;自身不含业务规则 |
+
+一个关键理解:**service 调用 port 是合法的**——port 是领域自己声明、自己拥有的接口,调用自己的抽象不构成对外圈的依赖。运行时注入的实现来自外圈,但 service 只见 Protocol 类型。
+
**纪律**:这一层零基础设施依赖——没有 SQL、没有 HTTP、没有配置读取、不看时钟(`now` 一律由调用方显式传入)。CI 的 `tests/test_harness_domain_purity.py` 会 AST 扫描整个 `domain/` 目录执法。唯一的第三方依赖是 `croniter`,它是确定性纯计算库(无 IO、无全局状态),与标准库 `zoneinfo` 同性质——日历计算本身就是定时任务的领域知识。
```mermaid
@@ -359,21 +371,144 @@ flowchart TD
---
-## 7. 二次开发指引
+## 7. 端口:领域对外的四个依赖
-### 7.1 给任务加一个字段
+`ports.py` 声明领域需要外界做什么,由外圈实现。签名一律技术中立——出现 SQL、表名、HTTP 状态码就是越界了。
+
+| 端口 | 回答什么问题 |
+|---|---|
+| `ScheduledTaskRepository` | 规则存在哪、怎么按用户隔离、怎么原子地认领到期任务 |
+| `ScheduledRunRepository` | 执行记录存在哪、谁来仲裁"一个任务只能有一条活跃执行" |
+| `RunLauncher` | 怎么真正启动一次 agent run |
+| `ThreadLookup` | 这个 thread 存在吗、这个用户能用吗 |
+
+外加两个 DTO:`LaunchedRun`(启动成功后拿到的 run 身份)、`RunOutcome`(一次执行到达终态的领域表述)。
+
+### 7.1 三条约定,比签名更重要
+
+**① 越权一律表现为"不存在"。** 别人的任务在读取时返回 `None` / `False` / 从列表里消失,而不是抛权限错误——调用方不能借此判断"这个 id 到底存不存在"。
+
+**② `RunLauncher` 只允许两种异常逃逸。**
+
+```
+执行 thread 已经忙 -> ThreadBusyError
+其他任何失败 -> LaunchFailedError
+```
+
+这条约定是**整个重构的支点**。旧代码的编排层要靠 `isinstance(exc, HTTPException) and exc.status_code == 409` 去嗅探"线程忙",于是业务逻辑依赖了 Web 框架。翻译交给适配器之后,领域只认自己的两个错误。两者必须区分,因为结果不同:自动调度遇到线程忙是一次跳过的机会,真正的失败则要记为失败。
+
+**③ `RunOutcome` 挡住运行时类型。** 旧的完成回调直接吃 `deerflow.runtime.RunRecord`——一个基础设施类型,纯度测试会拦下它。现在由外圈先转换成 `RunOutcome`,顺带承担了旧代码内联做的过滤:一次不带定时任务元数据、或还没到终态的 run,压根产生不出 `RunOutcome`,service 也就不会被调用。
+
+### 7.2 两个刻意的缺席
+
+**没有 `Clock` 端口。** `now` 一律由调用方显式传参(`run_once(now=...)`、`dispatch_task(now=...)`),领域从不读时钟,测试天然确定。再加一个 `Clock` 只会制造"到底该用参数还是 `self._clock.now()`"的第二个真相源。
+
+**`claim_due` 不接收"谁在认领"。** 那是**进程身份**,不是规则。适配器可以记一个(诊断用),但没有任何代码读回来——决定认领能否被接管的只有过期时间。相比之下 `lease_seconds`(多久算过期)留在了 `SchedulePolicy` 里,因为它直接决定崩溃后多快能恢复,是领域关心的策略。
+
+### 7.3 原子性归实现,不归契约
+
+`claim_due` 的 docstring 明说:单线程语义(选哪些行、写什么状态)是契约的一部分,**原子性不是**。一个内存实现可以满足全部单线程规则却毫无并发保证。
+
+这条边界很重要——`tests/schedule_fakes.py` 的模块 docstring 和 `test_schedule_fakes.py` 都重复了它:**契约测试全绿不代表可以跑多个调度器实例**。并发由真数据库上的 `test_scheduled_task_dispatch_race.py` 单独负责。
+
+---
+
+## 8. 应用服务:用例编排
+
+`ScheduleService` 是这个上下文的 input port。主适配器(HTTP router、轮询器、完成回调)调它,然后把返回值翻译成自己的协议。它**自身不含业务规则**——每个判断都委托给聚合。
+
+### 8.1 用例清单
+
+| 分类 | 方法 |
+|---|---|
+| 读 | `list_tasks` · `list_tasks_by_thread` · `get_task` · `list_task_runs` |
+| 写 | `create_task` · `update_task` · `pause_task` · `resume_task` · `delete_task` |
+| 派发 | `trigger_task`(手动) · `run_once`(轮询一轮) · `dispatch_task`(单次派发) |
+| 生命周期 | `handle_run_completion` · `reconcile_on_startup` |
+
+### 8.2 `dispatch_task`:四条出口
+
+整个模块的风险中心。它把一个到期任务变成一次执行,有且只有四种结局:
+
+```mermaid
+flowchart TD
+ A["dispatch_task(task, now, trigger)"] --> B["resolve_execution_thread()
只调一次"]
+ B --> C{"skips_on_overlap
且已有活跃执行?"}
+ C -->|是, 手动| D["CONFLICT
不留任何记录"]
+ C -->|是, 自动| E["SKIPPED
写终态墓碑"]
+ C -->|否| F["创建 queued 记录"]
+ F -->|"活跃槽位被抢"| G{"trigger?"}
+ G -->|手动| D
+ G -->|自动| E
+ F -->|插入成功| H["launcher.launch(...)"]
+ H -->|ThreadBusyError + 自动 + skip| E
+ H -->|ThreadBusyError + 其他| I["CONFLICT
留失败记录"]
+ H -->|LaunchFailedError| J["FAILED"]
+ H -->|成功| K["LAUNCHED
回写两张表"]
+
+ style D fill:#fff0e0
+ style E fill:#e8f0ff
+ style J fill:#ffe0e0
+ style K fill:#e2f7e2
+```
+
+**快路径与槽位仲裁必须产生相同结果。** `has_active` 是非原子的快路径——两个并发派发可以都通过它。真正的仲裁者是仓储:第二条活跃记录会被拒绝,抛 `ActiveRunConflictError`。调用方**不能分辨自己被哪一种机制拦下**,否则重试行为就会分叉。`test_schedule_service.py` 里那条断言逐字段比较两条路径的 `DispatchResult`,就是钉死这一点。
+
+**只有 launch 被 try 包住。** 端口契约保证它只逃逸两种异常,所以后续记账写入的失败是真故障,会如实冒泡。旧代码把 launch 和记账一起包在 `except Exception` 里,结果是一次**已经成功启动**的执行会因为记账失败被标记成 failed。
+
+### 8.3 `run_once`:预算是全局的
+
+```python
+active = await runs.count_active() # 跨所有任务
+budget = policy.max_concurrent_runs - active
+if budget <= 0: return []
+claimed = await tasks.claim_due(now=..., lease_seconds=..., limit=budget)
+```
+
+`max_concurrent_runs` 限制的是**同时活跃的执行总数**,不是每轮的批量大小。长时间运行的任务会跨轮次累积,所以每一轮只能认领进剩余的额度。把它当成"每轮最多认领 N 个"会让长任务把系统压垮。
+
+### 8.4 更新任务:为什么 `context` 是打包的
+
+```python
+await service.update_task(
+ task_id, user_id=..., now=...,
+ title=None, # None = 不改
+ prompt=None,
+ schedule=None,
+ context=ContextChange(ContextMode.REUSE_THREAD, "thread-1"),
+)
+```
+
+`None` 表示"没传",不需要哨兵值。这能成立的唯一原因是:所有可选字段里,只有 `thread_id` 的 `None` 本身有含义(解绑),而它和 `context_mode` 本来就一起变化——`with_context` 同时接收两者,切换到 fresh 模式就意味着清空绑定。打包成 `ContextChange` 之后歧义消失,其余字段就能用最朴素的 `None`。
+
+这也正好对上 HTTP 层:router 的 PATCH 本来就是 `exclude_none` 语义。
+
+### 8.5 完成回调与启动清扫
+
+`handle_run_completion(outcome, now)` 先写执行记录的终态,再看任务:`once` 推向终态(成功→completed / 中断→cancelled / 失败→failed),`cron` 保持状态不变。**但 `last_error` 无条件写入**——cron 任务保住了调度,仍然要报告上次出了什么问题。任务在 run 飞行途中被删掉不算错误,静默返回。
+
+`reconcile_on_startup(error)` 依次跑两个清扫并返回修复计数。它**不吞异常**——部分清扫失败要不要阻塞启动,是调用方的策略,不是领域的。
+
+---
+
+## 9. 二次开发指引
+
+### 9.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. 域测试补一条
+3. 若用户要能设置它:`service.py` 的 `create_task` / `update_task` 加参数
+4. `persistence/scheduled_tasks/model.py` 的 ORM 行加列
+5. 新增一个 alembic revision(`cd backend && make migrate-rev MSG="..."`),用 `_helpers.py` 的幂等 helper
+6. router 的请求/响应模型加字段
+7. 前端 `frontend/src/core/scheduled-tasks/types.ts` 同步
+8. 域测试补一条;若走了第 3 步,service 测试也补一条
-### 7.2 加一种调度类型
+端口通常不用动——`add` / `save` 交换的是整个聚合,多一个字段不改变签名。
+
+### 9.2 加一种调度类型
例:加 `interval`(每 N 分钟)。
@@ -385,11 +520,23 @@ flowchart TD
不需要改数据库——`schedule_spec` 是 JSON 列。
-### 7.3 改一条状态推导规则
+### 9.3 改一条状态推导规则
-只改 `model/task.py` 对应的那个方法,**顺便改 `app/scheduler/service.py` 里的旧副本**(见 §2)。域测试里对应的真值表用例必须同步更新——那张表就是规则的规格说明。
+只改 `model/task.py` 对应的那个方法,**顺便改 `app/scheduler/service.py` 里的旧副本**(见 §2)。`domain/schedule/service.py` 一般不用动——它只调用聚合,不复制规则。
-### 7.4 加一种重叠策略
+域测试里对应的真值表用例必须同步更新——那张表就是规则的规格说明。
+
+### 9.4 加一个用例
+
+例:加"克隆一个任务"。
+
+1. `service.py` 加方法:读出聚合 → 用聚合方法或 `replace` 造出新的 → 经 `add` 持久化
+2. **不要在 service 里写规则**。如果发现自己在写 `if task.status is ...`,那条判断属于聚合
+3. 只有当现有端口回答不了你的问题时才加端口方法——先问"这是新的存储能力,还是我把编排写复杂了"
+4. service 测试补一组(全 fake,零 IO)
+5. router 加端点,把领域错误映射成 HTTP 码
+
+### 9.5 加一种重叠策略
例:加 `queue`(排队而非跳过)。
@@ -401,15 +548,17 @@ flowchart TD
动手前先读 `backend/AGENTS.md` 里关于这个索引的整段说明。
-### 7.5 不要做的事
+### 9.6 不要做的事
-- **不要在 router 或 service 里新增业务判断**——那是迁移前的旧形态,新规则一律进领域模型
+- **不要在 router 或旧 service 里新增业务判断**——新规则一律进聚合
+- **不要在 `domain/schedule/service.py` 里写规则**——它只编排;出现 `if task.status is ...` 就说明放错地方了
- **不要在领域层读配置或时钟**——阈值通过 `SchedulePolicy` 注入,`now` 显式传参;CI 的纯度测试会拦截基础设施导入
+- **不要让端口签名沾上技术词汇**——`Mapping[str, Any]`、HTTP 状态码、表名出现在 `ports.py` 里都是信号
- **不要为定时执行另建一套运行栈**——必须复用现有的 run 生命周期
---
-## 8. 常见陷阱速查
+## 10. 常见陷阱速查
| 陷阱 | 后果 |
|---|---|
@@ -420,10 +569,13 @@ flowchart TD
| 改了 `ACTIVE_RUN_STATUSES` 没改索引谓词 | 快路径与数据库仲裁者判断不一致 |
| 终态任务改了调度但没重新武装 | 接口返回 200,任务永不触发 |
| 只改领域模型,忘了 `app/scheduler/service.py` | 迁移完成前,生产行为不变 |
+| 适配器让 `launch` 逃逸出第三种异常 | 领域收到不认识的错误;线程忙被记成失败而不是跳过 |
+| 快路径与槽位仲裁产生不同结果 | 调用方能分辨被哪种机制拦下,重试行为分叉 |
+| 把契约测试全绿当成可以多实例 | fake 没有任何原子性;并发由真数据库的 dispatch_race 测试负责 |
---
-## 9. 术语表
+## 11. 术语表
| 术语 | 含义 |
|---|---|
@@ -436,21 +588,31 @@ flowchart TD
| 重叠(overlap) | 到点时上一次执行还没结束 |
| 墓碑(tombstone) | 被跳过的那次执行留下的终态记录 |
| 重新武装(re-arm) | 把终态任务拉回 `enabled` 使其可再次被认领 |
+| 端口(port) | 领域声明、外圈实现的技术中立接口 |
+| input port | 用例接口,被入口调用——这里就是 `ScheduleService` |
+| output port | 领域对外的依赖,被适配器实现——这里是四个 Protocol |
+| 聚合(aggregate) | 一致性边界,不变量在构造期成立 |
+| 活跃槽位(active slot) | 一个任务同时最多持有一条 `queued`/`running` 执行 |
---
-## 10. 代码索引
+## 12. 代码索引
-**领域模型(已迁移,本文覆盖)**
+**内圈(已迁移,本文覆盖)**
| 文件 | 内容 |
|---|---|
-| [`domain/schedule/model/enums.py`](../packages/harness/deerflow/domain/schedule/model/enums.py) | `TaskStatus` `RunStatus` `ScheduleType` `ContextMode` `TriggerKind` |
+| [`domain/schedule/service.py`](../packages/harness/deerflow/domain/schedule/service.py) | `ScheduleService` · `DispatchResult` · `ContextChange` |
+| [`domain/schedule/ports.py`](../packages/harness/deerflow/domain/schedule/ports.py) | 4 个 Protocol · `LaunchedRun` · `RunOutcome` |
+| [`domain/schedule/model/enums.py`](../packages/harness/deerflow/domain/schedule/model/enums.py) | `TaskStatus` `RunStatus` `ScheduleType` `ContextMode` `TriggerKind` `DispatchOutcome` |
| [`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;四张真值表逐格覆盖 |
+| [`tests/test_schedule_service.py`](../tests/test_schedule_service.py) | 用例测试;完整生命周期跑在 fake 上,是迁移的验收标准 |
+| [`tests/schedule_fakes.py`](../tests/schedule_fakes.py) | 四个端口的内存实现 |
+| [`tests/test_schedule_fakes.py`](../tests/test_schedule_fakes.py) | 端口语义;适配器落地后升级成契约测试 |
**尚未迁移(生产路径,见 §2)**