AGAgent 学习路线
第 12 / 20
CHAPTER 12 · GPT 生成学习页

Task DAG 任务引擎

用五个工具、三种状态和持久化依赖图回答“现在谁能做什么”。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

Task DAG 任务引擎

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • TODO 是会话计划,Task DAG 是 workspace 级状态。
  • claim 需要原子性和所有权。
  • 进程退出后任务图仍能恢复。
1pending:等待依赖
2claimed:已认领
3completed:完成
4依赖解锁下一项
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

16 个小节11 组代码32 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

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_writeJSON 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_progresscompleted 必须有非空 owner。
  • 额外字段、未知状态、坏 UTF-8 和坏 JSON 都明确失败。

这里没有第四个 blocked 状态。“阻塞”是一个派生事实:任务仍是 pending,并且至少一个依赖未完成。这样完成上游时不需要批量改写所有下游文件。

状态机只允许单向推进:

pending --claim--> in_progress --complete--> completed

当前章节没有 updatedeleteunclaim 或自动回退。进程重建后,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_tasklist_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 由可注入生成器产生,不让模型指定。锁内创建会按顺序验证:

  1. 新 ID 没有碰撞。
  2. 所有依赖已经存在。
  3. Task 不依赖自己。
  4. 把新节点加入候选图后,全图 DFS 不存在环。
  5. 全部通过后才原子写入。

原文把缺失依赖解释成“先保持阻塞”,还把环检测留给未来。这会把拼错 ID 和循环依赖变成永远 pending 的静默故障。当前实现选择在图边界显式失败。

合法 API 只允许新 Task 指向已存在 Task,所以多节点环通常只能来自手工破坏或旧数据。重建仍会执行全图检查:两个磁盘文件互相依赖时,list_tasks 不会跳过它们或返回半张图,而是报告 TaskGraphError


08五个工具,schema 与 handler 同源五个定义由 registerTaskTools(registry, store) 从同一组 TypeScript ToolDefinition 逐项注册:

五个定义由 registerTaskTools(registry, store) 从同一组 TypeScript ToolDefinition 逐项注册:

工具Effect输入成功结果
create_taskwritesubject、description、blocked_by新 Task JSON
get_taskreadtask_id完整 Task JSON
list_tasksread按 ID 稳定排序的完整任务集合
claim_taskwritetask_id带 owner 的 in_progress Task
complete_taskwritetask_idcompleted 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_foundcanonical 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 完成后:

  • endpointsdocs 都直接依赖 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)、activeFormmetadatacomplete_task 实际上也通过 TaskUpdate 实现。P12 把依赖声明集中在 create_task(blockedBy=...) 中一次性完成,没有独立的更新、删除或释放工具。

高水位标防 ID 重用。 CC 使用 .highwatermark 文件记录曾分配过的最高序数 ID,即使任务被删除也不会被重用。P12 使用 UUID v4,因此没有 ID 重用风险。

9 字段 vs 6 字段。 CC 的 TaskRecordidsubjectdescriptionactiveFormownerstatusblocks(下游列表)、blockedBymetadata 共 9 个字段。P12 的 Task 只有 idsubjectdescriptionownerstatusblockedBy 共 6 个字段,缺少 activeFormblocksmetadata

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 在等待期间继续处理其他工作。

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始