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 算式手推一遍。