Book 第十七章:Claude Code 里其实有两套任务系统
第四部分:扩展与协作

第十七章:Claude Code 里其实有两套任务系统

区分持久化工作项和后台执行任务两套不同的 task runtime。

1. 先把最容易看错的地方说清楚

这一组源码最容易让人误判的地方,是它们都叫 Task*,但其实在操作 两套不同的 task 系统

第一套是 结构化工作项

  • TaskCreate
  • TaskGet
  • TaskUpdate
  • TaskList

这套东西更像 Jira / Todo / Kanban 里的任务条目。

第二套是 后台执行任务

  • TaskStop
  • TaskOutput

这套东西更像运行时里的 process / job / background worker。

所以如果你后面要做 C# 版,第一件事不是抄接口,而是先把这两个概念拆开。

Claude Code 的“task”不是一个统一对象,而是“工作项”和“执行任务”两套模型并存。

2. 先说结论

我对这一层的总体判断是:

Claude Code 正在把 V1 的 todo 清单升级成 V2 的结构化工作项系统,同时保留一套独立的后台执行任务系统。

更直白一点:

  • TaskCreate/Get/Update/List 解决的是“接下来有哪些工作、谁在做、谁阻塞谁”
  • TaskStop/TaskOutput 解决的是“那个已经跑起来的后台执行单元现在怎么样、怎么停、怎么拿结果”

这两个系统都重要,但绝对不能混成一个类。

如果你把它们拍扁成一个 Task,后面无论是权限、状态机、持久化还是 UI,都会很快变乱。

3. 源码锚点

这次主要看的源码是:

  • src/tools/TaskCreateTool/TaskCreateTool.ts
  • src/tools/TaskGetTool/TaskGetTool.ts
  • src/tools/TaskUpdateTool/TaskUpdateTool.ts
  • src/tools/TaskListTool/TaskListTool.ts
  • src/tools/TaskStopTool/TaskStopTool.ts
  • src/tools/TaskOutputTool/TaskOutputTool.tsx

底层重点还包括:

  • src/utils/tasks.ts
  • src/utils/todo/types.ts
  • src/Task.ts
  • src/tasks/types.ts
  • src/tasks/stopTask.ts
  • src/utils/task/diskOutput.ts
  • src/utils/task/framework.ts
  • src/utils/task/outputFormatting.ts

4. 总体结构图

flowchart TD
    A["Claude Code task runtime"] --> B["结构化工作项系统"]
    A --> C["后台执行任务系统"]

    B --> B1["TaskCreate"]
    B --> B2["TaskGet"]
    B --> B3["TaskUpdate"]
    B --> B4["TaskList"]
    B --> B5["utils/tasks.ts"]

    B5 --> B6["磁盘 JSON 文件存储"]
    B5 --> B7[".lock + .highwatermark"]
    B5 --> B8["owner / blockedBy / blocks"]
    B5 --> B9["team 共享任务板"]

    C --> C1["TaskStop"]
    C --> C2["TaskOutput"]
    C --> C3["AppState.tasks"]
    C --> C4["Local Bash / Local Agent / Remote Agent"]
    C --> C5["task output file"]

    C3 --> C6["运行态状态机"]
    C6 --> C7["pending / running / completed / failed / killed"]

这一层最重要的认知切换是:

结构化工作项是“计划与协作状态”,后台执行任务是“运行中进程状态”。

5. 结构化工作项系统:V2 任务板,不是 transcript 小技巧

先看 TaskCreate/Get/Update/List 这四个工具。

它们背后的底座是 src/utils/tasks.ts,这个文件已经不是随手写的几个 helper 了,而是一个小型任务存储层。

5.1 持久化不是数据库,而是磁盘 JSON

任务会落到:

  • Claude 配置目录下的 tasks/<taskListId>/

每个任务一个 JSON 文件,外加:

  • .lock
  • .highwatermark

这个设计很朴素,但也很有 Claude Code 风格:

  • 不上数据库
  • 直接文件系统
  • 但把并发和 ID 分配认真补齐

5.2 taskListId 决定这块任务板是谁的

getTaskListId() 的解析顺序也很关键:

  1. 显式环境变量
  2. in-process teammate 的 team name
  3. CLAUDE_CODE_TEAM_NAME
  4. leader 创建 team 后的 team name
  5. session id

这说明 Claude Code 的任务板不是固定绑定到单个会话,而是可以:

  • 单 session 使用
  • 多 teammate 共享
  • leader / tmux / in-process teammate 共用

