245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进

2026.06.30

·技术harnessx

20260630_2.webp|1224

重点

第 12 课当前要做的是:把“大目标” 拆成能跨会话保存的小任务,并且让这些任务按依赖顺序推进。

第 5 课的 todo_write 像一张临时便签。它适合当前这一轮对话里提醒 Agent:先看文件,再改代码,最后总结。

第 12 课的 Task System 像贴在墙上的任务板。它不是临时步骤,而是长期工单

  • 每个任务都落成一个 JSON 文件。
  • 每个任务都有状态。
  • 每个任务可以声明自己被哪些上游任务挡住
  • Agent 可以先认领能做的任务,做完后解锁后面的任务。
  • 关掉当前会话以后,.tasks/ 还在,下一次还能接着看。
    • 这个很关键,即使 ”关掉任务“ ,重新打开会话,还能够恢复
    • 因为都在本地

这节课真正新增的不是一个更大的 todo,而是一个可恢复的任务状态层

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 1

用装修工地排工单理解 Task System

把一个软件项目想成装修一间房。

你不能一上来就刷墙。

  • 水电没走完,墙面不能封;
  • 墙面没封,油漆不能刷;
  • 油漆没干,家具不能进场。

如果只有一张临时便签,工人今天可能写:

bash
# 当前会话里的临时步骤
先看户型图
再买水管
然后通知电工

这张便签对今天有用,但它不适合管理整个装修项目。明天换一个工人,他不知道哪些工单已经完成、哪些还被上游挡住。

Task System 做的是墙上的工单板:

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 2

这个例子里,blockedBy 就是“必须先完成谁”。 换言之 ”某个工单依赖另外一个工单“

  • 封墙走水电 挡住。
  • 刷漆封墙 挡住。
  • 装灯 也被 封墙 挡住。

Agent 想认领任务时,不能只看自己想做什么。它要先看这个任务的 blockedBy 里所有上游任务是不是都已经 completed

整体流程

第 12 课没有改掉 Agent Loop。按照参考课的事实,它是在原来的基础工具上新增 5 个任务工具

原教程表格里写的是:

bash
# S11
bash, read_file, write_file (3)

# S12
+ create_task, list_tasks, get_task, claim_task, complete_task (8)

也就是:S12 之后工具总数从 3 个变成 8 个。新增的 5 个工具分别是:

  • create_task
  • list_tasks
  • get_task
  • claim_task
  • complete_task

但从架构理解上,它不是五套机制,而是一套 Task System,被拆成五个 function calling 动作。

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 3

关键点是职责分开:

  • Agent Loop
    • 仍然负责请求模型、执行工具、回填结果。
  • Task System
    • 只负责任务 JSON 的创建、读取、认领、完成。
  • System Prompt
    • 只负责告诉模型:
      • 什么时候用便签,什么时候用任务板。

如果从架构层看,它其实是在原来的 Agent Loop 旁边加了一块“本地状态板”。

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 4

这张图比代码重要。它说明第 12 课没有把 Agent 变复杂,只是把“任务进度”从 messages 里拿出来,放到一个稳定地方。

事实是新增 5 个工具,但它们归属同一个任务系统

tools 列表看,第 12 课确实新增了 5 个工具。模型能看到的不是一个叫 task_system 的总工具,而是 5 个独立工具名。

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 5

但这 5 个工具不是 5 个独立系统,它们共享同一个 .tasks/ 状态目录,共同组成 Task System。

这里的取舍更像 HTTP API 设计。

你可以做一个大接口:

bash
POST /task-system
{ "action": "claim", "task_id": "task_123" }

也可以拆成更明确的动作:

bash
POST /tasks
GET /tasks
GET /tasks/task_123
POST /tasks/task_123/claim
POST /tasks/task_123/complete

