TodoWrite & Tasks 完整运行原理
Claude Code 同时存在两套任务管理系统:轻量的 TodoWrite (V1) 用于单会话内存清单, 持久化的 Tasks (V2) 用于跨会话、跨进程、多 agent 协作。本文从工具定义、 存储模型、锁机制、Hook 系统、UI 渲染到惰性提醒,逐层解构两套系统的实现细节。
01概览:为什么有两套系统
乍看之下 TodoWrite 和 Task* 工具做的是同一件事 —— 维护一个任务清单。但它们的存储位置、生命周期、并发模型、UI 表现、可观察性完全不同,设计目标也不同。
1.1 V1 vs V2 对照
| 维度 | V1 · TodoWrite | V2 · Tasks (TaskCreate/Update/List/Get) |
|---|---|---|
| 存储位置 | AppState.todos[agentId] — 进程内存 |
~/.claude/tasks/<listId>/<id>.json — 磁盘 JSON 文件 |
| 生命周期 | 会话结束即丢失 | 跨会话持久,需要显式 reset 或全部 completed 5s 自动清理 |
| 数据结构 | 一次性整体替换:整个 TodoList 数组 |
原子操作:create / update / delete / list / get |
| 核心字段 | content / activeForm / status |
id / subject / description / activeForm / owner / status / blocks / blockedBy / metadata |
| 并发控制 | 单线程,无锁 | proper-lockfile 文件锁,30 次重试,~2.6s 总等待 |
| 跨进程 | 不支持 | 支持 通过文件系统 + fs.watch 同步 |
| 依赖关系 | 无 | 有 blocks / blockedBy 双向引用 |
| 所有权 | 无 | 有 owner 字段,支持 swarm 多 agent |
| 启用条件 | !isTodoV2Enabled()(SDK / 非交互模式默认) |
isTodoV2Enabled()(交互式 CLI 默认 + CLAUDE_CODE_ENABLE_TASKS=1) |
| UI 表现 | 简单 todo 列表 | 专门的 TaskListV2 组件,文件 watcher 驱动实时刷新 |
| Hook 支持 | 无 | TaskCreated / TaskCompleted hook,可阻断完成 |
export function isTodoV2Enabled(): boolean {
// SDK / 非交互模式可显式启用
if (isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TASKS)) {
return true
}
// 交互式 CLI 默认启用 V2
return !getIsNonInteractiveSession()
}
1.2 工具注册逻辑(src/tools.ts:214-226)
// V1 TodoWriteTool 始终注册,但其 isEnabled 依赖 !isTodoV2Enabled()
TodoWriteTool,
...
// V2 Task* 工具:仅在 V2 启用时加入工具列表
...(isTodoV2Enabled()
? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool]
: []),
两套工具不会同时启用,模型在每个会话只能看到其中一套。TodoWriteTool.isEnabled() 返回 !isTodoV2Enabled(),从而和 Task* 工具自动互斥。
02系统架构总览
把任务管理系统画成分层图,可以看到从工具调用到 UI 渲染的完整数据流。
03V1 · TodoWrite 内存模型
V1 是一个极简的内存清单。整个工具的核心逻辑只有 ~30 行,但有几个细节值得注意。
3.1 数据 schema
// utils/todo/types.ts
const TodoStatusSchema = z.enum(['pending', 'in_progress', 'completed'])
export const TodoItemSchema = z.object({
content: z.string().min(1), // 祈使句:"Run tests"
status: TodoStatusSchema,
activeForm: z.string().min(1), // 进行时:"Running tests"
})
export const TodoListSchema = z.array(TodoItemSchema)
- content(祈使句):"Run tests"、"Build project" —— 列表显示用
- activeForm(进行时):"Running tests"、"Building project" —— spinner 显示用
in_progress 时,UI spinner 显示 activeForm,让"思考中"的提示自然地变成"正在跑测试"。
强制要求两个字段虽然啰嗦,但显著提升用户感知的"具体进度"。
3.2 整体替换语义(TodoWriteTool.ts:65-94)
async call({ todos }, context) {
const appState = context.getAppState()
const todoKey = context.agentId ?? getSessionId() // agent 隔离
const oldTodos = appState.todos[todoKey] ?? []
// 全部完成时直接清空,而非保留历史
const allDone = todos.every(_ => _.status === 'completed')
const newTodos = allDone ? [] : todos
// 整体替换 -- 一次 setAppState 完成
context.setAppState(prev => ({
...prev,
todos: { ...prev.todos, [todoKey]: newTodos },
}))
return { data: { oldTodos, newTodos: todos, verificationNudgeNeeded } }
}
3.3 agent 隔离
todoKey = context.agentId ?? getSessionId() 决定了:
- 主线程 agent → 用 sessionId 作为 key
- 每个子 agent(SubagentTask)→ 用各自的 agentId 作为 key
同一进程里所有 agent 的 todos 都存在 AppState.todos: { [agentId]: TodoList } 这一个 map 里,但读写互不干扰。
3.4 自动清理:全部 completed 即清空
当传入 allDone = true 时,不仅返回值是空数组,实际写入的也是空数组。这避免了"昨天完成的 5 项任务"长期挂在状态里污染 UI。
但oldTodos 仍然返回真实的旧值,Hook 和 verification nudge 可以基于它判断"刚关闭了几项"。
04V2 · Tasks 文件持久化模型
V2 把每个 task 存成一个独立 JSON 文件,文件名就是 task ID。这种"一文件一任务"的设计是 swarm 多进程协作的基础。
4.1 完整数据 schema
// utils/tasks.ts:76
export const TaskSchema = z.object({
id: z.string(), // "1", "2", ... 单调递增数字
subject: z.string(), // 任务标题(祈使句)
description: z.string(), // 详细描述
activeForm: z.string().optional(), // 进行时(spinner 用)
owner: z.string().optional(), // agent ID / agent name
status: TaskStatusSchema, // pending | in_progress | completed
blocks: z.array(z.string()), // 这个 task 阻塞了哪些 task ID
blockedBy: z.array(z.string()), // 这个 task 被哪些 task ID 阻塞
metadata: z.record(z.string(), z.unknown()).optional(), // 任意键值对
})
4.2 目录结构示意
4.3 sanitizePathComponent 防止路径穿越
// utils/tasks.ts:217
export function sanitizePathComponent(input: string): string {
return input.replace(/[^a-zA-Z0-9_-]/g, '-')
}
因为 listId 来自用户配置(team 名、env 变量),必须做强清洗 —— 否则 ../../etc/passwd 之类的输入会泄漏到任意路径。
4.4 schema 迁移(ant-only)
// utils/tasks.ts:319 — 读取时自动迁移旧字段
if (process.env.USER_TYPE === 'ant') {
if (data.status === 'open') data.status = 'pending'
else if (data.status === 'resolved') data.status = 'completed'
else if (['planning', 'implementing', 'reviewing', 'verifying'].includes(data.status))
data.status = 'in_progress'
}
Anthropic 内部用过更细的 status 枚举,后来收敛到 pending/in_progress/completed 三态。
读取时静默迁移,保证旧会话不丢失。
05V2 五大工具详解
V2 暴露了 4 个核心工具(TaskCreate / TaskUpdate / TaskList / TaskGet)+ 1 个 V1 兼容工具(TodoWrite)。每个工具都做严格的 input schema 校验和 output schema 绑定。
5.1 工具职责对照
| 工具 | 读/写 | 并发安全? | 典型用途 |
|---|---|---|---|
| TaskCreate | 写 | 是 | 创建 pending 任务,模型规划新工作时调用 |
| TaskUpdate | 写 | 是 | 改 status / 改 owner / 加依赖 / 删除任务 |
| TaskList | 读 | 是 | 看所有任务概要,寻找下一个可做的 |
| TaskGet | 读 | 是 | 看单个任务的完整 description |
| TodoWrite | 写 | 否 | V1 兼容,SDK 模式默认 |
5.2 TaskCreate 详解
除了写文件,还要触发 Hook、自动展开 UI、可能因 Hook 阻断而回滚:
async call({ subject, description, activeForm, metadata }, context) {
// 1. 写文件(获取 list 锁,分配新 ID)
const taskId = await createTask(getTaskListId(), {
subject, description, activeForm,
status: 'pending',
owner: undefined,
blocks: [], blockedBy: [],
metadata,
})
// 2. 触发 TaskCreated hook(可能多个 hook 同时跑)
const blockingErrors = []
for await (const result of executeTaskCreatedHooks(taskId, subject, description, ...)) {
if (result.blockingError) blockingErrors.push(getTaskCreatedHookMessage(...))
}
// 3. 任一 Hook 阻断 → 删除刚创建的任务并抛错(原子回滚)
if (blockingErrors.length > 0) {
await deleteTask(getTaskListId(), taskId)
throw new Error(blockingErrors.join('\n'))
}
// 4. 自动展开 UI 任务面板
context.setAppState(prev =>
prev.expandedView === 'tasks' ? prev : { ...prev, expandedView: 'tasks' }
)
return { data: { task: { id: taskId, subject } } }
}
5.3 TaskUpdate 详解(TaskUpdateTool.ts)
这是最复杂的工具,支持 8 种字段更新,每种都有专门处理:
| 字段 | 特殊行为 |
|---|---|
status: 'deleted' |
把"deleted"作为伪状态:实际调用 deleteTask 删除文件,并清理其他任务里指向它的 blocks/blockedBy |
status: 'completed' |
触发 TaskCompleted hook,任一阻断 → 不更新,返回 success: false + error 原文 |
status: 'in_progress' 且无 owner(swarm 模式) |
自动把 owner 设为当前 agentName(getAgentName()) |
owner |
swarm 模式下还会通过 mailbox 给新 owner 发任务分配通知 |
addBlocks / addBlockedBy |
调用 blockTask 双向更新两端任务的引用 |
metadata |
合并语义:value === null 删除该 key,否则覆盖。比 React 的 setState 更细粒度 |
5.4 TaskList 的"过滤已完成阻塞"细节
// TaskListTool.ts:73-83
const resolvedTaskIds = new Set(
allTasks.filter(t => t.status === 'completed').map(t => t.id),
)
const tasks = allTasks.map(task => ({
id: task.id,
subject: task.subject,
status: task.status,
owner: task.owner,
// 关键:blockedBy 只显示"未完成的阻塞者"
blockedBy: task.blockedBy.filter(id => !resolvedTaskIds.has(id)),
}))
原因:blockedBy 是历史性的引用(永远存),但模型只关心"我现在能不能开始"。
已经 completed 的阻塞者对模型来说是"已解锁",所以 List 输出会自动过滤掉。
5.5 _internal metadata 隐藏
const allTasks = (await listTasks(taskListId)).filter(
t => !t.metadata?._internal, // ← 内部任务对模型不可见
)
系统可以创建带 metadata: { _internal: true } 的任务用作内部追踪(比如 BG sessions),这些不会出现在模型的 TaskList 输出里,但仍然在文件系统、UI 里存在。
06TaskListID 解析优先级
getTaskListId() 决定任务存到哪个目录。这个函数有 5 级优先级,直接影响 swarm 多进程是否共享任务列表。
6.1 实现(utils/tasks.ts:199)
export function getTaskListId(): string {
// 1. 显式 env 变量,最高优先级(测试/远程 agent 用)
if (process.env.CLAUDE_CODE_TASK_LIST_ID) {
return process.env.CLAUDE_CODE_TASK_LIST_ID
}
// 2. 进程内 teammate(同 Node.js 进程 fork 出的子 agent)
// 用 leader 的 teamName,这样 tmux/iTerm2 teammates 也能 resolve 到同一目录
const teammateCtx = getTeammateContext()
if (teammateCtx) {
return teammateCtx.teamName
}
// 3-5. 按顺序回退
return getTeamName() || leaderTeamName || getSessionId()
}
- 同一 Node.js 进程的不同 worker(in-process teammate)
- 同一机器的不同 tmux 窗口(每个 tmux pane 跑一个 claude)
- iTerm2 的多个 split pane
07文件锁与并发控制
V2 用 proper-lockfile 实现跨进程互斥,而不是依赖系统调用(flock 在 macOS/NFS 上行为不一致)。
所有写操作都要先拿锁。
7.1 锁配置
// utils/tasks.ts:102
const LOCK_OPTIONS = {
retries: {
retries: 30, // 最多重试 30 次
minTimeout: 5, // 5ms 起退避
maxTimeout: 100, // 最长 100ms 间隔
},
}
readdir + N×readFile + writeFile,
慢盘上 ~50-100ms。10 个 swarm agent 同时抢锁,最后那个等约 900ms。
30 次重试 → 最长 ~2.6 秒等待,在并发上限和体感延迟之间取平衡。
7.2 两层锁机制
V2 有两种粒度的锁,根据操作的影响范围选择:
| 锁文件 | 用途 | 获取者 |
|---|---|---|
<listDir>/.lock |
列表级锁:任何会改变列表整体的操作 | createTask, resetTaskList, claimTaskWithBusyCheck |
<listDir>/<id>.json |
任务级锁:只改单个任务 | updateTask, claimTask(无 busy 检查) |
7.3 列表级锁的获取流程
7.4 锁文件创建技巧 — 'wx' flag
// utils/tasks.ts:511
async function ensureTaskListLockFile(taskListId): Promise<string> {
await ensureTasksDir(taskListId)
const lockPath = getTaskListLockPath(taskListId)
try {
// 'wx' = write-exclusive: 文件不存在才创建,存在就抛 EEXIST
await writeFile(lockPath, '', { flag: 'wx' })
} catch {
// EEXIST 或其他 — 文件已存在,正常
}
return lockPath
}
多个进程同时调用 ensureTaskListLockFile 时,只有第一个写入成功,其他的拿到 EEXIST。
这就完成了"原子创建锁文件"的语义,不需要再加更高一级的锁。
7.5 内部 vs 外部 update 函数
// utils/tasks.ts:354
// Internal: no lock. 调用方已经拿了锁就用这个,避免死锁(claimTask 等)。
async function updateTaskUnsafe(taskListId, taskId, updates) { ... }
// External: 自己拿任务级锁后调用 unsafe。
export async function updateTask(taskListId, taskId, updates) {
const path = getTaskPath(taskListId, taskId)
// 提前检查存在,避免 lockfile 在不存在文件上抛错
if (!(await getTask(taskListId, taskId))) return null
try {
release = await lockfile.lock(path, LOCK_OPTIONS)
return await updateTaskUnsafe(taskListId, taskId, updates)
} finally {
await release?.()
}
}
08High Water Mark · ID 单调递增
V2 task ID 是简单的递增整数,但必须保证全历史唯一 —— 删除一个任务后,新建的任务不能复用它的 ID。这是为了防止 mailbox / blocks 引用悬挂。
8.1 问题场景
8.2 实现
// utils/tasks.ts:271
async function findHighestTaskId(taskListId): Promise<number> {
const [fromFiles, fromMark] = await Promise.all([
findHighestTaskIdFromFiles(taskListId), // 从 *.json 找
readHighWaterMark(taskListId), // 从 .highwatermark 读
])
return Math.max(fromFiles, fromMark)
}
// utils/tasks.ts:393 — 删除时更新 high water mark
export async function deleteTask(taskListId, taskId) {
const numericId = parseInt(taskId, 10)
if (!isNaN(numericId)) {
const currentMark = await readHighWaterMark(taskListId)
if (numericId > currentMark) {
await writeHighWaterMark(taskListId, numericId)
}
}
await unlink(path)
...
}
8.3 resetTaskList:把 mark 抬到当前最大
resetTaskList(团队解散、自动清理)不会重置 mark,反而会把当前最大值记进 mark:
// utils/tasks.ts:147
export async function resetTaskList(taskListId): Promise<void> {
const release = await lockfile.lock(lockPath, LOCK_OPTIONS)
const currentHighest = await findHighestTaskIdFromFiles(taskListId)
if (currentHighest > 0) {
const existingMark = await readHighWaterMark(taskListId)
if (currentHighest > existingMark) {
await writeHighWaterMark(taskListId, currentHighest)
}
}
// 然后才 unlink 所有 *.json
...
}
这样新会话从 currentHighest + 1 开始计数,即使前一个会话留下来的 mailbox / log 引用了旧 task ID,也不会撞车。
09依赖关系 (blocks/blockedBy)
V2 用双向引用(redundant edges)记录依赖,这样无论从哪一端查询都不需要遍历整个列表。
9.1 数据模型
// 任务 #2 由 #1 阻塞:
{ id: "1", blocks: ["2"], blockedBy: [] }
{ id: "2", blocks: [], blockedBy: ["1"] }
↑ ↑
#1 阻塞了哪些? #2 被谁阻塞?
9.2 blockTask:维护双向引用(tasks.ts:458)
export async function blockTask(taskListId, fromTaskId, toTaskId) {
const [fromTask, toTask] = await Promise.all([
getTask(taskListId, fromTaskId),
getTask(taskListId, toTaskId),
])
if (!fromTask || !toTask) return false
// 1. fromTask.blocks 加入 toTaskId
if (!fromTask.blocks.includes(toTaskId)) {
await updateTask(taskListId, fromTaskId, {
blocks: [...fromTask.blocks, toTaskId],
})
}
// 2. toTask.blockedBy 加入 fromTaskId
if (!toTask.blockedBy.includes(fromTaskId)) {
await updateTask(taskListId, toTaskId, {
blockedBy: [...toTask.blockedBy, fromTaskId],
})
}
return true
}
updateTask 没有放在同一个锁内 —— 如果第二次失败,第一次已经持久化,会出现单向引用残留。
但因为模型查询时只看 blockedBy(决定能不能 claim),单向残留只是显示问题,不会影响调度正确性。
这是有意的设计权衡:简单 > 强一致性。
9.3 deleteTask 清理悬挂引用(tasks.ts:420)
// 删除一个 task 后,扫一遍其他所有 task,从 blocks/blockedBy 里去掉它
const allTasks = await listTasks(taskListId)
for (const task of allTasks) {
const newBlocks = task.blocks.filter(id => id !== taskId)
const newBlockedBy = task.blockedBy.filter(id => id !== taskId)
if (newBlocks.length !== task.blocks.length ||
newBlockedBy.length !== task.blockedBy.length) {
await updateTask(taskListId, task.id, {
blocks: newBlocks,
blockedBy: newBlockedBy,
})
}
}
O(n) 扫描成本对小列表无所谓(典型 < 50 tasks)。如果列表上万,这里会变瓶颈,但目前还没出现这种规模。
9.4 TaskList 输出的"已解锁"语义
前面 5.4 提到过,TaskList 返回的 blockedBy 已经过滤掉 completed 任务。
所以模型看到的 blockedBy: [] 直接意味着"现在可做",不需要再去查每个阻塞者的状态。
10Claim 任务的原子性
在 swarm 模式下,多个 agent 可能同时尝试认领同一个 pending 任务。claimTask 提供了精细的原子性保证。
10.1 五种失败原因
type ClaimTaskResult = {
success: boolean
reason?: 'task_not_found' // 任务文件不存在
| 'already_claimed' // 已被其他 agent 拿走
| 'already_resolved' // 已经 completed
| 'blocked' // 还有未完成的阻塞者
| 'agent_busy' // 当前 agent 还有别的任务在做(可选检查)
task?: Task
busyWithTasks?: string[]
blockedByTasks?: string[]
}
10.2 两种锁粒度的 claim 路径
场景 A:不需要 busy 检查
claimTask(..., { checkAgentBusy: false }) → 只锁 单个 task 文件。
- 预检查存在:
getTask一次,不存在直接返回task_not_found。proper-lockfile 在不存在文件上会抛错,先检查避免异常。 - 拿任务级锁:
lockfile.lock(taskPath) - 重读任务状态:被锁前可能已被改
- 检查 owner:已被别人占 →
already_claimed - 检查 status:已 completed →
already_resolved - 遍历 listTasks 查 blockedBy:有未完成阻塞者 →
blocked - updateTaskUnsafe(owner = me):已经持锁,用 unsafe 版本避免死锁
- finally release
场景 B:需要 busy 检查(swarm)
claimTaskWithBusyCheck → 锁 整个列表(.lock 文件)。
原因:busy 检查需要原子地遍历"agent 拥有的所有未完成 task",这是跨多个 task 文件的查询。
// utils/tasks.ts:618
release = await lockfile.lock(lockPath, LOCK_OPTIONS) // 列表级锁
const allTasks = await listTasks(taskListId)
// 1. 找目标任务
// 2. 检查 owner / status / blockedBy
// 3. 检查 agent 自己是否 busy
const agentOpenTasks = allTasks.filter(t =>
t.status !== 'completed' &&
t.owner === claimantAgentId &&
t.id !== taskId
)
if (agentOpenTasks.length > 0) {
return { success: false, reason: 'agent_busy', busyWithTasks: agentOpenTasks.map(t => t.id) }
}
// 4. 通过所有检查 → updateTask 设置 owner
- Agent A 拿着 task #5 的锁,扫描其他 tasks → 发现自己只拥有 #5,不 busy
- 同时 Agent A 在另一进程拿到 task #8 的锁,做同样检查 → 也不 busy
- 两个进程都成功 claim,Agent A 同时拥有 #5 和 #8
11TaskCreated / TaskCompleted Hooks
V2 任务的关键生命周期点都暴露给用户配置的 hook。Hook 可以阻断任务创建/完成,常用于"完成前必须跑测试"这类工作流。
11.1 触发点与时机
| Hook 事件 | 触发位置 | 阻断效果 |
|---|---|---|
TaskCreated |
TaskCreate.call() 内 — 已写完文件之后 | 阻断 → deleteTask 回滚 + 抛出错误,工具调用失败 |
TaskCompleted |
TaskUpdate.call() 内 — 更新前 | 阻断 → 不写文件,返回 success: false + error 给模型 |
11.2 Hook 输入数据
// hooks.ts:3907
const hookInput: TaskCreatedHookInput = {
...createBaseHookInput(permissionMode), // session_id, cwd, ...
hook_event_name: 'TaskCreated',
task_id: taskId,
task_subject: taskSubject,
task_description: taskDescription,
teammate_name: teammateName, // 谁创建的
team_name: teamName,
}
11.3 阻断回滚示例
用户在 settings.json 里配 TaskCreated hook 检查"任务标题不能是 TODO":
{
"hooks": {
"TaskCreated": [{
"command": "bash -c 'jq -r .task_subject | grep -qv ^TODO || exit 2'"
}]
}
}
当模型尝试创建 { subject: "TODO: implement" }:
createTask已写入~/.claude/tasks/<listId>/N.json- Hook 跑出 exit 2 → blockingError
- TaskCreate 看到 blockingErrors.length > 0 → 调用
deleteTask(N)回滚 - throw 错误 → 工具结果是
tool_result(is_error: true, content: hook 错误信息) - 模型读到错误,下一回合改 subject 再试
12惰性提醒机制
系统会在模型连续 10 个 turn 没用 TodoWrite/TaskUpdate 时,自动注入一条 system-reminder,提示模型考虑用任务列表追踪进度。
12.1 触发条件(utils/attachments.ts:3326)
if (
turnsSinceLastTodoWrite >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE && // 10
turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS // 10
) {
return [{ type: 'todo_reminder', content: todos, itemCount: todos.length }]
}
计数逻辑(attachments.ts:3238):从消息列表反向遍历,跳过 thinking 消息,统计直到遇到上一个 TodoWrite 或上一个 todo_reminder 之间的 assistant turn 数。
12.2 提醒文案
// messages.ts:3629
let message = `The TodoWrite tool hasn't been used recently. ...
This is just a gentle reminder - ignore if not applicable.
Make sure that you NEVER mention this reminder to the user`
if (todoItems.length > 0) {
message += `\n\nHere are the existing contents of your todo list:\n\n[${todoItems}]`
}
return wrapMessagesInSystemReminder([
createUserMessage({ content: message, isMeta: true }),
])
isMeta: true:这条消息不会显示在 UI 给用户看<system-reminder>包裹:模型 prompt 里专门指导"反应这种格式但不要提起"- "NEVER mention this reminder":防止模型在回答里说"我看到一个提醒说..."
12.3 V2 task_reminder 路径
// attachments.ts:910
maybe('todo_reminders', () =>
isTodoV2Enabled()
? getTaskReminderAttachments(messages, toolUseContext) // V2 走这条
: getTodoReminderAttachments(messages, toolUseContext),
)
V2 提醒文案略有不同 —— 它检查 TaskCreate / TaskUpdate 这两种工具的最近使用, 并把 listTasks 的结果嵌入提醒,让模型直接看到当前任务清单。
12.4 跳过条件
| 条件 | 原因 |
|---|---|
| 没有 TodoWrite/TaskUpdate 工具 | tool 不可用就不要纠缠模型 |
BRIEF_TOOL_NAME 工具存在(SendUserMessage) |
brief 工作流下 TodoWrite 是边路通道,提醒会冲突 |
| messages 为空 | 会话刚开始,无须提醒 |
| USER_TYPE === 'ant' (V2 only) | 内部用户已经熟悉工具,提醒变 spam |
13Verification Nudge
这是一个更精细的"工作流补强"机制:当模型刚刚关闭一个 3+ 项的任务清单,而清单里没有任何 verification 步骤,系统会在 tool_result 末尾追加一段提醒,引导模型调用 verification agent。
13.1 触发条件(TodoWriteTool.ts:77)
let verificationNudgeNeeded = false
if (
feature('VERIFICATION_AGENT') &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
!context.agentId && // 仅主线程
allDone && // 所有任务都 completed
todos.length >= 3 && // 清单 ≥ 3 项
!todos.some(t => /verif/i.test(t.content)) // 没有 verify 步骤
) {
verificationNudgeNeeded = true
}
13.2 注入文案
// TodoWriteTool.ts:107
const nudge = verificationNudgeNeeded
? `\n\nNOTE: You just closed out 3+ tasks and none of them was a verification step.
Before writing your final summary, spawn the verification agent (subagent_type="${VERIFICATION_AGENT_TYPE}").
You cannot self-assign PARTIAL by listing caveats in your summary —
only the verifier issues a verdict.`
: ''
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: base + nudge,
}
13.3 TaskUpdate 镜像逻辑
V2 在 TaskUpdate 里有同样的 nudge 实现(TaskUpdateTool.ts:333),触发条件:
updates.status === 'completed' 且 allDone 且 allTasks.length ≥ 3 且没有 verification 任务。
14UI 渲染与文件监听
V2 的 UI 必须感知跨进程的任务变化。TasksV2Store 是一个 React 18 的 useSyncExternalStore 适配器,管理 fs.watch + 进程内信号 + 5s fallback poll。
14.1 三种数据源
14.2 关键优化:为什么是单例 store
// hooks/useTasksV2.ts:23 注释
// Multiple hook instances (REPL, Spinner, PromptInputFooterLeftSide)
// subscribe to one shared store instead of each setting up their own fs.watch
// on the same directory. The Spinner mounts/unmounts every turn —
// per-hook watchers caused constant watch/unwatch churn.
14.3 自动隐藏定时器
// useTasksV2.ts:113
const hasIncomplete = current.some(t => t.status !== 'completed')
if (hasIncomplete || current.length === 0) {
// 有未完成任务 / 空清单 — 重置 hide 状态
this.#hidden = current.length === 0
this.#clearHideTimer()
} else if (this.#hideTimer === null && !this.#hidden) {
// 全部刚刚 completed — 启动 5 秒倒计时
this.#hideTimer = setTimeout(
this.#onHideTimerFired.bind(this, taskListId),
HIDE_DELAY_MS, // 5000
)
}
5 秒后,如果任务全部仍为 completed,调用 resetTaskList 删掉所有 JSON 文件,并把 store 标记为 hidden。
用户就能看到"任务列表 → 5 秒后自动消失"的效果。
14.4 task list ID 可中途变化的处理
// useTasksV2.ts:113 — 每次 fetch 都重新解析 listId
#fetch = async (): Promise<void> => {
const taskListId = getTaskListId()
// listId 可能变(例如 TeamCreateTool 设置 leaderTeamName)— 重新指 watcher
this.#rewatch(getTasksDir(taskListId))
...
}
当 leader 创建团队后,setLeaderTeamName 会调 notifyTasksUpdated,
触发 fetch 重新读 listId,把 fs.watch 切到新目录 —— 全程不需要重启进程。
15shouldDefer 与工具懒加载
所有 5 个任务工具都设置了 shouldDefer: true。这是 Claude Code 的"工具懒加载"机制,在工具数量增多时降低 prompt token 占用。
15.1 deferred 工具的语义(src/Tool.ts:447)
/**
* When true, this tool is deferred (sent with defer_loading: true) and requires
* ToolSearch to be used before it can be called.
*/
readonly shouldDefer?: boolean
带 shouldDefer: true 的工具:
- 初始 prompt 里只发工具名 + searchHint,不发 schema
- 模型必须先用
ToolSearch查找到这个工具 - 查到后 schema 才会被加入"已解锁"列表,模型才能调用它
15.2 searchHint:让 ToolSearch 能找到
TaskCreateTool: { name: 'TaskCreate', searchHint: 'create a task in the task list' }
TaskUpdateTool: { name: 'TaskUpdate', searchHint: 'update a task' }
TaskListTool: { name: 'TaskList', searchHint: 'list all tasks' }
TaskGetTool: { name: 'TaskGet', searchHint: 'retrieve a task by ID' }
TodoWriteTool: { name: 'TodoWrite', searchHint: 'manage the session task checklist' }
这些 hint 是模型决定"我现在需不需要查任务管理工具"的关键。simulating 时模型大致会这样推理: "用户让我做一件复杂的事 → 我应该追踪进度 → 搜 'task' / 'checklist' → 找到这些工具 → 调用"。
16Swarm 多人协作模式
V2 任务系统的最复杂场景:多个 Claude 进程作为 teammates 协作,一个 leader 分派任务,teammates 自主认领。
16.1 swarm 流程
16.2 自动 owner 设置
// TaskUpdateTool.ts:188 — 标 in_progress 时若没传 owner,自动设为当前 agent
if (
isAgentSwarmsEnabled() &&
status === 'in_progress' &&
owner === undefined &&
!existingTask.owner
) {
const agentName = getAgentName()
if (agentName) {
updates.owner = agentName
updatedFields.push('owner')
}
}
16.3 mailbox 任务分配通知
// TaskUpdateTool.ts:277 — owner 变化时给新 owner 发邮箱通知
if (updates.owner && isAgentSwarmsEnabled()) {
const assignmentMessage = JSON.stringify({
type: 'task_assignment',
taskId,
subject: existingTask.subject,
description: existingTask.description,
assignedBy: senderName,
timestamp: new Date().toISOString(),
})
await writeToMailbox(updates.owner, { ...message }, taskListId)
}
16.4 Agent 状态(idle/busy)派生
// utils/tasks.ts:763 — 从 task ownership 推导 agent 状态
export async function getAgentStatuses(teamName) {
const teamData = await readTeamMembers(teamName)
const allTasks = await listTasks(sanitizeName(teamName))
// 按 owner 分组未完成任务
const unresolvedByOwner = new Map()
for (const task of allTasks) {
if (task.status !== 'completed' && task.owner) {
const existing = unresolvedByOwner.get(task.owner) || []
existing.push(task.id)
unresolvedByOwner.set(task.owner, existing)
}
}
// 同时按 name 和 agentId 查(向后兼容)
return teamData.members.map(member => ({
...member,
status: currentTasks.length === 0 ? 'idle' : 'busy',
currentTasks,
}))
}
16.5 Teammate 死亡的优雅处理
// utils/tasks.ts:818 — teammate 被 kill 或主动 shutdown 时
export async function unassignTeammateTasks(teamName, teammateId, teammateName, reason) {
const tasks = await listTasks(teamName)
const unresolvedAssigned = tasks.filter(t =>
t.status !== 'completed' &&
(t.owner === teammateId || t.owner === teammateName)
)
// 把所有未完成、属于该 teammate 的任务重置回 pending + 清 owner
for (const task of unresolvedAssigned) {
await updateTask(teamName, task.id, { owner: undefined, status: 'pending' })
}
// 给 leader 准备一条通知:N 个任务被解放,可重新分配
return { unassignedTasks, notificationMessage }
}
这是 swarm 系统的容错机制:teammate 进程崩了,它的任务不会永远卡在 owner 字段上,会自动回流到 pending 池。
17端到端示例
用一个具体场景串起所有机制。
场景:用户让模型实现 "添加 dark mode 切换"
-
用户输入
"给 settings 页面加一个 dark mode 切换,做完跑测试和 build" -
模型识别为复杂任务,调用 TaskCreate × 5
每次调用都会:
- 获取
~/.claude/tasks/<sessionId>/.lock - 读
findHighestTaskId:首次返回 0,然后 1, 2, ... - 写
1.json,2.json, ... - 触发 TaskCreated hook(用户没配 → 不阻断)
- 设
expandedView: 'tasks'让 UI 展开
~/.claude/tasks/abc-session/ ├── 1.json: {subject:"Create dark mode toggle component", status:"pending", ...} ├── 2.json: {subject:"Add dark mode state management", status:"pending", ...} ├── 3.json: {subject:"CSS-in-JS dark theme styles", status:"pending", ...} ├── 4.json: {subject:"Update components to support theming", status:"pending", ...} ├── 5.json: {subject:"Run tests and build", status:"pending", ...}同时,
notifyTasksUpdated触发TasksV2Store.#debouncedFetch(50ms debounce), UI 上的 TaskListV2 立即刷新显示这 5 项。 - 获取
-
模型 TaskUpdate(taskId:"1", status:"in_progress")
swarm 关闭 → 不自动设 owner。仅状态变化。
UI 端 spinner 把"思考中"改成 task 1 的 activeForm("Creating dark mode toggle component")。
-
模型连续调用 FileEdit / FileWrite 完成 task 1
这些工具不影响任务状态,只是修改文件。
-
TaskUpdate(taskId:"1", status:"completed")
触发 TaskCompleted hook(无配置)→ 不阻断。
verification nudge 检查:5 个 task 还没全部完成,不触发。
tool_result content:
"Updated task #1 status"。 - 重复 step 3-5 处理 task 2-5
-
关键时刻:task 5 ("Run tests and build") 标 completed
verification nudge 检查:
feature('VERIFICATION_AGENT') ✓ GrowthBook tengu_hive_evidence ✓ !context.agentId (主线程) ✓ updates.status === 'completed' ✓ allTasks.every(completed) ✓ (5/5) allTasks.length >= 3 ✓ no task subject matches /verif/i ✓ (没有 verification 步骤) → verificationNudgeNeeded = truetool_result 末尾追加:
NOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent (subagent_type="verifier"). You cannot self-assign PARTIAL by listing caveats in your summary — only the verifier issues a verdict. -
模型读到 nudge,主动调用 Agent 工具启动 verifier
verifier 子 agent 跑测试、看代码,产出 PASS/PARTIAL/FAIL verdict。
-
TasksV2Store 自动清理
所有 task completed → 启动 5s 倒计时 → 时间到 → 调
resetTaskList:- 读最高 ID = 5
- 写
.highwatermark = 5 - unlink 所有 *.json
this.#hidden = truenotify()→ UI 任务面板淡出
之后如果用户再发新任务 → 重新 TaskCreate → 从 ID = 6 开始,不复用旧 ID。
关键时间线
18核心设计原则
从这套实现中可以提炼出 7 条可迁移到其他 agent 系统的设计原则。
-
双轨制 V1/V2 共存,而非一刀切迁移
SDK 用户依赖纯内存清单(简单、无副作用),交互用户需要持久化 + 多进程协作。 通过
isTodoV2Enabled()在工具注册阶段做排他启用, 模型每个会话只看到一套接口,不会混淆。这是"渐进式架构演进"的好范例。 -
文件系统作为多进程的唯一真理来源
不引入数据库、不依赖 IPC,直接用
~/.claude/tasks/<listId>/<id>.json+ proper-lockfile + fs.watch。这种设计天然兼容 tmux/iTerm2/独立进程等任意运行形态, 只要能访问同一文件系统就能协作。 -
双向引用 + 单向真理:权衡一致性与简单性
blocks/blockedBy 双向冗余,但 blockTask 不是事务。模型只读 blockedBy 来决定能否 claim, 单向残留只影响显示。"哪一端是真理"比"两端绝对一致"更值得设计。
-
High Water Mark:抗 ID 复用
哪怕全部任务删除,新任务也从历史最大 + 1 开始。这种"单调递增不回头"的策略 让任意外部引用(mailbox、log、git commit message)都能保持稳定。
-
惰性提醒 + 强约束:控制模型行为而非命令
10 turn 没用 task 工具 → 注入 system-reminder;3+ 任务关闭无 verifier → 注入 nudge。 这些都是"行为推荐"而非"硬性中断", 让模型保留判断权但减少漏掉关键步骤的概率。
-
Hook 阻断必须可回滚
TaskCreate 已经写文件再调 hook,如果阻断就 deleteTask 回滚 —— 任务从未存在过。 这给用户的 hook 提供了真正的"否决权",而不是"事后补救"。
-
shouldDefer:工具数量与 prompt 成本的平衡
5 个任务工具加起来 4-6k token,但 90% query 用不到。把它们标记为 deferred, 需要时由 ToolSearch 唤醒,既保留功能又避免 token 浪费。 这是大型工具集 agent 系统的标准模式。