所以这套任务系统从一开始就是按“多人协作看板”在设计的。

5.3 它用锁和高水位线解决并发创建问题

createTask() 并不是简单读目录、加一、写文件。

它会:

  • 先锁住任务列表
  • 读最高 ID
  • .highwatermark 防止删除后复用旧 ID
  • 再写新任务文件

这说明 Claude Code 明确在防:

  • 多 agent 并发创建任务时撞号
  • 删除任务后旧编号被重用,导致引用错乱

这个细节对 C# 版很值得抄,因为一旦你做 swarm,没有稳定 ID 很快就会出事故。

6. TaskCreate:创建的是“待分配工作项”,不是立刻开始执行

TaskCreateTool 的行为非常克制。

它创建出来的任务默认是:

  • status: pending
  • owner: undefined
  • blocks: []
  • blockedBy: []

也就是说,Claude Code 的默认语义不是“创建 = 开工”,而是:

先把工作项放进任务板,再决定谁来领。

6.1 它还会跑 hook,hook 可以 veto 创建

创建任务以后,TaskCreateTool 会跑 executeTaskCreatedHooks(...)

如果 hook 返回 blocking error,它会:

  • 直接把刚创建的任务删掉
  • 然后抛错

所以这不是“先写成功再说”,而是带事务味道的:

  • 先落盘
  • 再跑策略钩子
  • 有问题就回滚

6.2 它会主动把 UI 切到 tasks 面板

这也是 Claude Code 的典型习惯:

不是只在 transcript 里说“task created”,而是顺手把:

  • expandedView = 'tasks'

让用户和 agent 都进入任务视图。

所以工具不只是改数据,也在推 UI 状态。

7. TaskGet + TaskList:一个看细节,一个看全局

这两个工具是典型的 summary / detail 分层。

7.1 TaskGet

TaskGet 拿的是单个任务的完整上下文:

  • subject
  • description
  • status
  • blocks
  • blockedBy

它更像“打开某张任务卡片”。

7.2 TaskList

TaskList 返回的是摘要视图:

  • id
  • subject
  • status
  • owner
  • blockedBy

更关键的是,它会主动过滤:

  • metadata._internal 的内部任务
  • 已经完成的 blocker

也就是说,TaskList 展示给模型的不是“原始任务图”,而是“当前仍然有效的开放阻塞关系”。

这是一种很实用的运行时裁剪。

7.3 它甚至在 prompt 里教 teammate 怎么排队领任务

TaskList 的 prompt 明确写了 teammate workflow:

  • 先找 pending
  • 找没 owner 的
  • blockedBy 为空的
  • 多个可选时优先低 ID

这说明 Claude Code 的任务板不是“给用户看的”,而是明确在教 agent 如何在共享任务板上自组织。

8. TaskUpdate:这套系统真正的核心

如果说 TaskCreate 是入板,TaskUpdate 就是整套 V2 任务系统真正的控制中心。

它能改的东西很多:

  • status
  • subject
  • description
  • activeForm
  • owner
  • metadata
  • addBlocks
  • addBlockedBy

但最重要的不是字段多,而是它做了很多运行时附加处理。

8.1 deleted 不是普通状态,而是特殊动作

虽然输入里把 deleted 放进了 status,但实现上它不是普通状态迁移,而是:

  • 直接删任务文件
  • 清理其他任务对它的引用

也就是说,Claude Code 没把删除硬塞进状态机,而是把它当成一类特殊命令。

8.2 完成任务前会跑 completed hooks

当你把任务标成 completed 时,TaskUpdateTool 会先跑:

  • executeTaskCompletedHooks(...)

如果 hook 认为不该完成,就直接阻止。

这说明 Claude Code 对“完成”这个动作是很敏感的,它不信模型一句“做完了”就够。

8.3 owner 不是纯展示字段,而是协作路由字段

如果在 swarm 场景里,把任务标成 in_progress 但没显式给 owner,Claude Code 会自动:

  • 把当前 agent name 设成 owner

如果 owner 被改了,它还会通过 mailbox 给新 owner 发送一条 task_assignment 消息。

所以 owner 的语义并不是“挂个名字”,而是:

  • 代表谁领了活
  • 触发协作通知
  • 参与 idle / busy 判断

8.4 依赖关系是双向维护的

addBlocksaddBlockedBy 不是只改当前任务一侧。

底层 blockTask() 会同时维护:

  • A.blocks += B
  • B.blockedBy += A

这是非常必要的,因为如果只维护单边,列表视图和任务详情很快就会不一致。