第 12 课选后者,因为教学和模型调用都更清楚:

  • 每个动作的参数更少。
  • 每个动作的语义更明确。
  • 模型不用自己猜 action 字段该填什么。
  • HarnessX 本地分发也更简单。
  • 以后看日志时,> claim_task> task_system action=claim 更直观。

但从架构上,它仍然是一个能力:持久化任务板

TodoWrite 和 Task System 的分工

这两个东西最容易混。

todo_write 是 Agent 自己手里的小纸条。它记录“我接下来几步怎么做”。

Task System 是项目墙上的工单板。它记录“这个项目有哪些任务,谁挡住谁,做到哪一步了”。

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 6

一个具体例子:

  • 用户说“帮我完成一个模块改造”:
    • Agent 可以先用 todo_write 写当前执行步骤。
    • 如果这个目标要拆成数据库、API、测试、文档这些长期任务,就用 create_task 写进 .tasks/
  • 用户说“列出任务板,认领能做的任务”:
    • 这不是临时便签问题。
    • 应该走 list_tasksclaim_task

任务板上的数据关系

一个任务不是一段代码,而是一张卡片。

这张卡片上最重要的是三块信息:

  • status:这张卡现在走到哪一步。
  • owner:谁认领了它。
  • blockedBy:它被哪些上游任务挡住。

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 7

这张类图 把“任务板、任务卡、磁盘文件” 三件事画清楚。

一个任务怎么从 pending 走到 completed

Task System 的状态很少,故意保持简单。

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 8

第 12 课只实现这条最小链路:

  • create_task
    • 创建 pending 任务。
    • 写入 .tasks/task_*.json
  • claim_task
    • 检查任务必须是 pending
    • 检查 blockedBy 里的上游任务都必须是 completed
    • 通过后写入 owner: "agent",状态改成 in_progress
  • complete_task
    • 检查任务必须是 in_progress
    • 状态改成 completed
    • 扫描其他任务,找出刚刚被解锁的下游任务。

这里故意不做三件事:

  • 不做环检测。
  • 不做 release / unassign。
  • 不做 worktree 隔离。

这些不是本课核心。当前只要讲清楚“任务图可以落盘,依赖会挡住任务开始”。

工具调用的一来一回

模型不会直接改 .tasks/。它只会在返回里提出工具调用。

HarnessX 收到工具调用以后,本地 Node.js 代码才真正写 JSON 文件。

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 9

这一来一回要看清楚:

  • 程序发给 DeepSeek 的是 messages工具说明
  • DeepSeek 返回的是 tool_calls
  • HarnessX 本地执行工具。
  • 工具结果再作为 role: "tool" 回填给模型。
  • 下一轮模型才能基于刚创建出来的任务 ID 继续创建依赖任务

具体请求和返回长什么样

专业程序员看这一课,最好直接看 function calling 的一来一回。

先看 HarnessX 发给 DeepSeek 的请求。这里省略了无关字段,只保留第 12 课关键部分。

json
{
  "model": "deepseek-chat",
  "messages": [
    {
      "role": "system",
      "content": "你是 HarnessX 编程 Agent。当前真正启用的工具包括 create_task/list_tasks/get_task/claim_task/complete_task..."
    },
    {
      "role": "user",
      "content": "创建一个有依赖的任务板:先创建 setup database schema;再创建 create API endpoints,blockedBy 指向 schema..."
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "create_task",
        "description": "创建一个跨会话持久化任务。适合记录大目标拆出的任务、任务描述和 blockedBy 依赖。",
        "parameters": {
          "type": "object",
          "properties": {
            "subject": { "type": "string" },
            "description": { "type": "string" },
            "blockedBy": {
              "type": "array",
              "items": { "type": "string" }
            }
          },
          "required": ["subject"]
        }
      }
    }
  ],
  "tool_choice": "auto"
}

模型第一次不会直接替我们写文件。它返回的是“我要调用哪个工具,以及参数是什么”。

