Task DAG 任务引擎
用五个工具、三种状态和持久化依赖图回答“现在谁能做什么”。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
Task DAG 任务引擎
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- TODO 是会话计划,Task DAG 是 workspace 级状态。
- claim 需要原子性和所有权。
- 进程退出后任务图仍能恢复。
顺着原文把边界看清
01导读:问题背景与本章目标盖房子不能先盖屋顶再打地基。对 Agent 来说,真正麻烦的不是列出“建库表、写接口、加测试、补文档”,而是回答三个更具体的问题:⌄
盖房子不能先盖屋顶再打地基。对 Agent 来说,真正麻烦的不是列出“建库表、写接口、加测试、补文档”,而是回答三个更具体的问题:
- 哪些任务现在可以开始?
- 谁已经认领了任务,谁有权完成它?
- 进程退出后,任务图能否原样恢复?
第 5 章的 todo_write 解决的是当前会话里的执行清单。它没有依赖边,也不跨会话保存。第 12 章增加的是另一套独立状态:workspace 级的 JSON Task DAG。
本章只增加一个能力,但它由三部分组成:五个任务工具、三个状态、一个加锁的持久化任务图。后台执行、Cron、teammate 和工作窃取都留在后续章节。
先看配套代码的入口,后续小节会逐个解释实现。P12 不是新建一套框架,而是在累进快照上增加一个持久化能力,因此下面只列本章新增或改动的文件:
| 文件 | 职责 |
|---|---|
| [chapters/ch12.ts](code/chapters/ch12/src/chapters/ch12.ts) | P12 固定入口,直接加载 P12 profile 并交给 CLI 运行 |
| [core/profiles.ts](code/chapters/ch12/src/core/profiles.ts) | 声明 task_dag_json capability;P12 是 P11 能力集的冻结扩展 |
| [features/tasks.ts](code/chapters/ch12/src/features/tasks.ts) | Task 领域对象、三态/owner/依赖不变量、错误类型、TaskStore 接口和五个工具 |
| [adapters/task-json.ts](code/chapters/ch12/src/adapters/task-json.ts) | JSON 持久化适配器:整图锁、锁内重载、全图校验、原子替换(注意:原子写保证「不会写坏」,不保证「不会覆盖」——互斥必须靠锁,两者是两个独立机制) |
| [bootstrap.ts](code/chapters/ch12/src/bootstrap.ts) | 组合根:按能力位强制注入 TaskStore,主/子 Agent 共享同一任务图 |
| [cli.ts](code/chapters/ch12/src/cli.ts) | 真实运行入口:按 task_dag_json capability 创建 JsonTaskStore(workspace) |
| [tests/ch12-tasks.test.ts](code/chapters/ch12/tests/ch12-tasks.test.ts) | 离线验证:坏 JSON、缺边、环、ID 碰撞、原子写失败、junction 逃逸和并发唯一认领 |
前 11 章的 shell、文件、权限、Hook、TODO、subagent、Skill、压缩、记忆、动态 Prompt 与恢复文件仍然存在于 ch12 snapshot 中,但它们不是本章新增边界;P12 的讲解只把注意力放在任务 DAG 以及它如何接入既有组合根。
02TODO 与 Task 不是一回事| 维度 | todowrite | JSON Task DAG |⌄
两者字面上都像“待办”,生命周期却完全不同:
| 维度 | todo_write | JSON Task DAG |
|---|---|---|
| 粒度 | 当前工作的步骤快照 | 可独立认领和完成的项目任务 |
| 所有者 | 单个 Agent session | 当前 workspace |
| 存储 | 进程内存 | .agent_tutorial/.tasks/{uuid}.json |
| 依赖 | 无 | blocked_by 有向边 |
| 状态 | 提醒 Agent 更新清单 | 锁内条件迁移 |
| 重建 | 新会话为空 | 从磁盘严格恢复 |
因此 P12 仍保留 todo_write。模型可以用 TODO 拆解“怎样完成当前任务”,同时用 Task DAG 管理“整个项目里哪些任务先做、哪些任务被阻塞”。两套状态不会自动同步,也不会互相覆盖。
P06 还存在一个名为 task 的一次性 subagent 工具。它表示“委派一次工作”,也不等于项目 Task。P12 新增的五个工具都使用带动词的完整名称,避免三种概念混在一起。
03一个 Task 的严格模型import { Task, TaskStatus } from "./chapters/ch12/src/features/tasks.js";⌄
领域模型位于 tasks.ts。状态只有三个值:
import { Task, TaskStatus } from "./chapters/ch12/src/features/tasks.js";
const task = new Task({
id: "9d59f5ec-7c94-49a7-b728-f36007fd912c",
subject: "endpoints",
description: "create API endpoints",
status: TaskStatus.PENDING,
owner: null,
blockedBy: [],
});Task 是构造时校验并冻结的 TypeScript 领域对象,而不是松散字典。加载每个 JSON 文件时都会验证:
id必须是 canonical UUID,文件名必须与 payload ID 完全相同。subject去除首尾空白后不能为空。blocked_by中每个值都是 canonical UUID,且不能重复。pending必须满足owner === null。in_progress和completed必须有非空 owner。- 额外字段、未知状态、坏 UTF-8 和坏 JSON 都明确失败。
这里没有第四个 blocked 状态。“阻塞”是一个派生事实:任务仍是 pending,并且至少一个依赖未完成。这样完成上游时不需要批量改写所有下游文件。
状态机只允许单向推进:
pending --claim--> in_progress --complete--> completed当前章节没有 update、delete、unclaim 或自动回退。进程重建后,in_progress 仍保持原状态和 owner,不会猜测未知副作用是否可以重放。
04路径属于 workspace,但不直接散落在根目录架构总则要求运行数据都位于 workspace/.agenttutorial/。P12 的相对目录是 .tasks/,合起来的唯一实际位置是:⌄
架构总则要求运行数据都位于 workspace/.agent_tutorial/。P12 的相对目录是 .tasks/,合起来的唯一实际位置是:
workspace/
.agent_tutorial/
.tasks.lock
.tasks/
1b73c624-7ae6-4e78-83d5-7bcf91dd5f13.json
9d59f5ec-7c94-49a7-b728-f36007fd912c.json一个文件只保存一个 Task。内容使用 UTF-8、稳定字段顺序和结尾换行:
{
"blocked_by": [
"1b73c624-7ae6-4e78-83d5-7bcf91dd5f13"
],
"description": "create API endpoints",
"id": "9d59f5ec-7c94-49a7-b728-f36007fd912c",
"owner": null,
"status": "pending",
"subject": "endpoints"
}外部传入的 task ID 先解析成 canonical UUID,再映射为文件名。../outside、绝对路径、UUID 的替代拼写都无法到达文件系统。
JsonTaskStore 还会分别验证 .agent_tutorial 和 .tasks 的解析结果仍在 workspace 内。目录 symlink 或 Windows junction 指向 workspace 外时,创建任何 Task 文件之前就会失败。
05为什么锁的是整张图适配器位于 task-json.ts。P12 使用 proper-lockfile 提供的跨平台排他锁保护整张图,而不是只锁单个目标文件。⌄
适配器位于 task-json.ts。P12 使用 proper-lockfile 提供的跨平台排他锁保护整张图,而不是只锁单个目标文件。
原因很直接:claim 的判断不仅依赖目标 Task,还依赖所有 blocked_by 任务;create 还要验证全图没有缺边和环。只锁一个文件,无法把“读取依赖 -> 判断 -> 写入”做成同一个原子决策。
每次操作都遵循同一顺序:
import { lock as acquireFileLock } from "proper-lockfile";
async #withLock<T>(
paths: JsonTaskPaths,
operation: () => Promise<T>,
): Promise<T> {
const release = await acquireFileLock(paths.tasks, {
lockfilePath: paths.lock,
realpath: true,
stale: 30_000,
update: 10_000,
retries: 0,
});
try {
await this.#validatePaths(paths);
return await operation(); // operation 内重新读取完整 DAG 并判断 claim
} finally {
await release();
}
}关键不是这段示意代码的函数名,而是锁内重新读取。两个进程可以同时在锁外决定“我要认领 A”,但进入临界区后,第二个进程看到的已是 in_progress,因此只有一个成功。
get_task 和 list_tasks 也取得同一把锁。它们返回的是一致图快照,不会一半来自迁移前、一半来自迁移后。
06原子 JSON 替换持有锁仍不等于写入不会中途损坏。每次保存都先在目标目录写临时文件,sync(),最后用 rename() 原子替换:⌄
持有锁仍不等于写入不会中途损坏。每次保存都先在目标目录写临时文件,sync(),最后用 rename() 原子替换:
import { randomUUID } from "node:crypto";
import { open, rename, rm } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { dirname, join } from "node:path";
async function atomicReplace(path: string, content: Buffer): Promise<void> {
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
let handle: FileHandle | undefined;
try {
handle = await open(temporary, "wx");
await handle.writeFile(content);
await handle.sync();
await handle.close();
handle = undefined;
await rename(temporary, path);
} finally {
if (handle !== undefined) {
await handle.close();
}
await rm(temporary, { force: true });
}
}如果临时写入、sync() 或 rename() 失败,旧 Task 文件保持原字节,临时文件被清理,调用方收到 TaskStorageError。生成的 UUID 与现有文件碰撞时也明确失败,绝不覆盖旧任务。
这不是数据库事务。它保证一次 Task 文件替换不会出现半文件;图级锁保证条件判断和这一替换之间没有另一个合规 writer 插入。P17 才会把多任务工作窃取和 lease 放进 SQLite 事务。
07创建时先证明它仍是 DAG"description": "create API endpoints",⌄
create_task 的输入只有三个字段:
{
"subject": "endpoints",
"description": "create API endpoints",
"blocked_by": [
"1b73c624-7ae6-4e78-83d5-7bcf91dd5f13"
]
}UUID 由可注入生成器产生,不让模型指定。锁内创建会按顺序验证:
- 新 ID 没有碰撞。
- 所有依赖已经存在。
- Task 不依赖自己。
- 把新节点加入候选图后,全图 DFS 不存在环。
- 全部通过后才原子写入。
原文把缺失依赖解释成“先保持阻塞”,还把环检测留给未来。这会把拼错 ID 和循环依赖变成永远 pending 的静默故障。当前实现选择在图边界显式失败。
合法 API 只允许新 Task 指向已存在 Task,所以多节点环通常只能来自手工破坏或旧数据。重建仍会执行全图检查:两个磁盘文件互相依赖时,list_tasks 不会跳过它们或返回半张图,而是报告 TaskGraphError。
08五个工具,schema 与 handler 同源五个定义由 registerTaskTools(registry, store) 从同一组 TypeScript ToolDefinition 逐项注册:⌄
五个定义由 registerTaskTools(registry, store) 从同一组 TypeScript ToolDefinition 逐项注册:
| 工具 | Effect | 输入 | 成功结果 |
|---|---|---|---|
create_task | write | subject、description、blocked_by | 新 Task JSON |
get_task | read | task_id | 完整 Task JSON |
list_tasks | read | 无 | 按 ID 稳定排序的完整任务集合 |
claim_task | write | task_id | 带 owner 的 in_progress Task |
complete_task | write | task_id | completed Task 与本次直接解锁集合 |
所有输入 Zod schema 都使用 .strict()。OpenAI schema 和运行时 handler 来自同一个 ToolDefinition,不会出现“模型看得见工具但 registry 没有 handler”的平行表漂移。
工具描述也写得更明确。create_task 说明“规划后创建、只接受 canonical UUID 依赖”;get_task 说明“只读,不修改状态”;list_tasks 说明“返回完整集合,排序稳定”。claim_task 明确“owner 由运行时 identity 写入,不接受模型传 owner”;complete_task 明确“只有当前 owner 能完成,并返回本次直接解锁集合”。边界说明不会改变 schema 或 handler,却能让模型在调用前减少试错。
已知领域错误会保留稳定错误码:
| 错误码 | 含义 |
|---|---|
task_not_found | canonical ID 指向的 Task 不存在 |
task_graph_error | 缺依赖、自依赖、环或 ID 碰撞 |
task_blocked | 至少一个依赖未完成 |
task_invalid_state | 状态不是本次迁移要求的状态 |
task_owner_mismatch | 完成者不是当前 owner |
task_storage_error | 持久化文件或目录边界损坏 |
因此模型能区分“现在还不能认领”和“存储已经损坏”,不会全部退化成通用 tool_execution_error。
不是 canonical UUID 的 task_id 会更早在工具 schema 层返回 invalid_arguments,不会进入 TaskStore 或文件系统。
09owner 不能由模型参数伪造claimtask 的 schema 只有 taskid。owner 来自可信运行时的 ToolContext.identity:⌄
claim_task 的 schema 只有 task_id。owner 来自可信运行时的 ToolContext.identity:
const claimTask = async (input: TaskIdInput, context: ToolContext): Promise<ToolResult> => {
try {
const task = await store.claimTask(input.task_id, normalizeOwner(context.identity));
return toolSuccess(encodePayload(taskPayload(task)));
} catch (error) {
return taskToolError(error);
}
};claim 成功时,pending 原子迁移为 in_progress,并保存该 identity。complete_task 同样从 ToolContext 读取调用者;只有状态为 in_progress 且 owner 完全匹配时才允许变成 completed。
模型不能通过 {"task_id":"...","owner":"someone-else"} 冒充另一身份,因为额外字段会在任何副作用前被 schema 拒绝。
10完成上游时只报告本次直接解锁schema ---> endpoints ---> tests⌄
看一个四节点图:
schema ---> endpoints ---> tests
|
+------> docs初始时只有 schema ready。提前认领 endpoints、tests 或 docs,都会返回 task_blocked,并保持状态、owner 和文件字节不变。
schema 完成后:
endpoints和docs都直接依赖schema,且全部依赖已完成,所以它们成为本次报告的 ready 任务。- tests 仍依赖尚未完成的 endpoints,不会被报告。
- 一个从一开始就没有依赖的其他 pending Task 已经 ready,也不会被误报为“本次解锁”。
实现只返回新满足条件的 Task 快照,不改写下游文件。下游仍是 pending;下一步必须由某个 identity 显式 claim。
11重建不是“扫到几个算几个”新建 JsonTaskStore(workspace) 时不依赖旧进程内存。第一次 get/list/claim/complete 会在锁内读取全部 JSON,然后验证:⌄
新建 JsonTaskStore(workspace) 时不依赖旧进程内存。第一次 get/list/claim/complete 会在锁内读取全部 JSON,然后验证:
- 每个文件是普通 UTF-8 JSON 文件。
- filename、payload ID 和 UUID 规范一致。
- 字段 schema 与 owner/status 不变量成立。
- 每条依赖都指向当前集合中的 Task。
- 全图无自依赖和环。
任何一个文件损坏都会让整张图明确失败。跳过坏文件会让其他 Task 的依赖语义悄悄改变,不能称为恢复。
重建成功后,Task、status、owner 和 blockedBy 与上一次完全一致(磁盘 JSON 字段仍为 blocked_by)。当前章节不猜测 in_progress 的 handler 是否已经产生外部副作用,所以不会自动把它重置成 pending。
12接入累进 BootstrapP12 不复制 Loop。固定 profile 只在 P11 能力集合上增加 taskdagjson capability。⌄
P12 不复制 Loop。固定 profile 只在 P11 能力集合上增加 task_dag_json capability。
BuildDependencies 从本章起显式要求 taskStore:
- P12 缺少 store,构建立即失败。
- P11 及以前传入 store,也立即失败,避免“配置了但没有使用”。
- CLI 按 capability 显式创建
JsonTaskStore(workspace)。 - P12 的主工具序列保持完整 P11 前缀,最后只追加五个 Task 工具。(P12 主 Agent 工具序列 13 个:原 8 个 + 5 个任务工具)
TaskStore 是领域层 TypeScript interface。P12-P16 使用 JSON adapter;P17 会换成 SQLite adapter,但五个工具不需要改名,也不需要修改公共 Loop。
一次性 subagent 继续共享 workspace、权限、Hook 和同一个 TaskStore,因此看到的是同一张项目图;它仍然不能递归调用 P06 的 task subagent 工具。
13与 Claude Code 的差异参照 learn-claude-code/s12tasksystem/README.md 中深入 CC 源码的分析,P12 的任务系统在教学简化上与 Claude Code 的任务系统存在以下差异:⌄
参照 learn-claude-code/s12_task_system/README.md 中深入 CC 源码的分析,P12 的任务系统在教学简化上与 Claude Code 的任务系统存在以下差异:
锁粒度。 CC 对每个任务文件使用独立的 proper-lockfile 文件锁({taskId}.json),同时通过列表级 .lock 文件检查同一 Agent 是否已有其他 open task。P12 使用 .tasks.lock 全局锁保护整张图的读改写。因此 CC 可以在一个 Agent 认领 task A 时,另一个 Agent 并行读取或修改无关的 task B;P12 的全局锁把所有操作串行化。
TaskUpdate 分离。 CC 有独立的 TaskUpdate 工具管理依赖关系(addBlocks/addBlockedBy)、activeForm 和 metadata。complete_task 实际上也通过 TaskUpdate 实现。P12 把依赖声明集中在 create_task(blockedBy=...) 中一次性完成,没有独立的更新、删除或释放工具。
高水位标防 ID 重用。 CC 使用 .highwatermark 文件记录曾分配过的最高序数 ID,即使任务被删除也不会被重用。P12 使用 UUID v4,因此没有 ID 重用风险。
9 字段 vs 6 字段。 CC 的 TaskRecord 有 id、subject、description、activeForm、owner、status、blocks(下游列表)、blockedBy、metadata 共 9 个字段。P12 的 Task 只有 id、subject、description、owner、status、blockedBy 共 6 个字段,缺少 activeForm、blocks 和 metadata。
Release 释放路径。 CC 在 teammate 关闭或终止时调用 release(清空 owner,状态从 in_progress 恢复为 pending),让其他 Agent 可以重新认领中断的任务。P12 没有 unclaim 或 release 操作,in_progress 的任务只能向前推进到 completed。
fs.watch 响应式 UI。 CC 有 useTaskListWatcher 基于 fs.watch 监听任务目录变化,用于实时刷新任务状态 UI。P12 没有文件系统监控。
14运行与验证Set-Location 'F:\笔记\Agent实操\code'⌄
两种入口都绑定同一个 P12 Bootstrap:
Set-Location 'F:\笔记\Agent实操\code'
npm run ch12 -- --prompt "建立 schema、endpoints、tests 和 docs 的任务依赖"
npm run agent-tutorial -- run --chapter 12 --prompt "列出当前项目任务并认领一个 ready 任务"聚焦验证覆盖持久化、并发与真实组合入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch12
npm run typecheck
npm run lint
npm run format:check
npm run build测试使用两个 JsonTaskStore 实例并发竞争同一个 pending Task,断言只有一个 claim 成功。测试还会注入 atomic replace 失败、UUID 碰撞、坏 JSON、非法状态、环和 Windows junction。全程不需要 API Key,也不访问网络。
15本章刻意没有做什么P12 的边界到“持久、可原子认领的 JSON DAG”为止:⌄
P12 的边界到“持久、可原子认领的 JSON DAG”为止:
- 不在这里实现慢工具后台化;那是 P13。
- 不在这里实现 Cron;那是 P14。
- 不在这里实现持续 teammate 和 mailbox;那是 P15。
- 不在这里实现 request-response 协议或计划门控;那是 P16。
- 不在这里实现 lease、claim token 和工作窃取;那是 P17 的 SQLite TaskStore。
JSON + 排他文件锁足以证明本章的状态和持久化契约,但不会把外部副作用变成数据库事务。Agent 认领后执行文件、Shell 或网络操作,仍然必须经过原有权限、Hook、取消和恢复边界。
16小结- TODO 与 Task 分层,当前会话步骤和跨会话项目状态各自有唯一 owner。⌄
第 12 章把“任务清单”升级成了可验证的项目任务图:
- TODO 与 Task 分层,当前会话步骤和跨会话项目状态各自有唯一 owner。
- Task 只允许 pending、in_progress、completed 三态,阻塞由依赖派生。
- 五个工具从同一 ToolDefinition 生成 schema 与 handler。
- 全图排他锁包住加载、DAG 校验、条件迁移与原子 JSON 替换。
- claim 只有一个并发赢家,complete 只能由 owner 执行。
- 重建严格恢复整张图,损坏、缺边和环不会被静默跳过。
下一篇进入后台任务。Task DAG 已经知道“什么可以做”,但慢操作仍会占住当前 Agent Loop。P13 会增加受管后台 job 和 typed event inbox,让 Agent 在等待期间继续处理其他工作。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始