8.5 它会在收尾时提醒做 verification

和 V1 TodoWrite 一样,V2 TaskUpdate 也保留了 verification nudge。

如果满足这些条件:

  • 主线程 agent
  • 这次把任务标成 completed
  • 全部任务都完成了
  • 总任务数至少 3
  • 没有 verification 类任务

它会在结果里加一句提醒,让你去拉 verification agent。

这说明任务板在 Claude Code 里不仅是记录工具,还是流程治理钩子。

9. 这套工作项系统其实还有“抢任务”逻辑

虽然 TaskCreate/Get/Update/List 没直接暴露 claimTask(),但 utils/tasks.ts 里已经有很完整的抢任务语义了。

它会处理:

  • 任务不存在
  • 已被别人认领
  • 已完成
  • 仍被 blocker 阻塞
  • 当前 agent 已经忙着别的未完成任务

而且在 checkAgentBusy 打开时,它会用任务列表级锁来避免 TOCTOU race。

这说明 Claude Code 的任务板不是摆设,它已经按多人并行领取任务的场景设计过。

10. 后台执行任务系统:这不是任务板,而是运行时 task registry

接下来换到另一套完全不同的 task。

TaskStopTaskOutput 操作的不是 utils/tasks.ts 里的工作项,而是:

  • AppState.tasks

这套任务的类型定义在 src/Task.ts / src/tasks/types.ts 里,状态机也完全不同。

10.1 这里的 task 是运行中的执行单元

类型包括:

  • local_bash
  • local_agent
  • remote_agent
  • in_process_teammate
  • local_workflow
  • monitor_mcp
  • dream

状态包括:

  • pending
  • running
  • completed
  • failed
  • killed

这和工作项系统的:

  • pending
  • in_progress
  • completed

完全不是一回事。

所以你做 C# 版时一定要把状态枚举拆开。

11. TaskStop:它是后台执行任务的终止器,不是删任务卡片

TaskStopTool 做的事情很单纯,但语义很关键。

11.1 它停的是运行中的 runtime task

它先在 appState.tasks 里找:

  • 有没有这个 task id
  • 状态是不是 running

然后把 stop 动作委派给:

  • stopTask(...)

再由 stopTask(...) 根据 task type 找具体实现去 kill。

这说明 Claude Code 把“停止执行”这件事下沉到了 task type implementation,不是由工具自己写一堆 if/else 去停不同任务。

11.2 它还保留了旧名字兼容

TaskStopTool 还保留了:

  • KillShell

这个 alias,同时支持旧参数:

  • shell_id

这说明 Claude Code 在重构工具语义时,不会直接把旧 transcript 和 SDK 用户全部打断,而是保留兼容层。

11.3 停 bash 时会主动压掉噪音通知

stopTask.ts 里有个很 runtime 的细节:

如果停的是 local shell task,它会:

  • 直接把 task 标成已通知
  • 压掉“exit code 137”这种噪音
  • 但仍显式补一条 SDK terminated event

这说明 Claude Code 很在意:

  • 给 UI 和 SDK 足够信息
  • 但别把“强杀进程”的低价值噪音刷满用户视图

12. TaskOutput:它其实是一层被保留下来的兼容桥

TaskOutputTool 在源码里已经说得很直接了:

  • [Deprecated]
  • 更推荐直接 Read 背景任务输出文件

所以它现在的角色,不是核心能力,而是兼容层。

12.1 为什么还保留它

因为它确实还做了一些统一处理:

  • 对不同 task type 统一读取输出
  • 支持 block=true/false
  • 能等待任务完成
  • 会给出结构化 retrieval_status

所以它还在,但定位已经变成“旧接口兼容 + 统一读取包装器”。

12.2 它还专门为 local_agent 做了干净结果提取

这个点很重要。

local_agent,它不会直接把磁盘上的 transcript 原样扔回去,而是优先拿:

  • in-memory final result

也就是只给最终回答,不把整份 JSONL 工具噪音都塞给上层。

这说明 Claude Code 明确知道:

子 agent 的完整 transcript 和“父级真正想知道的结果”不是同一个东西。

12.3 它有阻塞等待模式和进度提示

如果 block=true,它会:

  • 轮询等待任务结束
  • waiting_for_task 进度消息
  • 超时后返回 timeout

这套设计说明它不是简单的“读日志文件”,而是兼容了“等等看结果再回来”的旧调用方式。

12.4 它会做输出裁剪,并把完整文件路径告诉你