json
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_schema_001",
      "type": "function",
      "function": {
        "name": "create_task",
        "arguments": "{\"subject\":\"setup database schema\",\"description\":\"设计并创建数据库表结构\",\"blockedBy\":[]}"
      }
    }
  ],
  "finish_reason": "tool_calls"
}

HarnessX 看到这个 tool_call 后,才在本地执行工具,写入 .tasks/task_*.json

本地工具执行后的结果会作为 role: "tool" 回填给下一轮模型。

json
{
  "role": "tool",
  "tool_call_id": "call_schema_001",
  "content": "Created task_1782807117176_3ffd8cca: setup database schema\n{\n  \"id\": \"task_1782807117176_3ffd8cca\",\n  \"subject\": \"setup database schema\",\n  \"status\": \"pending\",\n  \"owner\": null,\n  \"blockedBy\": []\n}"
}

下一轮请求里,messages 已经带上了这个工具结果。模型才能知道 schema 的真实任务 ID,然后创建依赖它的 API 任务。

json
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_api_001",
      "type": "function",
      "function": {
        "name": "create_task",
        "arguments": "{\"subject\":\"create API endpoints\",\"description\":\"实现 API 接口\",\"blockedBy\":[\"task_1782807117176_3ffd8cca\"]}"
      }
    }
  ],
  "finish_reason": "tool_calls"
}

所以这里的专业理解是:

  • DeepSeek 不直接写 .tasks/
  • DeepSeek 只返回结构化的 tool_calls
  • HarnessX 本地代码才是 side effect 的执行者。
  • tool_call_id 把“模型要求调用工具”和“本地工具执行结果”配对起来。
  • blockedBy 必须等第一轮工具结果回来后,才能填入真实任务 ID。

怎么跑

沿用第 0 课的 npm link 和 DeepSeek 配置。

先让 Agent 创建一张有依赖的任务板:

bash
# 这个命令会触发 create_task 多次
# 观察重点:终端里应该出现 > create_task subject=...
# 观察重点:当前目录会生成 .tasks/task_*.json
hx agent "创建一个有依赖的任务板:先创建 setup database schema;再创建 create API endpoints,blockedBy 指向 schema;再创建 write tests,blockedBy 指向 API;再创建 write docs,blockedBy 指向 schema;最后列出任务。"

预期能看到类似输出:

bash
# 关键不是 ID 一模一样,而是这些工具调用和状态出现
> create_task subject=setup database schema
Created task_...

> create_task subject=create API endpoints
Created task_... blockedBy=task_...

> list_tasks
[ ] task_...: setup database schema [pending]
[ ] task_...: create API endpoints [pending] blockedBy=task_...

再验证依赖会挡住任务开始:

bash
# API 任务依赖 schema;schema 没完成前,API 不能被认领
hx agent "从任务列表里找到 create API endpoints,尝试认领它,并说明为什么现在能不能开始。"

预期关键输出:

bash
# 说明 blockedBy 生效了
> claim_task task_id=task_...
Blocked by: task_...

最后完成上游任务,观察下游解锁:

bash
# schema 完成以后,API 和 docs 应该被解锁
hx agent "从任务列表里找到 setup database schema,认领并完成它;然后列出刚刚被解锁的任务。"

预期关键输出:

bash
> claim_task task_id=task_...
Claimed task_...

> complete_task task_id=task_...
Completed task_...
Unblocked:
- task_...: create API endpoints
- task_...: write docs

判断跑通的标准:

  • .tasks/ 下出现任务 JSON。
  • 任务 JSON 里有 statusownerblockedBy
  • 被依赖挡住的任务不能认领。
  • 上游完成后,下游任务出现在 Unblocked 里。

这一课真正要记住

Task:一张长期工单。它不是当前步骤,而是可以被保存、认领、完成的项目任务。

blockedBy:任务依赖。意思是“我必须等这些上游任务完成以后才能开始”。

claim_task:认领任务。它不是做完任务,只是把任务从 pending 推到 in_progress

