第十七章:Claude Code 里其实有两套任务系统
区分持久化工作项和后台执行任务两套不同的 task runtime。
1. 先把最容易看错的地方说清楚
这一组源码最容易让人误判的地方,是它们都叫 Task*,但其实在操作 两套不同的 task 系统。
第一套是 结构化工作项:
TaskCreateTaskGetTaskUpdateTaskList
这套东西更像 Jira / Todo / Kanban 里的任务条目。
第二套是 后台执行任务:
TaskStopTaskOutput
这套东西更像运行时里的 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.tssrc/tools/TaskGetTool/TaskGetTool.tssrc/tools/TaskUpdateTool/TaskUpdateTool.tssrc/tools/TaskListTool/TaskListTool.tssrc/tools/TaskStopTool/TaskStopTool.tssrc/tools/TaskOutputTool/TaskOutputTool.tsx
底层重点还包括:
src/utils/tasks.tssrc/utils/todo/types.tssrc/Task.tssrc/tasks/types.tssrc/tasks/stopTask.tssrc/utils/task/diskOutput.tssrc/utils/task/framework.tssrc/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() 的解析顺序也很关键:
- 显式环境变量
- in-process teammate 的 team name
CLAUDE_CODE_TEAM_NAME- leader 创建 team 后的 team name
- 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: pendingowner: undefinedblocks: []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 拿的是单个任务的完整上下文:
subjectdescriptionstatusblocksblockedBy
它更像“打开某张任务卡片”。
7.2 TaskList
TaskList 返回的是摘要视图:
idsubjectstatusownerblockedBy
更关键的是,它会主动过滤:
metadata._internal的内部任务- 已经完成的 blocker
也就是说,TaskList 展示给模型的不是“原始任务图”,而是“当前仍然有效的开放阻塞关系”。
这是一种很实用的运行时裁剪。
7.3 它甚至在 prompt 里教 teammate 怎么排队领任务
TaskList 的 prompt 明确写了 teammate workflow:
- 先找
pending - 找没 owner 的
- 找
blockedBy为空的 - 多个可选时优先低 ID
这说明 Claude Code 的任务板不是“给用户看的”,而是明确在教 agent 如何在共享任务板上自组织。
8. TaskUpdate:这套系统真正的核心
如果说 TaskCreate 是入板,TaskUpdate 就是整套 V2 任务系统真正的控制中心。
它能改的东西很多:
statussubjectdescriptionactiveFormownermetadataaddBlocksaddBlockedBy
但最重要的不是字段多,而是它做了很多运行时附加处理。
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 依赖关系是双向维护的
addBlocks 和 addBlockedBy 不是只改当前任务一侧。
底层 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。
TaskStop 和 TaskOutput 操作的不是 utils/tasks.ts 里的工作项,而是:
AppState.tasks
这套任务的类型定义在 src/Task.ts / src/tasks/types.ts 里,状态机也完全不同。
10.1 这里的 task 是运行中的执行单元
类型包括:
local_bashlocal_agentremote_agentin_process_teammatelocal_workflowmonitor_mcpdream
状态包括:
pendingrunningcompletedfailedkilled
这和工作项系统的:
pendingin_progresscompleted
完全不是一回事。
所以你做 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会过滤内部任务和已解决 blockerTaskUpdate会在 swarm 场景自动补 owner,并发送 task assignment 消息TaskUpdate会在结束时追加 verification nudgeutils/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);
}
然后把类型也拆开:
WorkItemStatusPending / InProgress / CompletedBackgroundTaskStatusPending / Running / Completed / Failed / Killed
再保留几个我觉得特别关键的概念:
TaskListIdResolverWorkItemDependencyGraphHighWatermarkIdAllocatorTaskOutputStoreRuntimeTaskTypeDispatcher
其中最重要的设计点只有一个:
不要把“工作项 ID”系统和“后台执行 task ID”系统混用。
前者现在是递增数字,后者是带类型前缀的随机 ID,它们的用途完全不一样。
16. 我的总体评价
拆到这一层之后,Claude Code 的任务设计已经很清楚了:
- V1
TodoWrite是轻量 checklist - V2
TaskCreate/Get/Update/List是持久化工作项系统 TaskStop/TaskOutput是后台执行任务控制系统
也就是说,它不是只有一个“todo 升级版”,而是把:
- 计划怎么拆
- 任务怎么分
- agent 谁空闲
- 谁阻塞谁
- 后台执行怎么停
- 后台输出怎么读
都逐渐拉成了正式 runtime 对象。
这套思路对 C# 版非常有价值。
因为它真正抄的不是几个工具,而是一个判断:
协作里的“工作项”和运行时里的“执行任务”必须分层。
这是 Claude Code 这套设计里非常值得保留的一刀。