formatTaskOutput() 默认会把输出裁到上限内,只保留尾部,并在头上加:

  • 完整输出文件路径

所以 Claude Code 的思路是:

  • 先给模型一个可消费的小结果
  • 真要全量内容,再去读文件

这和前面 WebFetch、Read、AgentTool 的处理一脉相承,都是 prompt 预算优先。

13. 任务输出文件子系统说明它们真的是运行时对象

src/utils/task/diskOutput.ts 这块也很值得一提。

它不是随手 appendFile 一下,而是认真处理了很多运行时问题:

  • 输出目录会把 session id 固定在首次调用时,避免 /clear 后路径漂移
  • O_NOFOLLOW 防 symlink 攻击
  • 单任务输出上限 5GB
  • 写入队列按 chunk drain,避免长链 Promise 持有大量内存

这说明后台 task 在 Claude Code 里是真正长期运行、可能产出大量输出的对象,不是一次函数调用。

14. Claude Code 在这组工具上做了哪些额外处理

把关键细节收一下,大概有这些:

  • V2 工作项系统只在 isTodoV2Enabled() 时启用,默认 interactive session 打开
  • TaskCreate 创建后会自动展开任务面板
  • TaskCreate / TaskUpdate 都会跑 hook,hook 可以阻止创建和完成
  • TaskList 会过滤内部任务和已解决 blocker
  • TaskUpdate 会在 swarm 场景自动补 owner,并发送 task assignment 消息
  • TaskUpdate 会在结束时追加 verification nudge
  • utils/tasks.ts 用文件锁和高水位线解决并发和 ID 复用问题
  • TaskStop 保留 KillShell / shell_id 向后兼容
  • TaskStop 和 SDK 共用同一个 stopTask() 逻辑
  • TaskOutput 已废弃,但保留 block / timeout / multi-task-type 统一读取
  • TaskOutput 对 agent task 会优先给 clean final result,而不是原始 transcript
  • 任务输出文件系统有独立安全和容量控制

这些细节一起说明:

Claude Code 的任务层不是一个“进度列表”,而是“工作编排 + 运行执行”双系统。

15. 转成 C# 时我建议怎么拆

如果你后面要做 C# 版,我强烈建议你直接拆成两套接口,而不是共用一个 TaskService

public interface IWorkItemStore
{
    Task<string> CreateAsync(WorkItemCreateRequest request, CancellationToken ct);
    Task<WorkItem?> GetAsync(string listId, string itemId, CancellationToken ct);
    Task<IReadOnlyList<WorkItem>> ListAsync(string listId, CancellationToken ct);
    Task<WorkItemUpdateResult> UpdateAsync(string listId, string itemId, WorkItemPatch patch, CancellationToken ct);
}

public interface IBackgroundTaskRegistry
{
    Task<BackgroundTaskInfo?> GetAsync(string taskId, CancellationToken ct);
    Task<BackgroundTaskStopResult> StopAsync(string taskId, CancellationToken ct);
    Task<BackgroundTaskOutputResult> ReadOutputAsync(string taskId, bool block, TimeSpan timeout, CancellationToken ct);
}

然后把类型也拆开:

  • WorkItemStatus Pending / InProgress / Completed
  • BackgroundTaskStatus Pending / Running / Completed / Failed / Killed

再保留几个我觉得特别关键的概念:

  • TaskListIdResolver
  • WorkItemDependencyGraph
  • HighWatermarkIdAllocator
  • TaskOutputStore
  • RuntimeTaskTypeDispatcher

其中最重要的设计点只有一个:

不要把“工作项 ID”系统和“后台执行 task ID”系统混用。

前者现在是递增数字,后者是带类型前缀的随机 ID,它们的用途完全不一样。

16. 我的总体评价

拆到这一层之后,Claude Code 的任务设计已经很清楚了:

  • V1 TodoWrite 是轻量 checklist
  • V2 TaskCreate/Get/Update/List 是持久化工作项系统
  • TaskStop/TaskOutput 是后台执行任务控制系统

也就是说,它不是只有一个“todo 升级版”,而是把:

  • 计划怎么拆
  • 任务怎么分
  • agent 谁空闲
  • 谁阻塞谁
  • 后台执行怎么停
  • 后台输出怎么读

都逐渐拉成了正式 runtime 对象。

这套思路对 C# 版非常有价值。

因为它真正抄的不是几个工具,而是一个判断:

协作里的“工作项”和运行时里的“执行任务”必须分层。

这是 Claude Code 这套设计里非常值得保留的一刀。