complete_task:完成任务。它会把任务推到 completed,并检查哪些下游任务被解锁。

todo_write:当前会话的临时便签。它仍然有用,但不要拿它管理跨会话任务图。

bash
# 第 12 课一句话
Task System = 把大目标拆成落盘任务,用 blockedBy 控制开始顺序,用 status 记录可恢复进度。

源码

源码

源码部分只保留图和 JS 伪码。代码本身价值不大,真正要记的是这层机制怎么嵌进 Agent Loop,以及状态怎么从模型意图落到磁盘。

源码大流程图

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 10

这张图只讲动作,不讲函数名。

第 12 课源码要看懂的不是“某个函数怎么写”,而是这条链路:

bash
用户目标 -> 模型选择任务动作 -> 本地写 .tasks -> 工具结果回填 -> 模型继续决策

源码分层图

先看层,不看文件。

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 11

第 12 课只新增下面两块:

  • 工具分发表里的任务板动作。
  • 磁盘状态层里的 .tasks/

数据流图

再看数据怎么流。

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 12

这个数据流有一个关键判断:模型只决定“要调用什么工具”。真正保存任务的是本地工具。

JS 伪码:任务板主流程

这段不是源码,是把第 12 课机制压成一眼能看懂的伪码。

js
// 用户给大目标
用户输入 = "创建 schema、API、tests、docs 四个任务,并写清依赖"

// Agent Loop 把工具说明发给模型
模型看到 = [
  "可以创建任务",
  "可以列出任务",
  "可以认领任务",
  "可以完成任务",
]

// 模型决定先创建第一张任务卡
工具调用 = {
  name: "create_task",
  input: { subject: "setup database schema" }
}

// 本地工具把任务卡写到墙上
任务卡 = {
  id: "task_xxx",
  subject: "setup database schema",
  status: "pending",
  blockedBy: []
}

写入(".tasks/task_xxx.json", 任务卡)
把结果回填给模型("Created task_xxx")

JS 伪码:依赖关系怎么形成

js
// schema 是第一张卡
schema = 创建任务("setup database schema")

// API 必须等 schema
api = 创建任务("create API endpoints", {
  blockedBy: [schema.id]
})

// tests 必须等 API
tests = 创建任务("write tests", {
  blockedBy: [api.id]
})

// docs 只需要等 schema
docs = 创建任务("write docs", {
  blockedBy: [schema.id]
})

这比真实函数更重要:blockedBy 不是文字说明,它是任务 ID 组成的依赖边。

JS 伪码:认领任务

js
function 认领任务(taskId) {
  任务 = 从任务板读取(taskId)

  if (任务.status !== "pending") {
    return "不能认领:它不是等待开始状态"
  }

  未完成的上游 = 任务.blockedBy.filter((上游ID) => {
    上游任务 = 从任务板读取(上游ID)
    return 上游任务.status !== "completed"
  })

  if (未完成的上游.length > 0) {
    return "Blocked by: " + 未完成的上游.join(", ")
  }

  任务.owner = "agent"
  任务.status = "in_progress"
  保存回任务板(任务)
  return "可以开始做了"
}

JS 伪码:完成任务并解锁下游

js
function 完成任务(taskId) {
  任务 = 从任务板读取(taskId)

  if (任务.status !== "in_progress") {
    return "不能完成:它还没有被认领"
  }

  任务.status = "completed"
  保存回任务板(任务)

  刚解锁的任务 = 所有任务
    .filter(任务 => 任务.status === "pending")
    .filter(任务 => 任务.blockedBy 全部都是 completed)

  return {
    completed: 任务,
    unblocked: 刚解锁的任务,
  }
}

最后收束成一张图

245. 技术:HarnessX 第 12 课:便签、任务板、用持久化任务板让大目标按依赖顺序推进 图表 13

所以第 12 课不是“多了几个函数”。它是把 Agent 的目标推进,从临时对话步骤,升级成了能恢复、能排序、能解锁的任务图。