workflow / schedule / plan / todo:编排原语的取舍
四种编排原语各管什么,为什么没做成一个大而全。核心文档:docs/subsystems/workflow.zh.md 等四篇。
四个真实场景,每个场景选一个原语。选错也没关系,判词会讲清楚为什么。点「播放」可以看自动讲解,自己点卡片可以随时抢答。
docs/subsystems/workflow.zh.md、schedule.zh.md、plan.zh.md 与 packages/todo/tool-todo/README.zh.md,核对日期 2026-08-13。先给结论:这四个原语没有共享一个任务引擎,它们连持久化形态都不一样。workflow 是一次性的:模型写一段 JS 脚本,引擎在 node:worker_threads 的 vm 里执行,脚本里的 agent() 打回宿主起子 Agent,跑完只留结果与展示记录,逻辑本身不落成持久状态机。schedule 是持久的:create、dispatch、delete 都是 schedule/change 会话事件,回放日志就能重建全部提醒状态。plan 更轻,就是一个 plan/mode 布尔事件的日志折叠。todo 是快照:每次 todo_write 整表替换,UI 靠投影渲染最新一份。
大纲里那个问题「多步编排应该是模型写脚本还是框架状态机」,DSH 的回答是两个都要,但分工明确。执行编排交给模型写脚本,因为编排逻辑千变万化,框架预设不完;时间、姿态、展示交给框架状态机,因为这三样需要跨轮次甚至跨重启的确定性,模型的脚本给不了。workflow 文档自己说了,它的 meta 字段词汇与 Claude Code 的 dynamic workflows 对齐(workflow.zh.md 第 41、49 行),思路同源,落点不同。
还有一条容易忽略的纪律。workflow 脚本里拼错一个 agent() 选项,抛的是 fatal: true 的 WorkflowError,parallel() 组合器对它直接重抛、终止整个脚本;只有子 Agent 真实的运行失败才映射成逐项的 null(workflow.zh.md 第 116 行)。写错代码和运行失败是两类错误,混在一起脚本就没法调了。
plan 不是权限plan mode 是软性指引:激活时往系统提示词里加一段 plan:policy,工具目录一个不变(为了请求缓存稳定)。真正拦住写操作的是沙箱和审批,两者都不读 plan 状态,要分别配。
schedule 不出会话提醒只以 followup 轮次回到原会话,没有推送、没有外部通知通道,冷会话不干活。交付语义是至少一次:准入后、落 dispatch 前崩溃,恢复会重复一次提醒。
todo 不驱动执行todo_write 是纯展示状态:整表替换、落日志、投影给 UI。没有部分更新、没有回读工具、没有稳定 id。把它当任务引擎用,是对这个原语最常见的误读。
先看 schedule 的固定速率决策,这是本课唯一值得整段看的代码:会话离线错过了 N 个到期时点,恢复后不逐个补发,一次除法直接算出最新一次到期,再把记录推进到未来。不枚举、不回放、不积压:
const steps = Math.floor((acceptedAt - target) / interval)
const occurrence = target + steps * interval
/* v8 ignore next -- bounded operands and a quotient-derived product stay safe. */
if (!Number.isSafeInteger(occurrence) || occurrence < target || occurrence > acceptedAt) {
throw new ScheduleLogError('every occurrence arithmetic must stay within the accepted interval')
}
const occurrenceAt = new Date(occurrence).toISOString()
const next = occurrence + interval
packages/schedule/schedule/src/domain.ts,核对日期 2026-08-13。代码块保留源码原文。第二条边界是 plan mode 的生效时机,逻辑用文字讲。用户在模型流式输出时点了切换,插件不立刻写日志,选择先挂在进程内存的 pending 里,等下一个轮内 pre-step 边界才动手。顺序讲究得很:监听器先 await next() 问下游这一步收不收,下游拒绝、信号已取消或者没有 pending,都原样放行;三关都过了才把选择追加进日志。追加万一失败,只记一条 warn 日志然后放行这一步,绝不因为一次姿态切换失败就阻塞整个轮次。这也回答了崩溃语义:pending 只活在进程内存,切换还没落日志时崩溃,重启后 plan mode 维持切换前的状态。
出处:packages/plan/plan-mode/src/index.ts 第 205 至 218 行的 agent/pre-step 监听器,核对日期 2026-08-13。
todo 那条最有态度的设计不用贴代码:allowParallelInProgress 是必填配置,schema 里写的是 z.boolean().required(),没有默认值(packages/todo/tool-todo/src/index.ts 第 41 至 43 行)。允不允许多个任务同时进行中,取决于这个部署跑不跑并发子 Agent,工具自己观测不到,所以强制部署方表态。设成 false 后,模型多标一个进行中就吃 Error: invalid todos: at most one task may be in_progress(第 107 至 109 行)。
Claude Code 走的是聚合路线:七种异步工作(shell 命令、本地子 Agent、远程 Agent、Teammate、工作流、MCP 监控、记忆整合)统一挂在一个 Task 框架下,共享 registerTask、updateTaskState、kill 一套生命周期(书稿 study/chapters/06-task-system.md 第 27 至 47 行引 tasks/types.ts)。DSH 相反,subagent 文档明确写着可继续路径「不会创建 Task,也不会创建承载中间结果的包装层」,四个编排原语更是各有各的持久化形态。聚合换来统一的进度 UI 和管理入口,拆分换来每个原语能把自己的语义说到底,比如 schedule 的错过合并、plan 的 pending 切换,塞进统一框架里都得妥协。
todo 这个小工具上的分歧最能看出两家的脾气。Claude Code 的 TodoWrite 在提示词里硬编码了纪律:「Exactly ONE task must be in_progress at any time (not less, not more)」,条目还要求 content 加 activeForm 双形态,执行中显示进行时文案(书稿 study/chapters/14-all-prompts.md 第 1243 至 1293 行引 TodoWriteTool/prompt.ts 原文)。DSH 把同一条纪律做成了必填的部署配置:跑并发子 Agent 的组合选 true,单线程纪律选 false,选了 false 就由代码拒绝而非提示词劝告;条目形状刻意最小,只有 content 和三态 status。一个用提示词约束模型,一个用 schema 约束部署,然后让代码执行。
推演两条边界
其一:模型正在流式输出一大段方案,用户此刻点了「进入 plan mode」,这个选择什么时候真正写进日志、什么时候开始影响模型请求?如果这一轮结束前进程崩了,重启后 plan mode 是开还是关?(提示:pending 只存在于进程内存。)其二:一条 every_seconds: 3600 的提醒,会话离线 5 小时后恢复,恢复瞬间会触发几次提醒、下一次目标定在哪?用本课第一段源码里的 steps 算式手推一遍。